[{"content":"This notebook is the working file of a reversing / vulnerability-analysis practice, not a link blog.\nWhat a note contains If a page does not have tool output, a figure, and a sanitized reproduction, it is unfinished.\nTypical skeleton:\nLab binary or APK I compiled (or a public CVE class, not a stolen sample) readelf / otool / jadx / gdb / lldb / Frida transcript ARM64 or x64 disassembly of the one function that matters Reproduction that stops at crash, ASAN, or a redacted trace The patch or the detection check I would actually ship Secrets, load addresses, team IDs, and tokens are [REDACTED] or truncated.\nTracks Network Security — parsers, auth tokens, HTTP framing, TLS metadata Binary Analysis — ELF/Mach-O/JNI, GOT/PLT, ObjC, vtables Operating Systems — tokens, Kerberos fields, containers, kernel invariants (no LPE recipes) What I will not paste Working exploit chains, FairPlay unwrap, jailbreak AMFI bypasses, or live credentials. The lab memcpy that ASAN flags is here so the write-up has a crash; a shell is not.\n","permalink":"https://blog.omiilgo.com/posts/welcome/","summary":"Lab notes: dumps, disassembly, sanitized crashes. Network, binary, Android, iOS, OS internals.","title":"Welcome to Security Notes"},{"content":"This is a handshake lab, not a middlebox bypass. Target is nginx on 127.0.0.1:8443 with a lab certificate whose CN I redact here, plus openssl s_client -msg. Goal: name the records I can see in TLS 1.2 vs 1.3, paste a redacted -msg excerpt, and show a verify failure when the name does not match. I do not ship a custom ClientHello that downgrades a real server, and I do not disable certificate checks in anything but this lab command.\n1Figure 1. After ChangeCipherSpec / EncryptedExtensions, the useful fields go dark. 2ClientHello -\u0026gt; ServerHello -\u0026gt; Certificate* -\u0026gt; Finished 3* cleartext on TLS 1.2; encrypted on TLS 1.3 Lab layout 1labs/tls_lab/ 2 nginx.conf 3 certs/lab.pem # CN=[REDACTED], SAN=127.0.0.1 4 certs/lab.key 5 bad-name.conf # server_name mismatch for the fail case 1# nginx.conf — loopback only 2events { worker_connections 8; } 3http { 4 server { 5 listen 127.0.0.1:8443 ssl; 6 server_name 127.0.0.1; 7 ssl_certificate /labs/tls_lab/certs/lab.pem; 8 ssl_certificate_key /labs/tls_lab/certs/lab.key; 9 ssl_protocols TLSv1.2 TLSv1.3; 10 location / { return 200 \u0026#34;tls-lab\\n\u0026#34;; } 11 } 12} Certificate I minted with a throwaway CA. The CN is not a customer name.\n1$ openssl x509 -in certs/lab.pem -noout -subject -issuer -dates 2subject=CN = [REDACTED] 3issuer=CN = lab-ca-[REDACTED] 4notBefore=Sep 1 00:00:00 2026 GMT 5notAfter=Sep 1 00:00:00 2027 GMT 6$ openssl x509 -in certs/lab.pem -noout -ext subjectAltName 7X509v3 Subject Alternative Name: 8 IP Address:127.0.0.1 Artifact: s_client -msg (TLS 1.2 forced, so records stay readable) TLS 1.3 encrypts the certificate. For a first pass I force 1.2 so the notebook has a cleartext cert record, then I repeat on 1.3 and note what vanished.\n1openssl s_client -connect 127.0.0.1:8443 -tls1_2 -msg -CAfile certs/lab-ca.pem \\ 2 -servername 127.0.0.1 \u0026lt;/dev/null 2\u0026gt;/tmp/tls12.msg Excerpt (-msg writes a mix of stderr and the hex dump; this is the text I keep):\n1\u0026gt;\u0026gt;\u0026gt; TLS 1.2, Handshake [length 013c], ClientHello 2 01 00 01 38 03 03 [REDACTED_RANDOM] 3 cipher suites (truncated): TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 4\u0026lt;\u0026lt;\u0026lt; TLS 1.2, Handshake [length 0051], ServerHello 5 02 00 00 4d 03 03 [REDACTED_RANDOM] 6 cipher: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 7\u0026lt;\u0026lt;\u0026lt; TLS 1.2, Handshake [length 05c4], Certificate 8 0b 00 05 c0 00 05 bd 00 05 ba 9 subject: CN=[REDACTED] 10 issuer: CN=lab-ca-[REDACTED] 11\u0026lt;\u0026lt;\u0026lt; TLS 1.2, Handshake [length 012d], ServerKeyExchange 12\u0026lt;\u0026lt;\u0026lt; TLS 1.2, Handshake [length 0004], ServerHelloDone 13\u0026gt;\u0026gt;\u0026gt; TLS 1.2, Handshake [length 0046], ClientKeyExchange 14\u0026gt;\u0026gt;\u0026gt; TLS 1.2, Handshake [length 0010], Finished 15\u0026lt;\u0026lt;\u0026lt; TLS 1.2, ChangeCipherSpec [length 0001] 16\u0026lt;\u0026lt;\u0026lt; TLS 1.2, Handshake [length 0010], Finished 17--- 18Verify return code: 0 (ok) What I extract from that dump, and only that:\nVersion actually negotiated (TLS 1.2), not the ssl_protocols wish list. Cipher (ECDHE_RSA_AES_128_GCM_SHA256) — AEAD, ECDHE. A CBC or RSA-key-transport suite would be a finding. Cert CN/SAN, here already [REDACTED] / IP:127.0.0.1. Verify return code 0 against my lab CA. verify error:num=18:self signed is a different ticket. TLS 1.3 on the same server:\n1$ openssl s_client -connect 127.0.0.1:8443 -tls1_3 -msg -CAfile certs/lab-ca.pem \u0026lt;/dev/null 2\u0026gt;\u0026gt;\u0026gt; TLS 1.3, Handshake [length ...], ClientHello 3\u0026lt;\u0026lt;\u0026lt; TLS 1.3, Handshake [length ...], ServerHello 4\u0026lt;\u0026lt;\u0026lt; TLS 1.3, Handshake [length ...], EncryptedExtensions 5\u0026lt;\u0026lt;\u0026lt; TLS 1.3, Handshake [length ...], Certificate # encrypted on the wire 6\u0026lt;\u0026lt;\u0026lt; TLS 1.3, Handshake [length ...], CertificateVerify 7\u0026lt;\u0026lt;\u0026lt; TLS 1.3, Handshake [length ...], Finished 8Verify return code: 0 (ok) 9# s_client still prints the cert because it is the terminator Packet capture of 1.3 shows Application Data where 1.2 showed a clear Certificate. Middleboxes that parsed 1.2 certs on the wire break here. That is a defender fact, not a request to strip TLS.\nAnalysis steps on a capture Failure before or after ServerHello? No ServerHello → TCP/ACL/SNI mismatch, not a cipher problem. Alert record: handshake_failure vs bad_certificate vs protocol_version. SNI: did the client send the name the vhost expects? Resumption: 1.2 session id / ticket; 1.3 PSK. 0-RTT is a replay discussion, out of this lab. Do not trust a screenshot of a green padlock. Trust Verify return code against a CA you pin. 1$ echo | openssl s_client -connect 127.0.0.1:8443 -servername wrong.lab \\ 2 -CAfile certs/lab-ca.pem -tls1_2 2\u0026gt;\u0026amp;1 | tail -20 3# nginx still serves the only cert; OpenSSL: 4verify error:num=62:hostname mismatch 5Verify return code: 62 (hostname mismatch) Hostname mismatch is the sanitized failure I keep. The HTTP layer may still 200 if a client ignores verify (curl -k). I do not use -k except to confirm the vhost is up, then I throw that output away.\nSanitized reproduction (alert / crash only) 1curl -sv --cacert certs/lab-ca.pem https://127.0.0.1:8443/ -o /tmp/body 2# * TLSv1.3 (IN), TLS handshake, Finished (20): 3# \u0026lt; HTTP/1.1 200 OK 4# tls-lab 5 6curl -sv --cacert certs/lab-ca.pem --resolve \u0026#39;wrong.lab:8443:127.0.0.1\u0026#39; \\ 7 https://wrong.lab:8443/ -o /dev/null 8# * SSL: certificate subject name \u0026#39;[REDACTED]\u0026#39; does not match target host name \u0026#39;wrong.lab\u0026#39; 9# curl: (60) SSL certificate problem: ... A truncated handshake (client sends ClientHello, then FIN) shows up as nginx:\n12026/09/18 14:41:02 [info] 4412#0: *9 SSL_do_handshake() failed 2 (SSL: error:0A000126:SSL routines::unexpected eof while reading) 3 while SSL handshaking, client: 127.0.0.1, server: 127.0.0.1:8443 ASAN is not in openssl here. Unexpected EOF is the crash analog I file for scanners that drop the handshake.\nFailed-auth at the HTTP layer after a good handshake (so we do not mix TLS and app tickets):\n12026-09-18T14:44:11+08:00 GET /admin 2 src: 127.0.0.1 tls: TLSv1.3 cipher: TLS_AES_256_GCM_SHA384 3 auth: 401 www-authenticate: Basic realm=\u0026#34;lab\u0026#34; 4 user: [REDACTED] TLS verified; HTTP did not. Two rows in two files.\nAlerts I actually keep openssl s_client will print Alert records when the server hates us. Two lab cases:\n1# protocol the server disabled 2$ openssl s_client -connect 127.0.0.1:8443 -tls1 \u0026lt;/dev/null 31404:error:0A0000BF:SSL routines:tls_setup_handshake:no protocols available 4# nginx error.log: 5# SSL_do_handshake() failed (SSL: error:0A00006C:SSL routines::version too low) 6 7# cipher we removed (TLS1.2 only suite the server does not offer) 8$ openssl s_client -connect 127.0.0.1:8443 -tls1_2 -cipher \u0026#39;RC4-SHA\u0026#39; \u0026lt;/dev/null 9error:141A90B5:SSL routines:ssl_cipher_list_to_bytes:no ciphers available version too low and no ciphers are expected after hardening. A sudden spike of unexpected eof from one /24 is a scanner; a spike of hostname mismatch from our own synthetic monitor is a broken SNI in the probe, not an attack.\nMitigation / what I tick ssl_protocols TLSv1.2 TLSv1.3; — no 1.0/1.1 on anything I own. Ciphers: GCM/CHACHA, ECDHE or TLS 1.3 suites. No RC4, no 3DES, no NULL, no export. Cert: SAN contains the names clients actually send (IP or DNS). CN is leftover; SAN is what s_client matches. Pin the CA in the client (--cacert / system store), not InsecureSkipVerify. Log TLS version + cipher on the edge. The 401 line above is the shape. 1.3: expect encrypted certs in pcaps; use the terminator\u0026rsquo;s view (s_client, nginx $ssl_client_verify) not Wireshark\u0026rsquo;s cleartext fields from 2014. 1log_format tls \u0026#39;$remote_addr $ssl_protocol $ssl_cipher $ssl_session_reused \u0026#39; 2 \u0026#39;$request $status\u0026#39;; What I file after this lab nginx 127.0.0.1:8443, cert SAN IP:127.0.0.1, CN [REDACTED] TLS 1.2 -msg: ClientHello → ServerHello → Certificate (CN redacted) → Finished, verify 0 TLS 1.3: Certificate is encrypted on the wire; s_client still verify 0 Name mismatch: OpenSSL 62, curl 60, no -k in the kept evidence Truncated handshake: nginx unexpected eof while reading Fix: TLS 1.2/1.3 only, AEAD ciphers, SAN match, pin CA, log protocol+cipher Commands appendix 1openssl s_client -connect 127.0.0.1:8443 -tls1_2 -msg -CAfile certs/lab-ca.pem \u0026lt;/dev/null 2openssl s_client -connect 127.0.0.1:8443 -tls1_3 -CAfile certs/lab-ca.pem \u0026lt;/dev/null 3openssl x509 -in certs/lab.pem -noout -ext subjectAltName 4curl -sv --cacert certs/lab-ca.pem https://127.0.0.1:8443/ ","permalink":"https://blog.omiilgo.com/posts/network-tls-handshake-notes/","summary":"Lab openssl s_client -msg against a loopback nginx: handshake record dump, redacted CN, alert on a bad name — not a MITM cookbook.","title":"TLS Handshake Notes for Defenders"},{"content":"Most Linux reversing starts with two tables: names that still exist, and bytes the loader will patch. This lab is one 20-line PIE and a complete readelf -s / readelf -r session on it, then the same session on a stripped copy. I am not summarizing ELF. I am dumping the files.\nFigure 1. R_AARCH64_JUMP_SLOT is the reloc that names puts. The GOT slot is the Offset column.\rLab binary 1/* sym_lab.c — one local, one global, one libc import, one global object */ 2#include \u0026lt;stdio.h\u0026gt; 3 4int g_counter = 7; 5 6static int hidden_add(int a, int b) 7{ 8 return a + b + g_counter; 9} 10 11int main(void) 12{ 13 int v = hidden_add(1, 2); 14 printf(\u0026#34;v=%d\\n\u0026#34;, v); 15 return v; 16} 1cc -O0 -fPIE -pie -g -o sym_lab sym_lab.c 2cp -a sym_lab sym_lab.unstripped 3strip -o sym_lab.strip sym_lab 4file sym_lab.unstripped sym_lab.strip 5# both: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked 6# unstripped: not stripped strip: stripped readelf -h / -l / -S (where the tables live) 1$ readelf -h sym_lab.unstripped | egrep \u0026#39;Class|Type|Machine|Entry|Magic\u0026#39; 2 Magic: 7f 45 4c 46 02 01 01 00 ... 3 Class: ELF64 4 Type: DYN (Shared object file) 5 Machine: AArch64 6 Entry point address: 0x6a0 7 8$ readelf -S sym_lab.unstripped | egrep \u0026#39;symtab|dynsym|rela|plt|text|dynstr\u0026#39; 9 [ 6] .dynsym DYNSYM 00000000000003f0 000003f0 10 [ 7] .dynstr STRTAB 00000000000004c8 000004c8 11 [10] .rela.dyn RELA 00000000000005b0 000005b0 12 [11] .rela.plt RELA 00000000000006c8 000006c8 13 [12] .plt PROGBITS 00000000000006b0 000006b0 14 [14] .text PROGBITS 00000000000007a0 000007a0 15 [28] .symtab SYMTAB 0000000000000000 00001280 16 [29] .strtab STRTAB 0000000000000000 00001490 .symtab has no runtime VA (offset only). Strip deletes .symtab / .strtab. .dynsym stays; the dynamic linker needs it.\n1$ readelf -S sym_lab.strip | egrep \u0026#39;symtab|dynsym\u0026#39; 2 [ 6] .dynsym DYNSYM 00000000000003f0 000003f0 3# no .symtab line readelf -s — full symbol dump, then the rows I keep 1$ readelf -s sym_lab.unstripped 2Symbol table \u0026#39;.dynsym\u0026#39; contains 8 entries: 3 Num: Value Size Type Bind Vis Ndx Name 4 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 5 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.17 (2) 6 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.17 (2) 7 3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 8 4: 00000000000007c8 72 FUNC GLOBAL DEFAULT 14 main 9 5: 0000000000001108 4 OBJECT GLOBAL DEFAULT 23 g_counter 10 6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterT... 11 7: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerT... 12 13Symbol table \u0026#39;.symtab\u0026#39; contains 44 entries: 14 Num: Value Size Type Bind Vis Ndx Name 15 ... 16 18: 00000000000007a4 36 FUNC LOCAL DEFAULT 14 hidden_add 17 32: 00000000000007c8 72 FUNC GLOBAL DEFAULT 14 main 18 33: 0000000000001108 4 OBJECT GLOBAL DEFAULT 23 g_counter 19 40: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@@GLIBC_2.17 Rows that matter:\nName Table Bind Ndx Meaning printf .dynsym GLOBAL UND import; Value=0 until bind main both GLOBAL 14 (.text) exported from the PIE g_counter both GLOBAL 23 (.data) object, size 4 hidden_add .symtab only LOCAL 14 strip kills this name __gmon_start__ .dynsym WEAK UND UND optional; linker does not fail if missing 1$ readelf -s sym_lab.strip 2Symbol table \u0026#39;.dynsym\u0026#39; contains 8 entries: 3 ... same UND printf, GLOBAL main, GLOBAL g_counter ... 4# no .symtab, no hidden_add nm is the short form of the same tables:\n1$ nm -C sym_lab.unstripped | egrep \u0026#39;main|hidden|g_counter|printf\u0026#39; 20000000000001108 D g_counter 300000000000007a4 t hidden_add 400000000000007c8 T main 5 U printf 6 7$ nm -D sym_lab.strip | egrep \u0026#39;main|hidden|g_counter|printf\u0026#39; 80000000000001108 D g_counter 900000000000007c8 T main 10 U printf 11# hidden_add gone from -D as well (it was never dynamic) Local functions are the first thing strip steals. Imports and exported globals survive. That is why a stripped daemon still shows printf and not hidden_add.\nreadelf -r — full reloc dump 1$ readelf -r sym_lab.unstripped 2Relocation section \u0026#39;.rela.dyn\u0026#39; at offset 0x5b0 contains 6 entries: 3 Offset Info Type Sym. Value Sym. Name + Addend 40000000000000fd8 000000000403 R_AARCH64_RELATIVE 7c8 50000000000000fe0 000000000403 R_AARCH64_RELATIVE 7a0 60000000000001108 000000000403 R_AARCH64_RELATIVE 1108 70000000000000fa8 000300000401 R_AARCH64_GLOB_DAT 0000000000000000 __gmon_start__ + 0 80000000000000fb0 000200000401 R_AARCH64_GLOB_DAT 0000000000000000 __libc_start_main + 0 90000000000000fb8 000100000401 R_AARCH64_GLOB_DAT 0000000000000000 printf + 0 10 11Relocation section \u0026#39;.rela.plt\u0026#39; at offset 0x6c8 contains 2 entries: 12 Offset Info Type Sym. Value Sym. Name + Addend 130000000000000ff8 000100000402 R_AARCH64_JUMP_SLOT 0000000000000000 printf@GLIBC_2.17 + 0 140000000000001000 000200000402 R_AARCH64_JUMP_SLOT 0000000000000000 __libc_start_main@GLIBC_2.17 + 0 How I read one row, every time:\n1Offset = GOT / data slot the loader writes (file VA; add bias at runtime) 2Type = what kind of write 3Name = which symbol (empty for RELATIVE) 4Addend = for RELATIVE, the file VA being slid Three types on this binary:\nR_AARCH64_RELATIVE — *(bias+Offset) = bias + Addend. Used for internal pointers in a PIE (here Addend 0x7c8 is main). No name needed. Strip does not remove these. R_AARCH64_GLOB_DAT — fill a GOT cell with the symbol\u0026rsquo;s resolved address (function or object). Eager even under lazy bind, typically inside the RELRO window. R_AARCH64_JUMP_SLOT — the PLT GOT cell. Lazy unless BIND_NOW. This is the printf@plt slot. Same dance as the GOT/PLT lab. x86_64 names are R_X86_64_RELATIVE, R_X86_64_GLOB_DAT, R_X86_64_JUMP_SLOT. Same columns.\nreadelf -r on the stripped copy is byte-identical for these sections. Relocs are not in .symtab.\n1$ readelf -r sym_lab.strip | md5sum 2$ readelf -r sym_lab.unstripped | md5sum 3# same digest on this toolchain readelf -d — who consumes those tables 1$ readelf -d sym_lab.unstripped 2Dynamic section at offset 0xe28 contains 24 entries: 3 Tag Type Name/Value 4 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 5 0x000000000000000c (INIT) 0x6a8 6 0x000000000000000d (FINI) 0x8b8 7 0x0000000000000019 (INIT_ARRAY) 0xfd8 8 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes) 9 0x0000000000000005 (STRTAB) 0x4c8 10 0x0000000000000006 (SYMTAB) 0x3f0 ; this is .dynsym, not .symtab 11 0x000000000000000a (STRSZ) 130 (bytes) 12 0x000000000000000b (SYMENT) 24 (bytes) 13 0x0000000000000015 (DEBUG) 0x0 14 0x0000000000000003 (PLTGOT) 0xfe8 15 0x0000000000000002 (PLTRELSZ) 48 (bytes) 16 0x0000000000000014 (PLTREL) RELA 17 0x0000000000000017 (JMPREL) 0x6c8 ; .rela.plt 18 0x0000000000000007 (RELA) 0x5b0 ; .rela.dyn 19 0x0000000000000008 (RELASZ) 144 (bytes) 20 0x0000000000000009 (RELAENT) 24 (bytes) 21 0x000000006ffffffb (FLAGS_1) Flags: PIE 22 0x000000006ffffffe (VERNEED) 0x590 23 0x000000006fffffff (VERNEEDNUM) 1 24 0x000000006ffffff0 (VERSYM) 0x560 SYMTAB in dynamic tags points at .dynsym. I have mixed that up with .symtab on stripped files and then wondered why readelf -s still printed printf.\nNo BIND_NOW here → lazy JUMP_SLOT. RELRO is Partial (see RELRO note).\nobjdump: printf is a reloc, not an immediate 1$ objdump -d sym_lab.unstripped | sed -n \u0026#39;/\u0026lt;main\u0026gt;:/,/ret/p\u0026#39; 200000000000007c8 \u0026lt;main\u0026gt;: 3 7c8: a9be7bfd stp x29, x30, [sp, #-32]! 4 7cc: 910003fd mov x29, sp 5 7d0: 52800020 mov w0, #1 6 7d4: 52800041 mov w1, #2 7 7d8: 97fffff3 bl 7a4 \u0026lt;hidden_add\u0026gt; ; direct, same module 8 7dc: 2a0003e1 mov w1, w0 9 7e0: 90000000 adrp x0, 0 10 7e4: 91208000 add x0, x0, #0x820 ; \u0026#34;v=%d\\n\u0026#34; 11 7e8: 97ffffc2 bl 6d0 \u0026lt;printf@plt\u0026gt; ; JUMP_SLOT, not libc 12 7ec: 52800120 mov w0, #9 ; 1+2+7 13 7f0: a8c27bfd ldp x29, x30, [sp], #32 14 7f4: d65f03c0 ret hidden_add is a bl with a relative immediate — no reloc. printf is a bl into .plt. After strip, objdump still labels printf@plt because .dynsym + .rela.plt survived. It labels hidden_add as \u0026lt;main-0x24\u0026gt; or a raw VA.\n1$ objdump -d sym_lab.strip | sed -n \u0026#39;/\u0026lt;main\u0026gt;:/,+12p\u0026#39; 200000000000007c8 \u0026lt;main\u0026gt;: 3 7c8: ... 4 7d8: 97fffff3 bl 7a4 \u0026lt;main-0x24\u0026gt; ; was hidden_add 5 7e8: 97ffffc2 bl 6d0 \u0026lt;printf@plt\u0026gt; ; name kept That is the practical difference between the two symbol tables, in one diff.\ngdb: UND becomes a libc VA 1$ gdb -q ./sym_lab.unstripped 2(gdb) set disable-randomization on 3(gdb) break printf@plt 4(gdb) run 5Breakpoint 1, 0x0000aaaaaaab06d0 in printf@plt () 6(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8 ; JUMP_SLOT Offset 0xff8 + bias 7$1 = 0x0000aaaaaaab06e0 ; still PLT tail 8(gdb) finish 9v=10 10(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8 11$2 = 0x0000fffff7e9c4a0 ; libc printf [slide REDACTED] 12(gdb) info symbol 0xfffff7e9c4a0 13printf in section .text of /lib/aarch64-linux-gnu/libc.so.6 g_counter is a RELATIVE / data object. gdb sees the slid address:\n1(gdb) p \u0026amp;g_counter 2$3 = (int *) 0xaaaaaaab1108 3(gdb) p g_counter 4$4 = 7 File VA 0x1108 + bias 0xaaaaaaab0000 = $3. RELATIVE reloc did that, not .symtab.\nSanitized reproduction (crash only) A planted strcpy into a 8-byte global, so the overflow is in .data next to g_counter. Crash / ASan, not a reloc rewrite.\n1/* -DPLANT_BUG */ 2void load_tag(const char *s) 3{ 4 char tag[8]; 5 strcpy(tag, s); 6 g_counter = tag[0]; 7} 1$ cc -O0 -fPIE -pie -fsanitize=address -g -o sym_asan sym_lab.c 2$ ./sym_asan $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*32)\u0026#39;) 3==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 4WRITE of size 33 at ... thread T0 5 #0 strcpy 6 #1 load_tag sym_lab.c:6 7 #2 main 8 This frame has 1 object(s): 9 [32, 40) \u0026#39;tag\u0026#39; (line 5) \u0026lt;== Memory access at offset 40 Without ASan, 32 As smash the frame and pc becomes 0x4141… the same way as the other labs. I do not smash a JUMP_SLOT here; the RELRO note already did the str into .got.plt.\nWhat I file after this lab .dynsym: UND printf, T main, D g_counter; no hidden_add .symtab (unstripped only): t hidden_add at 0x7a4 .rela.plt: JUMP_SLOT printf at Offset 0xff8 .rela.dyn: RELATIVE addend 0x7c8 (main), GLOB_DAT for the UND names Strip deletes .symtab, keeps relocs and .dynsym Repro: strcpy 33 bytes into tag[8], ASan stack-buffer-overflow Patch / detection strip --strip-unneeded is fine for shipping; do not expect local names in incident work. Audit imports from .dynsym UND / JUMP_SLOT, not from a wishful nm without -D. PIE internals: treat every code pointer in .data as RELATIVE until readelf -r says otherwise. Screenshot VAs without bias are wrong. Commands appendix 1readelf -h \u0026#34;$1\u0026#34; | egrep \u0026#39;Class|Type|Machine|Entry\u0026#39; 2readelf -S \u0026#34;$1\u0026#34; | egrep \u0026#39;dynsym|symtab|rela|plt\u0026#39; 3readelf -s \u0026#34;$1\u0026#34; 4readelf -r \u0026#34;$1\u0026#34; 5readelf -d \u0026#34;$1\u0026#34; | egrep \u0026#39;NEEDED|SYMTAB|JMPREL|BIND_NOW|FLAGS_1\u0026#39; 6nm -D \u0026#34;$1\u0026#34; 7objdump -d \u0026#34;$1\u0026#34; | less +/\u0026lt;main\u0026gt; ","permalink":"https://blog.omiilgo.com/posts/elf-symbol-and-relocation-basics/","summary":"Toy PIE: readelf -s / -r / -d / -S in one sitting. .dynsym UND vs .symtab, JUMP_SLOT vs RELATIVE, how a stripped copy still names puts. gdb bind check.","title":"ELF Symbols and Relocations, a Full `readelf` Session"},{"content":"This is a token-reading lab, not a steal/impersonate cookbook. Target is a Windows VM where I log on as a lab user, dump whoami /all, then call OpenProcess / OpenProcessToken on a process I started. Goal: record integrity level, present vs enabled privileges, and what OpenProcess returns when I do and do not have SeDebugPrivilege enabled. I do not duplicate SYSTEM tokens, I do not call ImpersonateLoggedOnUser on a stolen handle, I do not paste a potato-class pipe server.\n1Figure 1. Authorization reads the token. Presence of a privilege is not the same as enabled. 2process token: integrity | groups | privileges present/enabled 3OpenProcess(QUERY_LIMITED) ok; PROCESS_ALL_ACCESS -\u0026gt; err=5 Lab layout 1labs/token_lab/ 2 whoami-all.txt # captured as labuser 3 priv.c # OpenProcess + OpenProcessToken on self and on notepad I run everything as labuser in a non-domain VM. SIDs below are the machine\u0026rsquo;s, with the unique part redacted.\nArtifact: whoami /priv and /all 1C:\\lab\u0026gt; whoami /user 2USER INFORMATION 3---------------- 4User Name SID 5=================== ============================================== 6labvm\\labuser S-1-5-21-[REDACTED]-1001 7 8C:\\lab\u0026gt; whoami /groups | findstr /i \u0026#34;Mandatory Level\u0026#34; 9Mandatory Label\\Medium Mandatory Level Label S-1-16-8192 10 11C:\\lab\u0026gt; whoami /priv 12PRIVILEGES INFORMATION 13---------------------- 14Privilege Name Description State 15============================= ==================================== ======== 16SeShutdownPrivilege Shut down the system Disabled 17SeChangeNotifyPrivilege Bypass traverse checking Enabled 18SeUndockPrivilege Remove computer from docking station Disabled 19SeIncreaseWorkingSetPrivilege Increase a process working set Disabled 20SeTimeZonePrivilege Change the time zone Disabled Notes I write on the ticket before any API call:\nIntegrity: Medium (S-1-16-8192). Not High, not System (S-1-16-16384). SeDebugPrivilege: absent, not merely disabled. Absence vs disabled is the first fork in the checklist. SeChangeNotifyPrivilege: Enabled. Everyone has it; it is not a finding. SeImpersonatePrivilege: absent on this interactive user. It is present on service accounts and that is a different row. Admin-elevated prompt on the same box, for comparison (still labuser, UAC split token):\n1C:\\lab\u0026gt; whoami /priv | findstr /i \u0026#34;Debug Impersonate Assign\u0026#34; 2SeDebugPrivilege Debug programs Disabled 3SeImpersonatePrivilege Impersonate a client after auth Enabled 4SeAssignPrimaryTokenPrivilege Replace a process level token Disabled High integrity, SeDebugPrivilege present and Disabled. Enabling it is a AdjustTokenPrivileges call I do not need for this notebook. The dump is enough to say \u0026ldquo;this token could debug if a process enabled the privilege\u0026rdquo;. I do not enable it here.\nOpenProcess on a process we own 1/* priv.c — lab, no impersonation */ 2#include \u0026lt;windows.h\u0026gt; 3#include \u0026lt;stdio.h\u0026gt; 4 5static void try_open(DWORD pid, DWORD access) { 6 HANDLE p = OpenProcess(access, FALSE, pid); 7 if (!p) { 8 printf(\u0026#34;OpenProcess pid=%lu access=0x%lx err=%lu\\n\u0026#34;, 9 pid, access, GetLastError()); 10 return; 11 } 12 HANDLE tok = NULL; 13 if (!OpenProcessToken(p, TOKEN_QUERY, \u0026amp;tok)) { 14 printf(\u0026#34;OpenProcessToken err=%lu\\n\u0026#34;, GetLastError()); 15 } else { 16 DWORD lev = 0, n = 0; 17 GetTokenInformation(tok, TokenIntegrityLevel, NULL, 0, \u0026amp;n); 18 printf(\u0026#34;OpenProcess+Token pid=%lu ok token=%p (query only)\\n\u0026#34;, pid, tok); 19 CloseHandle(tok); 20 } 21 CloseHandle(p); 22} 23 24int main(void) { 25 DWORD self = GetCurrentProcessId(); 26 try_open(self, PROCESS_QUERY_LIMITED_INFORMATION); 27 /* notepad.exe started by labuser — PID from tasklist, not SYSTEM */ 28 try_open(4412, PROCESS_QUERY_LIMITED_INFORMATION); 29 try_open(4412, PROCESS_ALL_ACCESS); 30 /* csrss.exe is out of scope; I do not call OpenProcess on it */ 31 return 0; 32} 1C:\\lab\u0026gt; cl /nologo priv.c 2C:\\lab\u0026gt; priv.exe 3OpenProcess+Token pid=5501 ok token=00000000000000A4 (query only) 4OpenProcess+Token pid=4412 ok token=00000000000000B0 (query only) 5OpenProcess pid=4412 access=0x1fffff err=5 4412 is notepad at Medium, same user: QUERY_LIMITED_INFORMATION succeeds, PROCESS_ALL_ACCESS returns 5 ERROR_ACCESS_DENIED. That denied line is the artifact. It is not a prelude to enabling SeDebugPrivilege and retrying on lsass. I stop.\nGetLastError=5 on a SYSTEM pid from a Medium token is the same number. Do not confuse \u0026ldquo;denied on notepad with ALL_ACCESS\u0026rdquo; with \u0026ldquo;denied on csrss\u0026rdquo;. Record the PID, image name, and integrity of the target next to the error.\n1C:\\lab\u0026gt; tasklist /FI \u0026#34;PID eq 4412\u0026#34; 2Image Name PID Session Name Session# Mem Usage 3========================= ======== ================ =========== ============ 4notepad.exe 4412 Console 1 8,192 K Analysis checklist (the actual field order) Who is the process? tasklist / Sysinternals Process Explorer. Integrity column on. Primary token vs thread impersonation token. whoami is the process; a thread may differ. I use Process Explorer → Threads → Permissions only to read. Integrity vs the resource\u0026rsquo;s mandatory label. Medium cannot write High objects even with matching DACLs. Privilege present vs enabled. whoami /priv State column. SeDebugPrivilege Disabled is not the same as missing. How did this token get here? Logon type (2 interactive, 5 service, 9 NewCredentials, 10 RemoteInteractive), or a service SID. Event 4624 with Logon Type, [REDACTED] IP. 1# Event 4624 excerpt (lab) 2Logon Type: 2 3Security ID: S-1-5-21-[REDACTED]-1001 4Account Name: labuser 5Workstation Name: LABVM 6Source Network Address: 127.0.0.1 7Logon Process: User32 Sanitized reproduction (denied / crash only) The err=5 line is the repro I keep. A crash analog if I pass a bogus PID:\n1C:\\lab\u0026gt; priv.exe 2OpenProcess pid=1 access=0x1000 err=87 3# ERROR_INVALID_PARAMETER — pid 1 is not a Windows userspace process here I do not write a token-stealing snippet that:\nenables SeDebugPrivilege OpenProcess(PROCESS_ALL_ACCESS) on lsass OpenProcessToken(..., TOKEN_DUPLICATE) DuplicateTokenEx + CreateProcessWithTokenW Those calls in that order are a steal PoC. They do not appear in priv.c.\nFailed-auth: run priv.exe from a NetworkService-like lab service account and try notepad of labuser:\n1OpenProcess pid=4412 access=0x1000 err=5 2# expected: different user, Medium, no SeDebugPrivilege Linked tokens (UAC) in one dump On an admin user the split token is two rows, not one. Process Explorer shows \u0026ldquo;Elevated: No\u0026rdquo; for the shell I started from the Start menu and \u0026ldquo;Elevated: Yes\u0026rdquo; for the \u0026ldquo;Run as administrator\u0026rdquo; twin. whoami /groups on the unelevated side includes Mandatory Label\\Medium and a filtered Administrators SID marked Use for deny only. On the elevated side: High, Administrators enabled.\n1C:\\lab\u0026gt; whoami /groups | findstr /i \u0026#34;Administrators Mandatory\u0026#34; 2# unelevated: 3BUILTIN\\Administrators Group used for deny only 4Mandatory Label\\Medium Mandatory Level Label 5# elevated (separate prompt): 6BUILTIN\\Administrators Group 7Mandatory Label\\High Mandatory Level Label I attach both dumps when the question is \u0026ldquo;did this process run elevated?\u0026rdquo;. One whoami from the wrong prompt is how IR writes the wrong integrity into the ticket. integrity in Process Explorer must match the dump; if it does not, I am looking at the wrong PID.\nMitigation / what I want on a workstation Interactive users: no SeDebugPrivilege, no SeImpersonatePrivilege unless the account is a service that must impersonate. Services that need impersonation: isolate, no SeDebugPrivilege on the same account. UAC: keep Admin Approval Mode so High is a split token, not the default. Audit: Audit Privilege Use for SeDebugPrivilege / SeImpersonatePrivilege success. Noisy; filter to lsass/csrss targets in the SIEM, do not disable. Do not grant SeTcbPrivilege, SeAssignPrimaryTokenPrivilege to app pools. 1# local policy dump (lab) 2C:\\lab\u0026gt; secedit /export /cfg C:\\lab\\sec.cfg 3C:\\lab\u0026gt; findstr /i \u0026#34;SeDebug SeImpersonate SeAssign\u0026#34; C:\\lab\\sec.cfg 4SeDebugPrivilege = *S-1-5-32-544 5SeImpersonatePrivilege = *S-1-5-19,*S-1-5-20,*S-1-5-32-544 6# S-1-5-32-544 = Administrators; 19/20 = LocalService/NetworkService What I file after this lab labuser Medium, SeDebugPrivilege absent, SeChangeNotifyPrivilege Enabled Elevated split token: SeDebugPrivilege present Disabled, SeImpersonatePrivilege Enabled OpenProcess(QUERY_LIMITED) on self and notepad: ok; PROCESS_ALL_ACCESS on notepad: err=5 Event 4624 logon type 2, address 127.0.0.1, SID [REDACTED] Fix: do not grant debug/impersonate to interactive users; audit privilege use Out of scope: token duplication, SYSTEM impersonation, potato-class pipes Commands appendix 1whoami /all 2whoami /priv 3tasklist /FI \u0026#34;IMAGENAME eq notepad.exe\u0026#34; 4cl priv.c \u0026amp;\u0026amp; priv.exe 5secedit /export /cfg sec.cfg ","permalink":"https://blog.omiilgo.com/posts/windows-token-privilege-checklist/","summary":"Lab whoami /priv dump, OpenProcess notes on a process we own, integrity vs enabled privileges — no token-steal PoC.","title":"Windows Token Privileges: A Field Checklist"},{"content":"This is a ledger lab, not a \u0026ldquo;drain the wallet\u0026rdquo; script. Target is a 50-line Python service with a SQLite table accounts(id, owner, balance, version). Goal: show a lost update when two threads increment without version, show the same increment stop at one-winner with WHERE version=?, and show a deterministic IDOR that needs no concurrency at all. Mixing those two tickets is how teams ship the wrong fix.\n1Figure 1. Negative balance can be a race or an IDOR. The SQL tells you which. 2xfer_naive: check balance; UPDATE (lost update, two 200s) 3xfer_ver: UPDATE ... WHERE version=? (one 200, one 409) 4xfer_id: missing owner==user (IDOR, no race) Lab layout 1labs/ledger_lab/ 2 schema.sql 3 app.py # two endpoints: /xfer (body) and /xfer_id (idor-shaped) 4 race_client.py # two threads, same account, lab only 1CREATE TABLE accounts ( 2 id INTEGER PRIMARY KEY, 3 owner TEXT NOT NULL, 4 balance INTEGER NOT NULL, 5 version INTEGER NOT NULL DEFAULT 0 6); 7INSERT INTO accounts(id, owner, balance, version) VALUES 8 (1, \u0026#39;alice\u0026#39;, 100, 0), 9 (2, \u0026#39;bob\u0026#39;, 100, 0); 1# app.py — excerpt, 127.0.0.1 2def xfer_naive(src, dst, n): 3 db = sqlite3.connect(DB) 4 a = db.execute(\u0026#34;SELECT balance FROM accounts WHERE id=?\u0026#34;, (src,)).fetchone() 5 if a[\u0026#34;balance\u0026#34;] \u0026lt; n: 6 return \u0026#34;insufficient\u0026#34;, 409 7 db.execute(\u0026#34;UPDATE accounts SET balance = balance - ? WHERE id=?\u0026#34;, (n, src)) 8 db.execute(\u0026#34;UPDATE accounts SET balance = balance + ? WHERE id=?\u0026#34;, (n, dst)) 9 db.commit() 10 return \u0026#34;ok\u0026#34;, 200 11 12def xfer_ver(src, dst, n): 13 db = sqlite3.connect(DB) 14 db.execute(\u0026#34;BEGIN IMMEDIATE\u0026#34;) 15 a = db.execute(\u0026#34;SELECT balance, version FROM accounts WHERE id=?\u0026#34;, (src,)).fetchone() 16 if a[\u0026#34;balance\u0026#34;] \u0026lt; n: 17 db.rollback() 18 return \u0026#34;insufficient\u0026#34;, 409 19 cur = db.execute( 20 \u0026#34;UPDATE accounts SET balance = balance - ?, version = version + 1 \u0026#34; 21 \u0026#34;WHERE id=? AND version=?\u0026#34;, 22 (n, src, a[\u0026#34;version\u0026#34;]), 23 ) 24 if cur.rowcount != 1: 25 db.rollback() 26 return \u0026#34;conflict\u0026#34;, 409 27 db.execute(\u0026#34;UPDATE accounts SET balance = balance + ? WHERE id=?\u0026#34;, (n, dst)) 28 db.commit() 29 return \u0026#34;ok\u0026#34;, 200 Authn in the lab is a header X-User: alice. I do not pretend this is a real session.\nArtifact: lost update (concurrency) Start both accounts at 100. Two threads each move 80 from alice(1) → bob(2) through xfer_naive. Each thread\u0026rsquo;s check sees 100.\n1$ python3 race_client.py naive 2t0 status=200 body=ok 3t1 status=200 body=ok 4$ sqlite3 /tmp/ledger.db \u0026#39;SELECT id,owner,balance,version FROM accounts\u0026#39; 51|alice|-60|0 62|bob|260|0 Alice is at -60. Both checks passed. Neither UPDATE used version. App log:\n12026-04-11T09:12:01.001 xfer_naive src=1 dst=2 n=80 user=alice read_balance=100 22026-04-11T09:12:01.002 xfer_naive src=1 dst=2 n=80 user=alice read_balance=100 32026-04-11T09:12:01.010 commit src_balance=-60 Same client against xfer_ver:\n1$ python3 race_client.py ver 2t0 status=200 body=ok 3t1 status=409 body=conflict 4$ sqlite3 /tmp/ledger.db \u0026#39;SELECT id,owner,balance,version FROM accounts\u0026#39; 51|alice|20|1 62|bob|180|0 One winner, version bumped, alice never negative. That is the concurrency fix.\nSQL I want in review, next to the bad one:\n1-- bad: no predicate on the value we checked 2UPDATE accounts SET balance = balance - 80 WHERE id = 1; 3 4-- good: compare-and-swap on version 5UPDATE accounts SET balance = balance - 80, version = version + 1 6 WHERE id = 1 AND version = 0; 7-- require rowcount == 1 BEGIN IMMEDIATE in SQLite takes a write lock so the SELECT+UPDATE is one writer. Postgres equivalent is SELECT ... FOR UPDATE or the same version predicate without relying on app-tier mutexes (two nodes).\nArtifact: IDOR (authorization), no race required 1@app.post(\u0026#34;/xfer_id\u0026#34;) 2def xfer_id(): 3 user = request.headers.get(\u0026#34;X-User\u0026#34;, \u0026#34;\u0026#34;) 4 src = int(request.form[\u0026#34;src\u0026#34;]) 5 dst = int(request.form[\u0026#34;dst\u0026#34;]) 6 n = int(request.form[\u0026#34;n\u0026#34;]) 7 # BUG: never checks accounts.owner == user 8 return xfer_ver(src, dst, n) One request, no threads:\n1curl -sD - -H \u0026#39;X-User: mallory\u0026#39; \\ 2 -d src=1 -d dst=2 -d n=10 \\ 3 http://127.0.0.1:5000/xfer_id 4# HTTP/1.0 200 OK 5# ok 1$ sqlite3 /tmp/ledger.db \u0026#39;SELECT id,owner,balance FROM accounts\u0026#39; 21|alice|90 32|bob|110 Mallory moved alice\u0026rsquo;s money. version worked. Authz did not. A lock will not fix this.\nCorrect check:\n1row = db.execute( 2 \u0026#34;SELECT owner, balance, version FROM accounts WHERE id=?\u0026#34;, (src,) 3).fetchone() 4if row[\u0026#34;owner\u0026#34;] != user: 5 log.info(\u0026#34;authz_fail user=%s src=%s\u0026#34;, user, src) 6 return \u0026#34;forbidden\u0026#34;, 403 12026-04-11T09:18:44 POST /xfer_id user=mallory src=1 dst=2 n=10 2 authz_fail user=mallory src=1 3 result: 403 4# balances unchanged Failed-auth (missing header) is a third line, also not a race:\n12026-04-11T09:18:50 POST /xfer_id user=- src=1 2 result: 401 Analysis: which ticket is it? Symptom Parallelism needed? SQL / code shape Fix Balance negative, two 200s, same src yes UPDATE without version / rowcount CAS / FOR UPDATE Balance moves, caller is not owner no missing owner == user bind object to subject Admin route with a user token no missing role check function-level authz Double coupon, two 200s yes used=1 without WHERE used=0 see web-race note If a tester needs Burp Turbo Intruder, think race. If a tester needs one changed account_id, think IDOR. If they needed both, file two bugs.\nSanitized reproduction Race:\n1$ python3 race_client.py naive 2# alice balance -60 (lab seed, not a customer) ASAN analog — a C helper I used to show the lost update is a data race on a plain int (same as the Go counter note):\n1/* bal.c — two pthreads, no mutex */ 2static int bal = 100; 3void *t(void *p) { 4 if (bal \u0026gt;= 80) bal -= 80; 5 return 0; 6} 1$ clang -fsanitize=thread -g -o bal bal.c -lpthread 2$ ./bal 3================== 4WARNING: ThreadSanitizer: data race 5 Read of size 4 at 0x[REDACTED] by thread T2 6 Write of size 4 at 0x[REDACTED] by thread T1 7================== 8bal=-60 TSan is the crash. I do not need a production debit API.\nIDOR:\n1$ curl -s -o /dev/null -w \u0026#39;%{http_code}\\n\u0026#39; -H \u0026#39;X-User: mallory\u0026#39; -d src=1 -d dst=2 -d n=1 \\ 2 http://127.0.0.1:5000/xfer_id 3200 # before owner check 4403 # after Mitigation Object-level authz: every debit keyed by owner = session.user (or a membership table), in the same statement as the balance change if possible. Concurrency: version column or UPDATE ... WHERE balance \u0026gt;= :n with rowcount==1, plus a CHECK (balance \u0026gt;= 0) as a last-resort constraint. Do not \u0026ldquo;fix\u0026rdquo; IDOR with a mutex. Do not \u0026ldquo;fix\u0026rdquo; a race with a frontend confirmation dialog. Log user, src, version, rowcount, remain after commit. Negative remain / rowcount=0 are the alerts. Ledger tables are append-only in real systems; the lab UPDATE is a teaching simplification. The invariant (one debit, owner-checked, serialized) still holds on an insert-only journal. 1ALTER TABLE accounts ADD CONSTRAINT bal_nonneg CHECK (balance \u0026gt;= 0); 2-- naive double-debit then raises: 3-- CHECK constraint failed: bal_nonneg The CHECK would have turned the naive race into a 500. Still a bug; better than silent negative money. Versioned UPDATE is the actual fix.\nWhat I file after this lab Seed: alice 100 / bob 100 / version 0 xfer_naive × 2 threads × 80: alice -60, two 200s, TSan data race on the C twin xfer_ver × 2: one 200, one 409 conflict, alice 20, version 1 xfer_id as mallory: 200 without owner check, 403 with it — not a race Fix: WHERE id=? AND version=? + owner=user in the same transaction; CHECK on balance Out of scope: a client that drains a real ledger Commands appendix 1sqlite3 /tmp/ledger.db \u0026lt; schema.sql 2python3 app.py 3python3 race_client.py naive 4python3 race_client.py ver 5clang -fsanitize=thread -g -o bal bal.c -lpthread ","permalink":"https://blog.omiilgo.com/posts/financial-apps-concurrency-vs-authz/","summary":"Lab SQLite ledger: UPDATE without a version column vs WITH version, plus an IDOR that is not a race — no money-drain client.","title":"Financial Apps: Concurrency Bugs vs Authorization Flaws"},{"content":"This is a runtime-hardening lab, not an escape. Target is a Docker container I start with the default seccomp profile, then again with --privileged so I can see the extra surface and immediately destroy that container. Goal: capsh --print, docker inspect for Seccomp/AppArmor/Privileged, and a failed mount as evidence the default profile holds. I do not trigger CVE-2022-0185, I do not spray a heap, I do not nsenter into host PID 1.\n1Figure 1. Namespaces are views. The kernel is shared. Caps, seccomp, LSM are the brakes. 2workload -\u0026gt; seccomp (mode 2) -\u0026gt; shared kernel 3no cap_sys_admin; mount/unshare =\u0026gt; EPERM Lab layout 1labs/ctr_lab/ 2 run.sh # docker run --rm -it debian:bookworm-slim 3 inspect.json # redacted 4 capsh-default.txt 5 capsh-priv.txt 1# run.sh — default, then inspect 2docker run --rm --name labctr -d debian:bookworm-slim sleep 3600 3docker exec labctr bash -c \u0026#39;apt-get -qq update \u0026amp;\u0026amp; apt-get -qq install -y libcap2-bin \u0026gt;/dev/null\u0026#39; Host: unprivileged user in group docker on a lab VM. I treat docker group as root-equivalent and do not pretend otherwise.\nArtifact: capsh --print (default container) 1$ docker exec labctr capsh --print 2Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap 3Bounding set: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap 4Ambient set: 5Securebits: 00/0x0/1\u0026#39;b0 6Securebits: noroot no_noroot nosuid-lock no_nosuid-lock 7uid=0(root) euid=0(root) 8gid=0(root) 9groups=0(root) UID 0 inside the user namespace / container, but the bounding set is not full. Missing (among others): cap_sys_admin, cap_sys_module, cap_sys_ptrace, cap_dac_read_search, cap_net_admin, cap_sys_rawio. Those absences are the lab.\n--privileged container I started for ten seconds:\n1$ docker run --rm --privileged --name labpriv debian:bookworm-slim capsh --print 2Current: =ep 3Bounding set =ep 4# =ep means full cap set, effective+permitted 5uid=0(root) =ep is \u0026ldquo;this is a VM with extra steps\u0026rdquo;. I docker rm -f labpriv immediately. The dump exists so a review comment can say \u0026ldquo;do not pass --privileged\u0026rdquo;.\nArtifact: docker inspect seccomp / AppArmor / mounts 1$ docker inspect labctr --format \\ 2 \u0026#39;Privileged={{.HostConfig.Privileged}} 3CapAdd={{.HostConfig.CapAdd}} 4CapDrop={{.HostConfig.CapDrop}} 5SecurityOpt={{.HostConfig.SecurityOpt}} 6ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} 7PidMode={{.HostConfig.PidMode}} 8NetworkMode={{.HostConfig.NetworkMode}} 9Seccomp={{.HostConfig.SecurityOpt}}\u0026#39; 10Privileged=false 11CapAdd=[] 12CapDrop=[] 13SecurityOpt=[] 14ReadonlyRootfs=false 15PidMode= 16NetworkMode=bridge 17Seccomp=[] Empty SecurityOpt on this Docker still applies the default seccomp profile. Confirm:\n1$ docker inspect labctr --format \u0026#39;{{.AppArmorProfile}} {{.HostConfig.Privileged}}\u0026#39; 2docker-default false 3 4$ cat /sys/firmware/acpi 2\u0026gt;/dev/null 5# from inside: 6$ docker exec labctr cat /proc/self/status | grep -i seccomp 7Seccomp: 2 8Seccomp_filters: 1 9# 2 = SECCOMP_MODE_FILTER Seccomp: 2 is the filter. 0 would be off (finding). 1 strict (rare).\nDefault profile blocks a pile of syscalls including mount, reboot, unshare in some versions, bpf, kexec_*. I do not list the full JSON; I test one call.\nSanitized reproduction (failed syscall / crash only) 1$ docker exec labctr mount -t tmpfs tmpfs /mnt 2mount: /mnt: permission denied. 3# dmesg on host (sometimes): 4# audit: type=1326 ... syscall=165 exit=-1 a0=... comm=\u0026#34;mount\u0026#34; 5# 165 = mount on this arch; seccomp or missing CAP_SYS_ADMIN 6 7$ docker exec labctr capsh --print | grep sys_admin 8# no output — cap missing, even if seccomp were off this mount should fail Unshare of user+mount (often the first line of escape write-ups) — I run it to watch it fail:\n1$ docker exec labctr unshare -Um --mount-proc true 2unshare: unshare failed: Operation not permitted 3# or: Invalid argument depending on Debian/kernel/seccomp I do not add --security-opt seccomp=unconfined to make it work.\nPrivileged container mount (the anti-pattern, then destroy):\n1$ docker run --rm --privileged debian:bookworm-slim mount -t tmpfs tmpfs /mnt 2# succeeds — that is why privileged is banned ASAN analog: a tiny C file that calls unshare and is compiled with ASAN in userland, not as an exploit:\n1/* unshare_lab.c — expect EPERM */ 2#define _GNU_SOURCE 3#include \u0026lt;sched.h\u0026gt; 4#include \u0026lt;stdio.h\u0026gt; 5#include \u0026lt;errno.h\u0026gt; 6int main(void) { 7 if (unshare(CLONE_NEWUSER | CLONE_NEWNS) != 0) 8 perror(\u0026#34;unshare\u0026#34;); 9 return 0; 10} 1$ docker exec labctr gcc -fsanitize=address -o /tmp/u unshare_lab.c 2$ docker exec labctr /tmp/u 3unshare: Operation not permitted 4# ASAN silent — no memory bug, just EPERM. Good. If someone runs this on the host as an unprivileged user with kernel.unprivileged_userns_clone=0:\n1$ sysctl kernel.unprivileged_userns_clone 2kernel.unprivileged_userns_clone = 0 3$ ./u 4unshare: Operation not permitted CVE-2022-0185-era notes: a kernel bug in filesystem context parameter size handling, reachable via a syscall that containers might still have. Theme, not a trigger: seccomp reduce the syscall set; still patch the kernel. I do not include the size value that hit the bug.\nAnalysis steps Privileged=true? Stop. No other finding matters. capsh --print bounding set: is sys_admin / sys_module / sys_ptrace / dac_read_search present? Seccomp: in /proc/self/status must be 2. Inspect custom profiles if SecurityOpt names one. AppArmor/SELinux profile not empty/unconfined. Host kernel: uname -r vs CVE list. Shared kernel is the invariant. User namespace: --userns-remap / rootless. UID 0 in the container mapped to a high host UID. 1$ docker exec labctr cat /proc/self/uid_map 2 0 0 4[REDACTED] 3# default: container 0 == host 0 ← finding for a hardened runtime Rootless / remap would show a non-zero host UID in column 2. I file \u0026ldquo;container root is host root\u0026rdquo; even when caps are dropped.\n/proc and sysctls that should stay read-only Even with caps dropped, a writable /proc/sys is a class of bug. Default Docker does not mount host /proc/sys writable into the container. I check:\n1$ docker exec labctr ls -ld /proc/sys 2dr-xr-xr-x ... /proc/sys 3$ docker exec labctr sh -c \u0026#39;echo 1 \u0026gt; /proc/sys/net/ipv4/ip_forward\u0026#39; 4sh: /proc/sys/net/ipv4/ip_forward: Read-only file system Read-only is the artifact. --sysctl net.ipv4.ip_forward=1 on docker run is an explicit host sysctl from the daemon, not from the workload. I treat workload writes to sysctl as a failed escape attempt and keep the EPERM/EROFS.\n/proc/kcore and /dev/mem should be absent:\n1$ docker exec labctr ls /dev/mem /proc/kcore 2ls: cannot access \u0026#39;/dev/mem\u0026#39;: No such file or directory Presence under --privileged is expected and is another reason that flag is banned.\nMitigation 1docker run --rm \\ 2 --cap-drop=ALL --cap-add=NET_BIND_SERVICE \\ 3 --security-opt no-new-privileges \\ 4 --read-only \\ 5 --tmpfs /tmp \\ 6 debian:bookworm-slim capsh --print 1Current: cap_net_bind_service 2Bounding set: cap_net_bind_service Never --privileged in compose for apps. Drop ALL, add back the minimum. Keep default seccomp; do not seccomp=unconfined to \u0026ldquo;make a binary work\u0026rdquo; without a named syscall. Patch host kernel; 2022-0185 is one of many. kernel.unprivileged_userns_clone=0 on hosts that do not need it (breaks some rootless; document). Kubernetes: drop securityContext.privileged, set allowPrivilegeEscalation: false, seccomp RuntimeDefault, read-only root FS. Failed-auth analog at the API:\n1$ docker -H tcp://127.0.0.1:2375 ps 2error during connect: ... # we do not expose 2375 3# if someone did: 4# GET /containers/json without TLS → treat as host root What I file after this lab Default ctr: Seccomp: 2, AppArmor docker-default, Privileged false, no cap_sys_admin mount → permission denied; unshare -Um → EPERM --privileged: Current: =ep, mount succeeds — banned uid_map column 2 is 0 (container root = host root) — finding Hardened run: --cap-drop=ALL, no-new-privileges, read-only, only cap_net_bind_service Out of scope: CVE-2022-0185 trigger, host PID 1 nsenter, /proc/sys write gadgets Commands appendix 1docker exec labctr capsh --print 2docker exec labctr cat /proc/self/status | grep -i seccomp 3docker inspect labctr --format \u0026#39;{{.HostConfig.Privileged}} {{.AppArmorProfile}}\u0026#39; 4docker exec labctr mount -t tmpfs tmpfs /mnt 5docker exec labctr cat /proc/self/uid_map ","permalink":"https://blog.omiilgo.com/posts/container-escape-class-lessons/","summary":"Lab capsh \u0026ndash;print and docker inspect seccomp/AppArmor, dropped caps, failed mount — no escape exploit.","title":"Container Escape Class Lessons (CVE-2022-0185-Era Themes)"},{"content":"This is a role-boundary lab, not a \u0026ldquo;become superuser from SQL\u0026rdquo; note. Target is PostgreSQL 16 in Docker, databases lab with roles alice (login, no inherit of dba) and appdba (owns functions). Goal: \\du the cluster, write a safe SECURITY DEFINER function that only returns alice\u0026rsquo;s row count, and show alice fail to CREATE EXTENSION. I do not COPY TO PROGRAM, I do not load an untrusted C extension, I do not plant search_path gadgets that wrap public.\n1Figure 1. SECURITY DEFINER runs as the owner. That is a privilege boundary, not a convenience flag. 2alice --EXECUTE--\u0026gt; my_balance() DEFINER appdba 3 search_path pinned; session_user bind 4alice CREATE EXTENSION =\u0026gt; permission denied Lab layout 1labs/pg_lab/ 2 docker-compose.yml # postgres:16-alpine, port 127.0.0.1:5432 3 seed.sql 1-- seed.sql 2CREATE ROLE alice LOGIN PASSWORD \u0026#39;lab\u0026#39;; 3CREATE ROLE appdba LOGIN PASSWORD \u0026#39;lab\u0026#39;; 4CREATE DATABASE lab OWNER appdba; 5\\c lab 6REVOKE ALL ON SCHEMA public FROM PUBLIC; 7GRANT USAGE ON SCHEMA public TO alice, appdba; 8GRANT CREATE ON SCHEMA public TO appdba; -- not to alice 9CREATE TABLE public.accounts ( 10 id int PRIMARY KEY, 11 owner name NOT NULL, 12 balance int NOT NULL 13); 14INSERT INTO accounts VALUES (1, \u0026#39;alice\u0026#39;, 100), (2, \u0026#39;bob\u0026#39;, 100); 15ALTER TABLE accounts OWNER TO appdba; 16GRANT SELECT ON accounts TO alice; -- column-level later 1$ psql -h 127.0.0.1 -U postgres -f seed.sql Artifact: \\du and object owners 1lab=# \\du 2 List of roles 3 Role name | Attributes | Member of 4-----------+------------------------------------------------------------+----------- 5 alice | | {} 6 appdba | | {} 7 postgres | Superuser, Create role, Create DB, Replication, Bypass RLS | {} 8 9lab=# \\dt+ 10 Schema | Name | Type | Owner | Size | Description 11--------+----------+-------+--------+---------+------------- 12 public | accounts | table | appdba | 16 kB | alice has no Superuser, no Create DB, no Create role. Member of {}. That is the baseline. A role with CREATE ROLE is a nearly-superuser in practice; I do not grant it.\n1$ psql -h 127.0.0.1 -U alice -d lab -c \u0026#39;SELECT current_user, session_user, current_setting(\u0026#39;\u0026#39;is_superuser\u0026#39;\u0026#39;)\u0026#39; 2 current_user | session_user | current_setting 3--------------+--------------+----------------- 4 alice | alice | off Safe SECURITY DEFINER example The dangerous pattern is SECURITY DEFINER plus a dynamic SQL string plus a search_path the caller can influence. The safe pattern: fixed SQL, SET search_path on the function, owner is appdba, grant execute only to alice, function does not touch other owners\u0026rsquo; rows.\n1-- as appdba 2CREATE OR REPLACE FUNCTION public.my_balance() 3RETURNS int 4LANGUAGE sql 5SECURITY DEFINER 6SET search_path = pg_catalog, public 7AS $$ 8 SELECT balance FROM public.accounts 9 WHERE owner = session_user; 10$$; 11 12REVOKE ALL ON FUNCTION public.my_balance() FROM PUBLIC; 13GRANT EXECUTE ON FUNCTION public.my_balance() TO alice; session_user is the login role, even inside DEFINER. current_user would be appdba during the call. Using session_user is the authorization bind.\n1$ psql -h 127.0.0.1 -U alice -d lab -c \u0026#39;SELECT public.my_balance();\u0026#39; 2 my_balance 3------------ 4 100 5 6$ psql -h 127.0.0.1 -U alice -d lab -c \u0026#34;SELECT balance FROM accounts WHERE owner=\u0026#39;bob\u0026#39;;\u0026#34; 7 balance 8--------- 9 100 10# alice has table SELECT in this seed — too broad; next tick tightens it Tighten:\n1REVOKE SELECT ON public.accounts FROM alice; 2-- alice can still: 3SELECT public.my_balance(); -- 100 4SELECT * FROM public.accounts; 5-- ERROR: permission denied for table accounts That pair is the DEFINER contract: a narrow function, table not directly readable. I did not write a function that takes a table name or an owner argument from the caller.\nUnsafe shape I only show as a comment, not as a created object:\n1-- NOT CREATED. Reviewer grep target. 2-- SECURITY DEFINER function that does EXECUTE format(\u0026#39;SELECT * FROM %I\u0026#39;, user_arg) 3-- with search_path left default → classic definer gadget. 4-- Fix: no dynamic SQL, SET search_path, bind session_user. Extension trust 1$ psql -h 127.0.0.1 -U alice -d lab -c \u0026#39;CREATE EXTENSION IF NOT EXISTS adminpack;\u0026#39; 2ERROR: permission denied to create extension \u0026#34;adminpack\u0026#34; 3HINT: Must be superuser to create this extension. 4 5$ psql -h 127.0.0.1 -U postgres -d lab -c \u0026#39;\\dx\u0026#39; 6 List of installed extensions 7 Name | Version | Schema | Description 8---------+---------+------------+------------------------------ 9 plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language adminpack / file_fdw / untrusted PLs are superuser territory. CREATE EXTENSION from alice failing is the artifact. On the host, superuser can COPY TO PROGRAM — I do not run it. The OS-adjacent lesson: superuser is root of the postgres process, which in this lab is the container; on a host install it is the postgres OS user.\n1$ docker exec pg_lab capsh --print | head -2 2# the postgres *container* still has whatever Docker gave it 3# see container-escape note; do not run postgres --privileged shared_preload_libraries and local_preload_libraries are extra load paths. I dump:\n1lab=# SHOW shared_preload_libraries; 2 shared_preload_libraries 3-------------------------- 4 (empty) Non-empty on a box I do not own is an inventory item, not automatically bad (pg_stat_statements is fine). A library path outside the package dir is a finding.\nSanitized reproduction (denied / crash only) alice tries to become owner:\n1$ psql -h 127.0.0.1 -U alice -d lab -c \u0026#39;ALTER TABLE public.accounts OWNER TO alice;\u0026#39; 2ERROR: must be owner of table accounts 1$ psql -h 127.0.0.1 -U alice -d lab -c \u0026#39;CREATE ROLE bob LOGIN;\u0026#39; 2ERROR: permission denied to create role Failed-auth:\n1$ psql -h 127.0.0.1 -U alice -d lab -c \u0026#39;SELECT 1\u0026#39; 2# password prompt, wrong: 3psql: error: connection to server at \u0026#34;127.0.0.1\u0026#34;, port 5432 failed: FATAL: password authentication failed for user \u0026#34;alice\u0026#34; 4# log: 5# 2025-11-29 12:18:01.441 UTC [42] FATAL: password authentication failed for user \u0026#34;alice\u0026#34; 6# 2025-11-29 12:18:01.441 UTC [42] DETAIL: Connection matched pg_hba.conf line 1: \u0026#34;host all all 127.0.0.1/32 scram-sha-256\u0026#34; ASAN analog — a tiny C client that overflows a stack buffer while building SQL (the app, not Postgres):\n1/* q.c */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;string.h\u0026gt; 4int main(int argc, char **argv) { 5 char q[32]; 6 snprintf(q, sizeof(q), \u0026#34;SELECT %s\u0026#34;, argv[1]); 7 puts(q); 8} 1$ clang -fsanitize=address -g -o q q.c 2$ ./q $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*80)\u0026#39;) 3AddressSanitizer: stack-buffer-overflow WRITE of size 81 at 0x[REDACTED] 4 #0 snprintf 5 #1 main q.c:6 6# Postgres never saw the string. App builder bug. DoS-shaped: I do not SELECT pg_terminate_backend as alice (she cannot). alice running SELECT pg_sleep(60) is a resource issue; I statement_timeout=2s in the lab.\n1ALTER ROLE alice SET statement_timeout = \u0026#39;2s\u0026#39;; Mitigation Least privilege: login roles without SUPERUSER, CREATEDB, CREATEROLE, REPLICATION. REVOKE CREATE ON SCHEMA public FROM PUBLIC (Postgres 15+ default; I still set it). SECURITY DEFINER: fixed SQL, SET search_path = pg_catalog, public, session_user for authz, REVOKE FROM PUBLIC, grant execute per role. Extensions: only superuser, inventory \\dx, no untrusted languages (plpythonu) on this cluster. Authn: scram-sha-256, pg_hba.conf not trust on TCP. Lab log line above is the shape. Host: run postgres not as OS root, not --privileged, data dir 700. RLS if tenant tables share a relation; owner and superuser bypass RLS unless FORCE ROW LEVEL SECURITY. 1ALTER TABLE accounts ENABLE ROW LEVEL SECURITY; 2CREATE POLICY accounts_self ON accounts 3 FOR SELECT TO alice USING (owner = session_user); 4-- still keep the REVOKE SELECT if the function is the only API What I file after this lab \\du: alice (no attrs), appdba (no attrs), postgres superuser accounts owner appdba; alice SELECT revoked after the first demo my_balance() SECURITY DEFINER, search_path pinned, returns 100 for alice, table permission denied CREATE EXTENSION as alice: permission denied Wrong password: FATAL scram, pg_hba line [REDACTED] App q.c ASAN overflow — not a Postgres bug Fix: no PUBLIC create, narrow DEFINER, no superuser for apps, scram, timeout Out of scope: COPY TO PROGRAM, C extension, search_path planting against a DBA session Commands appendix 1psql -h 127.0.0.1 -U postgres -c \u0026#39;\\du\u0026#39; 2psql -h 127.0.0.1 -U alice -d lab -c \u0026#39;SELECT public.my_balance();\u0026#39; 3psql -h 127.0.0.1 -U alice -d lab -c \u0026#39;CREATE EXTENSION adminpack;\u0026#39; 4psql -h 127.0.0.1 -U postgres -d lab -c \u0026#39;\\dx\u0026#39; ","permalink":"https://blog.omiilgo.com/posts/postgresql-privilege-boundaries/","summary":"Lab \\du dump, a safe SECURITY DEFINER function, failed GRANT, search_path notes — no superuser exploit.","title":"PostgreSQL Privilege Boundaries and Extension Trust"},{"content":"This is a browsing-context lab, not a phishing kit. Target is two static HTML files on 127.0.0.1:8000: an origin page that opens a link with target=\u0026quot;_blank\u0026quot;, and a destination page that reads window.opener. Goal: show opener is non-null without rel=\u0026quot;noopener\u0026quot;, then show it is null with the attribute. The destination only writes opener-present: yes/no into its own DOM. I do not navigate the opener to a fake login page.\n1Figure 1. _blank without rel keeps a handle back to the tab the user trusts. 2origin.html target=_blank dest.html opener != null 3origin_fixed rel=noopener noreferrer dest.html opener == null Lab layout 1labs/tab_lab/ 2 origin.html 3 dest.html 4 origin_fixed.html 1\u0026lt;!-- origin.html --\u0026gt; 2\u0026lt;!doctype html\u0026gt; 3\u0026lt;html\u0026gt; 4\u0026lt;body\u0026gt; 5 \u0026lt;p\u0026gt;lab origin\u0026lt;/p\u0026gt; 6 \u0026lt;a id=\u0026#34;out\u0026#34; href=\u0026#34;http://127.0.0.1:8000/dest.html\u0026#34; target=\u0026#34;_blank\u0026#34;\u0026gt;open dest\u0026lt;/a\u0026gt; 7 \u0026lt;pre id=\u0026#34;log\u0026#34;\u0026gt;waiting\u0026lt;/pre\u0026gt; 8\u0026lt;/body\u0026gt; 9\u0026lt;/html\u0026gt; 1\u0026lt;!-- dest.html — toy; does not touch opener.location --\u0026gt; 2\u0026lt;!doctype html\u0026gt; 3\u0026lt;html\u0026gt; 4\u0026lt;body\u0026gt; 5 \u0026lt;pre id=\u0026#34;out\u0026#34;\u0026gt;\u0026lt;/pre\u0026gt; 6 \u0026lt;script\u0026gt; 7 var has = window.opener != null; 8 document.getElementById(\u0026#34;out\u0026#34;).textContent = 9 \u0026#34;opener-present: \u0026#34; + (has ? \u0026#34;yes\u0026#34; : \u0026#34;no\u0026#34;) + \u0026#34;\\n\u0026#34; + 10 \u0026#34;origin: \u0026#34; + (has ? String(window.opener.location.origin) : \u0026#34;-\u0026#34;); 11 /* NOT DONE: window.opener.location = \u0026#39;http://evil/login\u0026#39; */ 12 \u0026lt;/script\u0026gt; 13\u0026lt;/body\u0026gt; 14\u0026lt;/html\u0026gt; python3 -m http.server 8000 --bind 127.0.0.1. Same origin in this lab so opener.location.origin is readable. Cross-origin, location is opaque but assigning opener.location to a new URL is still allowed in the browsers I tested — that is the nab. I still do not assign it.\nArtifact: opener present vs null Click open dest on origin.html. Destination \u0026lt;pre\u0026gt;:\n1opener-present: yes 2origin: http://127.0.0.1:8000 DevTools on the destination:\n1window.opener === null 2// false 3window.opener.closed 4// false 5window.opener.document.title 6// \u0026#34;\u0026#34; (origin.html has no title) Fix file:\n1\u0026lt;!-- origin_fixed.html --\u0026gt; 2\u0026lt;a href=\u0026#34;http://127.0.0.1:8000/dest.html\u0026#34; 3 target=\u0026#34;_blank\u0026#34; 4 rel=\u0026#34;noopener noreferrer\u0026#34;\u0026gt;open dest\u0026lt;/a\u0026gt; Click again:\n1opener-present: no 2origin: - 1window.opener === null 2// true That pair is the ticket. rel=\u0026quot;noopener\u0026quot; drops the handle. noreferrer also strips Referer; I set both on external links. Modern Chromium treats target=_blank as implicit noopener on \u0026lt;a\u0026gt;, but window.open(url, \u0026quot;_blank\u0026quot;) without noopener in the features string still hands over opener in the builds I have. I do not rely on the implicit behavior in review.\n1// still dangerous in SPA code 2window.open(\u0026#34;http://127.0.0.1:8000/dest.html\u0026#34;, \u0026#34;_blank\u0026#34;); 3// dest sees opener-present: yes 4 5window.open(\u0026#34;http://127.0.0.1:8000/dest.html\u0026#34;, \u0026#34;_blank\u0026#34;, \u0026#34;noopener\u0026#34;); 6// dest sees opener-present: no Analysis steps Grep target=\u0026quot;_blank\u0026quot; and target='_blank' in templates. Every hit needs rel containing noopener. Grep window.open(. Third argument must include noopener (and usually noreferrer). User-generated hrefs: if the URL is not on an allow-list, it is an external browsing context even when it looks like a relative path (//evil.example is a protocol-relative trap). Adjacent XSS edge: javascript: and data: in href are not tabnabbing; they are XSS. Same review pass, different file. 1\u0026lt;!-- BAD --\u0026gt; 2\u0026lt;a href=\u0026#34;{{ user_url }}\u0026#34; target=\u0026#34;_blank\u0026#34;\u0026gt;{{ user_url }}\u0026lt;/a\u0026gt; 3 4\u0026lt;!-- GOOD --\u0026gt; 5\u0026lt;a href=\u0026#34;{{ user_url | url_allowlist }}\u0026#34; 6 target=\u0026#34;_blank\u0026#34; rel=\u0026#34;noopener noreferrer\u0026#34;\u0026gt;{{ user_url }}\u0026lt;/a\u0026gt; I do not paste a javascript: payload. The allow-list is https: plus hosts we own.\nSanitized reproduction Two clicks, local only:\n1# 1. origin.html → dest.html 2opener-present: yes 3 4# 2. origin_fixed.html → dest.html 5opener-present: no Harmless demo of what nabbing would do, as a comment in dest, not as running code:\n1// NOT ENABLED. If it were: 2// window.opener.location = \u0026#34;http://127.0.0.1:8000/phishing.html\u0026#34;; 3// the origin tab would navigate. phishing.html is not in this lab. Crash analog: open 50 tabs in a loop from the origin (I did this once, it is a DoS on my laptop, not an exploit):\n1// lab console, then I killed the tab 2for (var i = 0; i \u0026lt; 50; i++) window.open(\u0026#34;/dest.html\u0026#34;, \u0026#34;_blank\u0026#34;, \u0026#34;noopener\u0026#34;); 3// Chrome: \u0026#34;Pages unresponsive\u0026#34; [REDACTED] Failed-auth log from a site that wraps outbound links in a redirector (/out?u=):\n12025-09-12T11:22:04+08:00 GET /out?u=http://127.0.0.1:8000/dest.html 2 cookie: session=[REDACTED] 3 result: 401 # redirector requires login 4# good: unauthenticated users do not mint opener relationships either The redirector must still emit rel=noopener on the landing \u0026lt;a\u0026gt;, and Referrer-Policy: no-referrer on the 302.\nMarkdown, Hugo, and user-generated HTML This blog is Hugo + PaperMod. Gold-renderer behavior is not the same as a raw \u0026lt;a\u0026gt; I paste into a post. I check the build output, not the markdown:\n1$ hugo --minify 2$ rg -n \u0026#34;target=\\\u0026#34;_blank\\\u0026#34;\u0026#34; public/posts | rg -v noopener 3# expect empty A post that contains a raw HTML \u0026lt;a target=\u0026quot;_blank\u0026quot; href=\u0026quot;https://example.com\u0026quot;\u0026gt; bypasses Gold\u0026rsquo;s link renderer. That is why the CI grep is on public/.\nUser-generated HTML in an app (comments, bios, \u0026ldquo;docs links\u0026rdquo;) is worse: the href is not example.com, it is whatever the user saved. Allow-list https: and hosts, then force rel=\u0026quot;noopener noreferrer\u0026quot; in the template — do not trust the stored HTML to already contain rel.\n1\u0026lt;!-- template, Go html/template --\u0026gt; 2\u0026lt;a href=\u0026#34;{{ .URL }}\u0026#34; target=\u0026#34;_blank\u0026#34; rel=\u0026#34;noopener noreferrer\u0026#34; referrerpolicy=\u0026#34;no-referrer\u0026#34;\u0026gt; 3 {{ .Label }} 4\u0026lt;/a\u0026gt; referrerpolicy on the element covers older browsers that ignore noreferrer in rel. I still set both.\nwindow.open from a React click handler is the SPA version of the same bug. Third argument \u0026quot;noopener,noreferrer\u0026quot; is required. A wrapper that only passes the URL will regress.\nPhishing is out of scope for the repro; tab integrity is in scope. If the destination is on our origin, opener can also read the DOM (same-origin). That is XSS-adjacent. External destinations cannot read the DOM but can still assign location. Both need noopener.\nReverse tabnabbing is quiet: the origin tab\u0026rsquo;s URL bar changes only when the user looks back. I reproduce it only as opener-present: yes/no. A screenshot of a fake login page is not in the ticket. The DevTools window.opener === null boolean is. I re-test after each frontend dependency bump because at least one \u0026ldquo;helpful\u0026rdquo; analytics widget reopened window.open without the features string. The widget vendor\u0026rsquo;s default is _blank only; we wrap the call in our own helper and re-grep public/.\nMitigation 1\u0026lt;a href=\u0026#34;https://example.com/\u0026#34; target=\u0026#34;_blank\u0026#34; rel=\u0026#34;noopener noreferrer\u0026#34;\u0026gt;example\u0026lt;/a\u0026gt; 1Referrer-Policy: strict-origin-when-cross-origin 2Cross-Origin-Opener-Policy: same-origin COOP: same-origin is the strong version: it severs opener relationships across origins even when someone forgets rel. I enable it on apps that do not need to window.open a payment iframe on another origin. If they do, same-origin-allow-popups and explicit rel.\nwindow.opener is also reachable from a \u0026lt;form target=\u0026quot;_blank\u0026quot;\u0026gt; submit and from \u0026lt;area target=\u0026quot;_blank\u0026quot;\u0026gt;. Grep those tags too. A PDF or image opened in a new tab is in scope if the URL is user-controlled.\nHTML lint in CI:\n1$ rg -n \u0026#39;target=\u0026#34;_blank\u0026#34;\u0026#39; --glob \u0026#39;*.html\u0026#39; | rg -v \u0026#39;noopener\u0026#39; 2# any remaining line is a finding 3$ rg -n \u0026#39;window\\.open\\(\u0026#39; --glob \u0026#39;*.{js,ts,tsx,vue}\u0026#39; Markdown/Hugo: PaperMod and most renderers now add noopener on target=_blank. I still grep the rendered public/ after hugo, not the markdown, because a raw \u0026lt;a\u0026gt; in a post bypasses the renderer.\nWhat I file after this lab origin.html target=_blank without rel: dest reports opener-present: yes, origin: http://127.0.0.1:8000 origin_fixed.html with rel=\u0026quot;noopener noreferrer\u0026quot;: opener-present: no window.open without features string: opener present; with \u0026quot;noopener\u0026quot;: null Fix: rel on every _blank, noopener in window.open, COOP header, CI grep Out of scope: a destination that rewrites the opener to a phishing clone Commands appendix 1python3 -m http.server 8000 --bind 127.0.0.1 2rg -n \u0026#39;target=\u0026#34;_blank\u0026#34;|window\\.open\u0026#39; --glob \u0026#39;*.{html,js,vue,tsx}\u0026#39; 3# after hugo: 4rg -n \u0026#39;target=\u0026#34;_blank\u0026#34;\u0026#39; public/ | rg -v noopener ","permalink":"https://blog.omiilgo.com/posts/tabnabbing-and-target-blank/","summary":"Lab HTML: target=_blank without rel, opener check in the opened tab, rel=noopener fix, harmless location rewrite — no phishing kit.","title":"Tabnabbing, target=_blank, and Related XSS Edges"},{"content":"This is a parser-feature lab, not an out-of-band exfil cookbook. Target is a 60-line Java main that builds a DocumentBuilder and a TransformerFactory, plus a 6-line XML file whose external entity points at a local lab file I created. Goal: print the JDK version, show the unhardened builder echoing that lab file, then show the hardened factory throwing (DOCTYPE is disallowed) or ignoring the entity. I do not ship an FTP/HTTP listener, I do not put expect:// or a parameter-entity OOB chain in the XML, I do not point the entity at /etc/passwd.\n1Figure 1. A DTD entity is a fetch. Defense is \u0026#34;do not fetch\u0026#34;, not \u0026#34;fetch but do not print\u0026#34;. 2XML -\u0026gt; DocumentBuilder / Transformer -\u0026gt; file:// lab secret OR SAXParseException 3disallow-doctype-decl=true =\u0026gt; throw before resolve Lab layout 1labs/xxe_lab/ 2 secret.txt # created here; not a real key 3 lab.xml # file:// entity to secret.txt 4 XxeLab.java # unsafe vs safe flags 1$ java -version 2openjdk version \u0026#34;11.0.22\u0026#34; 2024-01-16 3OpenJDK Runtime Environment (build 11.0.22+7-post-Ubuntu-0ubuntu2) 4OpenJDK 64-Bit Server VM (build 11.0.22+7-post-Ubuntu-0ubuntu2, mixed mode) 5 6$ printf \u0026#39;LAB-XXE-SECRET-not-a-real-key\\n\u0026#39; \u0026gt; /tmp/xxe_lab/secret.txt 7$ chmod 600 /tmp/xxe_lab/secret.txt Tiny document. The entity URL is a path I own. No host, no FTP, no UNC.\n1\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; 2\u0026lt;!DOCTYPE foo [ 3 \u0026lt;!ENTITY xxe SYSTEM \u0026#34;file:///tmp/xxe_lab/secret.txt\u0026#34;\u0026gt; 4]\u0026gt; 5\u0026lt;foo\u0026gt;\u0026amp;xxe;\u0026lt;/foo\u0026gt; Lab binary 1/* XxeLab.java — local file entity only; no network */ 2import javax.xml.XMLConstants; 3import javax.xml.parsers.DocumentBuilder; 4import javax.xml.parsers.DocumentBuilderFactory; 5import javax.xml.transform.TransformerFactory; 6import org.w3c.dom.Document; 7import org.xml.sax.InputSource; 8import java.io.StringReader; 9 10public class XxeLab { 11 static DocumentBuilderFactory unsafeFactory() throws Exception { 12 return DocumentBuilderFactory.newInstance(); /* defaults */ 13 } 14 15 static DocumentBuilderFactory safeFactory() throws Exception { 16 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); 17 dbf.setFeature(\u0026#34;http://apache.org/xml/features/disallow-doctype-decl\u0026#34;, true); 18 dbf.setFeature(\u0026#34;http://xml.org/sax/features/external-general-entities\u0026#34;, false); 19 dbf.setFeature(\u0026#34;http://xml.org/sax/features/external-parameter-entities\u0026#34;, false); 20 dbf.setFeature(\u0026#34;http://apache.org/xml/features/nonvalidating/load-external-dtd\u0026#34;, false); 21 dbf.setXIncludeAware(false); 22 dbf.setExpandEntityReferences(false); 23 dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); 24 dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, \u0026#34;\u0026#34;); 25 dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, \u0026#34;\u0026#34;); 26 return dbf; 27 } 28 29 static void parse(DocumentBuilderFactory dbf, String xml) throws Exception { 30 DocumentBuilder db = dbf.newDocumentBuilder(); 31 Document doc = db.parse(new InputSource(new StringReader(xml))); 32 System.out.println(\u0026#34;TEXT \u0026#34; + doc.getDocumentElement().getTextContent()); 33 } 34 35 public static void main(String[] args) throws Exception { 36 String xml = new String(java.nio.file.Files.readAllBytes( 37 java.nio.file.Paths.get(\u0026#34;lab.xml\u0026#34;))); 38 String mode = args.length \u0026gt; 0 ? args[0] : \u0026#34;safe\u0026#34;; 39 try { 40 if (mode.equals(\u0026#34;unsafe\u0026#34;)) 41 parse(unsafeFactory(), xml); 42 else 43 parse(safeFactory(), xml); 44 } catch (Exception e) { 45 System.out.println(\u0026#34;THROW \u0026#34; + e.getClass().getName()); 46 System.out.println(\u0026#34;MSG \u0026#34; + e.getMessage()); 47 if (e.getCause() != null) 48 System.out.println(\u0026#34;CAUSE \u0026#34; + e.getCause().getMessage()); 49 } 50 } 51} 1javac XxeLab.java Artifact: unhardened parse echoes the lab file On this OpenJDK 11.0.22, DocumentBuilderFactory.newInstance() still expands a file:// general entity. That is the sink. The bytes are from /tmp/xxe_lab/secret.txt, a file I wrote.\n1$ java XxeLab unsafe 2TEXT LAB-XXE-SECRET-not-a-real-key That is in-band reflection: the parse tree contains the entity body, getTextContent() prints it. I stop here. I do not wrap the same entity in an FTP URL. I do not add a parameter-entity send to a listener.\nWhat I refuse to put in this notebook: SYSTEM \u0026quot;ftp://...\u0026quot;, SYSTEM \u0026quot;http://169.254.169.254/...\u0026quot;, expect://, a working OOB DTD on a second host, a billion-laughs bomb sized to freeze the JVM.\nSanitized reproduction: hardened factory throws 1$ java XxeLab safe 2THROW org.xml.sax.SAXParseException 3MSG DOCTYPE is disallowed when the feature 4 http://apache.org/xml/features/disallow-doctype-decl set to true. That is the ticket artifact. Input is lab.xml with a doctype; output is a throw before secret.txt is opened. Confirm with strace that the secret is not read on the safe path:\n1$ strace -e openat -f java XxeLab safe 2\u0026gt;\u0026amp;1 | grep secret 2# no hits 3 4$ strace -e openat -f java XxeLab unsafe 2\u0026gt;\u0026amp;1 | grep secret 5openat(AT_FDCWD, \u0026#34;/tmp/xxe_lab/secret.txt\u0026#34;, O_RDONLY) = 5 If a JDK already refuses DTDs by default, unsafe also throws. I still set the features explicitly. Defaults move; the explicit list does not.\nSecond path — doctype allowed, external general entities off — the parser ignores the entity instead of throwing. I keep this only for apps that must accept a doctype they author:\n1static DocumentBuilderFactory ignoreExt() throws Exception { 2 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); 3 dbf.setFeature(\u0026#34;http://xml.org/sax/features/external-general-entities\u0026#34;, false); 4 dbf.setFeature(\u0026#34;http://xml.org/sax/features/external-parameter-entities\u0026#34;, false); 5 dbf.setFeature(\u0026#34;http://apache.org/xml/features/nonvalidating/load-external-dtd\u0026#34;, false); 6 dbf.setExpandEntityReferences(false); 7 return dbf; 8} 1$ java XxeLab ignore 2TEXT 3# empty text node; entity not expanded. secret.txt not opened. 4THROW (none) Empty TEXT is “ignoring”. Prefer the throw (disallow-doctype-decl) when the app does not need DTDs.\nTransformerFactory: the other factory people forget XSL and some SOAP stacks go through TransformerFactory, not DocumentBuilder. Same class of fetch, different setters.\n1static TransformerFactory safeTf() throws Exception { 2 TransformerFactory tf = TransformerFactory.newInstance(); 3 tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); 4 tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, \u0026#34;\u0026#34;); 5 tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, \u0026#34;\u0026#34;); 6 return tf; 7} A stylesheet that tries to pull an external DTD or an external stylesheet with those attributes set to \u0026quot;\u0026quot;:\n1$ java TfLab lab.xsl 2THROW javax.xml.transform.TransformerConfigurationException 3MSG Access to external DTDs has been denied due to restriction 4 set by the accessExternalDTD property. FEATURE_SECURE_PROCESSING alone is not the whole fix on every JDK. I set ACCESS_EXTERNAL_DTD and ACCESS_EXTERNAL_STYLESHEET to the empty string as well. ACCESS_EXTERNAL_SCHEMA on the builder is the schema twin.\nStAX, for the same ticket:\n1XMLInputFactory xf = XMLInputFactory.newFactory(); 2xf.setProperty(XMLInputFactory.SUPPORT_DTD, false); 3xf.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); 1$ java StaxLab lab.xml 2THROW javax.xml.stream.XMLStreamException 3MSG DTD is not allowed Three APIs, one rule: no untrusted external resolution.\nFeatures I actually disable (checklist) API Feature / property Lab value DocumentBuilderFactory disallow-doctype-decl true DocumentBuilderFactory external-general-entities false DocumentBuilderFactory external-parameter-entities false DocumentBuilderFactory load-external-dtd false DocumentBuilderFactory XIncludeAware / ExpandEntityReferences false DocumentBuilderFactory FEATURE_SECURE_PROCESSING true DocumentBuilderFactory ACCESS_EXTERNAL_DTD / ACCESS_EXTERNAL_SCHEMA \u0026quot;\u0026quot; TransformerFactory FEATURE_SECURE_PROCESSING true TransformerFactory ACCESS_EXTERNAL_DTD / ACCESS_EXTERNAL_STYLESHEET \u0026quot;\u0026quot; XMLInputFactory SUPPORT_DTD false XMLInputFactory IS_SUPPORTING_EXTERNAL_ENTITIES false Exact key strings vary by parser (Xerces in the JDK vs a bundled Xerces vs Woodstox). If setFeature throws ParserConfigurationException for an unknown key, I catch and fail closed (do not continue with a half-hardened factory).\nMitigation Centralize XML construction in one helper that returns the safe factory. Ban DocumentBuilderFactory.newInstance() in application code via a checkstyle / Error Prone rule. Do not “fix” XXE by stripping DOCTYPE with a regex. Encodings and UTF-16 BOMs exist. Disable the features. Egress: app JVMs do not need FTP or arbitrary HTTP from the parser. Even with features off, I keep that ACL. I still do not test it with a working FTP entity. Billion-laughs is availability, not exfil. Entity-expansion limits (jdk.xml.entityExpansionLimit) are a second control after DTDs are off. SAML, SOAP, office-document extractors, and XML signatures are the real entry points. Grep those stacks for factory setup, not only *.xml upload handlers. 1$ grep -R \u0026#39;DocumentBuilderFactory\\|TransformerFactory\\|XMLInputFactory\u0026#39; \\ 2 --include=\u0026#39;*.java\u0026#39; . Any hit without the feature list above is a finding. A wrapper that takes Factory from the caller is the same finding one frame up.\nWhat I file after this lab JDK: OpenJDK 11.0.22 java XxeLab unsafe → TEXT LAB-XXE-SECRET-not-a-real-key (lab file we own) java XxeLab safe → SAXParseException DOCTYPE is disallowed strace: secret.txt opened only on the unsafe path TransformerFactory + empty ACCESS_EXTERNAL_DTD → TransformerConfigurationException Fix: feature table above, fail closed on unknown keys Out of scope: FTP/HTTP OOB, parameter-entity exfil, cloud metadata URLs Commands appendix 1printf \u0026#39;LAB-XXE-SECRET-not-a-real-key\\n\u0026#39; \u0026gt; /tmp/xxe_lab/secret.txt 2javac XxeLab.java 3java XxeLab unsafe 4java XxeLab safe 5strace -e openat -f java XxeLab safe 2\u0026gt;\u0026amp;1 | grep secret 6grep -R \u0026#39;DocumentBuilderFactory\u0026#39; --include=\u0026#39;*.java\u0026#39; . ","permalink":"https://blog.omiilgo.com/posts/java-xxe-exfiltration-paths/","summary":"Lab DocumentBuilder/TransformerFactory features to disable, tiny file:// entity to a lab file, parser throws or ignores — no FTP exfil.","title":"Java XXE Data Exfiltration Paths"},{"content":"Swift names die in nm the moment I strip. ObjC selector strings do not, because they live in __TEXT,__objc_methname as C strings the runtime has to see. This lab is a self-signed LabVault binary with one Swift class, one method that stays pure Swift, and one method marked @objc so I can still find it after strip. cryptid=1 stops the lab. FairPlay unwrap, jailbreak, AMFI bypass: out of scope.\nFigure 1. Only the @objc entry is reachable via objc_msgSend / methname. Pure Swift IMPs are $s… symbols.\rGate the image, then dump both name spaces 1$ file LabVault 2LabVault: Mach-O 64-bit executable arm64 3 4$ otool -l LabVault | egrep \u0026#39;cmd LC_ENCRYPTION|cryptid|segname __TEXT|__objc_methname|LC_CODE\u0026#39; 5 cmd LC_SEGMENT_64 6 segname __TEXT 7 sectname __objc_methname 8 cmd LC_ENCRYPTION_INFO_64 9 cryptid 0 10 cmd LC_CODE_SIGNATURE cryptid 0. If it is 1, I stop.\nLab source (the only Swift I need):\n1import Foundation 2 3@objc(LabVault) 4final class LabVault: NSObject { 5 /// Pure Swift. Will vanish from nm after strip. Never in methname. 6 func secretLength(_ token: String) -\u0026gt; Int { 7 token.count 8 } 9 10 /// Exposed to ObjC. Selector startWithToken: survives strip. 11 @objc(startWithToken:) 12 func startWithToken(_ token: String) { 13 _ = secretLength(token) 14 insecureCopy(token) 15 } 16 17 func insecureCopy(_ token: String) { 18 var buf = [CChar](repeating: 0, count: 16) 19 token.withCString { src in 20 memcpy(\u0026amp;buf, src, strlen(src) + 1) // lab bug 21 } 22 _scratch = buf[0] 23 } 24} nm before strip, then swift demangle 1$ nm LabVault | grep \u0026#39;$s8LabVault\u0026#39; 20000000100003a40 T $s8LabVaultAAC12secretLengthySiSSF 30000000100003b20 T $s8LabVaultAAC14startWithTokenyySSF 40000000100003c00 t $s8LabVaultAAC14startWithTokenyySSFTo 50000000100003d80 T $s8LabVaultAAC13insecureCopyyySSF 60000000100003f00 T $s8LabVaultAACMa 70000000100003f20 T $s8LabVaultAACN 80000000100004000 S _OBJC_CLASS_$_LabVault 9 U _objc_msgSend 10 U _memcpy 11 U _strlen $s is the Swift 5+ mangling prefix. To on the third symbol is the ObjC thunk (T = thunk, o = Objective-C): the thing objc_msgSend actually jumps to for -[LabVault startWithToken:]. Ma / N are type metadata / nominal type descriptor. _OBJC_CLASS_$_LabVault exists only because the class inherits NSObject and is @objc(LabVault).\n1$ xcrun swift demangle \\ 2 \u0026#39;$s8LabVaultAAC12secretLengthySiSSF\u0026#39; \\ 3 \u0026#39;$s8LabVaultAAC14startWithTokenyySSF\u0026#39; \\ 4 \u0026#39;$s8LabVaultAAC14startWithTokenyySSFTo\u0026#39; \\ 5 \u0026#39;$s8LabVaultAAC13insecureCopyyySSF\u0026#39; 6$s8LabVaultAAC12secretLengthySiSSF ---\u0026gt; LabVault.LabVault.secretLength(_:) -\u0026gt; Swift.Int 7$s8LabVaultAAC14startWithTokenyySSF ---\u0026gt; LabVault.LabVault.startWithToken(_:) -\u0026gt; () 8$s8LabVaultAAC14startWithTokenyySSFTo ---\u0026gt; thunk for @objc LabVault.LabVault.startWithToken(_:) -\u0026gt; () 9$s8LabVaultAAC13insecureCopyyySSF ---\u0026gt; LabVault.LabVault.insecureCopy(_:) -\u0026gt; () Reading the mangling without the tool, once: $s Swift, 8LabVault module, AA class with the same name as the module, C class, then the identifier, then the type. yySSF is (String) -\u0026gt; () with an empty first label. ySiSSF is (String) -\u0026gt; Int. I still run swift demangle instead of hand-parsing when the name has substitutions (A, B, …) in the middle.\nnm after strip vs __objc_methname 1$ strip LabVault -o LabVault.stripped 2$ file LabVault.stripped 3LabVault.stripped: Mach-O 64-bit executable arm64 4 5$ nm LabVault.stripped | grep \u0026#39;$s8LabVault\u0026#39; 6$ nm LabVault.stripped | grep secretLength 7$ nm LabVault.stripped | grep startWithToken 8$ nm LabVault.stripped | grep OBJC_CLASS 90000000100004000 S _OBJC_CLASS_$_LabVault The $s… text symbols are gone. _OBJC_CLASS_$_LabVault can remain as an exported ObjC class symbol depending on strip flags; on this run it did. The selector string is not in nm at all — it is a cstring section:\n1$ otool -v -s __TEXT __objc_methname LabVault.stripped | egrep \u0026#39;start|secret|insecure|length\u0026#39; 2Contents of (__TEXT,__objc_methname) section 30000000100004a10 startWithToken: 40000000100004a21 .cxx_destruct 5$ # no secretLength:, no insecureCopy: secretLength and insecureCopy were never @objc, so they were never in methname. Strip cannot remove startWithToken: without breaking objc_msgSend. That is the recovery rule: if I need a name after strip, it has to have crossed the ObjC boundary.\n1$ class-dump LabVault.stripped | sed -n \u0026#39;/LabVault/,+12p\u0026#39; 2@interface LabVault : NSObject 3- (void)startWithToken:(id)token; 4@end class-dump reads __objc_methname / class dumps. One method. The two pure-Swift methods are invisible here. That is not a failure of class-dump; that is the Swift ABI.\nARM64: thunk vs Swift IMP vs the lab copy 1; otool -tV LabVault (unstripped, file VA, slide 0) 2; thunk: $s8LabVaultAAC14startWithTokenyySSFTo == -[LabVault startWithToken:] 30000000100003c00 pacibsp 40000000100003c04 stp x22, x21, [sp, #-0x30]! 50000000100003c08 stp x20, x19, [sp, #0x10] 60000000100003c0c stp x29, x30, [sp, #0x20] 70000000100003c10 add x29, sp, #0x20 80000000100003c14 mov x19, x0 ; self 90000000100003c18 mov x20, x2 ; NSString * (ObjC arg) 100000000100003c1c mov x0, x20 110000000100003c20 bl 0x100005800 ; Swift bridge NSString → String 120000000100003c24 mov x0, x19 130000000100003c28 ; x1/x2 now hold the Swift String value 140000000100003c2c bl 0x100003b20 ; $s8LabVaultAAC14startWithTokenyySSF 150000000100003c30 ldp x29, x30, [sp, #0x20] 160000000100003c34 ldp x20, x19, [sp, #0x10] 170000000100003c38 ldp x22, x21, [sp], #0x30 180000000100003c3c retab x0 = self, x1 = _cmd, x2 = NSString * at the thunk. After the bridge, the Swift IMP takes a Swift String (two registers, not an object pointer). That is why a Frida ObjC.Object(args[2]) only makes sense on the thunk, not on $s8LabVaultAAC14startWithTokenyySSF.\n1; $s8LabVaultAAC13insecureCopyyySSF 20000000100003d80 pacibsp 30000000100003d84 stp x29, x30, [sp, #-0x40]! 40000000100003d88 mov x29, sp 50000000100003d8c sub sp, sp, #0x10 ; 16-byte buf 60000000100003d90 ; Swift String in x20 / x21 70000000100003d94 bl 0x100005840 ; String.withCString 8; ... callback: 90000000100003de0 add x0, x29, #0x20 ; \u0026amp;buf[16] 100000000100003de4 mov x1, x19 ; const char *src 110000000100003de8 mov x2, x8 ; strlen+1, unbounded 120000000100003dec bl 0x100005900 ; _memcpy Figure 2. Thunk frame saves the NSString; Swift IMP frame has the 16-byte buf under memcpy.\rFigure 3. methname is a __TEXT section. Strip removes the symbol table, not that section. cryptid=1 still stops the lab.\rlldb: break on the selector, or on the mangled name Unstripped:\n1(lldb) breakpoint set -n \u0026#39;$s8LabVaultAAC14startWithTokenyySSF\u0026#39; 2(lldb) breakpoint set -n \u0026#39;-[LabVault startWithToken:]\u0026#39; ; hits the To thunk Stripped, the first command fails (no symbol). The second still works if the ObjC runtime can see the class — or I break on objc_msgSend and filter:\n1(lldb) process launch --stop-at-entry 2(lldb) breakpoint set -n objc_msgSend 3(lldb) command script import lldb_sel.py 4; same helper as the objc_msgSend lab: stop only when sel == \u0026#39;startWithToken:\u0026#39; 5[msgSend] sel=startWithToken: self=0x0000000281a0c0c0 6(lldb) po $x0 7\u0026lt;LabVault: 0x281a0c0c0\u0026gt; 8(lldb) po [$x2 length] 936 10(lldb) # do not po the token 11(lldb) disassemble -s $pc -c 8 On the stripped binary I recover the thunk address from the live objc_msgSend stop (x0/x1 → class cache → IMP), not from nm.\n1$ otool -v -s __TEXT __objc_methname LabVault.stripped | grep start 20000000100004a10 startWithToken: 3$ # file VA of the cstring; lldb will slide it Sanitized reproduction UI pastes lab_ + 'A'*32. I log length.\n1(lldb) po [$x2 length] 236 3(lldb) memory read -c 8 (char *)[(NSString *)$x2 UTF8String] 40x0000000283bb4a00: 6c 61 62 5f 41 41 41 41 lab_AAAA 5# remaining 28 bytes not copied into the note Crash on the unstripped build (symbols still in the report):\n1* thread #1, queue = \u0026#39;com.apple.main-thread\u0026#39;, stop reason = EXC_BAD_ACCESS (code=2) 2 frame #0: 0x00000001890afc2c libsystem_platform.dylib`_platform_memmove + 204 3 frame #1: 0x0000000100003dec LabVault`$s8LabVaultAAC13insecureCopyyySSF + 0x6c 4 frame #2: 0x0000000100003b70 LabVault`$s8LabVaultAAC14startWithTokenyySSF + 0x50 5 frame #3: 0x0000000100003c2c LabVault`$s8LabVaultAAC14startWithTokenyySSFTo + 0x2c Same crash on the stripped build (names gone from frames 1–2):\n1 frame #1: 0x0000000100003dec LabVault` + 0x3dec 2 frame #2: 0x0000000100003b70 LabVault` + 0x3b70 3 frame #3: 0x0000000100003c2c LabVault`-[LabVault startWithToken:] + 0x2c Frame 3 still has the ObjC name because the thunk is the IMP the runtime registered. Frames 1–2 are recovered from the dSYM with atos, or not at all if I also stripped the dSYM. That is the operational difference @objc makes in a crash report.\nASAN:\n1==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 2WRITE of size 37 at ... thread T0 3 #0 memcpy 4 #1 $s8LabVaultAAC13insecureCopyyySSF LabVault.swift:24 5 #2 $s8LabVaultAAC14startWithTokenyySSF LabVault.swift:16 6Shadow bytes around the buggy address: 7 00 00 00 00[f1]f1 f1 f1 00 00[f3]f3 Repro is: 36-byte UTF-8, 16-byte Swift Array\u0026lt;CChar\u0026gt;, memcpy size 37, pc in insecureCopy, selector startWithToken: still in methname after strip. Not a decrypt, not a jailbreak.\nClosing $s… demangles while the symbol table exists. strip takes that away. @objc writes a selector into __objc_methname and a To thunk the runtime can call; class-dump and objc_msgSend breakpoints keep working. Pure Swift methods are recovered from a dSYM or not at all. cryptid=1 still ends the session before any of this.\nCommands appendix 1otool -l LabVault | egrep \u0026#39;cryptid|__objc_methname\u0026#39; 2nm LabVault | grep \u0026#39;$s8LabVault\u0026#39; 3xcrun swift demangle \u0026#39;$s8LabVaultAAC14startWithTokenyySSF\u0026#39; 4strip LabVault -o LabVault.stripped 5nm LabVault.stripped | grep \u0026#39;$s\u0026#39; 6otool -v -s __TEXT __objc_methname LabVault.stripped 7class-dump LabVault.stripped 8xcrun lldb ./LabVault ","permalink":"https://blog.omiilgo.com/posts/ios-swift-vs-objc-name-recovery/","summary":"Lab Swift class LabVault: $s… mangled names, xcrun swift demangle, nm before/after strip, the one @objc method that survives in __objc_methname. class-dump, lldb, ASAN. cryptid=1 stops the lab.","title":"Swift Mangling vs ObjC methname: Recovering Names After Strip"},{"content":"This replaces the survey version of the same filename. Target is com.lab.ndk.notes / libnotes.so, a debug APK I signed. Goal: walk the .so the way I do on a real sample, with dumps, not a tooling list.\nFigure 1. loadLibrary → JNI_OnLoad → Java_* or RegisterNatives → ARM64.\rLab layout 1com.lab.ndk.notes 2 NotesNative.java 3 lib/arm64-v8a/libnotes.so 1package com.lab.ndk.notes; 2 3public final class NotesNative { 4 static { System.loadLibrary(\u0026#34;notes\u0026#34;); } 5 6 public static native void nInit(String token); 7 public static native int nSum(byte[] buf); 8} 1/* notes.c — NDK r26, ANDROID_ABI=arm64-v8a, -O0 */ 2#include \u0026lt;jni.h\u0026gt; 3#include \u0026lt;string.h\u0026gt; 4 5static jint nSum(JNIEnv *env, jclass cls, jbyteArray buf) { 6 if (!buf) return 0; 7 jsize n = (*env)-\u0026gt;GetArrayLength(env, buf); 8 jbyte *p = (*env)-\u0026gt;GetByteArrayElements(env, buf, NULL); 9 jint s = 0; 10 for (jsize i = 0; i \u0026lt; n; i++) s += (jint)p[i]; 11 (*env)-\u0026gt;ReleaseByteArrayElements(env, buf, p, JNI_ABORT); 12 return s; 13} 14 15static const JNINativeMethod kTab[] = { 16 {\u0026#34;nSum\u0026#34;, \u0026#34;([B)I\u0026#34;, (void *)nSum}, 17}; 18 19jint JNI_OnLoad(JavaVM *vm, void *reserved) { 20 JNIEnv *env = NULL; 21 if ((*vm)-\u0026gt;GetEnv(vm, (void **)\u0026amp;env, JNI_VERSION_1_6) != JNI_OK) 22 return JNI_ERR; 23 jclass cls = (*env)-\u0026gt;FindClass(env, \u0026#34;com/lab/ndk/notes/NotesNative\u0026#34;); 24 if (!cls) return JNI_ERR; 25 (*env)-\u0026gt;RegisterNatives(env, cls, kTab, 1); 26 return JNI_VERSION_1_6; 27} 28 29JNIEXPORT void JNICALL 30Java_com_lab_ndk_notes_NotesNative_nInit(JNIEnv *env, jclass cls, jstring token) { 31 char buf[32]; 32 const char *u = (*env)-\u0026gt;GetStringUTFChars(env, token, NULL); 33 if (!u) return; 34 strcpy(buf, u); /* lab bug */ 35 (*env)-\u0026gt;ReleaseStringUTFChars(env, token, u); 36 (void)buf[0]; 37} Mixed bind: nInit is a Java_* export, nSum is RegisterNatives. Same pattern as the 2019 mangling lab; this session is the ELF / objdump pass.\nAPK → ELF facts 1unzip -l notes.apk | grep libnotes 2# 19856 2025-01-04 lib/arm64-v8a/libnotes.so 3 4unzip -p notes.apk lib/arm64-v8a/libnotes.so \u0026gt; libnotes.so 5file libnotes.so 6# ELF 64-bit LSB shared object, ARM aarch64, dynamically linked, not stripped 1$ readelf -h libnotes.so | egrep \u0026#39;Class|Machine|Type|Entry\u0026#39; 2 Class: ELF64 3 Type: DYN (Shared object file) 4 Machine: AArch64 5 Entry point address: 0x10d0 6 7$ readelf -d libnotes.so | egrep \u0026#39;NEEDED|SONAME|FLAGS_1\u0026#39; 8 0x0000000000000001 (NEEDED) Shared library: [liblog.so] 9 0x0000000000000001 (NEEDED) Shared library: [libm.so] 10 0x0000000000000001 (NEEDED) Shared library: [libdl.so] 11 0x0000000000000001 (NEEDED) Shared library: [libc.so] 12 0x000000000000000e (SONAME) Library soname: [libnotes.so] 13 0x000000006ffffffb (FLAGS_1) Flags: NOW 14 15$ readelf -s libnotes.so | grep -E \u0026#39;Java_|JNI_OnLoad|nSum|strcpy\u0026#39; 16 8: 00000000000011e0 96 FUNC GLOBAL DEFAULT 12 JNI_OnLoad 17 9: 0000000000001280 108 FUNC GLOBAL DEFAULT 12 Java_com_lab_ndk_notes_NotesNative_nInit 18 14: 0000000000001180 80 FUNC LOCAL DEFAULT 12 nSum 19 : 0000000000000000 0 FUNC GLOBAL DEFAULT UND strcpy NOW is Full RELRO-ish for the DSO (DT_FLAGS_1 NOW). nSum is LOCAL — nm -D will not show it. strcpy is UND, so the planted copy is a PLT call, easy to xref.\n1$ readelf -p .rodata libnotes.so 2 [ 00] com/lab/ndk/notes/NotesNative 3 [ 20] nSum 4 [ 25] ([B)I 5 6$ readelf -x .data libnotes.so | head 7Hex dump of section \u0026#39;.data\u0026#39;: 8 0x00023000 00200000 00000000 25200000 00000000 . ......% ...... 9 0x00023010 80110000 00000000 ........ Three pointers: name \u0026quot;nSum\u0026quot;, sig \u0026quot;([B)I\u0026quot;, fn 0x1180. That is the JNINativeMethod row. File VAs; runtime add the load bias.\nllvm-objdump -d: JNI_OnLoad 1llvm-objdump -d --no-show-raw-insn libnotes.so 100000000000011e0 \u0026lt;JNI_OnLoad\u0026gt;: 2 11e0: stp x29, x30, [sp, #-32]! 3 11e4: mov x29, sp 4 11e8: stp x19, x20, [sp, #16] 5 11ec: mov x19, x0 ; JavaVM* 6 11f0: ldr x8, [x19] 7 11f4: ldr x8, [x8, #48] ; GetEnv, slot 6, #0x30 8 11f8: mov x0, x19 9 11fc: add x1, sp, #16 ; JNIEnv** 10 1200: mov w2, #0x6 11 1204: movk w2, #0x1, lsl #16 ; JNI_VERSION_1_6 = 0x00010006 12 1208: blr x8 13 120c: cbnz w0, 1274 ; JNI_ERR 14 1210: ldr x0, [sp, #16] ; JNIEnv* 15 1214: ldr x8, [x0] 16 1218: ldr x8, [x8, #48] ; FindClass, slot 6 17 121c: adrp x1, 0x2000 18 1220: add x1, x1, #0 ; \u0026#34;com/lab/ndk/notes/NotesNative\u0026#34; 19 1224: blr x8 20 1228: cbz x0, 1274 21 122c: ldr x8, [sp, #16] 22 1230: ldr x9, [x8] 23 1234: ldr x9, [x9, #1720] ; RegisterNatives #0x6b8 = 1720 24 1238: mov x1, x0 ; jclass 25 123c: adrp x2, 0x23000 26 1240: add x2, x2, #0 ; kTab 27 1244: mov w3, #1 28 1248: mov x0, x8 29 124c: blr x9 30 1250: mov w0, #0x6 31 1254: movk w0, #0x1, lsl #16 ; JNI_VERSION_1_6 32 1258: ldp x19, x20, [sp, #16] 33 125c: ldp x29, x30, [sp], #32 34 1260: ret #48 is GetEnv on JavaVM and FindClass on JNIEnv — same slot number, different tables. I keep both cheat-sheets. #1720 is RegisterNatives (slot 215 × 8). If Ghidra names it FUN_xxx I rename from the offset, not from a guessed string.\nnInit export 10000000000001280 \u0026lt;Java_com_lab_ndk_notes_NotesNative_nInit\u0026gt;: 2 1280: stp x29, x30, [sp, #-64]! 3 1284: mov x29, sp 4 1288: stp x19, x20, [sp, #16] 5 128c: mov x19, x0 ; JNIEnv* 6 1290: mov x20, x2 ; jstring 7 1294: ldr x8, [x19] 8 1298: ldr x8, [x8, #1352] ; GetStringUTFChars #0x548 = 1352 9 129c: mov x0, x19 10 12a0: mov x1, x20 11 12a4: mov x2, xzr 12 12a8: blr x8 13 12ac: mov x1, x0 ; utf 14 12b0: add x0, sp, #32 ; char buf[32] 15 12b4: bl 10d0 \u0026lt;strcpy@plt\u0026gt; 16 12b8: ldr x8, [x19] 17 12bc: ldr x8, [x8, #1360] ; ReleaseStringUTFChars #0x550 18 12c0: mov x0, x19 19 12c4: mov x1, x20 20 12c8: mov x2, x1 ; (see note) 21 12cc: blr x8 strcpy@plt with destination sp+32 and a 64-byte frame: 32 bytes of buf, then saved regs. A 40-byte UTF string walks off the slot. I do not treat the ReleaseStringUTFChars operand mix-up in the comment as gospel — -O0 clang can reload the utf pointer into x2 from a stack spill; I confirm in gdb if I need the exact register.\nnSum (hidden) 10000000000001180 \u0026lt;nSum\u0026gt;: 2 1180: stp x29, x30, [sp, #-48]! 3 1184: mov x29, sp 4 1188: mov x19, x0 5 118c: mov x20, x2 ; jbyteArray 6 1190: ldr x8, [x19] 7 1194: ldr x8, [x8, #1368] ; GetArrayLength slot 171, #0x558 8 1198: mov x0, x19 9 119c: mov x1, x20 10 11a0: blr x8 ; w0 = jsize 11 11a4: mov w21, w0 12 11a8: ldr x8, [x19] 13 11ac: ldr x8, [x8, #1472] ; GetByteArrayElements slot 184 14 11b0: mov x0, x19 15 11b1: mov x1, x20 16 11b4: mov x2, xzr 17 11b8: blr x8 18; w21 times add of signed bytes, then ReleaseByteArrayElements JNI_ABORT=2 No Java_*nSum* symbol. Mapping is table-only. GetArrayLength then GetByteArrayElements is the normal byte[] path; JNI_ABORT means the native did not write back.\nLoad bias on device 1adb shell cat /proc/$(adb shell pidof com.lab.ndk.notes)/maps | grep libnotes 16f3a1c1000-6f3a1c6000 r-xp 00000000 ... /data/app/[REDACTED]/lib/arm64/libnotes.so 26f3a1d5000-6f3a1d6000 r--p 00004000 ... 36f3a1d6000-6f3a1d7000 rw-p 00005000 ... Bias 0x6f3a1c1000. JNI_OnLoad runtime VA = 0x6f3a1c1000 + 0x11e0. I redact the app path. Maps lines are enough to turn file offsets into later tombstone PCs.\nSanitized crash EditText → nInit, 40 As.\n1F DEBUG : ABI: \u0026#39;arm64-v8a\u0026#39; 2F DEBUG : pid: 5012, tid: 5012, name: lab.ndk.notes 3F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED] 4F DEBUG : backtrace: 5F DEBUG : #00 pc 00000000000012b4 libnotes.so (Java_com_lab_ndk_notes_NotesNative_nInit+0x34) 6F DEBUG : #01 pc 0000000000[REDACTED] libart.so (art::JNI\u0026lt;...\u0026gt;+...) PC 0x12b4 is the bl strcpy@plt. ASAN rebuild of the same notes.c:\n1==5012==ERROR: AddressSanitizer: stack-buffer-overflow 2WRITE of size 41 3 #0 strcpy 4 #1 Java_com_lab_ndk_notes_NotesNative_nInit notes.c:37 Frida, both bind paths, redacted:\n1/* notes_trace.js — com.lab.ndk.notes only */ 2const base = Module.getBaseAddress(\u0026#39;libnotes.so\u0026#39;); 3Interceptor.attach(base.add(0x1280), { 4 onEnter(args) { 5 const env = Java.vm.getEnv(); 6 const n = env.getStringUtfLength(args[2]); 7 const p = env.getStringUtfChars(args[2]).readCString(); 8 console.log(\u0026#39;[nInit] utf.len=\u0026#39; + n + \u0026#39; prefix=\u0026#39; + p.slice(0, 4)); 9 } 10}); 11Interceptor.attach(base.add(0x1180), { 12 onEnter(args) { 13 const env = Java.vm.getEnv(); 14 const n = env.getArrayLength(args[2]); 15 console.log(\u0026#39;[nSum] byte[].len=\u0026#39; + n); 16 } 17}); 1$ frida -U -f com.lab.ndk.notes -l notes_trace.js --no-pause 2[nInit] utf.len=6 prefix=labtok 3[nInit] utf.len=40 prefix=AAAA 4[nSum] byte[].len=4 No token body. nSum logs array length, not contents.\nWhat I file Java Bind VA nInit (Ljava/lang/String;)V export Java_com_lab_ndk_notes_NotesNative_nInit 0x1280 nSum ([B)I JNI_OnLoad → kTab[0] 0x1180 Bug class: GetStringUTFChars → strcpy into 32 bytes. Repro: 40-byte Java string, SIGSEGV at nInit+0x34. Fix: GetStringUTFLength + bound, or do not use a stack slot.\nCommands appendix 1unzip -p notes.apk lib/arm64-v8a/libnotes.so \u0026gt; libnotes.so 2readelf -h -d -s libnotes.so | less 3readelf -p .rodata libnotes.so 4llvm-objdump -d libnotes.so | less +/JNI_OnLoad 5frida -U -f com.lab.ndk.notes -l notes_trace.js --no-pause 6adb logcat -b crash -d | tail -40 ","permalink":"https://blog.omiilgo.com/posts/android-native-library-re-notes/","summary":"One lab APK, one arm64-v8a .so. file/readelf/llvm-objdump through JNI_OnLoad, Java_* exports, GetStringUTFChars, a 32-byte stack copy, tombstone and ASAN. Frida logs length and prefix only.","title":"NDK .so Session: readelf, llvm-objdump -d, JNI_OnLoad"},{"content":"This is a parser lab, not a gadget chain. Target is a 25-line Python CLI that loads a YAML file from cwd. Goal: show safe_load keeping a document as dicts and lists, show load with Loader=yaml.Loader warning, and show a resource-exhaustion crash on a cyclic alias. I do not instantiate os.system through !!python/object/apply. That tag is mentioned once as a grep target and never as a working payload.\n1Figure 1. JSON-shaped data is fine. Tags that call constructors are the footgun. 2ok.yaml -\u0026gt; safe_load -\u0026gt; dict/list 3tags -\u0026gt; UnsafeLoader constructors (not used) 4laughs -\u0026gt; RecursionError even on SafeLoader Lab layout 1labs/yaml_lab/ 2 load.py 3 ok.yaml 4 typed.yaml 5 laughs.yaml # small, still enough to blow a recursion limit 1# load.py — toy 2import sys 3import yaml 4 5path = sys.argv[1] 6mode = sys.argv[2] if len(sys.argv) \u0026gt; 2 else \u0026#34;safe\u0026#34; 7raw = open(path, \u0026#34;r\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;).read() 8if mode == \u0026#34;safe\u0026#34;: 9 data = yaml.safe_load(raw) 10else: 11 # explicit Loader so the call is greppable; still not FullLoader on untrusted input 12 data = yaml.load(raw, Loader=yaml.SafeLoader) 13print(type(data).__name__, data) Default path is safe_load. The else branch still uses SafeLoader on purpose: I will not put yaml.Loader / UnsafeLoader on a code path that reads a file whose name comes from argv in a committed lab.\nArtifact: the document I actually want 1# ok.yaml 2service: lab-api 3listen: 127.0.0.1 4port: 8080 5flags: 6 - verbose 7 - readonly 1$ python3 load.py ok.yaml safe 2dict {\u0026#39;service\u0026#39;: \u0026#39;lab-api\u0026#39;, \u0026#39;listen\u0026#39;: \u0026#39;127.0.0.1\u0026#39;, \u0026#39;port\u0026#39;: 8080, \u0026#39;flags\u0026#39;: [\u0026#39;verbose\u0026#39;, \u0026#39;readonly\u0026#39;]} Implicit typing, the everyday footgun (not RCE):\n1# typed.yaml 2country: NO # Norway, historically a boolean in YAML 1.1 3on: \u0026#34;ok\u0026#34; # quoted, stays str 4off: off # unquoted, becomes False under some loaders 5port: 8080 1$ python3 load.py typed.yaml safe 2dict {\u0026#39;country\u0026#39;: True, \u0026#39;on\u0026#39;: \u0026#39;ok\u0026#39;, \u0026#39;off\u0026#39;: False, \u0026#39;port\u0026#39;: 8080} 3# PyYAML 1.1 implicit: NO → True. This shipped as a prod bug in a lab I copied. country: True is the finding I file most often. Country codes, on/off, yes/no, never. Quote them. I do not need a gadget for this ticket to be real.\nWhat load does that safe_load refuses PyYAML constructors for !!python/object and !!python/object/apply exist on Loader / UnsafeLoader. They turn tags into import + apply. That is deserialization, not config.\nGrep I run, and the only form the tag takes in this notebook:\n1$ python3 - \u0026lt;\u0026lt;\u0026#39;PY\u0026#39; 2import yaml, inspect, yaml.constructor as c 3print(\u0026#34;SafeLoader has python/object:\u0026#34;, 4 any(\u0026#34;python/object\u0026#34; in str(k) for k in yaml.SafeLoader.yaml_constructors)) 5print(\u0026#34;UnsafeLoader sample keys:\u0026#34;) 6for k in list(yaml.UnsafeLoader.yaml_constructors)[:8]: 7 print(\u0026#34; \u0026#34;, k) 8PY 9# SafeLoader has python/object: False 10# UnsafeLoader sample keys: 11# tag:yaml.org,2002:python/none 12# tag:yaml.org,2002:python/bool 13# tag:yaml.org,2002:python/bytes 14# tag:yaml.org,2002:python/str 15# tag:yaml.org,2002:python/tuple 16# ... plus object/apply on the full map (not printed as a payload) Review comment I leave:\n1# BAD yaml.load(body) # default loader changed across versions 2# BAD yaml.load(body, Loader=yaml.Loader) 3# BAD yaml.unsafe_load(body) 4# GOOD yaml.safe_load(body) 5# GOOD yaml.load(body, Loader=yaml.SafeLoader) I will not paste a document that starts with !!python/object/apply:os.system. If I see that tag in an inbound webhook log, I hash the body and rotate whatever it touched; I do not replay it.\nSanitized reproduction (crash / reject only) Billion-laughs shape, sized to blow Python\u0026rsquo;s recursion or memory on my box, not to freeze a cluster. Nested aliases, depth 20.\n1# laughs.yaml — lab DoS, not an RCE 2a: \u0026amp;a [\u0026#34;x\u0026#34;, \u0026#34;x\u0026#34;] 3b: \u0026amp;b [*a, *a] 4c: \u0026amp;c [*b, *b] 5d: \u0026amp;d [*c, *c] 6e: \u0026amp;e [*d, *d] 7f: \u0026amp;f [*e, *e] 8g: \u0026amp;g [*f, *f] 9h: \u0026amp;h [*g, *g] 1$ python3 load.py laughs.yaml safe 2# hangs, then: 3RecursionError: maximum recursion depth exceeded 4# or MemoryError on a deeper file I do not keep in git ASAN-shaped analog if I wrap a C loader (libyaml via a tiny C helper). I do not fuzz libyaml for a CVE here; I just show the Python crash.\n1$ python3 -c \u0026#34;import yaml; yaml.safe_load(open(\u0026#39;not-yaml.yaml\u0026#39;))\u0026#34; 2yaml.scanner.ScannerError: mapping values are not allowed here 3 in \u0026#34;not-yaml.yaml\u0026#34;, line 1, column 4 Failed-auth log from a lab endpoint that accepts YAML config from an admin form (wrong cookie):\n12024-11-30T12:18:03+08:00 POST /admin/config 2 cookie: session=[REDACTED] 3 result: 401 4 body_sha256: [REDACTED] 5# parser never ran — authz first, then safe_load Order matters. safe_load on unauthenticated POST is still a DoS sink (laughs.yaml). Authn does not make unsafe_load safe.\nOther languages, same checklist I do not run all of these in this lab. I grep them when the service is not Python.\nEcosystem Unsafe-shaped API Safer-shaped API PyYAML yaml.load / unsafe_load safe_load / SafeLoader ruamel.yaml default that allows Python tags typ='safe' Ruby YAML.load (Psych, historically) YAML.safe_load + permitted classes Java SnakeYAML new Yaml() + !! constructor SafeConstructor Go gopkg.in/yaml usually structs, still watch custom unmarshalers decode into a struct, not interface{} + eval Helm / K8s values.yaml implicit bools quote NO, on, country codes Implicit bools hit Helm as often as RCE hits old PyYAML. Different severity, same parser family.\nMerge keys and duplicate keys YAML 1.1 merge \u0026lt;\u0026lt;: *anchor is a feature I disable by not using it in inbound documents. safe_load still expands merges. Duplicate keys: last-wins in PyYAML, which means an operator-looking port: 8080 at the top can be shadowed:\n1# dup.yaml 2port: 8080 3port: 65535 1$ python3 load.py dup.yaml safe 2dict {\u0026#39;port\u0026#39;: 65535} CI for our own YAML: fail on duplicate keys (yamllint rule key-duplicates). For untrusted YAML: after safe_load, schema-validate with a required-key set and numeric ranges. port \u0026gt; 65535 is a finding even when the parser is \u0026ldquo;safe\u0026rdquo;.\nMitigation 1# the only load path in the service 2from yaml import safe_load, YAMLError 3 4def parse_config(text: str) -\u0026gt; dict: 5 if len(text) \u0026gt; 64_000: 6 raise ValueError(\u0026#34;yaml too large\u0026#34;) 7 data = safe_load(text) 8 if not isinstance(data, dict): 9 raise ValueError(\u0026#34;yaml root must be mapping\u0026#34;) 10 return data CI grep: yaml.load(, unsafe_load, Loader=yaml.Loader, UnsafeLoader. Size cap and timeout around parse. laughs.yaml is why. Quote NO, on, off, yes, n, country codes in every values file. Do not accept YAML from users if JSON would do. JSON cannot !!python/object. Same route must not sniff Content-Type and switch to unsafe_load for application/yaml. Never log the full document if it might contain tags; log len + hash. Helm: quote country codes and on/off in values.yaml; helm lint does not catch NO → true. I keep a unit test that safe_loads typed.yaml and asserts country == \u0026quot;NO\u0026quot; after we quote it. Until that test is green, the implicit-bool row stays open. What I file after this lab ok.yaml + safe_load → dict, port int 8080 typed.yaml → country: True (YAML 1.1 implicit) — config bug laughs.yaml → RecursionError / MemoryError — DoS sink even on SafeLoader UnsafeLoader has python/object constructors; SafeLoader does not Fix: safe_load only, size cap, quote implicit-bool tokens, grep unsafe APIs Out of scope: a YAML document that runs os.system Commands appendix 1python3 load.py ok.yaml safe 2python3 load.py typed.yaml safe 3python3 load.py laughs.yaml safe 4python3 -c \u0026#39;import yaml; print(list(yaml.SafeLoader.yaml_constructors)[:5])\u0026#39; 5rg -n \u0026#39;yaml\\.(unsafe_)?load|UnsafeLoader|Loader=yaml\\.Loader\u0026#39; ","permalink":"https://blog.omiilgo.com/posts/yaml-parser-footguns/","summary":"Lab PyYAML: yaml.safe_load vs yaml.load, implicit typing, billion-laughs-sized crash — no os.system gadget via YAML tags.","title":"YAML Parser Footguns Across Languages"},{"content":"A crash report is two address spaces glued together. Frames in LabSession belong to my Mach-O and symbolicate from my dSYM. Frames in UIKitCore belong to the dyld shared cache and will never appear in nm LabSession. Mixing those up is how people “extract the cache” when they only needed atos and Xcode’s system symbols. This lab is a self-signed simulator build. cryptid=1 stops the lab. I do not dump, decrypt, or ship a dyld shared cache.\nFigure 1. LC_UUID is the key that matches the dSYM to the crashing image. cryptid=1 stops the lab.\rSame binary gate as always 1$ file LabSession 2LabSession: Mach-O 64-bit executable arm64 3 4$ otool -l LabSession | egrep \u0026#39;cmd LC_UUID|uuid |cryptid|LC_CODE|segname __TEXT\u0026#39; 5 cmd LC_SEGMENT_64 6 segname __TEXT 7 cmd LC_ENCRYPTION_INFO_64 8 cryptid 0 9 cmd LC_UUID 10 uuid A1B2C3D4-E5F6-7890-ABCD-EF1234567890 11 cmd LC_CODE_SIGNATURE cryptid 0. If it is 1, I stop; FairPlay-encrypted __TEXT is not a crash I can symbolicate from this file.\n1$ dwarfdump -u LabSession 2UUID: A1B2C3D4-E5F6-7890-ABCD-EF1234567890 (arm64) LabSession 3 4$ dwarfdump -u LabSession.app.dSYM 5UUID: A1B2C3D4-E5F6-7890-ABCD-EF1234567890 (arm64) LabSession The UUID in LC_UUID, the UUID dwarfdump -u prints on the binary, and the UUID on the dSYM must match. A rebuilt binary with a new UUID will not symbolicate an old .crash even if the source line numbers look close.\n1$ class-dump LabSession | sed -n \u0026#39;/LabSession/,+14p\u0026#39; 2@interface LabSession : NSObject 3- (void)startWithToken:(id)token; 4- (void)insecureCopy:(id)token; ; lab crash 5- (void)tapCrash:(id)sender; ; UIButton action 6@end Why UIKit symbols are not in the app 1$ nm LabSession | egrep \u0026#39;UIApplication|UIControl|objc_msgSend|insecureCopy\u0026#39; 20000000100001e10 t -[LabSession insecureCopy:] 30000000100001f80 t -[LabSession tapCrash:] 4 U _objc_msgSend 5$ nm LabSession | grep UIKit 6$ otool -L LabSession | grep -i uikit 7 /System/Library/Frameworks/UIKit.framework/UIKit (compatibility version 1.0.0) U _objc_msgSend is an undefined symbol. UIKit.framework is a load command, not a copy of UIKit’s __TEXT inside my file. On iOS / the simulator, UIKit’s pages come from the dyld shared cache (one giant mapped image, many frameworks). That is why a crash report shows:\n12 UIKitCore 0x000000018b12c4a8 -[UIApplication sendAction:to:from:forEvent:] + 96 and atos -o LabSession on that address is meaningless. The address is not in my Mach-O.\nI do not extract the device DSC with third-party dumpers to resolve that frame. Xcode already has DeviceSupport / iOS SDK symbols. symbolicatecrash uses those. Pulling a cache off a phone to “have UIKit locally” is out of scope here and is how people wander into decryption / DRM tooling I will not document.\nCrash report excerpt (paths redacted) Lab: simulator, Apple Silicon, I tapped the UIButton that calls tapCrash: → insecureCopy: with a 36-character stand-in token.\n1Incident Identifier: [REDACTED] 2Hardware Model: Mac[REDACTED] 3Process: LabSession [412] 4Path: /Users/[REDACTED]/Library/Developer/CoreSimulator/Devices/[REDACTED]/data/Containers/Bundle/Application/[REDACTED]/LabSession.app/LabSession 5Identifier: com.lab.session 6Version: 1.0 (1) 7Code Type: ARM-64 (Native) 8Parent Process: launchd_sim [REDACTED] 9Date/Time: 2024-08-19 15:02:11.180 +0800 10OS Version: iPhoneSimulator 17.x [REDACTED] 11Exception Type: EXC_BAD_ACCESS (SIGSEGV) 12Exception Subtype: KERN_PROTECTION_FAILURE at 0x[REDACTED] 13Termination Reason: SIGNAL 11 Segmentation fault: 11 14 15Thread 0 Crashed: 160 libsystem_platform.dylib 0x00000001890afc2c _platform_memmove + 204 171 LabSession 0x0000000104a81e38 -[LabSession insecureCopy:] + 0x28 182 LabSession 0x0000000104a81f98 -[LabSession tapCrash:] + 0x18 193 UIKitCore 0x000000018b12c4a8 -[UIApplication sendAction:to:from:forEvent:] + 96 204 UIKitCore 0x000000018b12c5f0 -[UIControl sendAction:to:forEvent:] + 128 215 UIKitCore 0x000000018b12c8a4 -[UIControl _sendActionsForEvents:withEvent:] + 352 22 23Binary Images: 240x104a80000 - 0x104a87fff LabSession arm64 \u0026lt;a1b2c3d4e5f67890abcdef1234567890\u0026gt; /Users/[REDACTED]/Library/Developer/CoreSimulator/Devices/[REDACTED]/data/Containers/Bundle/Application/[REDACTED]/LabSession.app/LabSession 250x180000000 - 0x18fffffff dyld shared cache arm64 \u0026lt;[REDACTED]\u0026gt; Frame 1–2 are mine. Frames 3–5 are the shared cache. Binary Images gives me the load address 0x104a80000 and the UUID a1b2c3d4… that must match dwarfdump -u.\natos on the lab frames File-unslid __TEXT vmaddr is 0x100000000 (otool -l). Load address in the report is 0x104a80000. Slide = 0x4a80000.\n1$ xcrun atos -o LabSession.app.dSYM/Contents/Resources/DWARF/LabSession \\ 2 -arch arm64 -l 0x104a80000 \\ 3 0x104a81e38 0x104a81f98 4-[LabSession insecureCopy:] (in LabSession) (LabSession.m:41) 5-[LabSession tapCrash:] (in LabSession) (LabSession.m:49) Same addresses through lldb after I reproduce under the debugger (no report needed):\n1(lldb) image list LabSession 2[ 0] A1B2C3D4-E5F6-7890-ABCD-EF1234567890 0x0000000104a80000 LabSession 3(lldb) image lookup -v -a 0x104a81e38 4 Address: LabSession[0x0000000100001e38] (LabSession.__TEXT.__text + 0x238) 5 Summary: LabSession`-[LabSession insecureCopy:] + 40 at LabSession.m:41 6(lldb) # UIKit address from the report — not in this image: 7(lldb) image lookup -a 0x18b12c4a8 8 Address: UIKitCore[0x000000018b12c4a8] 9 Summary: UIKitCore`-[UIApplication sendAction:to:from:forEvent:] + 96 image lookup on the UIKit address works in a live simulator process because dyld already mapped the cache. It does not work against the on-disk LabSession file. That is the whole lesson.\nFor a .crash file I do not have a live process for:\n1$ export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer 2$ xcrun symbolicatecrash LabSession.crash \u0026gt; LabSession.crash.symbolicated symbolicatecrash matches UUID → dSYM (Spotlight / dwarfdump) for my frames, and UUID → Xcode iOS SDK symbols for UIKit. I do not pass a hand-extracted DSC.\nARM64 at the crashing IMP 1; otool -tV LabSession file VA, slide 0 2; -[LabSession insecureCopy:] 30000000100001e10 pacibsp 40000000100001e14 stp x29, x30, [sp, #-0x30]! 50000000100001e18 mov x29, sp 60000000100001e1c stp x20, x19, [sp, #0x10] 70000000100001e20 sub sp, sp, #0x10 ; char buf[16] 80000000100001e24 mov x19, x2 ; NSString * token 90000000100001e28 mov x0, x19 100000000100001e2c bl 0x1000024a0 ; -[NSString UTF8String] 110000000100001e30 add x8, x29, #0x18 ; \u0026amp;buf 120000000100001e34 mov x1, x0 130000000100001e38 bl 0x1000024c4 ; _memcpy ← crash pc + 0x28 from start Report said insecureCopy: + 0x28. 0x100001e10 + 0x28 = 0x100001e38, which is the bl memcpy. That is the line atos named LabSession.m:41.\nFigure 2. Frame at insecureCopy: 16-byte local under memcpy. lr saved at [fp\u0026#43;8]; UIKit is the caller, not in this image.\rFigure 3. tapCrash: is an ObjC action. UIKit sendAction: is in the shared cache; the IMP it lands on is in LabSession.\rSanitized reproduction I paste a 36-character stand-in lab_ + 'A'*32. I log length, not the token.\n1(lldb) breakpoint set -n \u0026#39;-[LabSession insecureCopy:]\u0026#39; 2(lldb) po [$x2 length] 336 4(lldb) memory read -c 8 $x2 5; skip — NSString object header is not the bytes. UTF8: 6(lldb) p (char *)[(NSString *)$x2 UTF8String] 7(char *) $1 = 0x0000000283bb4a00 \u0026#34;lab_AAAA\u0026#34;... 8(lldb) # remaining 28 bytes not copied into the note ASAN rebuild of the same file:\n1==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 2WRITE of size 37 at ... thread T0 3 #0 memcpy 4 #1 -[LabSession insecureCopy:] LabSession.m:41 5 #2 -[LabSession tapCrash:] LabSession.m:49 6 #3 -[UIApplication sendAction:to:from:forEvent:] 7Shadow bytes around the buggy address: 8 00 00 00 00[f1]f1 f1 f1 00 00[f3]f3 Source of the lab bug:\n1- (void)insecureCopy:(NSString *)token { 2 char buf[16]; 3 const char *u = token.UTF8String; 4 memcpy(buf, u, strlen(u) + 1); // no bound 5 _scratch = buf[0]; 6} Repro is: 36-byte UTF-8, 16-byte buffer, memcpy size 37, pc insecureCopy:+0x28, UUID-matched atos line LabSession.m:41, UIKit frames left to symbolicatecrash. Not a DSC dump, not FairPlay, not a jailbreak.\nClosing dwarfdump -u ↔ LC_UUID ↔ crash Binary Images is the join key. atos -l \u0026lt;load address\u0026gt; resolves my frames from my dSYM. UIKit lives in the dyld shared cache; nm LabSession will never grow those symbols. Xcode’s symbolicatecrash is the supported path for system frames. Extracting the cache off a device is not.\nCommands appendix 1otool -l LabSession | egrep \u0026#39;cryptid|LC_UUID|uuid \u0026#39; 2dwarfdump -u LabSession 3dwarfdump -u LabSession.app.dSYM 4nm LabSession | egrep \u0026#39;insecureCopy|UIApplication|objc_msgSend\u0026#39; 5class-dump LabSession 6xcrun atos -o LabSession.app.dSYM/Contents/Resources/DWARF/LabSession -arch arm64 -l 0x104a80000 0x104a81e38 7xcrun symbolicatecrash LabSession.crash 8xcrun lldb ./LabSession ","permalink":"https://blog.omiilgo.com/posts/ios-dyld-shared-cache-symbolication/","summary":"Lab crash on a self-signed LabSession: dwarfdump -u, atos against the dSYM, why UIKit frames are not in nm of the app, crash report with [REDACTED] paths. No DSC extraction. cryptid=1 stops the lab.","title":"Symbolicating a Lab Crash When UIKit Lives in the dyld Shared Cache"},{"content":"Android kernel LPEs are usually the same three stories with new CVEs on the cover: a wait-queue object outlives the userspace memory that named it, a perf event outlives the mmap that backed it, or a userspace pointer is checked once and used twice. This note stays at that invariant level. There is no Android kernel exploit, no futex requeue recipe, no perf_event_attr payload, no get_user gadget chain.\nThe lab at the bottom is userspace: a file TOCTOU analog of “check, then use”, and an ASAN-visible stack copy. It is not a kernel PoC.\nWhat “class” means here An untrusted app reaches the Linux syscall/ioctl surface. A bug in the kernel (or a vendor module) breaks an invariant the rest of the kernel assumed. If the break is a use-after-free, overlapping object, or confused user/kernel copy, the rest of the sandbox is a policy on a process the kernel no longer considers untrusted.\n1untrusted app 2 → syscall / ioctl / binder 3 → kernel object lifetime (the invariant) 4 → credentials, maps, SELinux state I do not need the CVE number to audit a new driver: I need the invariant.\nFutex-era invariant A futex is a 32-bit userspace word plus a kernel wait queue keyed by that address. The kernel sometimes also walks a robust list the thread advertised, so it can wake waiters if the owner dies.\nInvariant the code wants:\nWhile this wait-queue node is hashed against uaddr, the calling task still owns the userspace page that uaddr points at, and the node is only unhashed by the same task’s exit or wake path.\nWhat historical CVEs broke, without the patch diff:\nThe wait-queue node was still hashed after the userspace mapping was gone (UAF on the kernel node, or a wake against a reused address). The robust-list walk trusted a userspace-linked list whose next pointer was mutated during the walk. Requeue moved a waiter from one uaddr to another without holding both hashes in an order that excluded a parallel unhash. Reachability is the reason this class mattered on phones: FUTEX_WAIT / FUTEX_WAKE are what pthread_mutex becomes. An ordinary app already calls them. The mitigation story is “patch the lifetime”, plus later sysctl locks and the usual KASLR/CFI tax. None of that is a how-to.\nWhat I write down when I read a new futex CVE: which object was hashed, which path unhashed it, which path still had a pointer. If those three lines are not in the advisory, the advisory is incomplete.\nperf_event-era invariant perf_event_open returns an fd. The fd can be mmap’d (ring buffer), ioctl’d (enable/disable/period), and closed. The kernel object is an perf_event with a lifetime tied to that fd and to CPU contexts that still have it scheduled.\nInvariant:\nNo CPU still has this event scheduled, and no mmap still points at its ring, after the fd’s last reference is dropped.\nBroken forms:\nInteger overflow / wrap in buffer-size accounting, so the mmap was smaller than the kernel believed. Enable vs close vs munmap races: one CPU writes the ring after the pages were reused. Attribute fields accepted from userspace that selected code paths meant for privileged counters. Android’s practical control is not “understand every attr bit”. It is: untrusted apps should not get perf_event_open (perf_event_paranoid, seccomp, sepolicy). If a build still exposes it to untrusted_app, that is the finding, even before a CVE.\nI do not document attr structs that hit the old races.\nget_user / put_user / copy_* invariant The primitive is: a syscall takes a userspace pointer, the kernel must read or write that memory. get_user / put_user / copy_from_user / copy_to_user exist so the access is fault-safe and (with PAN) distinct from kernel accesses.\nInvariant:\nThe pointer is checked (in-range, correct size, still the same object) at the moment of the copy, not at some earlier moment after which userspace can replace the page or the length.\nBroken forms:\nTOCTOU: access_ok / a length check, then a sleep, then a raw copy of the old pointer. Wrong-sized accessor (get_user of 4 bytes into a 8-byte field, or the reverse). Driver ioctl that copy_from_users a header, trusts header.len, then copies header.len bytes from a second pointer with no cap. Vendor GPU/camera ioctls are where this class keeps returning. Hardened usercopy and PAN raise the cost of using a confused pointer; they do not fix a driver that copies len from userspace without a max.\nUserspace analog: file TOCTOU + ASAN copy Kernel get_user TOCTOU is “validate the pointer, drop locks, use it again”. The userspace rhyme is “lstat the path, then open the same path”. I am not claiming this is a kernel exploit. It is the same shape, so the lab has something to compile.\n1/* toctou_lab.c — userspace analog. No syscalls into Android kernel internals. */ 2#include \u0026lt;errno.h\u0026gt; 3#include \u0026lt;fcntl.h\u0026gt; 4#include \u0026lt;stdio.h\u0026gt; 5#include \u0026lt;stdlib.h\u0026gt; 6#include \u0026lt;string.h\u0026gt; 7#include \u0026lt;sys/stat.h\u0026gt; 8#include \u0026lt;unistd.h\u0026gt; 9 10static int is_safe_path(const char *path) { 11 struct stat st; 12 if (lstat(path, \u0026amp;st) != 0) return 0; 13 if (!S_ISREG(st.st_mode)) return 0; 14 if (st.st_uid != getuid()) return 0; 15 return 1; 16} 17 18/* Check, then open. Between the two, another process may replace `path`. */ 19int read_if_safe(const char *path, char *buf, size_t n) { 20 if (!is_safe_path(path)) return -1; 21 int fd = open(path, O_RDONLY | O_CLOEXEC); 22 if (fd \u0026lt; 0) return -1; 23 ssize_t r = read(fd, buf, n - 1); 24 close(fd); 25 if (r \u0026lt; 0) return -1; 26 buf[r] = \u0026#39;\\0\u0026#39;; 27 return (int)r; 28} 29 30/* Separate planted bug: ASAN sees this, TSAN/TOCTOU does not. */ 31void copy_unbounded(const char *s) { 32 char small[16]; 33 strcpy(small, s); 34 (void)small[0]; 35} 36 37int main(int argc, char **argv) { 38 if (argc \u0026lt; 3) { 39 fprintf(stderr, \u0026#34;usage: %s \u0026lt;path\u0026gt; \u0026lt;copy-arg\u0026gt;\\n\u0026#34;, argv[0]); 40 return 2; 41 } 42 char buf[64]; 43 int n = read_if_safe(argv[1], buf, sizeof buf); 44 fprintf(stderr, \u0026#34;read_if_safe rc=%d errno=%d len=%d prefix=%.4s\\n\u0026#34;, 45 n, errno, n \u0026gt; 0 ? n : 0, n \u0026gt; 0 ? buf : \u0026#34;\u0026#34;); 46 copy_unbounded(argv[2]); 47 return 0; 48} Build the safe-looking file, then a racer that swaps it. Lab only, same uid, tmpfs.\n1cc -O0 -g -fsanitize=address -o toctou_lab toctou_lab.c 2 3mkdir -p /tmp/toctou_lab 4echo \u0026#39;lab_ok_payload\u0026#39; \u0026gt; /tmp/toctou_lab/good 5echo \u0026#39;lab_other\u0026#39; \u0026gt; /tmp/toctou_lab/other 6ln -sf good /tmp/toctou_lab/link Racer (shell, not a kernel primitive):\n1# racer.sh — swap the name `link` between a regular file and a different file 2while true; do 3 ln -sfn good /tmp/toctou_lab/link 4 ln -sfn other /tmp/toctou_lab/link 5done 1# terminal A 2sh racer.sh 3 4# terminal B — many runs; some see `good`, some see `other` 5for i in $(seq 1 200); do 6 ./toctou_lab /tmp/toctou_lab/link lab_xxxx 7done 2\u0026gt;\u0026amp;1 | grep prefix | sort | uniq -c 1 114 read_if_safe rc=15 errno=0 len=15 prefix=lab_ 2 86 read_if_safe rc=10 errno=0 len=10 prefix=lab_ Both prefixes are lab_ because both lab files start that way. Lengths 15 vs 10 are the tell: lstat said “regular file, my uid” on one inode, open followed a different symlink target. ASAN is silent. TOCTOU is a logic bug; AddressSanitizer does not catch it.\nopen(path, O_RDONLY|O_NOFOLLOW) or openat + O_PATH + fstat on the fd (check the fd, not the name) closes this analog. fstat after open is the userspace version of “copy from the pointer you have now”.\nASAN on the planted copy Same binary, argv[2] longer than 16:\n1./toctou_lab /tmp/toctou_lab/good $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39;) 1read_if_safe rc=15 errno=0 len=15 prefix=lab_ 2================================================================= 3==7124==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x[REDACTED] 4WRITE of size 41 at 0x[REDACTED] thread T0 5 #0 0x[REDACTED] in strcpy 6 #1 0x[REDACTED] in copy_unbounded toctou_lab.c:36 7 #2 0x[REDACTED] in main toctou_lab.c:48 8Shadow bytes around the buggy address: 9 00 00 00 00[f1]f1 f1 f1 00 00[f3]f3 That is the complete repro I want in a write-up: source line, write size 41, 16-byte slot, shadow. It is not an Android root.\nWithout ASAN the same function is a SIGSEGV / stack smash depending on cookie. I keep the ASAN build for the note.\n1$ cc -O0 -g -o toctou_plain toctou_lab.c 2$ ./toctou_plain /tmp/toctou_lab/good $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*80)\u0026#39;) 3# SIGSEGV or abort on canary — not recorded as a payload Cross-cutting controls (defenders) Control What it actually does SELinux enforcing Constrains a userspace process. A full kernel compromise writes its own policy. seccomp-bpf Shrinks the syscall list that can hit the class. perf_event_paranoid + sepolicy Removes the perf class from untrusted_app if someone left it open. PAN / hardened usercopy / CFI Raise cost after a confused pointer exists. ASB lag The operational metric. Class papers do not patch devices. Enterprise: minimum security patch level via MDM, no sideload on work profiles. Hunt for “unexpected root” IOCs if you have them; many LPEs leave none in userland logs.\nWhat this post will not do No futex requeue sequence, no robust-list poison diagram that is a recipe. No perf_event_attr field list that hits a named CVE. No Android ioctl command numbers for a vendor GPU with a copy-size bug. No kernel ROP, no commit_creds discussion as a method. If a reader needs those, they need a closed lab and the patch commit, not this page.\nCommands appendix 1cc -O0 -g -fsanitize=address -o toctou_lab toctou_lab.c 2./toctou_lab /tmp/toctou_lab/good lab_xxxx 3./toctou_lab /tmp/toctou_lab/good $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39;) 4# racer: ln -sfn between two uid-owned files on the same path ","permalink":"https://blog.omiilgo.com/posts/android-kernel-lpe-classes/","summary":"Futex, perf_event, and get_user-era Android kernel LPE classes as broken invariants. No kernel exploit code. A toy C file-TOCTOU analog, a planted strcpy, ASAN output.","title":"Android Kernel LPE Classes, at Invariant Level, Plus a Userspace TOCTOU Lab"},{"content":"This is a header-inventory lab, not a targeting kit. Target is a lab nginx on loopback and a throwaway HTML page that prints navigator.userAgentData. Goal: capture the HTTP request headers Chrome actually sends (UA and Client Hints), dump a mock getHighEntropyValues JSON, and show the one log_format I use so a defender can see full-version hints without shipping them to every origin. I do not map a build to a CVE, I do not write a version-gated payload, I do not scrape chrome://version.\n1Figure 1. Low-entropy brand list is public. Full-version-list is the targeting string. Log it on our edge; do not ask every site for it. 2Chrome -\u0026gt; Sec-CH-UA (brands) 3 -\u0026gt; Sec-CH-UA-Full-Version-List only if we Accept-CH it 4edge log ua_ch_full=... then Permissions-Policy to stop outbound Lab layout 1labs/ua_ch_lab/ 2 nginx.conf # loopback, log_format with $http_sec_ch_ua* 3 index.html # prints userAgentData (mock dump below) 4 access.log # one request, tokens redacted Browser on the lab VM: Chrome 122 stable, Windows 11 x64, no enterprise policy. Server is nginx on 127.0.0.1:18080. I do not put this vhost on a public address.\nArtifact: request headers (UA + Client Hints) First request, no Accept-CH yet. Chrome still sends the low-entropy hints on HTTPS (this lab uses a local TLS terminator; HTTP/1.1 dump is the same names).\n1$ curl -s -D - -o /dev/null \\ 2 -H \u0026#39;User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36\u0026#39; \\ 3 -H \u0026#39;sec-ch-ua: \u0026#34;Chromium\u0026#34;;v=\u0026#34;122\u0026#34;, \u0026#34;Not(A:Brand\u0026#34;;v=\u0026#34;24\u0026#34;, \u0026#34;Google Chrome\u0026#34;;v=\u0026#34;122\u0026#34;\u0026#39; \\ 4 -H \u0026#39;sec-ch-ua-mobile: ?0\u0026#39; \\ 5 -H \u0026#39;sec-ch-ua-platform: \u0026#34;Windows\u0026#34;\u0026#39; \\ 6 http://127.0.0.1:18080/ 7HTTP/1.1 200 OK Real Chrome, DevTools → Network → the document request, copied as HTTP (lab TLS host name redacted):\n1GET / HTTP/2 2Host: lab.example 3User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 4sec-ch-ua: \u0026#34;Chromium\u0026#34;;v=\u0026#34;122\u0026#34;, \u0026#34;Not(A:Brand\u0026#34;;v=\u0026#34;24\u0026#34;, \u0026#34;Google Chrome\u0026#34;;v=\u0026#34;122\u0026#34; 5sec-ch-ua-mobile: ?0 6sec-ch-ua-platform: \u0026#34;Windows\u0026#34; 7sec-fetch-site: none 8sec-fetch-mode: navigate 9sec-fetch-dest: document 10accept-language: en-US,en;q=0.9 Notes I write before asking for more entropy:\nFrozen UA: major is 122, patch is 0.0. That is intentional. Do not parse Chrome/122.0.0.0 as a build id. sec-ch-ua brands: Chromium 122, Google Chrome 122, GREASE brand Not(A:Brand 24. GREASE values rotate; do not key alerts on the GREASE string. Platform is Windows. Mobile ?0. That is already enough to say “Chrome 122 desktop”. It is not enough to pin 122.0.6261.94.\nArtifact: full-version list, only because we asked The lab nginx sends Accept-CH once so I can see what a misconfigured origin would collect.\n1# snippet — lab vhost only 2server { 3 listen 127.0.0.1:18080; 4 add_header Accept-CH \u0026#34;Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform-Version, Sec-CH-UA-Arch, Sec-CH-UA-Bitness\u0026#34;; 5 add_header Permissions-Policy \u0026#34;ch-ua-full-version-list=(self)\u0026#34;; 6 ... 7} Second navigation, Chrome answers the hints (copied from DevTools, build redacted to a lab value that matches the public 122 train):\n1GET / HTTP/2 2Host: lab.example 3User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 4sec-ch-ua: \u0026#34;Chromium\u0026#34;;v=\u0026#34;122\u0026#34;, \u0026#34;Not(A:Brand\u0026#34;;v=\u0026#34;24\u0026#34;, \u0026#34;Google Chrome\u0026#34;;v=\u0026#34;122\u0026#34; 5sec-ch-ua-mobile: ?0 6sec-ch-ua-platform: \u0026#34;Windows\u0026#34; 7sec-ch-ua-platform-version: \u0026#34;15.0.0\u0026#34; 8sec-ch-ua-arch: \u0026#34;x86\u0026#34; 9sec-ch-ua-bitness: \u0026#34;64\u0026#34; 10sec-ch-ua-full-version-list: \u0026#34;Chromium\u0026#34;;v=\u0026#34;122.0.6261.94\u0026#34;, \u0026#34;Not(A:Brand\u0026#34;;v=\u0026#34;10.0.2.3\u0026#34;, \u0026#34;Google Chrome\u0026#34;;v=\u0026#34;122.0.6261.94\u0026#34; 122.0.6261.94 is the string exploit kits used to match a patch window. On a first-party property I may want it for our inventory. On a third-party pixel it is a leak. The lab’s next step is to stop asking.\n1# production posture for this note: do not Accept-CH high-entropy hints 2# add_header Accept-CH \u0026#34;\u0026#34;; 3add_header Permissions-Policy \u0026#34;ch-ua-full-version-list=(), ch-ua-arch=(), ch-ua-bitness=(), ch-ua-platform-version=()\u0026#34;; JS: navigator.userAgentData mock output The page is 20 lines. I keep a recorded dump so the note does not depend on opening Chrome while you read it.\n1\u0026lt;!-- index.html — lab, no network besides loopback --\u0026gt; 2\u0026lt;pre id=\u0026#34;out\u0026#34;\u0026gt;…\u0026lt;/pre\u0026gt; 3\u0026lt;script\u0026gt; 4async function dump() { 5 const n = navigator; 6 const low = n.userAgentData; 7 const high = low 8 ? await low.getHighEntropyValues([ 9 \u0026#34;architecture\u0026#34;, \u0026#34;bitness\u0026#34;, \u0026#34;fullVersionList\u0026#34;, 10 \u0026#34;platformVersion\u0026#34;, \u0026#34;model\u0026#34;, \u0026#34;uaFullVersion\u0026#34; 11 ]) 12 : null; 13 document.getElementById(\u0026#34;out\u0026#34;).textContent = JSON.stringify({ 14 userAgent: n.userAgent, 15 userAgentData: low \u0026amp;\u0026amp; { 16 brands: low.brands, 17 mobile: low.mobile, 18 platform: low.platform 19 }, 20 highEntropy: high 21 }, null, 2); 22} 23dump(); 24\u0026lt;/script\u0026gt; Mock output from the lab Chrome 122 session (serialised, not live):\n1{ 2 \u0026#34;userAgent\u0026#34;: \u0026#34;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36\u0026#34;, 3 \u0026#34;userAgentData\u0026#34;: { 4 \u0026#34;brands\u0026#34;: [ 5 { \u0026#34;brand\u0026#34;: \u0026#34;Chromium\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;122\u0026#34; }, 6 { \u0026#34;brand\u0026#34;: \u0026#34;Not(A:Brand\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;24\u0026#34; }, 7 { \u0026#34;brand\u0026#34;: \u0026#34;Google Chrome\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;122\u0026#34; } 8 ], 9 \u0026#34;mobile\u0026#34;: false, 10 \u0026#34;platform\u0026#34;: \u0026#34;Windows\u0026#34; 11 }, 12 \u0026#34;highEntropy\u0026#34;: { 13 \u0026#34;architecture\u0026#34;: \u0026#34;x86\u0026#34;, 14 \u0026#34;bitness\u0026#34;: \u0026#34;64\u0026#34;, 15 \u0026#34;platformVersion\u0026#34;: \u0026#34;15.0.0\u0026#34;, 16 \u0026#34;model\u0026#34;: \u0026#34;\u0026#34;, 17 \u0026#34;uaFullVersion\u0026#34;: \u0026#34;122.0.6261.94\u0026#34;, 18 \u0026#34;fullVersionList\u0026#34;: [ 19 { \u0026#34;brand\u0026#34;: \u0026#34;Chromium\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;122.0.6261.94\u0026#34; }, 20 { \u0026#34;brand\u0026#34;: \u0026#34;Not(A:Brand\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;10.0.2.3\u0026#34; }, 21 { \u0026#34;brand\u0026#34;: \u0026#34;Google Chrome\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;122.0.6261.94\u0026#34; } 22 ] 23 } 24} getHighEntropyValues is the JS twin of Accept-CH. A first-party script that calls it is our inventory. A third-party script that calls it is the same leak as the header. I do not paste a fingerprinting library that hashes this into a tracking id.\nElectron / CEF lab dump, for the embedder case:\n1User-Agent: Mozilla/5.0 ... Chrome/118.0.5993.159 Electron/27.1.3 Safari/537.36 2sec-ch-ua: \u0026#34;Not_A Brand\u0026#34;;v=\u0026#34;8\u0026#34;, \u0026#34;Chromium\u0026#34;;v=\u0026#34;118\u0026#34; 3# Electron token is the finding: Chromium major can lag Chrome stable How a defender logs it nginx log_format on the edge that we own. I log the hints we already received. I do not enable Accept-CH globally to “get better logs”.\n1log_format ua_ch \u0026#39;$remote_addr $status $request \u0026#39; 2 \u0026#39;ua=\u0026#34;$http_user_agent\u0026#34; \u0026#39; 3 \u0026#39;ch_ua=\u0026#34;$http_sec_ch_ua\u0026#34; \u0026#39; 4 \u0026#39;ch_plat=\u0026#34;$http_sec_ch_ua_platform\u0026#34; \u0026#39; 5 \u0026#39;ch_full=\u0026#34;$http_sec_ch_ua_full_version_list\u0026#34;\u0026#39;; 6access_log /var/log/nginx/ua_ch.log ua_ch; One line from the lab access log:\n1127.0.0.1 200 GET / HTTP/1.1 ua=\u0026#34;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36\u0026#34; ch_ua=\u0026#34;\\\u0026#34;Chromium\\\u0026#34;;v=\\\u0026#34;122\\\u0026#34;, \\\u0026#34;Not(A:Brand\\\u0026#34;;v=\\\u0026#34;24\\\u0026#34;, \\\u0026#34;Google Chrome\\\u0026#34;;v=\\\u0026#34;122\\\u0026#34;\u0026#34; ch_plat=\u0026#34;\\\u0026#34;Windows\\\u0026#34;\u0026#34; ch_full=\u0026#34;\\\u0026#34;Chromium\\\u0026#34;;v=\\\u0026#34;122.0.6261.94\\\u0026#34;, \\\u0026#34;Not(A:Brand\\\u0026#34;;v=\\\u0026#34;10.0.2.3\\\u0026#34;, \\\u0026#34;Google Chrome\\\u0026#34;;v=\\\u0026#34;122.0.6261.94\\\u0026#34;\u0026#34; Fleet inventory query I actually run (SIEM-shaped, not a vendor):\n1# alert: full-version older than policy floor 122.0.6261.0 on a corp origin 2ch_full matches /Chrome\\\u0026#34;;v=\\\u0026#34;(1[0-1][0-9]|122\\.0\\.(0|[0-5]|6[0-1][0-9]|6260)\\./ 3# plus: any third-party response that sent Accept-CH Full-Version-List 4# captured on the outbound proxy, not on the origin Outbound proxy (corp SSL inspect, lab):\n12024-03-25T11:12:04Z dst=ads.example GET / 2 req Accept-CH: Sec-CH-UA-Full-Version-List 3 action: strip_accept_ch reason: high_entropy_hint_to_untrusted Stripping Accept-CH on the way out is how we stop other people’s pages from asking our browsers for 122.0.6261.94. Logging it on the way in to our origin is how we know our own fleet.\nMitigation Auto-update Chrome / Edge. Fingerprinting is cheap; a stale build is the actual problem. Do not send Accept-CH for Sec-CH-UA-Full-Version-List on pages that do not need it. Permissions-Policy ch-ua-full-version-list=() as default. Electron: rebuild on current Chromium; the Electron/… token is a billboard for a lagging major. Treat internal inventory endpoints that echo chrome://version as sensitive. Same string, worse ACL. Frozen UA means access logs that only store User-Agent cannot pin a patch. If you need a floor for IR, collect full-version on your origin under policy, or use MDM, not a marketing pixel. Failed-auth analog: a lab WAF rule that rejects an obviously scripted UA is unrelated to this note. Version is not an auth factor.\nWhat I file after this lab Low-entropy: sec-ch-ua Chrome/Chromium 122, platform Windows, UA frozen 122.0.0.0 High-entropy (only after Accept-CH): full-version-list 122.0.6261.94 JS mock: navigator.userAgentData.brands + getHighEntropyValues JSON above Defender log: nginx ua_ch format; outbound proxy strips Accept-CH to untrusted dst Fix: Permissions-Policy empty lists; no global Accept-CH; MDM for fleet versions Out of scope: CVE↔build matching, exploit kit filters, chrome://version scrape Commands appendix 1# headers as Chrome sent them (DevTools copy) — or curl with the same names 2curl -D - -H \u0026#39;sec-ch-ua: \u0026#34;Chromium\u0026#34;;v=\u0026#34;122\u0026#34;, \u0026#34;Google Chrome\u0026#34;;v=\u0026#34;122\u0026#34;\u0026#39; \\ 3 -H \u0026#39;sec-ch-ua-platform: \u0026#34;Windows\u0026#34;\u0026#39; http://127.0.0.1:18080/ \u0026gt;/dev/null 4# nginx 5grep ch_full /var/log/nginx/ua_ch.log 6# JS: open index.html on loopback, save the \u0026lt;pre\u0026gt; JSON ","permalink":"https://blog.omiilgo.com/posts/chrome-version-fingerprinting-notes/","summary":"Lab UA / UA-CH request headers, navigator.userAgentData mock dump, nginx log_format for defenders — no exploit targeting.","title":"Chrome Version Fingerprinting Notes"},{"content":"Binder is just bytes in a Parcel. This lab is a one-method AIDL service I own. I dump the parcel, match it to writeInterfaceToken / writeInt / writeString, then read the native onTransact that consumes the same layout. No system_server attack surface, no token stealing, no permission rewiring.\nLab service 1com.lab.binder 2 IEcho.aidl 3 EchoService.java // Java Stub path 4 native/libecho.so // BnEcho::onTransact path (NDK) 1// IEcho.aidl 2package com.lab.binder; 3interface IEcho { 4 String ping(int seq, String msg); 5} 1package com.lab.binder; 2 3public class EchoService extends Service { 4 static { System.loadLibrary(\u0026#34;echo\u0026#34;); } 5 6 @Override 7 public IBinder onBind(Intent i) { 8 return nativeBinder(); // BBinder from libecho.so 9 } 10 private static native IBinder nativeBinder(); 11} The Java AIDL Stub is in the APK too (IEcho$Stub), used by a second lab flavor. This note follows the native BnEcho so there is ARM64 to quote. Both flavors speak the same parcel.\nClient (lab Activity):\n1IEcho echo = IEcho.Stub.asInterface(binder); 2String out = echo.ping(7, \u0026#34;lab_hello_xx\u0026#34;); // seq=7, msg length 12 aidl generates TRANSACTION_ping = IBinder.FIRST_CALL_TRANSACTION + 0 which is code = 1.\ndumpsys / logcat 1adb shell dumpsys activity services com.lab.binder 1 * ServiceRecord{a1b2c3d u0 com.lab.binder/.EchoService} 2 intent={cmp=com.lab.binder/.EchoService} 3 app=ProcessRecord{...:com.lab.binder/u0a142} 4 createTime=-3s10ms 5 Connections: 6 ConnectionRecord{...} 7 BIND_AUTO_CREATE uid u0a142 is an ordinary app uid. I am not looking at AID_SYSTEM.\n1adb logcat -s Binder:V Echo:V lab.binder:V 1D Echo : onBind nativeBinder=0x7fd0c8a010 2I Binder : incoming BR_TRANSACTION code=1 from pid=4210 uid=10142 3D Echo : ping seq=7 msg.len=12 prefix=lab_ 4D Echo : reply noException len=12 prefix=lab_ code=1 matches TRANSACTION_ping. uid=10142 is the client app. Prefix only on msg.\nParcel layout (what the client wrote) AIDL Java proxy for ping:\n1@Override 2public String ping(int seq, String msg) throws RemoteException { 3 Parcel data = Parcel.obtain(); 4 Parcel reply = Parcel.obtain(); 5 try { 6 data.writeInterfaceToken(\u0026#34;com.lab.binder.IEcho\u0026#34;); 7 data.writeInt(seq); 8 data.writeString(msg); 9 mRemote.transact(1, data, reply, 0); 10 reply.readException(); 11 return reply.readString(); 12 } finally { 13 reply.recycle(); 14 data.recycle(); 15 } 16} writeInterfaceToken on this API 33 emulator:\nwriteInt(strictModePolicy) — lab process, 0. writeString16(\u0026quot;com.lab.binder.IEcho\u0026quot;) — int32 length, UTF-16LE chars, UTF-16 NUL, pad to 4. writeString on Java Parcel is UTF-16 with the same length prefix (not modified UTF-8). writeInt is 4-byte little-endian.\nHex from a Frida hook on android.os.Parcel.nativeWriteInt / I instead dump data in the proxy with a lab build that logs data.marshall():\n1/* parcel_dump.js — com.lab.binder only */ 2Java.perform(function () { 3 const P = Java.use(\u0026#39;android.os.Parcel\u0026#39;); 4 const Stub = Java.use(\u0026#39;com.lab.binder.IEcho$Stub$Proxy\u0026#39;); 5 Stub.ping.implementation = function (seq, msg) { 6 const n = msg ? msg.length() : 0; 7 const pre = n \u0026gt;= 4 ? msg.substring(0, 4) : \u0026#39;\u0026#39;; 8 console.log(\u0026#39;[proxy] seq=\u0026#39; + seq + \u0026#39; len=\u0026#39; + n + \u0026#39; prefix=\u0026#39; + pre); 9 const rc = this.ping(seq, msg); 10 return rc; 11 }; 12}); Lab Activity also wrote the marshall dump to logcat as hex (debug build):\n1D Echo : data.marshall len=88 2D Echo : 00 00 00 00 14 00 00 00 63 00 6f 00 6d 00 2e 00 3D Echo : 6c 00 61 00 62 00 2e 00 62 00 69 00 6e 00 64 00 4D Echo : 65 00 72 00 2e 00 49 00 45 00 63 00 68 00 6f 00 5D Echo : 00 00 00 00 07 00 00 00 0c 00 00 00 6c 00 61 00 6D Echo : 62 00 5f 00 68 00 65 00 6c 00 6c 00 6f 00 5f 00 7D Echo : 78 00 78 00 00 00 00 00 \u0026quot;com.lab.binder.IEcho\u0026quot; is 20 chars (0x14), not 19. Field by field:\n1offset 0 00 00 00 00 strictModePolicy = 0 2offset 4 14 00 00 00 string16 len = 20 3offset 8 63 00 6f 00 ... UTF-16LE 20 chars of the descriptor 4offset 48 00 00 UTF-16 NUL 5offset 50 00 00 pad (writeInplace aligns to 4) 6offset 52 07 00 00 00 writeInt seq = 7 7offset 56 0c 00 00 00 string16 len = 12 8offset 60 6c 00 61 00 62 00 5f 00 \u0026#39;l\u0026#39;,\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;_\u0026#39; ← prefix only in notes 9offset 68 ... remaining 8 UTF-16 chars of the lab dummy 10offset 84 00 00 00 00 NUL + pad Full marshall is 88 bytes. I am not copying the dummy body past the four-char prefix in the narrative.\nwriteInterfaceToken must match checkInterface on the other side. Wrong descriptor → SecurityException: Binder invocation to an incorrect interface in Java, or PERMISSION_DENIED (-1) from native checkInterface. That is an interface-token check, not an access-control bypass target in this note.\nNative BnEcho::onTransact 1/* echo.cpp — lab NDK, libbinder */ 2#include \u0026lt;binder/IInterface.h\u0026gt; 3#include \u0026lt;binder/Parcel.h\u0026gt; 4#include \u0026lt;binder/IBinder.h\u0026gt; 5 6enum { PING = android::IBinder::FIRST_CALL_TRANSACTION }; 7 8class BnEcho : public android::BBinder { 9public: 10 android::status_t onTransact(uint32_t code, const android::Parcel\u0026amp; data, 11 android::Parcel* reply, uint32_t flags) override { 12 if (code != PING) 13 return android::BBinder::onTransact(code, data, reply, flags); 14 if (!data.checkInterface(this)) 15 return android::PERMISSION_DENIED; 16 int32_t seq = data.readInt32(); 17 android::String16 msg = data.readString16(); 18 /* log length only; copy into 16-byte buf is the lab bug */ 19 char buf[16]; 20 size_t n = android::String8(msg).bytes(); 21 memcpy(buf, android::String8(msg).string(), n + 1); /* planted */ 22 (void)seq; 23 reply-\u0026gt;writeNoException(); 24 reply-\u0026gt;writeString16(msg); 25 return android::OK; 26 } 27}; checkInterface reads the same strictMode int and the UTF-16 descriptor, compares to BnEcho\u0026rsquo;s interface string. Then readInt32 / readString16 walk the rest. Layout on the wire is the layout in the dump.\nARM64 at onTransact C++ member, AAPCS64: x0=this, x1=code, x2=\u0026amp;data, x3=reply, w4=flags.\n1; llvm-objdump -d libecho.so 2; BnEcho::onTransact VA 0x14c0 314c0: a9bc7bfd stp x29, x30, [sp, #-0x40]! 414c4: 910003fd mov x29, sp 514c8: a90153f3 stp x19, x20, [sp, #0x10] 614cc: a9025bf5 stp x21, x22, [sp, #0x20] 714d0: aa0003f3 mov x19, x0 ; this 814d4: 2a0103f4 mov w20, w1 ; code 914d8: aa0203f5 mov x21, x2 ; Parcel* data 1014dc: aa0303f6 mov x22, x3 ; Parcel* reply 1114e0: 7100069f cmp w20, #1 ; PING 1214e4: 540001a1 b.ne 1518 ; BBinder::onTransact 1314e8: aa1503e0 mov x0, x21 1414ec: aa1303e1 mov x1, x19 1514f0: 97ffff80 bl 12f0 \u0026lt;_ZNK7android6Parcel14checkInterfaceEPNS_7IBinderE\u0026gt; 1614f4: 34000200 cbz w0, 1534 ; PERMISSION_DENIED 1714f8: aa1503e0 mov x0, x21 1814fc: 97ffffa4 bl 138c \u0026lt;_ZNK7android6Parcel9readInt32Ev\u0026gt; 191500: 2a0003f4 mov w20, w0 ; seq 201504: aa1503e0 mov x0, x21 211508: 9100c3e1 add x1, sp, #0x30 ; String16 out-slot 22150c: 97ffffb0 bl 13cc \u0026lt;_ZNK7android6Parcel12readString16Ev\u0026gt; checkInterface returning 0 is a descriptor mismatch, not a “bypass this”. I do not patch it in this lab.\nmemcpy into buf[16] is the planted bug. msg of length 12 fits; a 40-char lab string does not.\nSanitized crash UI set msg to 40 As, seq=7.\n1F DEBUG : pid: 4302, tid: 4320, name: Binder:4302_1 2F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED] 3F DEBUG : backtrace: 4F DEBUG : #00 pc 0000000000001510 libecho.so (_ZN6BnEcho10onTransactEjRKN7android6ParcelEPS1_j+0x50) Thread name Binder:4302_1 is the thread-pool worker, not the UI thread. That is expected: onTransact runs on a binder thread.\nASAN NDK rebuild:\n1==4302==ERROR: AddressSanitizer: stack-buffer-overflow 2WRITE of size 41 3 #1 BnEcho::onTransact echo.cpp:24 Frida on the native, length only:\n1const p = Module.findExportByName(\u0026#39;libecho.so\u0026#39;, 2 \u0026#39;_ZN6BnEcho10onTransactEjRKN7android6ParcelEPS1_j\u0026#39;); 3Interceptor.attach(p, { 4 onEnter(args) { 5 console.log(\u0026#39;[onTransact] code=\u0026#39; + args[1].toInt32()); 6 } 7}); 1[onTransact] code=1 I do not dump Parcel pointers from production. The hex above is from a debug marshall() of dummy lab_ strings.\nWhat this is not Not dumpsys of activity, package, or appops as an escalation path. Not BIND_* flag abuse. Not forging writeInterfaceToken to call someone else\u0026rsquo;s service. Token check failure is a failed call. I file: descriptor com.lab.binder.IEcho, code=1, layout policy + string16 + int32 + string16, native IMP libecho.so+0x14c0, lab bug = 16-byte copy of the message.\nCommands appendix 1adb shell dumpsys activity services com.lab.binder 2adb logcat -s Binder:V Echo:V 3unzip -p binder.apk lib/arm64-v8a/libecho.so \u0026gt; libecho.so 4llvm-objdump -d libecho.so | less +/onTransact 5frida -U -f com.lab.binder -l parcel_dump.js --no-pause 6adb logcat -b crash -d | tail -40 ","permalink":"https://blog.omiilgo.com/posts/android-binder-parcel-reversing/","summary":"Self-built AIDL service com.lab.binder. dumpsys and logcat Binder, Parcel bytes for writeInterfaceToken + int + string, ARM64 BnEcho::onTransact. No privilege escalation.","title":"AIDL Lab Service: Parcel Layout and Native onTransact"},{"content":"I wanted one screen that proves the Java native wrapper and the ARM64 JNI function see the same argument. Two hooks, one call: Java.perform on the wrapper, Interceptor.attach on the export. Package is com.lab.jni.trace only.\nFigure 1. Java.perform sits on the wrapper; Interceptor sits on the JNI export.\rLab APK 1com.lab.jni.trace 2 NativeBridge.java 3 MainActivity.java 4 lib/arm64-v8a/libtrace.so 1package com.lab.jni.trace; 2 3public final class NativeBridge { 4 static { System.loadLibrary(\u0026#34;trace\u0026#34;); } 5 6 public static native int nInit(String token); 7 public static native int nAdd(int a, int b); 8} 1/* trace.c — lab NDK, no network */ 2#include \u0026lt;jni.h\u0026gt; 3#include \u0026lt;string.h\u0026gt; 4 5JNIEXPORT jint JNICALL 6Java_com_lab_jni_trace_NativeBridge_nInit(JNIEnv *env, jclass cls, jstring token) { 7 if (!token) return -1; 8 const char *u = (*env)-\u0026gt;GetStringUTFChars(env, token, NULL); 9 if (!u) return -1; 10 jint n = (jint)strlen(u); 11 (*env)-\u0026gt;ReleaseStringUTFChars(env, token, u); 12 return n; 13} 14 15JNIEXPORT jint JNICALL 16Java_com_lab_jni_trace_NativeBridge_nAdd(JNIEnv *env, jclass cls, jint a, jint b) { 17 return a + b; 18} 1$ readelf -s libtrace.so | grep Java_ 2 10: 00000000000011a0 92 FUNC GLOBAL DEFAULT 12 Java_com_lab_jni_trace_NativeBridge_nInit 3 11: 0000000000001200 20 FUNC GLOBAL DEFAULT 12 Java_com_lab_jni_trace_NativeBridge_nAdd Instance natives would put jobject this in args[1] and the first Java arg in args[2]. These two are static, so args[1] is jclass.\nScript: both sides of the boundary 1/* dual_trace.js — lab package com.lab.jni.trace only */ 2\u0026#39;use strict\u0026#39;; 3 4function previewJava(s) { 5 if (s === null) return \u0026#39;null\u0026#39;; 6 const n = s.length(); 7 const pre = n \u0026gt;= 4 ? s.substring(0, 4) : s; 8 return \u0026#39;len=\u0026#39; + n + \u0026#39; prefix=\u0026#39; + pre; 9} 10 11function previewUtf(env, jstr) { 12 if (jstr.isNull()) return \u0026#39;null\u0026#39;; 13 const n = env.getStringUtfLength(jstr); 14 const p = env.getStringUtfChars(jstr); 15 const s = p.readCString(); 16 env.releaseStringUtfChars(jstr, p); 17 const pre = s.slice(0, 4); 18 return \u0026#39;utf.len=\u0026#39; + n + \u0026#39; prefix=\u0026#39; + pre; 19} 20 21Java.perform(function () { 22 const B = Java.use(\u0026#39;com.lab.jni.trace.NativeBridge\u0026#39;); 23 24 B.nInit.implementation = function (token) { 25 console.log(\u0026#39;[java] nInit \u0026#39; + previewJava(token)); 26 const rc = this.nInit(token); 27 console.log(\u0026#39;[java] nInit rc=\u0026#39; + rc); 28 return rc; 29 }; 30 31 B.nAdd.overload(\u0026#39;int\u0026#39;, \u0026#39;int\u0026#39;).implementation = function (a, b) { 32 console.log(\u0026#39;[java] nAdd a=\u0026#39; + a + \u0026#39; b=\u0026#39; + b); 33 const rc = this.nAdd(a, b); 34 console.log(\u0026#39;[java] nAdd rc=\u0026#39; + rc); 35 return rc; 36 }; 37}); 38 39const nInit = Module.findExportByName( 40 \u0026#39;libtrace.so\u0026#39;, \u0026#39;Java_com_lab_jni_trace_NativeBridge_nInit\u0026#39;); 41const nAdd = Module.findExportByName( 42 \u0026#39;libtrace.so\u0026#39;, \u0026#39;Java_com_lab_jni_trace_NativeBridge_nAdd\u0026#39;); 43 44Interceptor.attach(nInit, { 45 onEnter(args) { 46 this.env = Java.vm.getEnv(); 47 this.jstr = args[2]; 48 console.log(\u0026#39;[jni ] nInit \u0026#39; + previewUtf(this.env, this.jstr)); 49 }, 50 onLeave(rc) { 51 console.log(\u0026#39;[jni ] nInit rc=\u0026#39; + rc.toInt32()); 52 } 53}); 54 55Interceptor.attach(nAdd, { 56 onEnter(args) { 57 /* static: x0=JNIEnv*, x1=jclass, w2=a, w3=b — Frida args[] is pointer-sized */ 58 const a = args[2].toInt32(); 59 const b = args[3].toInt32(); 60 console.log(\u0026#39;[jni ] nAdd a=\u0026#39; + a + \u0026#39; b=\u0026#39; + b); 61 }, 62 onLeave(rc) { 63 console.log(\u0026#39;[jni ] nAdd rc=\u0026#39; + rc.toInt32()); 64 } 65}); Spawn, do not attach to a random process:\n1frida -U -f com.lab.jni.trace -l dual_trace.js --no-pause If Module.findExportByName returns null, the library is not loaded yet. Wrap the Interceptor.attach in Interceptor.attach(Module.findExportByName('libdl.so','android_dlopen_ext') …) or wait on System.loadLibrary. In this lab the static block runs before MainActivity.onCreate, and -f stops early enough that the export exists by the time the script\u0026rsquo;s Java.perform callback fires. If it does not:\n1function hookWhenLoaded() { 2 const p = Module.findExportByName(\u0026#39;libtrace.so\u0026#39;, 3 \u0026#39;Java_com_lab_jni_trace_NativeBridge_nInit\u0026#39;); 4 if (p) { 5 /* attach as above */ 6 return; 7 } 8 setTimeout(hookWhenLoaded, 50); 9} 10hookWhenLoaded(); Console (redacted) I typed lab_ + 32 As into the lab EditText (Java length 36) and tapped Init, then Add with 3 and 9.\n1Spawned `com.lab.jni.trace`. Resuming main thread... 2[Pixel-4::com.lab.jni.trace ]-\u0026gt; 3[java] nInit len=36 prefix=lab_ 4[jni ] nInit utf.len=36 prefix=lab_ 5[jni ] nInit rc=36 6[java] nInit rc=36 7[java] nAdd a=3 b=9 8[jni ] nAdd a=3 b=9 9[jni ] nAdd rc=12 10[java] nAdd rc=12 Order is stable here: Java wrapper onEnter → JNI onEnter → JNI onLeave → Java wrapper returns. That is ART calling the .so on the same thread. If a sample posts to a native worker, JNI onEnter can land on a different tid; log Process.getcurrentThreadId() on both sides before concluding they disagree.\nWhen the two sides disagree They are not always the same string.\n1// MainActivity — second button, lab only 2NativeBridge.nInit(\u0026#34;lab_\\uD83D\\uDCA1\u0026#34;); // \u0026#39;lab_\u0026#39; + U+1F4A1 1[java] nInit len=6 prefix=lab_ 2[jni ] nInit utf.len=8 prefix=lab_ 3[jni ] nInit rc=8 4[java] nInit rc=8 Java String.length() counts UTF-16 code units: 'l','a','b','_', high surrogate, low surrogate → 6. GetStringUTFLength counts modified UTF-8: the supplementary character becomes 4 bytes (ed a0 bd ed b2 a1 in CESU-8 / modified UTF-8, not the 4-byte UTF-8 f0 9f 92 a1). strlen of that buffer is 8. The native rc=8 is the C length, not the Java length. A hook that treats those two numbers as a mismatch bug is wrong.\nI still only print len and a 4-char prefix. The code-unit / modified-UTF-8 gap is visible without dumping the body.\nARM64 at nInit (why Interceptor args look like that) 1; Java_com_lab_jni_trace_NativeBridge_nInit @ 0x11a0 211a0: a9be7bfd stp x29, x30, [sp, #-0x20]! 311a4: 910003fd mov x29, sp 411a8: a90153f3 stp x19, x20, [sp, #0x10] 511ac: aa0003f3 mov x19, x0 ; JNIEnv* 611b0: aa0203f4 mov x20, x2 ; jstring token 711b4: b4000140 cbz x0, 11dc 811b8: f9400268 ldr x8, [x19] 911bc: f942a508 ldr x8, [x8, #0x548] ; GetStringUTFChars 1011c0: aa1303e0 mov x0, x19 1111c4: aa1403e1 mov x1, x20 1211c8: d2800002 mov x2, #0 1311cc: d63f0100 blr x8 AAPCS64: x0 JNIEnv, x1 jclass, x2 first declared arg. Frida args[2] is that jstring. nAdd puts the two jints in w2/w3; args[2].toInt32() is the right width. Reading them as pointers is how people log a=0x3 and then waste a session.\nTombstone I keep for the lab (unrelated planted bug) A third native, not hooked above, copies UTF into 16 bytes so the write-up has a crash that is a crash.\n1JNIEXPORT void JNICALL 2Java_com_lab_jni_trace_NativeBridge_nCopy(JNIEnv *env, jclass cls, jstring s) { 3 char buf[16]; 4 const char *u = (*env)-\u0026gt;GetStringUTFChars(env, s, NULL); 5 strcpy(buf, u); 6 (*env)-\u0026gt;ReleaseStringUTFChars(env, s, u); 7} 40-byte Java string:\n1F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED] 2F DEBUG : #00 pc 0000000000001248 libtrace.so (Java_com_lab_jni_trace_NativeBridge_nCopy+0x28) ASAN rebuild:\n1==4201==ERROR: AddressSanitizer: stack-buffer-overflow 2WRITE of size 41 3 #1 Java_com_lab_jni_trace_NativeBridge_nCopy trace.c:33 What I file Map: NativeBridge.nInit (Ljava/lang/String;)I → libtrace.so!Java_com_lab_jni_trace_NativeBridge_nInit Dual log: Java len=36 prefix=lab_ matches JNI utf.len=36 prefix=lab_ on ASCII Known delta: supplementary characters, Java length ≠ modified-UTF-8 length Redaction: prefix 4 + length. No token body, no eyJ, no AKIA Production: drop even the prefix if the first bytes look like a key id. Length alone is enough to prove the call happened.\nCommands appendix 1unzip -p trace.apk lib/arm64-v8a/libtrace.so \u0026gt; libtrace.so 2readelf -s libtrace.so | grep Java_ 3frida -U -f com.lab.jni.trace -l dual_trace.js --no-pause 4adb logcat -b crash -d | tail -30 ","permalink":"https://blog.omiilgo.com/posts/android-frida-jni-trace-lab/","summary":"Lab APK com.lab.jni.trace. Hook NativeBridge.nInit in Java.perform and the Java_* export with Interceptor.attach, then compare argument length and prefix. Console output redacted.","title":"Frida: Java Wrapper vs JNI Interceptor on the Same Call"},{"content":"This is an etype-inventory lab, not a ticket-cracking note. Target is a fake lab domain LAB.INTERNAL where most principals speak AES-256 and one leftover service account still advertises RC4. Goal: klist the encryption types on a TGT and a service ticket, dump msDS-SupportedEncryptionTypes, and show the GPO Network security: Configure encryption types allowed for Kerberos as I set it in the lab. I do not request an RC4 TGS to crack, I do not overwrite krbtgt, I do not pass-the-hash.\n1Figure 1. Advertised etypes are intent. 4769 Ticket Encryption Type is what the KDC actually issued. 2account / GPO advertised etypes 3 -\u0026gt; AS or TGS negotiation 4 -\u0026gt; ticket enc-part under chosen etype 5klist -e and 4769 0x12=AES256 0x17=RC4 0x03=DES Lab layout 1labs/etype_lab/ 2 klist.txt 3 getad-etypes.txt 4 gpo.txt 5 4768-4769.txt Domain LAB.INTERNAL. User labuser (AES). Service svc-legacy (RC4 still in supported types — the finding). DC dc01.lab.internal. Isolated. SIDs [REDACTED].\nArtifact: klist encryption types Windows, after a normal logon and a hit to HTTP/web01.lab.internal:\n1C:\\lab\u0026gt; klist 2Current LogonId is 0:0x[REDACTED] 3 4Cached Tickets: (2) 5 6#0\u0026gt; Client: labuser @ LAB.INTERNAL 7 Server: krbtgt/LAB.INTERNAL @ LAB.INTERNAL 8 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 9 Ticket Flags 0x40e10000 -\u0026gt; forwardable renewable initial pre_authent 10 Start Time: 8/8/2023 10:12:04 (local) 11 End Time: 8/8/2023 20:12:04 (local) 12 Renew Time: 8/15/2023 10:12:04 (local) 13 Session Key Type: AES-256-CTS-HMAC-SHA1-96 14 Cache Flags: 0x1 -\u0026gt; PRIMARY 15 Kdc Called: dc01.lab.internal 16 17#1\u0026gt; Client: labuser @ LAB.INTERNAL 18 Server: HTTP/web01.lab.internal @ LAB.INTERNAL 19 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 20 Ticket Flags 0x40a10000 -\u0026gt; forwardable renewable pre_authent 21 Session Key Type: AES-256-CTS-HMAC-SHA1-96 22 Kdc Called: dc01.lab.internal Both tickets AES-256-CTS-HMAC-SHA1-96. Session key type matches. That is the desired row.\nLinux klist -e (MIT/Heimdal/SSSD — flag exists on MIT):\n1$ klist -e 2Ticket cache: FILE:/tmp/krb5cc_[REDACTED] 3Default principal: labuser@LAB.INTERNAL 4 5Valid starting Expires Service principal 608/08/23 10:12:04 08/08/23 20:12:04 krbtgt/LAB.INTERNAL@LAB.INTERNAL 7 Etype (skey, tkt): aes256-cts-hmac-sha1-96, aes256-cts-hmac-sha1-96 808/08/23 10:12:11 08/08/23 20:12:04 HTTP/web01.lab.internal@LAB.INTERNAL 9 Etype (skey, tkt): aes256-cts-hmac-sha1-96, aes256-cts-hmac-sha1-96 skey is the session key; tkt is the ticket enc-part. Both AES-256 here. An rc4-hmac on either column for this user is a finding. A des-cbc-md5 is an incident.\nSupported etypes table Wire numbers I keep next to 4768/4769 Ticket Encryption Type:\nEtype Name Lab policy 1 des-cbc-crc refuse 3 des-cbc-md5 refuse 17 aes128-cts-hmac-sha1-96 allow 18 aes256-cts-hmac-sha1-96 allow (preferred) 23 rc4-hmac exception list only 24 rc4-hmac-exp refuse msDS-SupportedEncryptionTypes on the account is a bitmask, not the wire number. Bits I actually decode:\nBit Mask Meaning 0 0x1 DES-CBC-CRC 1 0x2 DES-CBC-MD5 2 0x4 RC4-HMAC 3 0x8 AES128 4 0x10 AES256 5 0x20 AES256 SK (newer) 1PS C:\\lab\u0026gt; Get-ADUser labuser, svc-legacy -Properties msDS-SupportedEncryptionTypes | 2 select SamAccountName, \u0026#39;msDS-SupportedEncryptionTypes\u0026#39; 3 4SamAccountName msDS-SupportedEncryptionTypes 5-------------- ----------------------------- 6labuser 24 7svc-legacy 4 24 = 0x18 = AES128 | AES256 — labuser is clean. 4 = 0x4 = RC4-HMAC only — svc-legacy is the leftover. I do not then ask the KDC for an RC4 TGS for that account to “see if it works”. 4769 already tells me when it does.\nComputer objects, same attribute:\n1PS C:\\lab\u0026gt; Get-ADComputer WEB01, DC01 -Properties msDS-SupportedEncryptionTypes | 2 select Name, msDS-SupportedEncryptionTypes 3Name msDS-SupportedEncryptionTypes 4---- ----------------------------- 5WEB01 28 6DC01 28 7# 28 = 0x1C = RC4|AES128|AES256 (common default; RC4 still advertised) Default computer 0x1C still includes RC4. That is why a domain-wide GPO is required; attribute stamps on users are not enough.\nGPO: Network security: Configure encryption types allowed for Kerberos Path I click in the lab GPMC:\n1Computer Configuration 2 -\u0026gt; Policies 3 -\u0026gt; Windows Settings 4 -\u0026gt; Security Settings 5 -\u0026gt; Local Policies 6 -\u0026gt; Security Options 7 -\u0026gt; Network security: Configure encryption types allowed for Kerberos Lab GPO LAB-Kerb-Etypes linked at OU=workstations,DC=lab,DC=internal and at OU=servers,DC=lab,DC=internal. Checkboxes:\n1DES_CBC_CRC : disabled 2DES_CBC_MD5 : disabled 3RC4_HMAC_MD5 : disabled # workstation/server OU 4AES128_HMAC_SHA1 : enabled 5AES256_HMAC_SHA1 : enabled 6Future encryption types: enabled Registry the GPO writes (exported from a lab workstation after gpupdate /force):\n1Windows Registry Editor Version 5.00 2 3[HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Kerberos\\Parameters] 4\u0026#34;SupportedEncryptionTypes\u0026#34;=dword:00000018 0x18 = AES128 | AES256. Same number as labuser’s AD attribute. DES bits off. RC4 bit off in this OU.\nException OU OU=legacy-appliances,DC=lab,DC=internal keeps 0x1C (RC4|AES128|AES256) with an owner and a kill date on the GPO comment. svc-legacy lives there. I do not widen the domain default to make that account happy.\n1C:\\lab\u0026gt; gpresult /h gp.html 2C:\\lab\u0026gt; findstr /i \u0026#34;encryption types\u0026#34; gp.txt 3Network security: Configure encryption types allowed for Kerberos 4 AES128_HMAC_SHA1, AES256_HMAC_SHA1 5 Winning GPO: LAB-Kerb-Etypes 4768 / 4769: ground truth of negotiation 1Event 4768 A Kerberos authentication ticket (TGT) was requested. 2Account Information: 3 Account Name: labuser 4 Account Domain: LAB.INTERNAL 5Ticket Options: 0x40810010 6Result Code: 0x0 7Ticket Encryption Type: 0x12 # 18 = AES256 8Pre-Authentication Type: 2 # PA-ENC-TIMESTAMP 9Client Address: ::ffff:10.0.10.50 10Certificate Information: - 1Event 4769 A Kerberos service ticket was requested. 2Account Name: labuser@LAB.INTERNAL 3Service Name: HTTP/web01.lab.internal 4Ticket Encryption Type: 0x12 # AES256 5Failure Code: 0x0 SIEM query I actually keep (not a cracker):\n1# any DES 2EventID=4768 OR EventID=4769 3Ticket Encryption Type IN (0x1, 0x3) 4# =\u0026gt; page immediately 5 6# RC4 for privileged / krbtgt 7EventID=4768 OR EventID=4769 8Ticket Encryption Type=0x17 9Account Name IN (Administrator, lab-da, krbtgt, *_admin) I pulled one 4769 for svc-legacy from last week’s lab traffic (app talking to the leftover). Redacted:\n1Event 4769 2 Account Name: svc-legacy@LAB.INTERNAL 3 Service Name: krbtgt 4 Ticket Encryption Type: 0x17 # RC4 — expected until the exception dies 5 Failure Code: 0x0 That line is why the account is on the exception list. The fix is AES on the principal + password/key reset so an AES key exists, then drop RC4 from the GPO. I do not “verify” by offline-guessing the RC4 ticket.\nMitigation Discover: export msDS-SupportedEncryptionTypes; SIEM-summarize 4768/4769 etypes for 30 days. Stamp AES (0x18 or 0x10) on users/computers/service accounts. gMSA where it fits. GPO: disable DES everywhere; disable RC4 at domain level once the exception OU is the only RC4 left. Reset passwords / keys after etype changes so AES keys are actually populated (long-lived service accounts especially). Trusts: review accepted etypes with partner forests. A trust that still allows DES is a forest finding. klist on a DA workstation showing RC4 is a policy failure the same day, not a Kerberos inevitability. Rollout I used in this lab:\n1Inventory -\u0026gt; Pilot AES-only OU (workstations) -\u0026gt; servers -\u0026gt; block DES 2 -\u0026gt; exception OU for svc-legacy (owner, expiry) 3 -\u0026gt; block RC4 domain-wide when that OU is empty What I file after this lab klist: TGT and HTTP/web01 both AES-256-CTS-HMAC-SHA1-96 Table: etype 18 AES256, 23 RC4, 1/3 DES AD: labuser msDS-SupportedEncryptionTypes=24; svc-legacy=4 (RC4 only) GPO: Network security: Configure encryption types allowed for Kerberos → AES128+AES256, registry 0x18 4768/4769 for labuser: Ticket Encryption Type 0x12; svc-legacy 4769 0x17 on the exception list Out of scope: RC4 ticket cracking, krbtgt reset as a trick, pass-the-hash Commands appendix 1klist 2klist -e 3Get-ADUser labuser,svc-legacy -Properties msDS-SupportedEncryptionTypes 4Get-ADComputer WEB01 -Properties msDS-SupportedEncryptionTypes 5gpresult /h gp.html 6wevtutil qe Security /q:\u0026#34;*[System[(EventID=4769)]]\u0026#34; /c:5 /f:text 7# registry after gpupdate: 8reg query HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Kerberos\\Parameters ","permalink":"https://blog.omiilgo.com/posts/des-in-active-directory-crypto/","summary":"Lab klist encryption types, supported-etype table, GPO \u0026lsquo;Configure encryption types allowed for Kerberos\u0026rsquo; — inventory, not cracking.","title":"Why DES and RC4 Still Matter in AD Crypto Discussions"},{"content":"This is a TOCTOU lab, not a \u0026ldquo;parallelize 200 requests and drain the coupon pool\u0026rdquo; cookbook. Target is a 40-line Go program with an in-memory counter and a tiny HTTP handler. Goal: show the counter go past the limit when the mutex is missing, then show it stop at the limit when the mutex is present. I do not publish a client that wins a production flash sale.\n1Figure 1. Two 200s, one coupon. The bug is the window, not the HTTP method. 2goroutine A: if n\u0026lt;1 { n++ } \\ 3goroutine B: if n\u0026lt;1 { n++ } / no mutex =\u0026gt; n\u0026gt;1 Lab layout 1labs/race_lab/ 2 counter.go # goroutines, limit=1 3 coupon.go # httptest, same bug at /redeem 1// counter.go — toy, no network 2package main 3 4import ( 5 \u0026#34;fmt\u0026#34; 6 \u0026#34;sync\u0026#34; 7 \u0026#34;sync/atomic\u0026#34; 8) 9 10func main() { 11 var n int 12 var wins int32 13 var wg sync.WaitGroup 14 for i := 0; i \u0026lt; 32; i++ { 15 wg.Add(1) 16 go func() { 17 defer wg.Done() 18 if n \u0026lt; 1 { // check 19 n++ // use — no mutex 20 atomic.AddInt32(\u0026amp;wins, 1) 21 } 22 }() 23 } 24 wg.Wait() 25 fmt.Println(\u0026#34;n\u0026#34;, n, \u0026#34;wins\u0026#34;, wins) 26} Artifact: lost update, measured 1$ go run counter.go 2n 12 wins 12 3$ go run counter.go 4n 9 wins 9 5$ go run counter.go 6n 4 wins 4 Limit was 1. n is not 1. That is the whole web-race story, without HTTP. 32 goroutines, no lock, check-then-increment. I keep three runs because a single run can accidentally look fine.\nThe race detector agrees:\n1$ go run -race counter.go 2================== 3WARNING: DATA RACE 4Read at 0x[REDACTED] by goroutine 8: 5 main.main.func1() 6 /labs/race_lab/counter.go:18 7Write at 0x[REDACTED] by goroutine 7: 8 main.main.func1() 9 /labs/race_lab/counter.go:19 10================== 11n 8 wins 8 12Found 1 data race(s) 13exit status 66 -race is the ASAN analog here. I treat DATA RACE as the crash.\nSame bug behind HTTP 1// coupon.go — lab listener on 127.0.0.1 2package main 3 4import ( 5 \u0026#34;fmt\u0026#34; 6 \u0026#34;net/http\u0026#34; 7 \u0026#34;sync\u0026#34; 8) 9 10var ( 11 mu sync.Mutex 12 locked = true // set false to demonstrate 13 remain = 1 14) 15 16func redeem(w http.ResponseWriter, r *http.Request) { 17 if r.Method != http.MethodPost { 18 http.Error(w, \u0026#34;method\u0026#34;, 405) 19 return 20 } 21 code := r.FormValue(\u0026#34;code\u0026#34;) 22 if code != \u0026#34;LAB\u0026#34; { 23 http.Error(w, \u0026#34;nope\u0026#34;, 400) 24 return 25 } 26 if locked { 27 mu.Lock() 28 defer mu.Unlock() 29 } 30 if remain \u0026lt; 1 { 31 http.Error(w, \u0026#34;sold out\u0026#34;, 409) 32 return 33 } 34 remain-- 35 fmt.Fprintln(w, \u0026#34;ok\u0026#34;, remain) 36} 37 38func main() { 39 http.HandleFunc(\u0026#34;/redeem\u0026#34;, redeem) 40 http.ListenAndServe(\u0026#34;127.0.0.1:8080\u0026#34;, nil) 41} With locked = true:\n1seq 8 | xargs -P8 -I{} curl -s -o /tmp/r{} -w \u0026#39;%{http_code}\\n\u0026#39; \\ 2 -X POST http://127.0.0.1:8080/redeem -d code=LAB 3# 200 4# 409 5# 409 6# 409 7# 409 8# 409 9# 409 10# 409 11$ cat /tmp/r* 12ok 0 13sold out One 200. remain ended at 0. Good.\nWith locked = false (the bug):\n1# eight parallel POSTs, same command 2200 3200 4200 5409 6... 7$ cat /tmp/r* 8ok 0 9ok -1 10ok -2 11sold out Three 200s, remain negative. App log:\n12023-05-19T15:11:02+08:00 POST /redeem code=LAB src=127.0.0.1 remain=0 22023-05-19T15:11:02+08:00 POST /redeem code=LAB src=127.0.0.1 remain=-1 32023-05-19T15:11:02+08:00 POST /redeem code=LAB src=127.0.0.1 remain=-2 I do not need 10k QPS. Eight curls with xargs -P is enough on this handler because there is no DB latency. Production races often need more overlap; the code shape is the same: read, branch, write, no atomic compare-and-set.\nFailed-auth is a different log line and I keep it so SOC does not merge the tickets:\n12023-05-19T15:11:09+08:00 POST /redeem code=WRONG src=127.0.0.1 2 result: 400 nope 3# remain unchanged Analysis steps I run on a real endpoint Name the side effect: \u0026ldquo;decrement remaining uses of code LAB\u0026rdquo;. Find the check: if remain \u0026lt; 1. Find the write: remain-- or UPDATE coupons SET used=1. Ask: is there a unique constraint / UPDATE ... WHERE used=0 / SELECT FOR UPDATE / mutex covering both? If the write is used=1 without WHERE used=0, file a race even if I cannot win it today. SQL shape I want, for the same ticket when a DB is involved (see also the financial-apps note):\n1-- bad 2UPDATE coupons SET used = 1 WHERE code = \u0026#39;LAB\u0026#39;; 3 4-- good 5UPDATE coupons SET used = 1 WHERE code = \u0026#39;LAB\u0026#39; AND used = 0; 6-- then inspect rows-affected == 1 I do not run the bad UPDATE against a shared database. The Go counter is the repro I keep.\nSanitized reproduction 1$ go run -race counter.go 2# DATA RACE + n != 1 → ticket evidence 3 4$ go test -race ./... 5# once coupon.go is a test: 6# --- FAIL: TestRedeemOnce (0.00s) 7# remain= -2 wins= 3 A unit test is the right regression. A public \u0026ldquo;race exploit client\u0026rdquo; is not.\nCrash analog if someone uses a map without a mutex (Go will actually panic):\n1fatal error: concurrent map writes 2goroutine 19 [running]: 3internal/runtime/maps.fatal(...) 4# stack [REDACTED] 5exit status 2 That panic is a gift. File it. Do not \u0026ldquo;fix\u0026rdquo; it by adding go more.\nMulti-node: the mutex is not enough coupon.go is one process. Two binaries behind a load balancer each have remain=1. I demonstrate with two processes, not with a clever HTTP client:\n1$ locked=true go run coupon.go # :8080 2$ locked=true PORT=8081 go run coupon.go 3$ curl -s -X POST http://127.0.0.1:8080/redeem -d code=LAB 4ok 0 5$ curl -s -X POST http://127.0.0.1:8081/redeem -d code=LAB 6ok 0 Two 200s, one coupon, both mutexes were fine. The fix moves to the database:\n1UPDATE coupons SET remain = remain - 1 2 WHERE code = \u0026#39;LAB\u0026#39; AND remain \u0026gt; 0; 3-- RowsAffected must be 1 I keep this next to the Go race so nobody ships a mutex as the production patch for a horizontally scaled redeem endpoint.\nMitigation Mutex / transaction around check+use. In process: sync.Mutex. In DB: UPDATE ... WHERE remaining \u0026gt; 0 and require RowsAffected()==1, or SELECT FOR UPDATE in a transaction, or a unique (user_id, coupon_code) insert. Idempotency key on the POST (Idempotency-Key header), stored uniquely, so retries are not extra redemptions. Do not \u0026ldquo;fix\u0026rdquo; with time.Sleep or a frontend disable-button. Log remain after the atomic write, with request id. The negative remain in the lab log is how I noticed the bug without a debugger. -race in CI for any handler that touches in-memory inventory. 1mu.Lock() 2defer mu.Unlock() 3if remain \u0026lt; 1 { 4 http.Error(w, \u0026#34;sold out\u0026#34;, 409) 5 return 6} 7remain-- That is the entire patch for coupon.go. For Postgres, the patch is the WHERE used = 0 line, not a mutex in the app (two app nodes). HTTP/2 multiplexing on a single connection is enough overlap for this handler; I do not need a botnet. xargs -P8 already produced three 200s.\nWhat I file after this lab counter.go: 32 goroutines, limit 1, observed n in {4,9,12}, -race DATA RACE at lines 18–19 coupon.go unlocked: 3× HTTP 200, remain in {-2,-1,0} coupon.go locked: 1× 200, 7× 409 Wrong code: 400, remain unchanged (authz/validation, not a race) Fix: mutex (single node) or UPDATE ... WHERE used=0 (multi node) Out of scope: a script that beats a production promo Commands appendix 1go run counter.go 2go run -race counter.go 3go run coupon.go # then xargs -P8 curl ... 4go test -race ./... ","permalink":"https://blog.omiilgo.com/posts/web-race-conditions-notes/","summary":"Lab Go counter without a mutex, then with one; HTTP coupon handler; lost-update logs — no inventory-drain exploit script.","title":"Web Race Conditions: Coupons, Balances, and Workflows"},{"content":"The sandbox is not a slogan on the lab app. It is a Seatbelt profile derived from the code-signature entitlements blob, plus a container path dyld and NSHomeDirectory() already agree on. This note is what I actually dump: codesign -d --entitlements, the two keys that matter for a debug build, where the simulator puts Documents/, and how a missing entitlement shows up as a deny(1) line instead of a file handle. Self-signed LabSession only. cryptid=1 stops the lab. I do not bypass AMFI and I do not jailbreak.\nFigure 1. LC_CODE_SIGNATURE holds the entitlements blob. cryptid=1 is a hard stop before any of this.\rSame first checks as every other lab 1$ file LabSession 2LabSession: Mach-O 64-bit executable arm64 3 4$ otool -l LabSession | egrep \u0026#39;cmd LC_|cryptid|segname __TEXT|LC_CODE|LC_ENCRYPT\u0026#39; 5 cmd LC_SEGMENT_64 6 segname __TEXT 7 cmd LC_ENCRYPTION_INFO_64 8 cryptid 0 9 cmd LC_CODE_SIGNATURE If cryptid is 1 I stop. FairPlay unwrap is out of scope. LC_CODE_SIGNATURE is required for codesign -d to have a blob to print; an unsigned intermediate from ld will just error.\n1$ class-dump LabSession | sed -n \u0026#39;/LabSession/,+18p\u0026#39; 2@interface LabSession : NSObject 3{ 4 NSString *_token; 5} 6- (id)initWithEnvironment:(id)env; 7- (void)startWithToken:(id)token; 8- (BOOL)readLabFile:(id)name; ; inside container 9- (BOOL)readOutside:(id)absPath; ; lab: should deny 10@end codesign -d --entitlements 1$ codesign -d --entitlements :- LabSession 2\u0026gt;/dev/null 2\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; 3\u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; 4 \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; 5\u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; 6\u0026lt;dict\u0026gt; 7 \u0026lt;key\u0026gt;application-identifier\u0026lt;/key\u0026gt; 8 \u0026lt;string\u0026gt;XXXXXX.com.lab.session\u0026lt;/string\u0026gt; \u0026lt;!-- team id redacted --\u0026gt; 9 \u0026lt;key\u0026gt;com.apple.developer.team-identifier\u0026lt;/key\u0026gt; 10 \u0026lt;string\u0026gt;XXXXXX\u0026lt;/string\u0026gt; 11 \u0026lt;key\u0026gt;get-task-allow\u0026lt;/key\u0026gt; 12 \u0026lt;true/\u0026gt; \u0026lt;!-- debugable lab build --\u0026gt; 13 \u0026lt;key\u0026gt;keychain-access-groups\u0026lt;/key\u0026gt; 14 \u0026lt;array\u0026gt; 15 \u0026lt;string\u0026gt;XXXXXX.com.lab.session\u0026lt;/string\u0026gt; 16 \u0026lt;/array\u0026gt; 17\u0026lt;/dict\u0026gt; 18\u0026lt;/plist\u0026gt; get-task-allow is why lldb can task_for_pid this process on a dev-signed build. I do not put that key on a distribution profile and I do not try to graft it onto someone else\u0026rsquo;s binary.\napplication-identifier is \u0026lt;TEAM\u0026gt;.\u0026lt;bundle id\u0026gt;. Team id is redacted to XXXXXX in every log I keep. The sandbox uses this string as the primary subject; two apps with different team ids do not share a container, even if the bundle id suffix looks similar.\nKeys not in this plist are as important as keys that are. There is no com.apple.security.exception.files.absolute-path.read-only, no app-group, no iCloud. A later open(\u0026quot;/etc/passwd\u0026quot;) is not going to grow a new entitlement at runtime.\nOn device, codesign -d --entitlements reads the same CMS blob from LC_CODE_SIGNATURE. I still only do this on a binary I signed.\nContainer paths on the simulator 1(lldb) po NSHomeDirectory() 2/Users/[REDACTED]/Library/Developer/CoreSimulator/Devices/[REDACTED]/data/Containers/Data/Application/[REDACTED] 3 4(lldb) po NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) 5\u0026lt;[REDACTED]/Documents\u0026gt; 6 7(lldb) po NSTemporaryDirectory() 8\u0026lt;[REDACTED]/tmp/\u0026gt; Three directories I treat as in-container: Documents/, Library/, tmp/. Everything else is outside, including:\n/etc/passwd, /private/var/… (device-shaped paths that the simulator still rejects) /Users/[REDACTED]/Desktop/outside.txt (host Desktop, not the container) another app\u0026rsquo;s Application/[OTHER-UUID]/ The simulator is weaker than a device (it is a host process under a Seatbelt profile, not a fully entitled iOS task), but file-read denials still show up. I do not use that gap as an AMFI or jailbreak footnote; I use it as a place I can tail sandbox logs without a special device.\nsandbox-exec as a reduced host stand-in sandbox-exec is a macOS tool. I use it on the host copy of a tiny helper that calls the same open() the app calls, with a profile that only allows the container subpath. It is not a replacement for the iOS profile, and it is not an exploit.\n1$ cat lab-container.sb 2(version 1) 3(deny default) 4(allow file-read-metadata) 5(allow file-read* file-write* 6 (subpath \u0026#34;/Users/[REDACTED]/Library/Developer/CoreSimulator/Devices/[REDACTED]/data/Containers/Data/Application/[REDACTED]\u0026#34;)) 7(allow process-exec (literal \u0026#34;/usr/bin/true\u0026#34;)) 8 9$ sandbox-exec -p \u0026#34;$(cat lab-container.sb)\u0026#34; \\ 10 /usr/bin/stat /Users/[REDACTED]/Desktop/outside.txt 11stat: /Users/[REDACTED]/Desktop/outside.txt: Operation not permitted Same profile, path inside the container:\n1$ sandbox-exec -p \u0026#34;$(cat lab-container.sb)\u0026#34; \\ 2 /usr/bin/stat /Users/[REDACTED]/Library/Developer/CoreSimulator/Devices/[REDACTED]/data/Containers/Data/Application/[REDACTED]/Documents/token.len 3# size: 3 (I store the decimal length, not the token) ARM64 at readOutside: 1; otool -tV LabSession (file addresses, slide 0) 2; -[LabSession readOutside:] 30000000100001f00 pacibsp 40000000100001f04 stp x20, x19, [sp, #-0x20]! 50000000100001f08 stp x29, x30, [sp, #0x10] 60000000100001f0c add x29, sp, #0x10 70000000100001f10 mov x19, x2 ; NSString * absPath 80000000100001f14 mov x0, x19 90000000100001f18 bl 0x1000024a0 ; -[NSString UTF8String] 100000000100001f1c mov x1, #0x0 ; O_RDONLY 110000000100001f20 bl 0x100002510 ; _open stub 120000000100001f24 cmn x0, #0x1 ; fd == -1 ? 130000000100001f28 cset w0, ne ; BOOL 140000000100001f2c ldp x29, x30, [sp, #0x10] 150000000100001f30 ldp x20, x19, [sp], #0x20 160000000100001f34 retab x0 = path, x1 = O_RDONLY. No O_NOFOLLOW even, because this is a lab. The interesting part is not the libc wrapper, it is that open returns -1 with EPERM / EACCES when Seatbelt denies, and the ObjC method turns that into NO.\nFigure 2. Frame at readOutside: saved fp/lr, x19 holds the path NSString, then open.\rlldb: break on open, log length, not the path 1(lldb) process launch --stop-at-entry 2(lldb) breakpoint set -n open 3(lldb) breakpoint command add 4\u0026gt; script 5import lldb 6f = lldb.debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetFrameAtIndex(0) 7p = f.GetRegisters().GetFirstValueByName(\u0026#39;x0\u0026#39;).GetValueAsUnsigned() 8err = lldb.SBError() 9s = f.GetThread().GetProcess().ReadCStringFromMemory(p, 256, err) 10print(\u0026#39;[open] len=%d prefix=%s\u0026#39; % (len(s), s[:24] if s.startswith(\u0026#39;/Users\u0026#39;) else s[:8])) 11# continue always — this is a log, not a stop 12lldb.debugger.HandleCommand(\u0026#39;continue\u0026#39;) 13\u0026gt; DONE 14(lldb) c 15[open] len=118 prefix=/Users/[REDACTED]/Librar 16[open] len=48 prefix=/Users/[REDACTED]/Desktop I print length + a redacted prefix. I do not paste full host paths or usernames into a note. The second line is the out-of-container attempt.\nHow a missing entitlement shows up as deny Simulator log stream, lab process name LabSession, host Desktop file:\n1$ xcrun simctl spawn booted log stream --style compact \\ 2 --predicate \u0026#39;eventMessage CONTAINS \u0026#34;Sandbox\u0026#34; AND eventMessage CONTAINS \u0026#34;LabSession\u0026#34;\u0026#39; 3error\t10:14:22.401 kernel\tSandbox: LabSession(412) deny(1) file-read-data /Users/[REDACTED]/Desktop/outside.txt 4error\t10:14:22.401 kernel\tSandbox: LabSession(412) deny(1) file-read-metadata /Users/[REDACTED]/Desktop/outside.txt Same call with a path inside Documents/ produces no deny line and open returns a fd. That is the whole difference.\nA second lab case: I temporarily drop get-task-allow from the entitlements plist, re-sign ad-hoc, and attach:\n1$ codesign -s - --entitlements lab-no-debug.entitlements LabSession 2$ xcrun lldb -n LabSession 3error: attach failed: cannot attach to process due to System Integrity / codesign That failure is the missing get-task-allow key, not an invitation to disable AMFI. I restore the debug entitlement on the lab target and move on.\nA third case, keychain: SecItemCopyMatching without the matching keychain-access-groups entry returns errSecMissingEntitlement (-34018) in the lab. I log the OSStatus, not the item contents.\nSanitized reproduction UI button “read outside” feeds readOutside: a 48-character Desktop path. Expected: deny log, method returns NO, no crash. I also planted a 16-byte stack copy of the UTF-8 path so a long path is a crash, not a novel sandbox escape.\n1// LabSession.m — intentional lab bug, not a bypass 2- (BOOL)readOutside:(NSString *)absPath { 3 char buf[16]; 4 const char *u = absPath.UTF8String; 5 memcpy(buf, u, strlen(u) + 1); // no bound; lab only 6 int fd = open(u, O_RDONLY); 7 return fd \u0026gt;= 0; 8} 1* thread #1, queue = \u0026#39;com.apple.main-thread\u0026#39;, stop reason = EXC_BAD_ACCESS (code=2) 2 frame #0: 0x00000001890afc2c libsystem_platform.dylib`_platform_memmove + 204 3 frame #1: 0x0000000104a81f40 LabSession`-[LabSession readOutside:] + 0x28 ASAN:\n1==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 2WRITE of size 49 at ... thread T0 3 #0 memcpy 4 #1 -[LabSession readOutside:] LabSession.m:77 Repro is: 48-byte path, 16-byte buffer, memcpy size 49, plus the deny(1) file-read-data line for the same path. Length of the path is what I keep. The sandbox denial is the analysis result; the ASAN hit is so the write-up has a crash, not a host-file dump.\nFigure 3. readLabFile: / readOutside: still go through objc_msgSend. Log class \u0026#43; selector, not path contents.\rClosing Entitlements are a plist inside LC_CODE_SIGNATURE. get-task-allow is why the debugger attaches; application-identifier (team id redacted) is why the container is unique. Missing keys surface as deny(1) / errSecMissingEntitlement / attach failure, not as silent success. sandbox-exec on the host is a reduced model of that deny. cryptid=1 still ends the session before any of this. No AMFI switch, no jailbreak, no FairPlay.\nCommands appendix 1otool -l LabSession | egrep \u0026#39;cryptid|LC_CODE_SIGNATURE\u0026#39; 2codesign -d --entitlements :- LabSession 3class-dump LabSession 4xcrun simctl get_app_container booted com.lab.session data 5xcrun simctl spawn booted log stream --predicate \u0026#39;eventMessage CONTAINS \u0026#34;Sandbox\u0026#34;\u0026#39; 6sandbox-exec -p \u0026#39;(version 1)(deny default)(allow file-read-metadata)\u0026#39; /usr/bin/stat /etc/passwd 7xcrun lldb -n LabSession ","permalink":"https://blog.omiilgo.com/posts/ios-entitlements-and-sandbox/","summary":"codesign -d \u0026ndash;entitlements on a self-signed LabSession: get-task-allow, redacted application-identifier, container paths on the simulator, sandbox-exec, deny logs when a file read walks outside the container. cryptid=1 stops the lab.","title":"Entitlements and the Simulator Sandbox on a Lab iOS App"},{"content":"This is a ticket-reading lab, not a Golden Ticket note. Target is a lab domain user on a joined VM. Goal: klist a TGT and a service ticket, write down authtime / start / end / renew-till, and name the checksum field I can see without decrypting. I do not mint tickets, I do not touch krbtgt hash, I do not pass /ptt.\n1Figure 1. authtime is when AS succeeded. endtime is when the ticket dies. They are not the same field. 2AS-REP TGT authtime/start/end/renew-till 3TGS-REP HTTP end capped by TGT end; no \u0026#39;initial\u0026#39; flag Lab layout 1labs/krb_lab/ 2 klist-tgt.txt 3 klist-http.txt 4 4768.json # event log export, SIDs redacted Account: labuser@LAB.INTERNAL. DC and workstation clocks NTP-synced. If they were not, the 401 at the bottom is the artifact.\nArtifact: klist (Windows) 1C:\\lab\u0026gt; klist 2Current LogonId is 0:0x[REDACTED] 3 4Cached Tickets: (2) 5 6#0\u0026gt; Client: labuser @ LAB.INTERNAL 7 Server: krbtgt/LAB.INTERNAL @ LAB.INTERNAL 8 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 9 Ticket Flags 0x40e10000 -\u0026gt; forwardable renewable initial pre_authent 10 Start Time: 3/14/2023 13:55:02 (local) 11 End Time: 3/14/2023 23:55:02 (local) 12 Renew Time: 3/21/2023 13:55:02 (local) 13 Session Key Type: AES-256-CTS-HMAC-SHA1-96 14 Cache Flags: 0x1 -\u0026gt; PRIMARY 15 Kdc Called: dc01.lab.internal 16 17#1\u0026gt; Client: labuser @ LAB.INTERNAL 18 Server: HTTP/web.lab.internal @ LAB.INTERNAL 19 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 20 Ticket Flags 0x40a10000 -\u0026gt; forwardable renewable pre_authent 21 Start Time: 3/14/2023 13:56:11 (local) 22 End Time: 3/14/2023 23:55:02 (local) 23 Renew Time: 3/21/2023 13:55:02 (local) 24 Session Key Type: AES-256-CTS-HMAC-SHA1-96 25 Cache Flags: 0 26 Kdc Called: dc01.lab.internal Field decode I write next to the dump:\nField TGT (#0) HTTP (#1) Meaning Client labuser @ LAB.INTERNAL same the principal the KDC authenticated Server krbtgt/LAB.INTERNAL HTTP/web.lab.internal SPN Enc type AES-256-CTS-HMAC-SHA1-96 same etype; RC4 here would be a finding Flags initial, pre_authent, renewable, forwardable no initial initial ⇒ this came from AS, not TGS Start 13:55:02 13:56:11 when the ticket becomes valid End 23:55:02 23:55:02 10h TGT lifetime; service ticket capped by TGT end Renew 3/21 13:55:02 same 7-day renew-till in this domain Kdc Called dc01.lab.internal same which DC; compare to 4768 End on #1 equals End on #0. That is normal: a TGS ticket cannot outlive the TGT that requested it. A service ticket with end far beyond the TGT end, or a TGT with end far beyond domain policy, is the forgery-class anomaly — I file it as \u0026ldquo;times do not match policy\u0026rdquo;, I do not \u0026ldquo;fix\u0026rdquo; it by forging a better one.\nLinux klist (same user, SSSD lab):\n1$ klist 2Ticket cache: FILE:/tmp/krb5cc_[REDACTED] 3Default principal: labuser@LAB.INTERNAL 4 5Valid starting Expires Service principal 603/14/23 13:55:02 03/14/23 23:55:02 krbtgt/LAB.INTERNAL@LAB.INTERNAL 7 renew until 03/21/23 13:55:02 803/14/23 13:56:11 03/14/23 23:55:02 HTTP/web.lab.internal@LAB.INTERNAL Same numbers, fewer flags. I still want the Windows dump when the question is PAC / etype.\nChecksums, without decrypting a ticket A Kerberos ticket is an encrypted blob plus some cleartext (realm, sname). Inside the enc-part: session key, times, flags, transited, authz-data (PAC on AD). The PAC carries its own checksums (ServerChecksum, KDCChecksum in Microsoft\u0026rsquo;s structure). From userland I do not dump those bytes. What I can see:\n1# Wireshark, lab capture, AS-REP 2kerberos.CNameString == labuser 3kerberos.realm == LAB.INTERNAL 4kerberos.ticket.sname == krbtgt 5kerberos.etype == 18 (aes256-cts-hmac-sha1-96) 6kerberos.checksum.type == 16 (hmac-sha1-96-aes256) # authenticator cksum 7kerberos.authtime == 2023-03-14 05:55:02 UTC 8kerberos.endtime == 2023-03-14 15:55:02 UTC kerberos.checksum.type on the authenticator is not the PAC KDC checksum. Do not mix them in the timeline. Authenticator checksum proves the client knew the session key for this AP-REQ. PAC KDC checksum is a DC-side integrity field; userland klist will not print it.\nEvent 4768 (AS success), redacted:\n1Account Name: labuser 2Supplied Realm Name: LAB.INTERNAL 3Service Name: krbtgt 4Service ID: S-1-5-21-[REDACTED]-502 5Ticket Options: 0x40810010 6Ticket Encryption Type: 0x12 # AES256 7Pre-Authentication Type: 2 # encrypted timestamp 8Client Address: 127.0.0.1 9Certificate Issuer Name: - Match: klist Kdc Called dc01, etype AES256, client labuser, time 13:55:02 local = 05:55:02 UTC. That is a reconstructed AS. A 4768 with etype 0x17 (RC4) for this user is a separate ticket.\nAnalysis steps for a suspicious ticket Compare klist End/Renew to domain policy (Maximum lifetime for user ticket = 10 hours here, Maximum lifetime for user ticket renewal = 7 days). initial flag on a ticket whose Server is not krbtgt/... is odd. Etype RC4 (0x17) when the account has AES keys: hunt for constrained RC4 or an old client, not immediately \u0026ldquo;forged\u0026rdquo;, but file it. authtime in the future or far in the past vs DC clock: clock-skew or crafted times. Kdc Called empty / not a DC you own: local cache import. I still do not run /ptt to confirm. Sanitized reproduction (clock skew / reject only) I moved the workstation clock +10 hours once. AP-REQ to the lab HTTP SPN:\n1C:\\lab\u0026gt; curl -u : --negotiate http://web.lab.internal/who 2curl: (6) / HTTP/1.1 401 Unauthorized 3WWW-Authenticate: Negotiate 4 5# DC 4769 failure (excerpt) 6Result Code: 0x25 # KRB_AP_ERR_SKEW 7Account Name: labuser 8Service Name: HTTP/web.lab.internal 9Client Address: 10.[REDACTED] 0x25 skew is the crash analog. I set the clock back. I do not \u0026ldquo;fix\u0026rdquo; skew by minting a ticket with adjusted authtime.\nFailed preauth (wrong password) is 4771, not a ticket:\n1Event 4771 Kerberos pre-authentication failed 2Account Name: labuser 3Pre-Auth Type: 2 4Failure Code: 0x18 # KDC_ERR_PREAUTH_FAILED 5Client Address: 127.0.0.1 No TGT in klist after that. Do not confuse 4771 with a checksum anomaly.\nPAC times vs logon times Event 4624 for the same logon (type 3 or 2) carries Logon Time that should sit within a few seconds of klist Start on the TGT. A 4624 at 13:55:02 and a TGT Start at 10:00:00 the previous day is a cache that survived a sleep, or a ticket that did not come from this logon. I record both clocks (UTC) before I say \u0026ldquo;forged\u0026rdquo;.\n1# 4624 excerpt, same session 2Logon Type: 2 3Authentication Package: Kerberos 4Logon Time: 2023-03-14T13:55:02.110 5Source Network Address: 127.0.0.1 Authentication Package: Kerberos plus a TGT in klist is consistent. NTLM plus a TGT from a different logon id (klist -li) is two stories; do not merge them.\nMitigation / policy I tick AES only: msDS-SupportedEncryptionTypes on accounts, disable RC4 domain-wide when the audit is clean. Ticket lifetimes: 10h / 7d is common; shorter for high-value. Log 4768 etype + options. Clock: NTP on DCs and members. 0x25 should be rare. Do not leave KRBTGT password unrotated; rotation is an ops note, not a forge recipe. I record last set time from a privileged query I do not paste. Detection: TGT end \u0026gt; policy; service ticket with initial; etype 0x17; 4768 from a workstation that has no corresponding logon 4624. What I file after this lab klist: TGT AES256, flags initial|pre_authent|renewable|forwardable, start 13:55:02, end +10h, renew +7d HTTP ticket: no initial, end equal to TGT end Wireshark: kerberos.etype==18, checksum.type==16 on authenticator, times UTC 4768 etype 0x12, preauth 2, address 127.0.0.1 Skew repro: 401 + 4769 0x25; bad password: 4771 0x18 Out of scope: forging PAC checksums, krbtgt hash, /ptt Commands appendix 1klist 2klist -li 0:0x[REDACTED] 3# Linux: klist; kvno HTTP/web.lab.internal 4wevtutil qe Security /q:\u0026#34;*[System[(EventID=4768)]]\u0026#34; /c:1 /f:text ","permalink":"https://blog.omiilgo.com/posts/kerberos-ticket-times-and-checksums/","summary":"Lab klist dump, field-by-field decode of times and checksum types, clock-skew 401 — no ticket-forging steps.","title":"Kerberos Ticket Times and Checksums for Forensics"},{"content":"This is an isolate-and-bounds lab, not a renderer 0-day. Target is d8 from a Chromium build I keep for notes (V8 version 10.9 era, matching a 2023 Chrome 109 lab image) and a 40-line JS file that allocates a Uint8Array and refuses out-of-range indexes in a lab assert. Goal: print the typed-array dump, throw on i \u0026gt;= length, and optionally %DebugPrint under --allow-natives-syntax. I do not JIT-spray, I do not confuse maps into a fake object, I do not turn an OOB into a backing-store primitive.\n1Figure 1. Untrusted JS hits V8, then the renderer sandbox. This lab stops at a thrown assert inside d8. 2script -\u0026gt; typed array bounds -\u0026gt; LAB_ASSERT throw (or d8 abort) 3JIT / maps / isolates are the map; we do not break them here Lab layout 1labs/v8_lab/ 2 toy.js # Uint8Array + lab assert 3 crash.js # uncaught throw for --abort-on-uncaught-exception 4 d8-version.txt 1$ ./d8 --version 2V8 version 10.9.194.5 3 4$ ./d8 --help | egrep \u0026#39;allow-natives-syntax|abort-on-uncaught|trace-maps\u0026#39; 5 --allow-natives-syntax (allow natives syntax) 6 --abort-on-uncaught-exception 7 --trace-maps Host is a Linux lab VM. d8 is the V8 shell, not Chrome. A crash here is process abort of the shell, not a sandbox escape.\nLab binary (JS) 1// toy.js — typed array, explicit bounds, no OOB primitive 2const a = new Uint8Array(8); 3a.fill(0x41); 4 5function read_at(i) { 6 if ((i \u0026gt;\u0026gt;\u0026gt; 0) \u0026gt;= a.length) { 7 throw new Error(\u0026#34;LAB_ASSERT oob i=\u0026#34; + i + \u0026#34; len=\u0026#34; + a.length); 8 } 9 return a[i]; 10} 11 12function write_at(i, v) { 13 if ((i \u0026gt;\u0026gt;\u0026gt; 0) \u0026gt;= a.length) { 14 throw new Error(\u0026#34;LAB_ASSERT oob i=\u0026#34; + i + \u0026#34; len=\u0026#34; + a.length); 15 } 16 a[i] = v \u0026amp; 0xff; 17} 18 19print(\u0026#34;len \u0026#34; + a.length); 20print(\u0026#34;a[0] \u0026#34; + read_at(0)); 21write_at(7, 0x5a); 22print(\u0026#34;a[7] \u0026#34; + read_at(7)); 23try { 24 print(read_at(8)); 25} catch (e) { 26 print(String(e)); 27} 1$ ./d8 toy.js 2len 8 3a[0] 65 4a[7] 90 5Error: LAB_ASSERT oob i=8 len=8 That is the whole happy path. Index 8 on length 8 is rejected in JS, not in a missing CheckBounds node. Engine bugs in this class are “the JIT deleted a check the interpreter still did”. I do not delete the check.\nOptional: --allow-natives-syntax Natives are a debug surface. I use them to print the object, not to call %SetAllocationTimeout tricks.\n1// toy_native.js — same array, DebugPrint, then the assert 2const a = new Uint8Array(8); 3a.fill(0x41); 4%DebugPrint(a); 5function read_at(i) { 6 if ((i \u0026gt;\u0026gt;\u0026gt; 0) \u0026gt;= a.length) throw new Error(\u0026#34;LAB_ASSERT oob i=\u0026#34; + i); 7 return a[i]; 8} 9read_at(8); 1$ ./d8 --allow-natives-syntax toy_native.js 2DebugPrint: 0x1a0[REDACTED]: [JSTypedArray] 3- map: 0x1a0[REDACTED] \u0026lt;Map(UINT8_ELEMENTS)\u0026gt; [FastProperties] 4- prototype: 0x1a0[REDACTED] \u0026lt;Object map = 0x...\u0026gt; 5- elements: 0x1a0[REDACTED] \u0026lt;FixedUint8Array[8]\u0026gt; [UINT8_ELEMENTS] 6- embedder fields: 2 7- length: 8 8- byte_length: 8 9- byte_offset: 0 10- buffer: 0x1a0[REDACTED] \u0026lt;ArrayBuffer map = 0x...\u0026gt; 11Error: LAB_ASSERT oob i=8 Addresses are redacted. What I actually need from %DebugPrint:\nUINT8_ELEMENTS — the elements kind. A real type-confusion write-up would claim this kind is no longer true. I do not force a transition. length: 8 / byte_length: 8 — the bounds the assert used. ArrayBuffer backing store — a separate object. Length mismatch between the view and the buffer is a class of bug; both are 8 here. I do not call %OptimizeFunctionOnNextCall + %NeverOptimize dances to land a check-elimination. Optional natives in this note stop at DebugPrint.\nSanitized reproduction: abort is a lab crash Uncaught throw, then the shell flag that turns it into a fatal:\n1// crash.js 2const a = new Uint8Array(4); 3function poke(i, v) { 4 if ((i \u0026gt;\u0026gt;\u0026gt; 0) \u0026gt;= a.length) 5 throw new Error(\u0026#34;LAB_ASSERT oob i=\u0026#34; + i + \u0026#34; len=\u0026#34; + a.length); 6 a[i] = v; 7} 8poke(4, 1); // uncaught 1$ ./d8 crash.js 2toy: uncaught: 3Error: LAB_ASSERT oob i=4 len=4 4 at poke (crash.js:5:11) 5 at crash.js:8:1 6 7$ ./d8 --abort-on-uncaught-exception crash.js 8# 9# Fatal error in , line 0 10# unreachable code 11# 12# 13# 14#FailureMessage Object: 0x7ff[REDACTED] 15==== C stack trace =============================== 16 d8 [REDACTED] 17# aborted (core dumped) ; lab only, same box 1$ gdb -q ./d8 core 2(gdb) bt 3#0 abort () from /lib/x86_64-linux-gnu/libc.so.6 4#1 V8_Fatal ... 5#2 v8::internal::Isolate::Throw ... 6# ... frames into d8 main [REDACTED] Tombstone: input poke(4,1) on Uint8Array(4), message LAB_ASSERT oob i=4 len=4, process abort with --abort-on-uncaught-exception. That is a lab assert, not an in-engine CHECK about a corrupted map.\nASan: I do not rebuild V8 with ASan for this note. A userspace analog — a C typed-buffer with the same bounds — does:\n1/* ta_lab.c — mirror of crash.js, ASan on the C side */ 2#include \u0026lt;stdint.h\u0026gt; 3#include \u0026lt;stdio.h\u0026gt; 4#include \u0026lt;stdlib.h\u0026gt; 5int main(void) { 6 uint8_t a[4] = {0}; 7 int i = 4; /* the JS poke(4) */ 8 if ((unsigned)i \u0026gt;= 4) { 9 fprintf(stderr, \u0026#34;LAB_ASSERT oob i=%d len=4\\n\u0026#34;, i); 10 return 1; 11 } 12 a[i] = 1; /* not reached */ 13 return 0; 14} 1$ cc -O0 -fsanitize=address -g -o ta_lab ta_lab.c 2$ ./ta_lab 3LAB_ASSERT oob i=4 len=4 4# ASan silent — we did not store. Remove the if to see heap/stack overflow; 5# I keep the if. The engine bug is when the equivalent if is gone after JIT. Isolates, JIT, renderer (the map, still no exploit) 1untrusted script / Wasm 2 -\u0026gt; V8 isolate (heap, maps, typed-array views) 3 -\u0026gt; interpreter / Sparkplug / Maglev / TurboFan 4 -\u0026gt; renderer process 5 -\u0026gt; sandbox + IPC brokers 6 -\u0026gt; OS What I write next to a Chrome V8 CVE, using this lab as vocabulary:\nIsolate / context — embedder wiring. Node and Electron often have no renderer sandbox. Same engine, different wrap. Map / elements kind — %DebugPrint showed UINT8_ELEMENTS. Type confusion = trusted kind was wrong. JIT check elimination — read_at in toy.js is the check TurboFan must not drop. Typed array view vs ArrayBuffer — length 8 / byte_length 8 in the dump. Mismatch is a class. Patch the embedder (Chrome/Electron/Node), not a handwritten d8 flag, in production. --trace-maps on the toy, truncated:\n1$ ./d8 --trace-maps toy.js 2[trace-maps] [0x...] Initial: ... JSObject 3[trace-maps] [0x...] Transition: ... UINT8_ELEMENTS 4len 8 5... Useful when a crash dump mentions a map id. Not useful as a primitive.\nMitigation Chrome auto-update; Electron rebuilt on current Chromium; Node on a supported line. Inventory V8 version, not only the product major. Site isolation / renderer sandbox on. A d8 abort in this lab is unsandboxed by design — do not run untrusted scripts in d8. Crash telemetry: cluster renderer crashes in V8 JIT ranges. This lab’s abort is V8_Fatal from an uncaught JS exception — different bucket than a SIGSEGV in generated code. Custom embeds: one isolate per trust domain; no privileged bindings on the untrusted context. --allow-natives-syntax is off in production. It is a debug switch. I used it for %DebugPrint only. What I file after this lab d8 --version → V8 version 10.9.194.5 toy.js: len 8, a[0]=65, LAB_ASSERT oob i=8 len=8 %DebugPrint: UINT8_ELEMENTS, length 8, ArrayBuffer backing, addresses redacted Crash: d8 --abort-on-uncaught-exception crash.js → V8_Fatal / abort, message LAB_ASSERT oob i=4 len=4 C mirror ta_lab.c with ASan: assert prints, no overflow Out of scope: map confusion, JIT check elimination, sandbox escape, d8 natives other than DebugPrint Commands appendix 1./d8 --version 2./d8 toy.js 3./d8 --allow-natives-syntax toy_native.js 4./d8 --abort-on-uncaught-exception crash.js 5cc -O0 -fsanitize=address -g -o ta_lab ta_lab.c \u0026amp;\u0026amp; ./ta_lab ","permalink":"https://blog.omiilgo.com/posts/v8-security-surface-notes/","summary":"d8 toy typed-array lab, LAB_ASSERT bounds throw, optional \u0026ndash;allow-natives-syntax DebugPrint — a crash, not an exploit.","title":"V8 Security Surface Notes for Defenders"},{"content":"jadx showed a pile of if (v == 0) … else if and a dead xor on the same register. apktool smali was an if-eqz / if-eq chain plus a junk block that never runs. It is not a commercial packer. It is a 40-line lab method that imitates the flattening I keep seeing in small SDKs. Goal: recover the switch the author meant.\nLab APK 1com.lab.smali.flow 2 Gate.java // source I compiled, then ran a tiny obfuscator over 3 lib/arm64-v8a/libgate.so // one native, only used for the crash section Intended Java before the obfuscator:\n1package com.lab.smali.flow; 2 3public final class Gate { 4 public static boolean accept(int code, String token) { 5 if (token == null || token.length() \u0026lt; 8) return false; 6 switch (code % 5) { 7 case 0: return token.charAt(0) == \u0026#39;l\u0026#39;; 8 case 1: return token.charAt(0) == \u0026#39;a\u0026#39;; 9 case 2: return token.charAt(0) == \u0026#39;b\u0026#39;; 10 case 3: return token.length() == 8; 11 default: return false; 12 } 13 } 14} The obfuscator (a 30-line Python rewriter I keep in labs/smali_flow/, not shipped) did three things:\nReplaced packed-switch with an if-eqz / if-eq chain. Inserted xor-int vX, p0, p0 / if-eqz opaque predicates (p0^p0 is always 0). Split the length check into two branches that both return false. No control-flow flattening VM. No DexGuard. If a sample has a real dispatcher + encrypted I-string, this note does not unpack it.\napktool smali 1apktool d flow.apk -o flow_d 2# I/Baksmali: flow_d/smali/com/lab/smali/flow/Gate.smali 1.class public final Lcom/lab/smali/flow/Gate; 2.super Ljava/lang/Object; 3.source \u0026#34;Gate.java\u0026#34; 4 5.method public static accept(ILjava/lang/String;)Z 6 .locals 5 7 .param p0, \u0026#34;code\u0026#34; # I 8 .param p1, \u0026#34;token\u0026#34; # Ljava/lang/String; 9 10 if-nez p1, :fail 11 12 invoke-virtual {p1}, Ljava/lang/String;-\u0026gt;length()I 13 move-result v0 14 15 if-eqz v0, :fail # length==0 → fail 16 const/16 v1, 0x8 17 if-lt v0, v1, :fail # length\u0026lt;8 → fail 18 19 xor-int v2, p0, p0 # opaque: always 0 20 if-nez v2, :dead # v2==0 → fall through 21 goto :live 22 23 :dead 24 const/4 v3, 0x1 25 return v3 # junk; predicate is false 26 27 :live 28 rem-int/lit8 v1, p0, 0x5 29 30 if-eqz v1, :c1 # v1==0 ? 31 goto :case0 32 33 :c1 34 const/4 v2, 0x1 35 if-eq v1, v2, :c2 36 goto :case1 37 38 :c2 39 const/4 v2, 0x2 40 if-eq v1, v2, :c3 41 goto :case2 42 43 :c3 44 const/4 v2, 0x3 45 if-eq v1, v2, :c4 46 goto :case3 47 48 :c4 49 goto :fail # default: 4, and any surprise 50 51 :case0 52 const/4 v2, 0x0 53 invoke-virtual {p1, v2}, Ljava/lang/String;-\u0026gt;charAt(I)C 54 move-result v2 55 const/16 v3, 0x6c # \u0026#39;l\u0026#39; 56 if-eq v2, v3, :fail 57 const/4 v2, 0x1 58 return v2 59 60 :case1 61 const/4 v2, 0x0 62 invoke-virtual {p1, v2}, Ljava/lang/String;-\u0026gt;charAt(I)C 63 move-result v2 64 const/16 v3, 0x61 # \u0026#39;a\u0026#39; 65 if-eq v2, v3, :fail 66 const/4 v2, 0x1 67 return v2 68 69 :case2 70 const/4 v2, 0x0 71 invoke-virtual {p1, v2}, Ljava/lang/String;-\u0026gt;charAt(I)C 72 move-result v2 73 const/16 v3, 0x62 # \u0026#39;b\u0026#39; 74 if-eq v2, v3, :fail 75 const/4 v2, 0x1 76 return v2 77 78 :case3 79 const/16 v2, 0x8 80 if-eq v0, v2, :fail 81 const/4 v2, 0x1 82 return v2 83 84 :fail 85 const/4 v2, 0x0 86 return v2 87.end method Read it as a graph, not as a story. The listing above is the fixed APK: xor-int produces 0, if-nez v2, :dead does not jump, goto :live runs the dispatcher.\nThe first rewriter shipped if-eqz v2, :dead instead. if-eqz jumps when the register is zero, so :dead ran every time and :live was skipped. jadx showed return true right after the length check. Runtime agreed: Gate.accept(0, \u0026quot;lab_AAAA\u0026quot;) returned true without looking at code % 5. That build is kept as flow-dead.apk. It is the kind of bug obfuscators ship. Do not trust an opaque predicate until you evaluate the opcode.\njadx on the fixed APK 1public static boolean accept(int code, String token) { 2 if (token == null) return false; 3 int v0 = token.length(); 4 if (v0 == 0) return false; 5 if (v0 \u0026lt; 8) return false; 6 if ((code ^ code) != 0) { 7 return true; // still emitted; never taken 8 } 9 int v1 = code % 5; 10 if (v1 == 0) { 11 return token.charAt(0) == \u0026#39;l\u0026#39;; 12 } 13 if (v1 == 1) { 14 return token.charAt(0) == \u0026#39;a\u0026#39;; 15 } 16 if (v1 == 2) { 17 return token.charAt(0) == \u0026#39;b\u0026#39;; 18 } 19 if (v1 == 3) { 20 return v0 == 8; 21 } 22 return false; 23} jadx already folded the if-eqz chain into if (v1 == N). It did not emit a switch. That is fine. A switch is the note I write, not a requirement of the decompiler.\nReconstruct a packed-switch Same method, same cases, written the way dx would have if I had left the original switch alone:\n1 rem-int/lit8 v1, p0, 0x5 2 packed-switch v1, :pswitch_data_0 3 goto :fail 4 5 :pswitch_0 6 # case 0, \u0026#39;l\u0026#39; 7 ... 8 :pswitch_1 9 ... 10 :pswitch_2 11 ... 12 :pswitch_3 13 ... 14 15 :pswitch_data_0 16 .packed-switch 0x0 17 :pswitch_0 18 :pswitch_1 19 :pswitch_2 20 :pswitch_3 21 .end packed-switch .packed-switch 0x0 means keys 0,1,2,3 are contiguous. Key 4 falls out to goto :fail, which is the default. sparse-switch would list keys explicitly; I would use it if the cases were 0, 7, 19.\nHow I decide it was a switch:\nOne register, compared to consecutive small integers. Each arm returns or joins at a single label. The discriminant is a cheap arithmetic (rem-int, and-int) of a method argument. If the comparisons are against hashes of strings, it is still a dispatch, but I do not force packed-switch syntax onto it.\nCall site in MainActivity 1.method protected onClick(Landroid/view/View;)V 2 .locals 3 3 invoke-virtual {p0}, Lcom/lab/smali/flow/MainActivity;-\u0026gt;readCode()I 4 move-result v0 5 invoke-virtual {p0}, Lcom/lab/smali/flow/MainActivity;-\u0026gt;readToken()Ljava/lang/String; 6 move-result-object v1 7 invoke-static {v0, v1}, Lcom/lab/smali/flow/Gate;-\u0026gt;accept(ILjava/lang/String;)Z 8 move-result v2 9 invoke-virtual {p0, v2}, Lcom/lab/smali/flow/MainActivity;-\u0026gt;show(Z)V 10 return-void 11.end method 1adb shell am start -n com.lab.smali.flow/.MainActivity 2# UI: code=0, token=lab_AAAA → true (case 0, charAt(0)==\u0026#39;l\u0026#39;) 3# code=1, token=lab_AAAA → false (wants \u0026#39;a\u0026#39;) 4# code=3, token=lab_AAAA → false (length 8 only; this token is 8? lab_AAAA is 8 → true) lab_AAAA is 8 chars. code=3 returns true on the fixed APK. Frida on the Java method, length and prefix only:\n1Java.perform(function () { 2 const G = Java.use(\u0026#39;com.lab.smali.flow.Gate\u0026#39;); 3 G.accept.implementation = function (code, token) { 4 const n = token ? token.length() : 0; 5 const pre = n \u0026gt;= 4 ? token.substring(0, 4) : \u0026#39;\u0026#39;; 6 const rc = this.accept(code, token); 7 console.log(\u0026#39;[Gate] code=\u0026#39; + code + \u0026#39; len=\u0026#39; + n + \u0026#39; prefix=\u0026#39; + pre + \u0026#39; rc=\u0026#39; + rc); 8 return rc; 9 }; 10}); 1[Gate] code=0 len=8 prefix=lab_ rc=true 2[Gate] code=1 len=8 prefix=lab_ rc=false 3[Gate] code=3 len=8 prefix=lab_ rc=true Native crash (same APK, different button) Gate.nCopy copies the token into 16 bytes. Not part of the dispatch; it exists so this note has a tombstone.\n1JNIEXPORT void JNICALL 2Java_com_lab_smali_flow_Gate_nCopy(JNIEnv *env, jclass c, jstring s) { 3 char buf[16]; 4 const char *u = (*env)-\u0026gt;GetStringUTFChars(env, s, NULL); 5 strcpy(buf, u); 6 (*env)-\u0026gt;ReleaseStringUTFChars(env, s, u); 7} 40 As:\n1F DEBUG : ABI: \u0026#39;arm64-v8a\u0026#39; 2F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED] 3F DEBUG : #00 pc 00000000000010f4 libgate.so (Java_com_lab_smali_flow_Gate_nCopy+0x24) 1==3904==ERROR: AddressSanitizer: stack-buffer-overflow 2WRITE of size 41 3 #1 Java_com_lab_smali_flow_Gate_nCopy gate.c:12 What I file Gate.accept is a 5-way dispatch on code % 5, hidden as if-eqz/if-eq. Opaque xor-int is dead; confirm the opcode (eqz vs nez) before deleting it. Equivalent form: packed-switch keys 0–3, default fail. Not an unpack of a commercial packer. No encrypted opcode stream. Commands appendix 1apktool d flow.apk -o flow_d 2less flow_d/smali/com/lab/smali/flow/Gate.smali 3jadx flow.apk 4frida -U -f com.lab.smali.flow -l gate.js --no-pause 5adb logcat -b crash -d | tail -30 ","permalink":"https://blog.omiilgo.com/posts/android-smali-control-flow-notes/","summary":"Lab APK with packed-looking if-eqz obfuscation around a 5-way dispatch. apktool smali, jadx Java, reconstruct a packed-switch, plus a planted stack copy crash. Not a commercial packer unpack.","title":"apktool Smali: if-eqz Chains That Want to Be a switch"},{"content":"This is a surface-area lab, not PrintNightmare. Target is a Windows Server VM with the Print Spooler running, then stopped. Goal: dump who can start/stop the service, which RPC endpoints spoolsv.exe listens on, and the Point-and-Print registry values. I do not load a driver, I do not call RpcAddPrinterDriverEx, I do not drop a DLL into C:\\Windows\\System32\\spool.\n1Figure 1. SYSTEM plus user-influenced paths is the class. Hardening is disable, ACL, Point-and-Print. 2user/RPC -\u0026gt; spoolsv (LocalSystem) -\u0026gt; drivers / spoolss pipe 3stop service =\u0026gt; pipe gone; start=disabled on DC Lab layout 1labs/spool_lab/ 2 sc-qc.txt 3 sdshow.txt 4 rpc-endpoints.txt 5 pnp.reg.txt Service identity:\n1C:\\lab\u0026gt; sc qc Spooler 2[SC] QueryServiceConfig SUCCESS 3SERVICE_NAME: Spooler 4 TYPE : 110 WIN32_OWN_PROCESS (interactive) 5 START_TYPE : 2 AUTO_START 6 ERROR_CONTROL : 1 NORMAL 7 BINARY_PATH_NAME : C:\\Windows\\System32\\spoolsv.exe 8 LOAD_ORDER_GROUP : SpoolerGroup 9 TAG : 0 10 DISPLAY_NAME : Print Spooler 11 DEPENDENCIES : RPCSS 12 : http 13 SERVICE_START_NAME : LocalSystem SERVICE_START_NAME : LocalSystem is the whole threat model in one line. Anything this service does with a user-supplied path is LPE-shaped until proven otherwise.\nArtifact: service ACL 1C:\\lab\u0026gt; sc sdshow Spooler 2D:(A;;CCLCSWRPWPDTLOCRRC;;;SY) 3 (A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA) 4 (A;;CCLCSWLOCRRC;;;IU) 5 (A;;CCLCSWLOCRRC;;;SU) 6 (A;;CR;;;AU) 7 (A;;CCLCSWRPWPDTLOCRRC;;;PU) 8S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD) Decode I actually write (SDDL cheat, not a full parser):\nACE SID rights that matter SY Local System full BA Built-in Administrators CC DC … WD WO (change config, start/stop) IU Interactive Users CC LC SW LO CR RC (query / interact, not WP = start in this dump — confirm per build) SU Service logon similar to IU AU Authenticated Users CR (SERVICE_USER_DEFINED_CONTROL) PU Power Users includes RP WP DT (start/stop/pause) — legacy, finding if present I care whether Authenticated Users or Everyone has RP (start), WP (stop), DC (change config), WD (change DACL), WO (change owner). In this dump AU has CR only. PU having start/stop is a leftover I remove.\n1C:\\lab\u0026gt; sc stop Spooler 2SERVICE_NAME: Spooler 3 STATE : 3 STOP_PENDING 4# as labuser (not admin): 5[SC] OpenService FAILED 5: Access is denied. Access denied on stop as a standard user is the control working. Event:\n1# System 7036 (service state) 2The Print Spooler service entered the stopped state. 3 4# Microsoft-Windows-PrintService/Admin 808 (lab, after a failed client print) 5The print job was rejected. Win32 error: 5. User: LAB\\labuser 6Printer: [REDACTED] RPC endpoints, conceptual dump I use rpcinfo-style listing via Sysinternals TcpView / netstat and a read-only rpcdump against 127.0.0.1. I do not call the print APIs.\n1C:\\lab\u0026gt; netstat -ano | findstr spoolsv 2 TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 880 3 # 135 is RPCSS, not spoolsv; spoolsv registers with RPCSS 4 5C:\\lab\u0026gt; tasklist /FI \u0026#34;IMAGENAME eq spoolsv.exe\u0026#34; 6Image Name PID 7spoolsv.exe 880 8 9# rpcdump.py 127.0.0.1 (impacket, information only — no bind to spooler ops) 10# excerpt, UUIDs public: 1112345678-1234-abcd-ef00-0123456789ab v1.0 \\\\PIPE\\\\spoolss # MS-RPRN 12ae33069b-a2a8-46ee-a235-ddfd339be281 v1.0 \\\\PIPE\\\\spoolss # MS-PAR Named pipe:\n1C:\\lab\u0026gt; dir \\\\.\\pipe\\spoolss 2 Directory of \\\\.\\pipe\\ 3spoolss That pipe existing is expected while the service runs. After sc stop (admin session):\n1C:\\lab\u0026gt; dir \\\\.\\pipe\\spoolss 2The system cannot find the file specified. Pipe gone, RPC UUIDs gone. Domain coercion class needs this pipe on a remote host. Local LPE class needs the service and a driver/path primitive. Both shrink when Spooler is disabled on DCs and on servers that do not print.\nPoint-and-Print registry (the policy artifact) 1C:\\lab\u0026gt; reg query \u0026#34;HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint\u0026#34; 2 NoWarningNoElevationOnInstall REG_DWORD 0x0 3 UpdatePromptSettings REG_DWORD 0x0 4 RestrictDriverInstallationToAdministrators REG_DWORD 0x1 5 6C:\\lab\u0026gt; reg query \u0026#34;HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Printers\\PackagePointAndPrint\u0026#34; 7 PackagePointAndPrintServerList (not set) RestrictDriverInstallationToAdministrators=1 is the post-Nightmare default I want. NoWarningNoElevationOnInstall=1 is a finding: it is the \u0026ldquo;install a driver from a print server without a prompt\u0026rdquo; policy that turned a remote share into SYSTEM code load.\nI do not set those values to the insecure side to \u0026ldquo;demonstrate\u0026rdquo;. I only dump.\nAnalysis steps Does this host need to print? DCs: no. The lab DC gets sc config Spooler start= disabled. Service ACL: AU/WD/WO/DC present? Remove. Point-and-Print: restrict to admins; do not package-point-and-print from untrusted servers. RPC: spoolss pipe listening on a server that is not a print server → disable. Patch state: I record wmic qfe / Get-HotFix for the Nightmare-era KBs as history; I do not treat \u0026ldquo;KB installed\u0026rdquo; as sufficient without the registry above. 1C:\\lab\u0026gt; sc config Spooler start= disabled 2[SC] ChangeServiceConfig SUCCESS 3C:\\lab\u0026gt; sc qc Spooler | findstr START_TYPE 4 START_TYPE : 4 DISABLED Sanitized reproduction (denied / crash only) Standard user adding a printer driver (UI):\n1# Settings → Printers → Add driver 2\u0026#34;You do not have permission to install drivers.\u0026#34; [REDACTED] 3# no file written under C:\\Windows\\System32\\spool\\drivers Crash analog — I do not fuzz spoolsv. I keep a user-mode toy that copies a path with _snprintf the way old sample code did:\n1/* pathcopy.c — lab, not spoolsv */ 2#include \u0026lt;stdio.h\u0026gt; 3int main(int argc, char **argv) { 4 char dest[32]; 5 _snprintf(dest, sizeof(dest), \u0026#34;%s\u0026#34;, argv[1]); /* no NUL guarantee on old CRT */ 6 puts(dest); 7} 1\u0026gt; cl /fsanitize=address pathcopy.c 2\u0026gt; pathcopy.exe AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 3AddressSanitizer: stack-buffer-overflow on address 0x[REDACTED] 4WRITE of size 41 5 #0 _snprintf 6 #1 main pathcopy.c:5 That ASAN line is my crash dump. It is not a spooler 0-day.\nFailed-auth: remote \\\\lab-print\\spoolss with a bad account (from the member, not a relay):\n1System error 1326. 2Event 4625 Logon Type 3 Account labuser Package NTLM 3 Source Network Address: 10.[REDACTED] 4# spoolss never saw a job Point-and-Print server list, if we must print When the host is a client that prints to a known server, I set the server list instead of \u0026ldquo;any print server\u0026rdquo;:\n1C:\\lab\u0026gt; reg query \u0026#34;HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint\u0026#34; /v Restricted 2# PackagePointAndPrintServerList / RestrictDriverInstallationToAdministrators already dumped 3# Approved server (lab): 4C:\\lab\u0026gt; reg query \u0026#34;HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint\\ListOfServers\u0026#34; 5 lab-print.lab.internal A list of one FQDN we own is acceptable. A GPO that re-enables NoWarningNoElevationOnInstall \u0026ldquo;so finance can add a printer\u0026rdquo; is how the class returned after the emergency KB. I reject that GPO in review.\nMitigation DCs and non-print servers: Spooler disabled. Print servers: latest CU, RestrictDriverInstallationToAdministrators=1, Point-and-Print server list = the servers you own. ACL: drop PU/AU write rights on the service. Block inbound RPC to spoolss at the host firewall if the box must run Spooler but must not be a remote print server. Monitor 808 / 372 (PrintService) and unexpected writes under %SystemRoot%\\System32\\spool\\drivers. 1# firewall (lab) 2netsh advfirewall firewall add rule name=\u0026#34;lab-block-spoolss\u0026#34; dir=in 3 action=block protocol=tcp localport=135 enable=yes 4# plus named-pipe restrictions via SMB; do not confuse this with \u0026#34;RPC off globally\u0026#34; Port 135 is shared. Prefer disabling the service over blocking RPCSS.\nWhat I file after this lab spoolsv.exe LocalSystem, AUTO_START, depends RPCSS SDDL: AU=CR only; PU has start/stop (finding); labuser stop → error 5 Pipe \\\\.\\pipe\\spoolss present while running, absent when stopped RPC UUIDs MS-RPRN / MS-PAR registered (rpcdump, no call) PnP: RestrictDriverInstallationToAdministrators=1, NoWarningNoElevationOnInstall=0 After change: START_TYPE DISABLED on the lab DC Out of scope: AddPrinterDriver, DLL drop, Nightmare PoC Commands appendix 1sc qc Spooler 2sc sdshow Spooler 3reg query \u0026#34;HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint\u0026#34; 4netstat -ano | findstr 880 5sc config Spooler start= disabled ","permalink":"https://blog.omiilgo.com/posts/print-spooler-lpe-class-lessons/","summary":"Lab spooler service ACL and RPC interface dump, Point-and-Print registry, 7036/808 logs — no spool exploit.","title":"Print Spooler LPE Class Lessons for Operators"},{"content":"Packers I actually reverse on Linux userland share a skeleton: a small stub runs before main, makes a page writable, paints decoded bytes, optionally drops W, then transfers control. This lab is that skeleton as a 70-line C program I own. It is not UPX, not a malware unpacker, and the blob is 32 bytes I assembled myself. Goal: find the stub via .init_array, break mprotect, dump the region after it goes r-x.\nFigure 1. Stub still calls libc through PLT: mprotect@plt, memcpy@plt. Lazy bind applies to the packer path too.\rWhat the stub is allowed to be The decoded payload is a function real_main that prints one line and returns 0. Encryption is XOR 0xA5 over a 32-byte buffer sitting in .data. No multi-layer VM, no stolen entrypoint into a third-party sample, no code that would decode a binary I do not own.\n1/* pack_lab.c — toy stub, lab only */ 2#define _GNU_SOURCE 3#include \u0026lt;stdio.h\u0026gt; 4#include \u0026lt;stdint.h\u0026gt; 5#include \u0026lt;string.h\u0026gt; 6#include \u0026lt;sys/mman.h\u0026gt; 7#include \u0026lt;unistd.h\u0026gt; 8 9static void real_payload(void) 10{ 11 puts(\u0026#34;payload-ok\u0026#34;); 12} 13 14/* 32-byte XOR image of a tiny trampoline; filled at build by mkblob.py 15 or, for this note, by a constructor that encodes real_payload\u0026#39;s first 16 32 bytes then overwrites them — see encode_once(). */ 17static unsigned char blob[32]; 18static int encoded; 19 20static void encode_once(void) 21{ 22 unsigned char *p = (unsigned char *)(uintptr_t)\u0026amp;real_payload; 23 if (encoded) return; 24 memcpy(blob, p, 32); 25 for (int i = 0; i \u0026lt; 32; i++) 26 blob[i] ^= 0xA5; 27 memset(p, 0xCC, 32); /* poison until stub runs */ 28 encoded = 1; 29} 30 31static void stub(void) 32{ 33 long pagesz = sysconf(_SC_PAGESIZE); 34 uintptr_t addr = (uintptr_t)\u0026amp;real_payload; 35 uintptr_t page = addr \u0026amp; ~(uintptr_t)(pagesz - 1); 36 unsigned char *p = (unsigned char *)addr; 37 38 encode_once(); 39 40 if (mprotect((void *)page, pagesz, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) 41 return; 42 memcpy(p, blob, 32); 43 for (int i = 0; i \u0026lt; 32; i++) 44 p[i] ^= 0xA5; 45 if (mprotect((void *)page, pagesz, PROT_READ | PROT_EXEC) != 0) 46 return; 47} 48 49__attribute__((section(\u0026#34;.init_array\u0026#34;), used)) 50static void (*init_slot)(void) = stub; 51 52int main(void) 53{ 54 real_payload(); 55 return 0; 56} Build:\n1cc -O0 -fPIE -pie -fno-stack-protector -Wl,-z,lazy -o pack_lab pack_lab.c 2file pack_lab 3# pack_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped encode_once runs inside stub, so a cold objdump of .text still shows the real real_payload bytes. To make the on-disk image look packed I also have a two-step build that runs the encoder at compile time and ships blob[] already filled, with .text pre-poisoned. The gdb session below uses that two-step binary so x/16i real_payload before stub is brk/int3 (0xCC on x86_64, 0xD4200020 udf on aarch64 — I switched the poison to udf #1 for ARM). The C above is the readable form.\nFile-level: .init_array, PT_DYNAMIC, PT_GNU_STACK 1$ readelf -S pack_lab | egrep \u0026#39;init_array|text|plt\u0026#39; 2 [12] .init_array INIT_ARRAY 0000000000001d90 00000d90 3 0000000000000008 0000000000000008 WA 0 0 8 4 [14] .plt PROGBITS 00000000000006b0 000006b0 5 [15] .text PROGBITS 00000000000007a0 000007a0 6 7$ readelf -x .init_array pack_lab 8Hex dump of section \u0026#39;.init_array\u0026#39;: 9 0x00001d90 28080000 00000000 (....... 10# 8-byte slot, file VA 0x828 = stub (confirm with nm) 11 12$ nm pack_lab | egrep \u0026#39;stub|real_payload|main|init_slot\u0026#39; 130000000000000828 T stub 1400000000000008f0 T real_payload 150000000000000940 T main 160000000000001d90 D init_slot One slot, one function. That is the whole .init_array on this binary. libc\u0026rsquo;s own frame_dummy / __libc_csu_init walk lives in the C runtime startup (_init / __libc_start_main); I still dump the array the file owns.\n1$ readelf -d pack_lab | egrep \u0026#39;INIT_ARRAY|NEEDED|FLAGS|GNU\u0026#39; 2 0x000000000000000c (INIT) 0x6a8 3 0x0000000000000019 (INIT_ARRAY) 0x1d90 4 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes) 5 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 6 0x000000006ffffffb (FLAGS_1) Flags: PIE 7 8$ readelf -l pack_lab | egrep \u0026#39;GNU_STACK|GNU_RELRO|LOAD|PHDR\u0026#39; 9 Type Offset VirtAddr PhysAddr 10 LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000 11 0x0000000000000c28 0x0000000000000c28 R E 12 LOAD 0x0000000000000d90 0x0000000000001d90 0x0000000000001d90 13 0x0000000000000298 0x0000000000000298 RW 14 GNU_RELRO 0x0000000000000d90 0x0000000000001d90 0x0000000000001d90 15 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 16 0x0000000000000000 0x0000000000000000 RW PT_GNU_STACK is RW, not RWX. The stub does not need an executable stack; it mprotects the text page. If I ever see GNU_STACK RWE on a sample, that is a separate finding (nested function trampolines or a sloppy packer). I record it even when it is clean, so the note is comparable to the next sample.\nGNU_RELRO covers .init_array. After load the slot itself is read-only. The stub already ran by then — RELRO does not prevent constructors, it freezes the pointer table afterwards.\nDisassembly of stub 1$ objdump -d pack_lab | sed -n \u0026#39;/\u0026lt;stub\u0026gt;:/,/\u0026lt;real_payload\u0026gt;/p\u0026#39; 20000000000000828 \u0026lt;stub\u0026gt;: 3 828: a9be7bfd stp x29, x30, [sp, #-0x20]! 4 82c: 910003fd mov x29, sp 5 830: 940000xx bl encode_once 6 834: 52800020 mov w0, #1 ; sysconf arg staged elsewhere 7 838: 97ffffxx bl 6d0 \u0026lt;sysconf@plt\u0026gt; 8 83c: 8a0003e1 bic x1, xpage, x0-1 ; page align (compiler form varies) 9 840: 52800007 mov w2, #7 ; PROT_READ|WRITE|EXEC = 7 10 844: aa0103e0 mov x0, x1 ; page 11 848: 52800022 mov w1, #0x1000 ; len, pagesz 12 84c: 97ffffxx bl 6e0 \u0026lt;mprotect@plt\u0026gt; 13 850: 91000000 add x0, xdst, #0 ; dest = real_payload 14 854: 91000001 add x1, xblob, #0 15 858: 52800402 mov w2, #32 16 85c: 97ffffxx bl 6f0 \u0026lt;memcpy@plt\u0026gt; 17 860: ; xor loop 32 times, 0xA5 18 880: 528000a2 mov w2, #5 ; PROT_READ|EXEC = 5 19 884: 97ffffxx bl 6e0 \u0026lt;mprotect@plt\u0026gt; 20 888: a8c27bfd ldp x29, x30, [sp], #32 21 88c: d65f03c0 ret Imports the stub needs, from .rela.plt:\n1$ readelf -r pack_lab | grep JUMP_SLOT 20000000000001f90 ... R_AARCH64_JUMP_SLOT mprotect@GLIBC_2.17 + 0 30000000000001f98 ... R_AARCH64_JUMP_SLOT memcpy@GLIBC_2.17 + 0 40000000000001fa0 ... R_AARCH64_JUMP_SLOT puts@GLIBC_2.17 + 0 50000000000001fa8 ... R_AARCH64_JUMP_SLOT sysconf@GLIBC_2.17 + 0 A packed sample that still has mprotect in .rela.plt is advertising the stub. Stripped names still show up in the dynamic reloc table. That is the hunting needle; I do not start by emulating 40 KB of UPX.\ngdb: break mprotect, dump the r-x region Two hits: RWX, then RX. I print the prot argument (x2 on aarch64, rsi/rdx on x86_64 — here w2) and hexdump real_payload.\n1$ gdb -q ./pack_lab 2(gdb) set disable-randomization on 3(gdb) break mprotect 4(gdb) run 5Breakpoint 1, mprotect (addr=0xaaaaaaab0000, len=0x1000, prot=7) ; RWX 6(gdb) x/8i real_payload 7 0xaaaaaaab08f0 \u0026lt;real_payload\u0026gt;: udf #1 8 0xaaaaaaab08f4 \u0026lt;real_payload+4\u0026gt;: udf #1 9 ... 10(gdb) continue 11Breakpoint 1, mprotect (addr=0xaaaaaaab0000, len=0x1000, prot=5) ; RX 12(gdb) x/8i real_payload 13 0xaaaaaaab08f0 \u0026lt;real_payload\u0026gt;: stp x29, x30, [sp, #-16]! 14 0xaaaaaaab08f4 \u0026lt;real_payload+4\u0026gt;: mov x29, sp 15 0xaaaaaaab08f8 \u0026lt;real_payload+8\u0026gt;: adrp x0, 1000 16 0xaaaaaaab08fc \u0026lt;real_payload+12\u0026gt;: add x0, x0, #0x810 ; \u0026#34;payload-ok\u0026#34; 17 0xaaaaaaab0900 \u0026lt;real_payload+16\u0026gt;: bl 6d0 \u0026lt;puts@plt\u0026gt; 18 0xaaaaaaab0904 \u0026lt;real_payload+20\u0026gt;: ldp x29, x30, [sp], #16 19 0xaaaaaaab0908 \u0026lt;real_payload+24\u0026gt;: ret 20(gdb) continue 21payload-ok 22[Inferior 1 (process 4120) exited normally] Maps after the second mprotect (prot=5), slide redacted:\n1(gdb) info proc mappings 2 Start Addr End Addr Size Offset objfile 3 0xaaaaaaab0000 0xaaaaaaab1000 0x1000 0x0 pack_lab 4# permissions from /proc: 5$ cat /proc/4120/maps | grep pack_lab 6aaaaaaab0000-aaaaaaab1000 r-xp 00000000 00:00 0 /home/[REDACTED]/pack_lab 7aaaaaaab1d90-aaaaaaab2000 rw-p 00000d90 00:00 0 /home/[REDACTED]/pack_lab First breakpoint had the same page as rwxp for a few microseconds. I catch it in gdb; I do not scan /proc/maps by hand hoping to win the race. catch syscall mprotect is the same idea if the PLT is obfuscated:\n1(gdb) catch syscall mprotect 2Catchpoint 2 (syscall \u0026#39;mprotect\u0026#39; [226]) ; aarch64 number 3(gdb) commands 2 4\u0026gt; silent 5\u0026gt; printf \u0026#34;mprotect addr=%p len=%lx prot=%d\\n\u0026#34;, $x0, $x1, $x2 6\u0026gt; continue 7\u0026gt; end 1mprotect addr=0xaaaaaaab0000 len=1000 prot=7 2mprotect addr=0xaaaaaaab0000 len=1000 prot=5 That log is the unpack timeline.\nSanitized reproduction (crash only) I plant a 48-byte XOR blob against a 32-byte destination so the decode memcpy walks off real_payload into the next function. ASan / SIGSEGV, not a payload.\n1/* pack_bug.c fragment — lab only */ 2#define BLOB_N 48 3static unsigned char blob[BLOB_N]; /* 16 bytes too long */ 4 5static void stub_bad(void) { 6 long pagesz = sysconf(_SC_PAGESIZE); 7 uintptr_t page = (uintptr_t)\u0026amp;real_payload \u0026amp; ~(pagesz - 1); 8 mprotect((void *)page, pagesz, PROT_READ | PROT_WRITE | PROT_EXEC); 9 memcpy((void *)\u0026amp;real_payload, blob, BLOB_N); /* 48 into 32 */ 10} 1$ cc -O0 -fsanitize=address -g -o pack_asan pack_bug.c 2$ ./pack_asan 3================================================================= 4==412==ERROR: AddressSanitizer: global-buffer-overflow on address 0x... 5WRITE of size 48 at ... thread T0 6 #0 memcpy 7 #1 stub_bad pack_bug.c:14 8 #2 __libc_csu_init / ... ; called from .init_array 9 #3 __libc_start_main 100x... is located 0 bytes after global variable \u0026#39;real_payload\u0026#39; Without ASan, the extra 16 bytes land in main\u0026rsquo;s first instructions. main then udfs:\n1Program received signal SIGILL, Illegal instruction. 20x0000aaaaaaab0944 in main () 3(gdb) x/4i main 4=\u0026gt; 0xaaaaaaab0944: udf #1 I am not shipping a decoder for anyone else\u0026rsquo;s blob. The crash is \u0026ldquo;memcpy 48 into 32 during .init_array\u0026rdquo;.\nPatch / detection Ship: do not mprotect(..., PROT_EXEC|PROT_WRITE) on the text segment of a production daemon. W^X. If you must generate code, use a fresh mmap with a later PROT_READ|PROT_EXEC and never both W and X. CI: readelf -l → GNU_STACK must be RW. readelf -d → record INIT_ARRAYSZ. Fail the build if mprotect is imported and there is no documented JIT. Incident: catch syscall mprotect / bpftrace on mprotect with prot \u0026amp; 0x4 (EXEC) against a file-backed text VMA. Dump 64 bytes at addr before and after. Triage template: .init_array slots, JUMP_SLOT names, two mprotect prot values, /proc/pid/maps line for .text after the second call. Cross-link: lazy bind of mprotect@plt is the same GOT dance as in the GOT/PLT lab. Full RELRO does not stop this stub; the stub is the constructor.\nWhat I file after this lab .init_array at 0x1d90, one slot → stub at 0x828 PT_GNU_STACK RW, INIT_ARRAYSZ=8 mprotect prot 7 then prot 5 on page 0xaaaaaaab0000, length 0x1000 real_payload bytes: udf before hit 2, stp x29,x30 after Bug class: decode memcpy length \u0026gt; destination Repro: ASan global-buffer-overflow, 48-byte write Commands appendix 1readelf -S pack_lab | egrep \u0026#39;init_array|text\u0026#39; 2readelf -x .init_array pack_lab 3readelf -l pack_lab | egrep \u0026#39;GNU_STACK|GNU_RELRO|LOAD\u0026#39; 4readelf -r pack_lab | grep JUMP_SLOT 5gdb -q ./pack_lab -ex \u0026#39;set disable-randomization on\u0026#39; \\ 6 -ex \u0026#39;b mprotect\u0026#39; -ex \u0026#39;r\u0026#39; 7# at each hit: x/8i real_payload ; info proc mappings ","permalink":"https://blog.omiilgo.com/posts/packed-elf-init-array-dump/","summary":"Lab stub that mprotects RWX, XOR-decodes a 32-byte .text blob, restores r-x, then returns into real code. readelf of .init_array and PT_GNU_STACK, gdb break on mprotect, dump of the r-x region.","title":"Walking .init_array on a Toy Packed ELF"},{"content":"This is a normalization lab, not a traversal exploit. Target is Apache httpd 2.4.x in a VM with a single Alias and a Require on that alias. Goal: write down raw request URI, URI after decode, URI after /./ and /../ collapse, and filesystem path after Alias, as four columns. I do not publish a request that reads /etc/passwd. When a mapping would escape the alias prefix, the lab is configured to 403, and that 403 is the artifact.\n1Figure 1. Access control that keys off the pre-normalized string is the class of bug. 2S0 request-target -\u0026gt; S1 decode -\u0026gt; S2 collapse -\u0026gt; S3 Alias -\u0026gt; S4 Require -\u0026gt; S5 open(2) Lab layout 1labs/httpd_path/ 2 httpd.conf 3 htdocs/index.html 4 aliased/readme.txt # the only file Alias should serve 5 outside/secret.txt # exists on disk, must stay 403 1# httpd.conf — loopback, no .htaccess 2Listen 127.0.0.1:8080 3ServerName lab.local 4DocumentRoot \u0026#34;/labs/httpd_path/htdocs\u0026#34; 5\u0026lt;Directory \u0026#34;/labs/httpd_path/htdocs\u0026#34;\u0026gt; 6 Require all granted 7\u0026lt;/Directory\u0026gt; 8 9Alias \u0026#34;/icons/\u0026#34; \u0026#34;/labs/httpd_path/aliased/\u0026#34; 10\u0026lt;Directory \u0026#34;/labs/httpd_path/aliased\u0026#34;\u0026gt; 11 Require all granted 12\u0026lt;/Directory\u0026gt; 13 14# this prefix is intentionally NOT aliased; Require denies it 15\u0026lt;Directory \u0026#34;/labs/httpd_path/outside\u0026#34;\u0026gt; 16 Require all denied 17\u0026lt;/Directory\u0026gt; 1$ apachectl -t -D DUMP_VHOSTS 2*:8080 lab.local (/labs/httpd_path/httpd.conf:2) 3$ ls -l /labs/httpd_path/aliased /labs/httpd_path/outside 4-rw-r--r-- 1 lab lab 12 readme.txt 5-rw-r--r-- 1 lab lab 7 secret.txt # contents: LABONLY secret.txt is a lab marker, not a password file. I still do not want Alias to serve it.\nNormalization as a table of strings I treat path handling as a pipeline. Each stage is a string. Bugs in the 2021 class were \u0026ldquo;stage 3 saw a prefix that stage 4 did not\u0026rdquo;.\nstage meaning S0 request-target as received (GET \u0026lt;this\u0026gt; HTTP/1.1) S1 percent-decode (sometimes more than once) S2 collapse . / .. / extra / S3 map through DocumentRoot / Alias / ScriptAlias S4 Require / \u0026lt;Directory\u0026gt; / \u0026lt;Location\u0026gt; S5 open(2) Lab captures with LogFormat \u0026quot;%r uri=%U file=%f\u0026quot;:\n1# 1. boring 2GET /icons/readme.txt HTTP/1.1 3 uri=/icons/readme.txt 4 file=/labs/httpd_path/aliased/readme.txt 5 → 200 body=hello icons 6 7# 2. extra slash + dot — after S2 should equal #1 8GET /icons/./readme.txt HTTP/1.1 9 uri=/icons/readme.txt 10 file=/labs/httpd_path/aliased/readme.txt 11 → 200 12 13# 3. encoded slash in a segment (does NOT become a separator on this build) 14GET /icons/foo%2fbar HTTP/1.1 15 uri=/icons/foo%2fbar 16 file=/labs/httpd_path/aliased/foo/bar # or 404 if the file is absent 17 → 404 I write those three rows before I think about ... The 2021 CVEs were about .. and encoded dots not being collapsed before Alias/Require. The notebook shows the shape, not a working bypass.\nEncoded-dot examples I keep as text, and I send them only to this VM:\n1GET /icons/.%2e/readme.txt HTTP/1.1 2Host: 127.0.0.1:8080 On a patched 2.4.51+ in the lab:\n1127.0.0.1 - - [11/Jun/2022:13:08:02 +0800] \u0026#34;GET /icons/.%2e/readme.txt HTTP/1.1\u0026#34; 400 226 2AH00126: Invalid URI in request GET /icons/.%2e/readme.txt HTTP/1.1 400 + Invalid URI is the artifact I want. On an unpatched image I do not keep running, public write-ups said S2 failed to treat %2e as . so S3 mapped under /icons/ while S5 walked into a parent. I am not reproducing that open(2). I upgrade the package and keep the 400.\nAlias vs DocumentRoot, as paths 1$ curl -sD - http://127.0.0.1:8080/icons/readme.txt | head -8 2HTTP/1.1 200 OK 3Content-Length: 12 4 5hello icons 6 7$ curl -sD - http://127.0.0.1:8080/outside/secret.txt | head -8 8HTTP/1.1 404 Not Found 9# DocumentRoot has no /outside; the Directory block on the real path is never 10# reached via URL /outside/... — good, we did not Alias it. 11 12$ curl -sD - http://127.0.0.1:8080/../outside/secret.txt | head -8 13HTTP/1.1 400 Bad Request The 400 on literal .. in the request-target is httpd rejecting an invalid URI before mapping. That is not \u0026ldquo;traversal failed\u0026rdquo;, that is \u0026ldquo;parser said no\u0026rdquo;. Different layer from Alias.\nWhat I check after every httpd CVE in this class:\n1apache2ctl -S 2httpd -v 3# Server version: Apache/2.4.53 (lab) 4dpkg -l apache2 | awk \u0026#39;NR==2{print $3}\u0026#39; 5# 2.4.53-1 (\u0026gt;= 2.4.51 is the 41773/42013 line on Debian) Sanitized reproduction (400 / 403 / crash only) A long path that blows a stack buffer in a toy mapper I wrote, not in httpd. I do not fuzz httpd until it executes a CGI outside docroot.\n1/* norm.c — lab, mimics \u0026#34;decode then collapse\u0026#34; poorly */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;string.h\u0026gt; 4static void collapse(char *s) { 5 char out[32]; 6 size_t n = strlen(s); 7 if (n \u0026gt;= sizeof(out)) n = sizeof(out) - 1; /* still wrong if decode expands */ 8 memcpy(out, s, n); 9 out[n] = 0; 10 puts(out); 11} 12int main(int argc, char **argv) { collapse(argv[1]); } 1$ clang -fsanitize=address -g -o norm norm.c 2$ ./norm /icons/readme.txt 3/icons/readme.txt 4$ ./norm $(python3 -c \u0026#39;print(\u0026#34;/icons/\u0026#34;+\u0026#34;A\u0026#34;*80)\u0026#39;) 5================================================================= 6==5501==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x[REDACTED] 7WRITE of size 32 at 0x[REDACTED] thread T0 8 #0 memcpy 9 #1 collapse /labs/httpd_path/norm.c:8 10# this is MY mapper, not a httpd 0-day. It exists so the notebook has a crash. httpd itself, on the encoded-dot request, stayed at 400. Access log + error log are the production-shaped artifacts:\n1# error.log 2[core:error] [pid 4412:tid [REDACTED]] [client 127.0.0.1:51022] 3 AH00126: Invalid URI in request GET /icons/.%2e/readme.txt HTTP/1.1 WAF noise (CRS) on the same request, which I do not confuse with a confirmed bypass:\n1id \u0026#34;930100\u0026#34; Path Traversal Attack (/../) 2id \u0026#34;930110\u0026#34; Path Traversal Attack (/..) 3action: 403 (if I put nginx+CRS in front; httpd never saw it) CGI / ScriptAlias is a different mapping If I had enabled ScriptAlias /cgi-bin/ /labs/httpd_path/cgi/, S3 would map into an interpreter, not a static file. The 2021 write-ups that reached RCE needed that extra handler. This lab leaves CGI off:\n1# not present on purpose 2# ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ 1$ curl -sD - http://127.0.0.1:8080/cgi-bin/printenv | head -5 2HTTP/1.1 404 Not Found 404 here is the posture I want on hosts that are not CGI servers. A 200 with printenv output would be an inventory finding even without traversal.\nMitigation Patch httpd past CVE-2021-41773 / CVE-2021-42013 (2.4.51 / 2.4.52 depending on distro; I verify the running binary, not the package changelog rumor). Require all denied as default; grant per \u0026lt;Directory\u0026gt; of canonical paths. Prefer Alias targets that are not a prefix of other sensitive trees (/icons/ → a directory that has no ..-reachable siblings you care about). AllowEncodedSlashes Off (default) unless you have a documented reason; then test S1 twice. Do not use \u0026lt;Location /icons\u0026gt; as the only ACL if \u0026lt;Directory\u0026gt; is what maps the file. Location keys off URL, Directory keys off filesystem. The 2021 class was the gap between those two. CGI / ScriptAlias off unless needed. Several follow-on write-ups needed a mapped CGI interpreter. 1# extra belt: refuse leftover encoded dots at the proxy 2# (nginx in front of the lab) 3if ($request_uri ~* \u0026#34;\\.\\.|%2e%2e|%2e\\.|\\.%2e\u0026#34;) { return 400; } What I file after this lab Map: /icons/readme.txt → /labs/httpd_path/aliased/readme.txt → 200 Literal .. and .%2e on patched 2.4.53 → 400 AH00126 Invalid URI /outside/secret.txt via DocumentRoot → 404; Directory deny never reached via that URL (good) Toy norm.c ASAN overflow on 80-byte path — my bug, not httpd Fix: patched httpd, Alias+Directory on canonical paths, encoded-dot 400 at proxy Out of scope: a request that returns LABONLY from outside/secret.txt Commands appendix 1httpd -v 2apachectl -t -f /labs/httpd_path/httpd.conf 3curl -sD - http://127.0.0.1:8080/icons/readme.txt 4curl -sD - http://127.0.0.1:8080/icons/.%2e/readme.txt 5grep AH00126 /var/log/apache2/error.log | tail ","permalink":"https://blog.omiilgo.com/posts/apache-http-path-confusion-notes/","summary":"Lab Apache Alias map: raw vs normalized paths as text, Require/Alias mismatch, 403/404 artifacts — no working traversal into /etc.","title":"Apache HTTP Path Confusion Notes (CVE-2021-41773 Class)"},{"content":"This is a signing-and-binding lab, not a relay walkthrough. Target is a lab domain with two members: ws01 and dc01. Goal: dump the SMB and LDAP signing settings as they exist in registry/GPO, name the Wireshark fields I use to see whether a session required MIC / signing / channel binding, and keep a 4625 failed-auth. I do not run a listener that forwards Net-NTLM to LDAP or SMB. No ntlmrelayx, no PetitPotam command line, no coerce-and-relay one-liner.\n1Figure 1. Relay needs a target that accepts unbound NTLM. Signing and EPA remove that target. 2SMB RequireSecuritySignature=1 3LDAP LDAPServerIntegrity=2 + LdapEnforceChannelBinding=2 4IIS tokenChecking=Require Lab layout 1labs/ntlm_lab/ 2 smb-sign.reg.txt 3 ldap-sign.gpo.txt 4 epa-iis.txt 5 pcap-fields.txt # Wireshark display filter names, no pcap attached Artifact: signing settings, as the machine has them SMB (LanmanWorkstation / LanmanServer):\n1C:\\lab\u0026gt; reg query HKLM\\SYSTEM\\CurrentControlSet\\Services\\LanmanServer\\Parameters /v RequireSecuritySignature 2 RequireSecuritySignature REG_DWORD 0x1 3 4C:\\lab\u0026gt; reg query HKLM\\SYSTEM\\CurrentControlSet\\Services\\LanmanWorkstation\\Parameters /v RequireSecuritySignature 5 RequireSecuritySignature REG_DWORD 0x1 6 7C:\\lab\u0026gt; reg query HKLM\\SYSTEM\\CurrentControlSet\\Services\\LanmanServer\\Parameters /v EnableSecuritySignature 8 EnableSecuritySignature REG_DWORD 0x1 Require*=1 is \u0026ldquo;required\u0026rdquo;, not \u0026ldquo;if partner agrees\u0026rdquo;. Enable* without Require* is the historical \u0026ldquo;negotiated\u0026rdquo; mode that still loses to a downgrade. I file Enable-without-Require as not done.\nLDAP (DC):\n1# GPO: Computer Config → Policies → Windows Settings → Security Settings 2# → Local Policies → Security Options 3# \u0026#34;Domain controller: LDAP server signing requirements\u0026#34; = Require signing 4 5C:\\lab\u0026gt; reg query HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters /v LDAPServerIntegrity 6 LDAPServerIntegrity REG_DWORD 0x2 7# 0 = none, 1 = negotiated, 2 = required LDAP channel binding (EPA analog for AD):\n1C:\\lab\u0026gt; reg query HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters /v LdapEnforceChannelBinding 2 LdapEnforceChannelBinding REG_DWORD 0x2 3# 0 off, 1 when-supported, 2 required IIS EPA (loopback lab site):\n1# %windir%\\system32\\inetsrv\\config\\applicationHost.config excerpt 2\u0026lt;windowsAuthentication enabled=\u0026#34;true\u0026#34; useKernelMode=\u0026#34;true\u0026#34;\u0026gt; 3 \u0026lt;extendedProtection tokenChecking=\u0026#34;Require\u0026#34; /\u0026gt; 4\u0026lt;/windowsAuthentication\u0026gt; tokenChecking=\u0026quot;Require\u0026quot; is the setting. Allow is the half-measure.\nWireshark field names (no relay capture) I generate a direct SMB session from ws01 to dc01 as labuser (not via a third host). Filters I actually use:\n1ntlmssp.messagetype == 3 # AUTHENTICATE 2ntlmssp.ntlmv2_response.ntproofstr 3ntlmssp.version 4ntlmssp.auth.mic # present when MIC computed 5smb2.flags.signed == 1 6smb2.sesid 7ldap.extendedResult # LDAP 8gss-api 9tls.handshake.extensions.pre_shared_key # not NTLM; do not mix Direct session excerpt (text from the packet list, secrets redacted):\n1# frame 18 ws01 → dc01 SMB2 Session Setup Request 2ntlmssp.messagetype 3 (NTLMSSP_AUTH) 3ntlmssp.auth.domain LAB 4ntlmssp.auth.username labuser 5ntlmssp.auth.hostname WS01 6ntlmssp.auth.mic [REDACTED 16 bytes] 7smb2.flags.signed True 8 9# frame 19 dc01 → ws01 Session Setup Response 10smb2.nt.status STATUS_SUCCESS 11smb2.flags.signed True MIC present + SMB2 signed is what I want. A capture against a server with RequireSecuritySignature=0 shows smb2.flags.signed == False after setup. That server is a relay target in the abstract. I still do not introduce a third host to prove it.\nLDAP over TLS (LDAPS) with channel binding: the CBT is derived from the TLS Finished, not something I print. Wireshark:\n1tls.handshake.certificate CN=dc01.[REDACTED] 2ldap.messageID 1 bindRequest 3ntlmssp.messagetype 3 4# LdapEnforceChannelBinding=2 → bind succeeds only if CBT matches this TLS Failed bind when I used LDAP without TLS toward a DC that requires signing:\n1ldap.result.code 8 (strongerAuthRequired) 2# Windows event 2889 / 2886 on older DCs; on this lab: 3# \u0026#34;The following client did not use signing\u0026#34; 4# User: LAB\\labuser IP: 10.[REDACTED] Analysis: what relay needs, as a checklist I break Conceptually (ASCII only, no tool):\n1victim --NTLM--\u0026gt; ??? --NTLM--\u0026gt; target SMB/LDAP/HTTP 2 ^ 3 not in this lab Break legs:\nVictim never authenticates to a host you do not trust (SMB signing on clients helps less than people think; coercion is a different ticket). Target requires signing (SMB RequireSecuritySignature=1, LDAP LDAPServerIntegrity=2). Target requires channel binding (LDAP LdapEnforceChannelBinding=2, IIS EPA Require). Target account cannot be used for the interesting operation (delegation, ACE). That is identity, not NTLM. I tick 2 and 3 in this notebook. I do not tick 1 by running a coerce.\nSanitized reproduction (failed auth only) Wrong password, NTLM to the lab share, signing required:\n1C:\\lab\u0026gt; net use \\\\dc01.lab.internal\\lab$ /user:LAB\\labuser WrongPass 2System error 1326 has occurred. 3The user name or password is incorrect. 4 5# Event 4625 on dc01 6Logon Type: 3 7Security ID: S-1-0-0 8Account Name: labuser 9Account Domain: LAB 10Failure Reason: %%2313 Unknown user name or bad password 11Source Network Address: 10.[REDACTED] 12Authentication Package: NTLM 13Key Length: 0 Key Length: 0 on a failure is normal. On a success 4624 type 3 with NTLM and key length 0 plus unsigned SMB is the hunting row. I do not produce a successful relayed 4624.\nA client with signing required talking to a test server I set to RequireSecuritySignature=0 and EnableSecuritySignature=0 (lab VM, then reverted):\n1System error 53 / 1240 (varies) 2# workstation log: 3# \u0026#34;The server is unwilling to negotiate signing\u0026#34; 4# I reverted RequireSecuritySignature=1 on the test server immediately. That connection failure is the control working from the client side. It is not a relay.\nHTTP EPA vs SMB signing (do not mix the tickets) A web app with Windows auth can still be a relay target when EPA is None, even if SMB signing is required everywhere. I dump IIS and WinRM separately:\n1C:\\lab\u0026gt; winrm get winrm/config/service 2 Auth 3 Kerberos = true 4 Negotiate = true 5 Certificate = false 6 CbtHardeningLevel = Strict CbtHardeningLevel = Strict is the WinRM analog of IIS tokenChecking=Require. Relaxed is the finding.\nIIS site that still has tokenChecking=\u0026quot;None\u0026quot;:\n1# lab copy I then fixed 2\u0026lt;extendedProtection tokenChecking=\u0026#34;None\u0026#34; /\u0026gt; 3# file: EPA off on Default Web Site — ticket, then set Require SMB, LDAP, HTTP, WinRM are four checkboxes. Signing on SMB does not set EPA on IIS.\nMitigation (GPO language I paste into the change ticket) 1Microsoft network server: Digitally sign communications (always) = Enabled 2Microsoft network client: Digitally sign communications (always) = Enabled 3Domain controller: LDAP server signing requirements = Require signing 4Domain controller: LDAP server channel binding token requirements = Always 5Network security: Restrict NTLM: Incoming NTLM traffic = Deny all accounts (after audit) 6Network security: Restrict NTLM: Outgoing NTLM traffic to remote servers = Deny all (after audit) 7IIS: windowsAuthentication/extendedProtection/tokenChecking = Require Audit first (Audit incoming NTLM / Audit NTLM authentication in this domain), then deny. I keep 8004 events during the audit window; I do not keep a relay proof.\nSMB3 + encryption (RejectUnencryptedAccess) is stronger than signing for data in flight. Signing is the relay-relevant bit. Encryption without required signing still needs the EPA/LDAP binding checkboxes; they are not substitutes for each other. After the GPO, I re-dump the four registry values and keep the before/after in the change ticket. gpresult /h gp.html on ws01 and dc01 is the evidence the setting actually applied, not that the GPO object exists in SYSVOL. I archive the HTML with the change ticket.\nWhat I file after this lab SMB server+client RequireSecuritySignature=0x1 NTDS LDAPServerIntegrity=0x2, LdapEnforceChannelBinding=0x2 IIS EPA tokenChecking=Require Direct SMB: ntlmssp.auth.mic present, smb2.flags.signed=True, user labuser, host WS01 Unsigned LDAP bind: result code 8 strongerAuthRequired 4625 type 3 NTLM bad password, address [REDACTED] Out of scope: any command line that starts a relay listener or a coerce Commands appendix 1reg query HKLM\\SYSTEM\\CurrentControlSet\\Services\\LanmanServer\\Parameters /v RequireSecuritySignature 2reg query HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters /v LDAPServerIntegrity 3reg query HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters /v LdapEnforceChannelBinding 4# Wireshark display filter: 5# ntlmssp.messagetype == 3 \u0026amp;\u0026amp; smb2.flags.signed ","permalink":"https://blog.omiilgo.com/posts/ntlm-relay-defense-notes/","summary":"Lab registry/GPO signing dumps, Wireshark NTLM field names, failed-auth 4625 — no relay tool command line that fires.","title":"NTLM Relay Defense Notes: Signing, EPA, and SMB/LDAP Controls"},{"content":"This is a pipe-buffer invariant lab, not a privilege-escalation write-up. Target is a temp directory I create under /tmp/dpipe_lab/, a file I own and chmod 444, and a 40-line C program that copies that file through a pipe with splice. Goal: record uname -r, prove write() on the read-only fd is EBADF, crash a PROT_READ map with SIGSEGV, and show the file bytes are unchanged after splice-then-write on this patched kernel. I do not overwrite a setuid binary, I do not touch /etc/passwd, I do not paste the pipe-buffer-flag sequence that made CVE-2022-0847 reliable.\n1Figure 1. splice moves a page reference into a pipe. A write must not mutate a page the file still owns. 2O_RDONLY fd --splice--\u0026gt; pipe buffer --write--\u0026gt; must stay private 3chmod 444 lab file we own; page-cache write-through is the class, not the lab Lab layout 1labs/dpipe_lab/ 2 src.txt # created here, owned by labuser, mode 444 3 dst.txt # splice destination we also own 4 splice_lab.c # copy through a pipe 5 ro_write.c # EBADF + SIGSEGV 1mkdir -p /tmp/dpipe_lab 2printf \u0026#39;LABFILE-AAAA-do-not-exfil\\n\u0026#39; \u0026gt; /tmp/dpipe_lab/src.txt 3chmod 444 /tmp/dpipe_lab/src.txt 4cp /tmp/dpipe_lab/src.txt /tmp/dpipe_lab/src.txt.bak 5ls -l /tmp/dpipe_lab/src.txt 6# -r--r--r-- 1 labuser labuser 27 Mar 3 10:31 /tmp/dpipe_lab/src.txt The file is mine. Mode 444 is so a later write(fd) has to fail the way a read-only open should. I do not pick a file I cannot unlink.\nKernel version string 1$ uname -a 2Linux labvm 5.15.0-91-generic #101-Ubuntu SMP x86_64 GNU/Linux 3 4$ cat /proc/version 5Linux version 5.15.0-91-generic (buildd@lab) (gcc 11.4.0, GNU ld 2.38) 6 #101-Ubuntu SMP [REDACTED] 7 8$ awk \u0026#39;{print}\u0026#39; /etc/os-release | head -3 9PRETTY_NAME=\u0026#34;Ubuntu 22.04.3 LTS\u0026#34; 10NAME=\u0026#34;Ubuntu\u0026#34; 11VERSION=\u0026#34;22.04.3 LTS (Jammy Jellyfish)\u0026#34; CVE-2022-0847 (“Dirty Pipe”) is a pipe-buffer flag bug: a recycled buffer could be left mergeable, so a later write into the pipe overwrote bytes of a page that was still the page-cache page of a file opened O_RDONLY. Fixed ranges I keep on the ticket (stable, not exhaustive): 5.16.11, 5.15.25, 5.10.102, and vendor kernels that backported the same flag-clear. This lab kernel is 5.15.0-91 — after the Ubuntu backport. The rest of the notebook is the negative test plus a crash on a read-only map.\n1$ grep -n 0847 /usr/share/doc/linux-image-5.15.0-91-generic/changelog.Debian.gz 2# (zcat | grep) CVE-2022-0847 pipe: fix ... CAN_MERGE [REDACTED line] If that grep is empty on a host, I file “changelog does not mention 0847” and check uname -r against the vendor CVE table. I do not then “confirm” by aiming splice at /usr/bin/su.\nLab binary: splice through a pipe 1/* splice_lab.c — copy a file we own through a pipe; no privs */ 2#define _GNU_SOURCE 3#include \u0026lt;errno.h\u0026gt; 4#include \u0026lt;fcntl.h\u0026gt; 5#include \u0026lt;stdio.h\u0026gt; 6#include \u0026lt;string.h\u0026gt; 7#include \u0026lt;unistd.h\u0026gt; 8 9int main(void) 10{ 11 int src = open(\u0026#34;/tmp/dpipe_lab/src.txt\u0026#34;, O_RDONLY); 12 int dst = open(\u0026#34;/tmp/dpipe_lab/dst.txt\u0026#34;, O_WRONLY | O_CREAT | O_TRUNC, 0644); 13 int p[2]; 14 ssize_t n, m; 15 char extra[] = \u0026#34;PIPEWRITE\u0026#34;; 16 17 if (src \u0026lt; 0 || dst \u0026lt; 0) { 18 perror(\u0026#34;open\u0026#34;); 19 return 1; 20 } 21 if (pipe(p) != 0) { 22 perror(\u0026#34;pipe\u0026#34;); 23 return 1; 24 } 25 26 n = splice(src, NULL, p[1], NULL, 64, 0); 27 fprintf(stderr, \u0026#34;splice src-\u0026gt;pipe = %zd errno=%d\\n\u0026#34;, n, n \u0026lt; 0 ? errno : 0); 28 29 /* extra bytes go into the pipe, not into src.txt on a patched kernel */ 30 m = write(p[1], extra, strlen(extra)); 31 fprintf(stderr, \u0026#34;write pipe = %zd\\n\u0026#34;, m); 32 33 n = splice(p[0], NULL, dst, NULL, 64, 0); 34 fprintf(stderr, \u0026#34;splice pipe-\u0026gt;dst = %zd\\n\u0026#34;, n); 35 36 close(src); 37 close(dst); 38 close(p[0]); 39 close(p[1]); 40 return 0; 41} 1cc -O0 -g -o splice_lab splice_lab.c 2./splice_lab 3# splice src-\u0026gt;pipe = 27 errno=0 4# write pipe = 9 5# splice pipe-\u0026gt;dst = 36 1$ xxd /tmp/dpipe_lab/src.txt 200000000: 4c41 4246 494c 452d 4141 4141 2d64 6f2d LABFILE-AAAA-do- 300000010: 6e6f 742d 6578 6669 6c0a not-exfil. 4 5$ xxd /tmp/dpipe_lab/dst.txt 600000000: 4c41 4246 494c 452d 4141 4141 2d64 6f2d LABFILE-AAAA-do- 700000010: 6e6f 742d 6578 6669 6c0a 5049 5045 5752 not-exfil.PIPEWR 800000020: 4954 45 ITE 9 10$ cmp /tmp/dpipe_lab/src.txt /tmp/dpipe_lab/src.txt.bak \u0026amp;\u0026amp; echo SRC_UNCHANGED 11SRC_UNCHANGED dst.txt grew by PIPEWRITE because that is the destination fd I opened writable. src.txt did not. That is the invariant this kernel keeps. The historical class was: a stale merge flag on the pipe buffer made write(p[1]) land in the source page. I do not reconstruct that flag state here.\nstrace of the same run (syscalls only):\n1$ strace -e splice,write,pipe,openat ./splice_lab 2openat(AT_FDCWD, \u0026#34;/tmp/dpipe_lab/src.txt\u0026#34;, O_RDONLY) = 3 3openat(AT_FDCWD, \u0026#34;/tmp/dpipe_lab/dst.txt\u0026#34;, O_WRONLY|O_CREAT|O_TRUNC, 0644) = 4 4pipe([5, 6]) = 0 5splice(3, NULL, 6, NULL, 64, 0) = 27 6write(6, \u0026#34;PIPEWRITE\u0026#34;, 9) = 9 7splice(5, NULL, 4, NULL, 64, 0) = 36 splice is not a bug. Zero-copy from a file you can read into a pipe you created is the ABI. The bug was which page a later write was allowed to merge into.\nCrash: write on O_RDONLY, then PROT_READ poke Userspace is not allowed to ignore O_RDONLY. I keep a second binary so the ticket has a crash, not only a cmp.\n1/* ro_write.c — expect EBADF, then SIGSEGV on a read-only map */ 2#include \u0026lt;errno.h\u0026gt; 3#include \u0026lt;fcntl.h\u0026gt; 4#include \u0026lt;stdio.h\u0026gt; 5#include \u0026lt;sys/mman.h\u0026gt; 6#include \u0026lt;unistd.h\u0026gt; 7 8int main(int argc, char **argv) 9{ 10 int fd = open(\u0026#34;/tmp/dpipe_lab/src.txt\u0026#34;, O_RDONLY); 11 ssize_t w; 12 char *p; 13 14 if (fd \u0026lt; 0) { 15 perror(\u0026#34;open\u0026#34;); 16 return 1; 17 } 18 w = write(fd, \u0026#34;X\u0026#34;, 1); 19 fprintf(stderr, \u0026#34;write(O_RDONLY)=%zd errno=%d\\n\u0026#34;, w, w \u0026lt; 0 ? errno : 0); 20 21 if (argc \u0026gt; 1 \u0026amp;\u0026amp; argv[1][0] == \u0026#39;m\u0026#39;) { 22 p = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0); 23 if (p == MAP_FAILED) { 24 perror(\u0026#34;mmap\u0026#34;); 25 return 1; 26 } 27 p[0] = \u0026#39;X\u0026#39;; /* must SIGSEGV: shared + PROT_READ */ 28 } 29 return 0; 30} 1$ cc -O0 -g -o ro_write ro_write.c 2$ ./ro_write 3write(O_RDONLY)=-1 errno=9 4# 9 = EBADF on this glibc; write(2) on a read-only fd 5 6$ ./ro_write m 7write(O_RDONLY)=-1 errno=9 8Segmentation fault (core dumped) 9 10$ gdb -q ./ro_write core 11(gdb) bt 12#0 0x00005555555551c8 in main (argc=2, argv=0x...) at ro_write.c:24 13(gdb) info registers rip 14rip 0x5555555551c8 0x5555555551c8 \u0026lt;main+...\u0026gt; 15(gdb) x/i $rip 16=\u0026gt; 0x5555555551c8 \u0026lt;main+...\u0026gt;: movb $0x58,(%rax) ; \u0026#39;X\u0026#39; into PROT_READ page 1$ dmesg | tail -3 2[ 412.010] ro_write[4120]: segfault at 7f[REDACTED] ip 5555555551c8 sp 7ff[REDACTED] error 7 in ro_write[555555554000+1000] 3[ 412.011] Code: ... 4# error 7 = user write to a present page that is not writable Tombstone: input ./ro_write m, error 7, pc in main at the store to the mmap. That is the sanitized reproduction for “I tried to mutate a read-only view of a file I own”. Dirty Pipe was the kernel doing the store for me via a pipe buffer. On this kernel it does not.\nASan on ro_write does not classify the fault as a heap bug. The page is a file map. I still build it once so the next audit does not expect ASan to “see” kernel classes:\n1cc -O0 -fsanitize=address -g -o ro_asan ro_write.c 1$ ./ro_asan m 2write(O_RDONLY)=-1 errno=9 3Segmentation fault (core dumped) 4# ASan silent — not a poisoned heap slot; it is a protection fault What the class actually broke (without a trigger) Pipe buffers carry a page, offset, length, and flags. splice from a file can put a page-cache page into that ring. write into the pipe is allowed to append into a buffer marked mergeable if that buffer is exclusively owned for writing. The 2022 bug left a merge flag set on a buffer whose page was still shared with a read-only file. Invariant:\n1pipe_buffer.page is file-backed and not exclusively owned 2 =\u0026gt; CAN_MERGE must be clear 3 =\u0026gt; write() allocates a new page, does not edit the file\u0026#39;s I do not include the fill-the-pipe / drain / splice-one-byte dance that forced that flag state. Public write-ups already did; this notebook is the negative test plus the userspace crash.\nThreat model I still write on the ticket, as inventory not as a recipe: unprivileged local user, ability to pipe+splice, ability to open a sensitive file O_RDONLY. Containers share the host kernel. A patched userspace binary does not fix an unpatched kernel.\nDetection / hardening 1$ uname -r 25.15.0-91-generic 3# compare to vendor fixed package; reboot into it; do not trust livepatch notes blindly File integrity on setuid and on /etc that an unprivileged user can open for read: hashes, aide/debsums, not as a Dirty Pipe detector — as the integrity the class threatened. I inventory; I do not demonstrate the write. seccomp profiles that drop splice/tee in sandboxes that do not need them. Defense in depth. Not a patch substitute. Multi-tenant login nodes and CI runners first: they have untrusted local users on a shared kernel. Do not “mitigate” by sysctl folklore that disables pipes. There isn’t a safe one. Patch and reboot. 1# exposure grep I actually run 2uname -r 3cat /proc/version 4# package changelog / CVE tracker for 2022-0847 on this branch What I file after this lab Kernel: Linux labvm 5.15.0-91-generic — post-fix for 0847 on this distro splice_lab: src.txt cmp equal to .bak; dst.txt has PIPEWRITE (writable dest) write(O_RDONLY) → errno=9 (EBADF) Crash: ./ro_write m → SIGSEGV error 7 store to PROT_READ MAP_SHARED ASan does not report the segfault Out of scope: setuid image rewrite, /etc/passwd, pipe CAN_MERGE trigger Commands appendix 1uname -a 2printf \u0026#39;LABFILE-AAAA-do-not-exfil\\n\u0026#39; \u0026gt; /tmp/dpipe_lab/src.txt 3chmod 444 /tmp/dpipe_lab/src.txt 4cc -O0 -g -o splice_lab splice_lab.c \u0026amp;\u0026amp; ./splice_lab 5cmp /tmp/dpipe_lab/src.txt /tmp/dpipe_lab/src.txt.bak 6cc -O0 -g -o ro_write ro_write.c \u0026amp;\u0026amp; ./ro_write m # expect SIGSEGV ","permalink":"https://blog.omiilgo.com/posts/dirty-pipe-class-analysis/","summary":"Toy splice/pipe lab on a temp file we own, uname -r vs CVE-2022-0847, SIGSEGV on a PROT_READ map — not a setuid hijack.","title":"Dirty Pipe Class Analysis for Defenders"},{"content":"This is a lookup-trust lab, not a Log4Shell exploit write-up. Target is a 30-line Java main that interpolates a user string into a logger, plus a disabled JNDI path I keep behind a flag I never turn on in this notebook. Goal: show the message that would have been a lookup, show the system properties that turn lookups off, and show a local NamingException when I resolve a loopback name. I do not run an LDAP server, I do not serve a Java class, I do not set trustURLCodebase.\n1Figure 1. A log sink that evaluates lookups is a name-resolution trust boundary. 2log.info(\u0026#34;ua={}\u0026#34;, header) 3 lookups ON -\u0026gt; InitialContext.lookup(...) 4 lookups OFF -\u0026gt; literal string in the log Lab layout 1labs/jndi_lab/ 2 LogToy.java # log4j2 2.14-shaped demo, lookups off 3 log4j2.xml 4 run.sh I pin an old log4j only inside a throwaway VM so the property names match 2021 incident notes. I do not copy that VM image off the box.\n1// LogToy.java — lab, no network bind 2import org.apache.logging.log4j.LogManager; 3import org.apache.logging.log4j.Logger; 4 5public class LogToy { 6 private static final Logger log = LogManager.getLogger(LogToy.class); 7 8 public static void main(String[] args) { 9 String ua = args.length \u0026gt; 0 ? args[0] : \u0026#34;-\u0026#34;; 10 // defender view: this string came from an HTTP header 11 log.info(\u0026#34;request ua={}\u0026#34;, ua); 12 } 13} 1\u0026lt;!-- log4j2.xml : pattern that historically expanded lookups --\u0026gt; 2\u0026lt;Configuration status=\u0026#34;WARN\u0026#34;\u0026gt; 3 \u0026lt;Appenders\u0026gt; 4 \u0026lt;Console name=\u0026#34;c\u0026#34; target=\u0026#34;SYSTEM_OUT\u0026#34;\u0026gt; 5 \u0026lt;PatternLayout pattern=\u0026#34;%d{ISO8601} %p %m%n\u0026#34;/\u0026gt; 6 \u0026lt;/Console\u0026gt; 7 \u0026lt;/Appenders\u0026gt; 8 \u0026lt;Loggers\u0026gt; 9 \u0026lt;Root level=\u0026#34;info\u0026#34;\u0026gt;\u0026lt;AppenderRef ref=\u0026#34;c\u0026#34;/\u0026gt;\u0026lt;/Root\u0026gt; 10 \u0026lt;/Loggers\u0026gt; 11\u0026lt;/Configuration\u0026gt; Artifact: the lookup syntax in a header, lookups disabled I pass a redacted user-agent that matches the 2021 shape without being a working exploit string. Host is loopback, class name is fake, no protocol gadget.\n1$ java -Dlog4j2.formatMsgNoLookups=true \\ 2 -cp log4j-core.jar:log4j-api.jar:. LogToy \\ 3 \u0026#39;${jndi:ldap://127.0.0.1:1/x}\u0026#39; 42022-01-28T10:12:01,441 INFO request ua=${jndi:ldap://127.0.0.1:1/x} The message is literal. That is the post-mitigation baseline. The same string without the property, on a vulnerable 2.14, would have asked JNDI to resolve ldap://127.0.0.1:1/x. I am not running that resolution against a real directory. Port 1 on loopback refuses the connection; I use it only in a unit test of the disable flag, not as an exploit.\nWhat I refuse to put in this notebook: a working jndi:ldap://attacker/... URL, a marshalled Reference, a codebase URL, a javax.naming.spi.ObjectFactory gadget.\nHow a lookup is just a Java call The dangerous API, independent of log4j, is:\n1// conceptual — do not call this on attacker input 2javax.naming.Context ctx = new javax.naming.InitialContext(); 3Object obj = ctx.lookup(userString); // name → object, possibly remote I keep a local-only exercise that looks up a nonexistent in-memory name so the exception is the artifact:\n1// LookupFail.java 2import javax.naming.*; 3public class LookupFail { 4 public static void main(String[] args) throws Exception { 5 Hashtable\u0026lt;String,String\u0026gt; env = new Hashtable\u0026lt;\u0026gt;(); 6 env.put(Context.INITIAL_CONTEXT_FACTORY, 7 \u0026#34;com.sun.jndi.fscontext.RefFSContextFactory\u0026#34;); 8 env.put(Context.PROVIDER_URL, \u0026#34;file:///tmp/jndi_lab\u0026#34;); 9 Context ctx = new InitialContext(env); 10 try { 11 ctx.lookup(\u0026#34;does-not-exist\u0026#34;); 12 } catch (NamingException e) { 13 System.out.println(\u0026#34;LOOKUP_FAIL \u0026#34; + e.getClass().getSimpleName()); 14 System.out.println(\u0026#34;msg \u0026#34; + e.getMessage()); 15 } 16 } 17} 1$ java LookupFail 2LOOKUP_FAIL NameNotFoundException 3msg does-not-exist That is JNDI as a dictionary. Log4Shell was \u0026ldquo;the logger called something like lookup() on a substring of the message\u0026rdquo;. Once you see it as a function call, \u0026ldquo;we only log\u0026rdquo; stops being a safety claim.\nSanitized reproduction (exception / timeout only) With lookups forced off, I still want a crash-like artifact for the IR playbook: a connection refused when someone tests a mis-patched box against loopback.\n1# THIS IS A NEGATIVE TEST. Destination is 127.0.0.1:1 (kernel discards). 2# No LDAP daemon is listening. No class is loaded. 3$ timeout 2 java -Dcom.sun.jndi.ldap.object.trustURLCodebase=false \\ 4 LookupTcp # tiny class that calls new InitialContext().lookup(\u0026#34;ldap://127.0.0.1:1/x\u0026#34;) 5LOOKUP_FAIL CommunicationException 6msg 127.0.0.1:1 [REDACTED] 1# thread dump excerpt if I forget timeout(2) 2java.naming.ldap.LdapClient.open ... 3java.net.Socket.connect ... 127.0.0.1:1 4# kill -9 the JVM; do not wait for a remote ASAN does not apply. The \u0026ldquo;crash\u0026rdquo; is CommunicationException or the timeout kill. Either proves outbound name resolution was attempted. On a patched box with formatMsgNoLookups=true and a current log4j, that stack does not appear from log.info.\nApp / HTTP log from the lab gateway in front of a toy service (token redacted):\n12022-01-28T10:18:44+08:00 edge GET /health 2 ua: ${jndi:ldap://127.0.0.1:1/x} 3 src: 127.0.0.1 req_id: [REDACTED] 4 action: 400 reason: ua_rejected_lookup_syntax 5# WAF/rule fired; origin logger never ran Failed-auth style: rejecting the header is correct. Logging the full header into a vulnerable logger is how the class of bug was reached. Detect in the WAF, then log a hash.\nHow I disable JNDI lookups (the actual checklist) Order I used in 2021 and still use on old images:\n1# 1. property — hot, then bake into the JVM flags 2-Dlog4j2.formatMsgNoLookups=true 3 4# 2. remove the JndiLookup class from the jar if we cannot upgrade yet 5zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class 6 7# 3. upgrade to 2.17.1+ (2.16 still had follow-on CVEs; read the matrix) 8 9# 4. JVM-wide, not log4j-specific 10-Dcom.sun.jndi.ldap.object.trustURLCodebase=false 11-Dcom.sun.jndi.rmi.object.trustURLCodebase=false 12-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false 1\u0026lt;!-- log4j2.xml : stop interpolating lookups in messages --\u0026gt; 2\u0026lt;PatternLayout pattern=\u0026#34;%d %p %m%n\u0026#34; alwaysWriteExceptions=\u0026#34;true\u0026#34;/\u0026gt; 3\u0026lt;!-- do not use %x / lookup plugins on untrusted data --\u0026gt; Network: egress from app JVMs to LDAP/RMI/IIOP off, except the directories we own. 127.0.0.1:1 in the negative test is not a substitute for an egress ACL; it is a unit test.\nInventory (what I grep):\n1$ grep -R \u0026#39;InitialContext\\|ctx.lookup\\|${jndi\u0026#39; --include=\u0026#39;*.java\u0026#39; --include=\u0026#39;*.xml\u0026#39; 2# app code plus log4j2.xml plus leftover leftover.xml in configmaps Any ctx.lookup(request.get*) is the same class without log4j in the name.\nWhat the 2021 follow-on CVEs changed in this lab I keep a matrix so \u0026ldquo;we set the flag\u0026rdquo; is not the whole ticket:\nBuild formatMsgNoLookups Nested ${lower:${jndi: Notes 2.14.1 needed still looks up if flag off do not run 2.15 lookups off by default, bypasses existed follow-on do not run 2.16 tighter still had a recursor upgrade 2.17.1+ current floor for this lab message lookups gone this is what we ship Thread context / ThreadContext.put(\u0026quot;ua\u0026quot;, header) plus a pattern %X{ua} is data. A pattern %X{ua} that the layout then interpolates as a lookup is the old bug in a hat. I dump log4j2.xml and look for ${ in the pattern, not only in logged messages.\n1$ grep -n \u0026#39;\\${\u0026#39; log4j2.xml 2# (no hits in the lab file) Any hit is a lookup in the layout. Layout lookups of date / pid are operator features; layout lookups of ctx:ua are the header again.\nMitigation beyond the flag Treat log messages as data. If you need structured fields, use a parameterized API (log.info(\u0026quot;ua={}\u0026quot;, ua)) and a patched core so the parameter is not re-interpolated. WAF: detect ${jndi: / ${lower: / nested variants at the edge; still patch, because encodings will evade. Outbound allow-list from the JVM namespace. No LDAP to the internet. trustURLCodebase=false everywhere, even after patch — remote codebase loading is a footgun of its own. Do not run a \u0026ldquo;canary LDAP listener\u0026rdquo; on a shared network as a joke; that is how people accidentally create the missing piece of an exploit path. What I file after this lab App: LogToy logs ua={} with formatMsgNoLookups=true; string stays literal Negative: InitialContext.lookup(\u0026quot;ldap://127.0.0.1:1/x\u0026quot;) → CommunicationException, no daemon File lookup: NameNotFoundException on does-not-exist Fix: upgrade log4j, JVM flags above, zip-delete JndiLookup only as a stopgap, egress deny LDAP/RMI Out of scope: LDAP exploit server, marshalled gadgets, trustURLCodebase=true Commands appendix 1java -Dlog4j2.formatMsgNoLookups=true -cp \u0026#34;$CP\u0026#34; LogToy \u0026#39;${jndi:ldap://127.0.0.1:1/x}\u0026#39; 2jar tf log4j-core-*.jar | grep JndiLookup 3zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class 4grep -R \u0026#39;InitialContext\u0026#39; --include=\u0026#39;*.java\u0026#39; ","permalink":"https://blog.omiilgo.com/posts/jndi-injection-class-lessons/","summary":"Lab Java snippet that logs a lookup string, how to disable JNDI/message lookups, sanitized crash on a bad URL — no LDAP exploit server.","title":"Lessons from the JNDI / Log4Shell Class of Bugs"},{"content":"This is a naming-and-PAC lab for defenders, not noPac. Target is a lab domain where I am allowed to create a computer object (the default ms-DS-MachineAccountQuota is 10; I treat that quota as part of the threat model). Goal: capture 4742 (computer changed) and 4768 (TGT issued) from a benign rename of a lab computer I own, then write down what the 2021 CVEs changed about sAMAccountName and PAC validation. I do not rename a machine to a DC\u0026rsquo;s short name, I do not request a TGT for dc01 without $, I do not chain 42278+42287.\n1Figure 1. The KDC must reject names that impersonate other principals and must bind PAC to the requester. 24742 computer rename (sAMAccountName ends $) 34768 TGT for labpc01b$ etype 0x12 result 0 Lab layout 1labs/nopac_lab/ # directory name is historical; no exploit inside 2 4742.txt 3 4768.txt 4 quota.txt 5 hotfix.txt 1C:\\lab\u0026gt; whoami 2lab\\labuser 3 4C:\\lab\u0026gt; net accounts /domain | findstr /i \u0026#34;lock\u0026#34; 5# not relevant; quota is an AD attribute: 6 7C:\\lab\u0026gt; powershell -NoP -C \u0026#34;(Get-ADObject (Get-ADRootDSE).defaultNamingContext -Properties ms-DS-MachineAccountQuota).\u0026#39;ms-DS-MachineAccountQuota\u0026#39;\u0026#34; 810 Quota 10 means a standard user can create computer objects. That is the pre-condition the 2021 class used. I do not need to abuse it to file \u0026ldquo;quota is 10\u0026rdquo;.\nArtifact: 4742 on a benign rename I created LAB\\labpc01$ with a privileged account, then renamed its display attributes in a supported way (Rename-Computer to labpc01b on the member). 4742 on the DC:\n1Event 4742 A computer account was changed. 2Subject: 3 Security ID: S-1-5-21-[REDACTED]-1001 4 Account Name: labuser 5 Account Domain: LAB 6 Logon ID: 0x[REDACTED] 7Computer Account That Was Changed: 8 Security ID: S-1-5-21-[REDACTED]-1110 9 Account Name: labpc01b$ 10 Account Domain: LAB 11Changed Attributes: 12 SAM Account Name: labpc01b$ 13 Display Name: LABPC01B 14 User Principal Name: - 15 DNS Host Name: labpc01b.lab.internal 16 Service Principal Names: 17 RestrictedKrbHost/labpc01b 18 RestrictedKrbHost/labpc01b.lab.internal 19 HOST/labpc01b 20 HOST/labpc01b.lab.internal 21 Additional Information: 22 Privileges: - What I require in this event for it to look normal:\nSAM Account Name ends with $. It does not equal a domain controller short name (DC01, DC01$ confusion). Subject is either the machine itself, a join account, or an admin I know. SPNs match the new DNS name. The 2021 issue (CVE-2021-42278) was insufficient validation that a computer sAMAccountName was well-formed and not colliding with another principal in ways the KDC later trusted. I do not demonstrate the collision.\nArtifact: 4768 for the same machine account 1Event 4768 A Kerberos authentication ticket (TGT) was requested. 2Account Information: 3 Account Name: labpc01b$ 4 Supplied Realm Name: LAB.INTERNAL 5 User ID: S-1-5-21-[REDACTED]-1110 6Service Information: 7 Service Name: krbtgt 8 Service ID: S-1-5-21-[REDACTED]-502 9Network Information: 10 Client Address: 10.[REDACTED] 11 Client Port: 49212 12Additional Information: 13 Ticket Options: 0x40810010 14 Result Code: 0x0 15 Ticket Encryption Type: 0x12 16 Pre-Authentication Type: 2 17 Certificate Issuer Name: - 18 Certificate Serial Number: - 19 Certificate Thumbprint: - Match row: Account Name labpc01b$ (with dollar), User ID equals the 4742 computer SID, etype AES256, preauth 2, result 0. That is a healthy machine TGT.\nAnomalous 4768 I would escalate (and have not produced):\nAccount Name without $ that still maps to a computer SID. Account Name equal to a DC / privileged user while Client Address is a workstation. Result 0 immediately after a 4742 that stripped $ or copied a privileged sAMAccountName. CVE-2021-42287: KDC did not adequately verify that the PAC in a TGS-REQ belonged to the requesting principal in a specific sequence. Combined with the naming bug, public reporting described domain-admin-equivalent PAC on unpatched DCs. I file \u0026ldquo;PAC must bind to requester\u0026rdquo; as the invariant. I do not request that TGS.\nPatch and control verification 1C:\\lab\u0026gt; wmic qfe get HotFixID,InstalledOn | findstr /i \u0026#34;5008602 5008601 5008380 5008452\u0026#34; 2KB5008380 11/10/2021 3# exact IDs depend on SKU; I record what is installed, then: 4 5C:\\lab\u0026gt; powershell -NoP -C \u0026#34;Get-ADDomainController | Select Name,OperatingSystem,OperatingSystemVersion\u0026#34; 6Name OperatingSystem OperatingSystemVersion 7DC01 Windows Server 2019 10.0 (17763) November 2021 updates plus the February 2022 enforcement phase are the historical line. On a 2026 image this is \u0026ldquo;is the DC build after those CUs\u0026rdquo;. I also check:\n1# PAC validation / related: Krbtgt and DC secure channel healthy 2C:\\lab\u0026gt; nltest /sc_query:LAB 3Flags: 30 HAS_IP HAS_TIMESERV 4Trusted DC Name \\\\dc01.lab.internal 5Trusted DC Connection Status Status = 0 0x0 NERR_Success ms-DS-MachineAccountQuota → 0 if users must not join machines (the actual structural fix for \u0026ldquo;any user creates a computer\u0026rdquo;):\n1C:\\lab\u0026gt; powershell -NoP -C \u0026#34;Set-ADDomain (Get-ADDomain) -Replace @{\u0026#39;ms-DS-MachineAccountQuota\u0026#39;=\u0026#39;0\u0026#39;}\u0026#34; 2# lab only; in production this is a change ticket I ran it, then set it back to 10 on this lab so other notes still join. Production: 0, plus a dedicated join account with constrained rights.\nSanitized reproduction (failed rename / failed AS) Attempt to set a computer sAMAccountName to a user name via a UI I do not have rights for:\n1C:\\lab\u0026gt; net user labpc01b$ /domain 2# not a user; expected 3 4# LDAP modify as labuser of sAMAccountName → \u0026#34;DC01\u0026#34; (NOT executed as a working exploit) 5# result on a patched DC: 6Insufficient access / constraint violation 7# Event 4742 does not fire 8# LDAP error 19 (constraint) or 50 (insufficientAccess) I stop at the constraint error. That deny is the control.\nFailed 4768 (unknown account), redacted:\n1Event 4768 2 Account Name: nosuchpc$ 3 Result Code: 0x6 # KDC_ERR_C_PRINCIPAL_UNKNOWN 4 Client Address: 10.[REDACTED] 0x6 is not 42287. Do not mix unknown-principal with PAC-mismatch.\nCrash analog: none in kernel. The closest is LSASS handling a malformed AS-REQ — I do not fuzz LSASS. I keep the constraint-violation LDAP error as the \u0026ldquo;loud fail\u0026rdquo;.\nComputer-account SPN inventory after the rename 1C:\\lab\u0026gt; setspn -L labpc01b$ 2Registered ServicePrincipalNames for CN=labpc01b,CN=Computers,DC=lab,DC=internal: 3 RestrictedKrbHost/labpc01b.lab.internal 4 RestrictedKrbHost/labpc01b 5 HOST/labpc01b.lab.internal 6 HOST/labpc01b SPNs still matching the old name (labpc01) after a 4742 are a hygiene finding, not 42278. SPNs matching HOST/DC01 on a workstation object are the impersonation class and I would page. I have not produced that row. Detection is a daily setspn -X (duplicate SPNs) plus a query for computer objects whose sAMAccountName does not end with $.\n1# lab check, should return 0 rows 2Get-ADComputer -Filter * -Properties sAMAccountName | 3 Where-Object { $_.sAMAccountName -notlike \u0026#39;*$\u0026#39; } | 4 Select-Object Name, sAMAccountName Mitigation Patch DCs past the 2021-11 / 2022-02 PAC enforcement KBs (verify build, not a blog). ms-DS-MachineAccountQuota=0 unless a join workflow needs it; then a dedicated OU + delegated join account. Alert: 4742 where SAM Account Name does not end in $ 4742 where new SAM equals a privileged user or DC name 4768 Account Name for a computer without $ burst of computer creates from one user SID (quota burn) dSHeuristics / PAC validation: do not disable PAC validation \u0026ldquo;for compatibility\u0026rdquo; without a named vendor ticket. What I file after this lab Quota was 10 (finding for a hardened domain) 4742: labpc01$ → labpc01b$, SPNs updated, subject labuser, SID [REDACTED]-1110 4768: Account Name labpc01b$, etype 0x12, preauth 2, result 0, same SID Patched DC: constraint on a bad sAMAccountName, no 4742 Unknown nosuchpc$: 4768 result 0x6 Fix: CU on DCs, quota 0, 4742/4768 detections on name shape Out of scope: noPac chain, TGT for a DC name, PAC editing Commands appendix 1powershell -NoP -C \u0026#34;(Get-ADObject (Get-ADRootDSE).defaultNamingContext -Properties ms-DS-MachineAccountQuota).\u0026#39;ms-DS-MachineAccountQuota\u0026#39;\u0026#34; 2wevtutil qe Security /q:\u0026#34;*[System[(EventID=4742)]]\u0026#34; /c:1 /f:text 3wevtutil qe Security /q:\u0026#34;*[System[(EventID=4768)]]\u0026#34; /c:1 /f:text 4nltest /sc_query:LAB ","permalink":"https://blog.omiilgo.com/posts/samaccountname-impersonation-class/","summary":"Lab event 4742/4768 samples redacted, computer-account naming checks, patch verification — no noPac exploit.","title":"sAMAccountName Impersonation Class (CVE-2021-42278 / CVE-2021-42287)"},{"content":"iOS userland reversing is mostly: find the selector, find the IMP, read the ARM64. objc_msgSend is the choke point. This lab uses a self-signed LabSession binary I compile with Xcode for the simulator / a dev device. cryptid=1 App Store slices are not decrypted here.\nFigure 1. Load commands I read before any disassembly. cryptid=1 stops the lab.\rFigure 2. self \u0026#43; SEL → cache or method list → IMP.\rConfirm the image is actually reverseable 1$ file LabSession 2LabSession: Mach-O 64-bit executable arm64 3 4$ otool -l LabSession | egrep \u0026#39;cmd |cryptid|segname|product\u0026#39; 5 cmd LC_SEGMENT_64 6 segname __TEXT 7 cmd LC_SEGMENT_64 8 segname __DATA_CONST 9 cmd LC_ENCRYPTION_INFO_64 10 cryptid 0 ; lab requirement 11 cmd LC_CODE_SIGNATURE 12 13$ codesign -d --entitlements :- LabSession 2\u0026gt;/dev/null | head 14\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; 15\u0026lt;plist\u0026gt; 16 \u0026lt;key\u0026gt;get-task-allow\u0026lt;/key\u0026gt; 17 \u0026lt;true/\u0026gt; ; debugable lab build 18 \u0026lt;key\u0026gt;application-identifier\u0026lt;/key\u0026gt; 19 \u0026lt;string\u0026gt;XXXXXX.com.lab.session\u0026lt;/string\u0026gt; ; team id redacted 20\u0026lt;/plist\u0026gt; If cryptid is 1, I stop and switch to a build I own. I do not document FairPlay unwrap.\nSelectors from the Mach-O, not from hope 1$ class-dump LabSession | sed -n \u0026#39;/LabSession/,+24p\u0026#39; 2@interface LabSession : NSObject 3{ 4 NSString *_token; // 0x08 5 NSURLSession *_http; // 0x10 6} 7- (id)initWithEnvironment:(id)env; 8- (void)startWithToken:(id)token; 9- (void)invalidate; 10@end startWithToken: is the method I care about. class-dump reads __objc_methname / class dumps; stripped bins still have selector strings unless they are obfuscated.\n1$ otool -v -s __TEXT __objc_methname LabSession | grep start 2Contents of (__TEXT,__objc_methname) section 30000000100003a10 startWithToken: Where objc_msgSend is called 1; Hopper / otool -tV (addresses with slide 0 for the file) 20000000100001c80 ldr x0, [x19, #0x0] ; LabSession * 30000000100001c84 adrp x1, 1 40000000100001c88 add x1, x1, #0x0a10 ; SEL startWithToken: 50000000100001c8c ldr x2, [sp, #0x18] ; NSString * token 60000000100001c90 bl 0x1000045c0 ; objc_msgSend stub x0 = self, x1 = SEL, x2 = first real argument. That is the ARM64 ObjC ABI, every time.\n1$ nm LabSession | grep msgSend 2 U _objc_msgSend The stub lives in the dyld shared cache on device; in the simulator it is still an undefined symbol bound at load.\nlldb: break, print class and selector, jump to IMP 1(lldb) process launch --stop-at-entry 2(lldb) breakpoint set -n objc_msgSend 3(lldb) breakpoint modify 1 -c \u0026#39;(BOOL)(void*)$x1 != 0\u0026#39; 4(lldb) # too hot. Filter by selector address we know: 5(lldb) breakpoint set -n objc_msgSend \\ 6 -C \u0026#39;bool ok = (bool)[(char*)$x1 contains \u0026#34;startWithToken\u0026#34;]; return ok;\u0026#39; 7# lldb cond syntax varies; I use a Python callback in practice: 8 9(lldb) command script import lldb_sel.py lldb_sel.py (lab):\n1# lldb_sel.py — print class + selector, skip everything else 2import lldb 3 4def sel_stop(frame, bp_loc, extra): 5 x0 = frame.FindRegister(\u0026#39;x0\u0026#39;).GetValueAsUnsigned() 6 x1 = frame.FindRegister(\u0026#39;x1\u0026#39;).GetValueAsUnsigned() 7 proc = frame.GetThread().GetProcess() 8 err = lldb.SBError() 9 sel = proc.ReadCStringFromMemory(x1, 128, err) 10 if sel != \u0026#39;startWithToken:\u0026#39;: 11 return False # continue 12 print(\u0026#39;[msgSend] sel=%s self=%#x\u0026#39; % (sel, x0)) 13 return True # stop 14 15def __lldb_init_module(debugger, internal_dict): 16 debugger.HandleCommand( 17 \u0026#39;breakpoint set -n objc_msgSend -s libobjc.A.dylib\u0026#39;) 18 debugger.HandleCommand( 19 \u0026#39;breakpoint command add -F lldb_sel.sel_stop 1\u0026#39;) When it stops:\n1[msgSend] sel=startWithToken: self=0x0000000281a0c0c0 2(lldb) po $x0 3\u0026lt;LabSession: 0x281a0c0c0\u0026gt; 4(lldb) po $x2 5\u0026lt;redacted: length=36, prefix=lab_\u0026gt; ; I use my own description method 6(lldb) # do not po tokens in a real app 7(lldb) disassemble -n \u0026#39;-[LabSession startWithToken:]\u0026#39; 8LabSession`-[LabSession startWithToken:]: 9 0x100001d20: pacibsp 10 0x100001d24: stp x20, x19, [sp, #-0x20]! 11 0x100001d28: stp x29, x30, [sp, #0x10] 12 0x100001d2c: add x29, sp, #0x10 13 0x100001d30: mov x19, x0 14 0x100001d34: mov x20, x2 ; token 15 0x100001d38: adrp x8, 2 16 0x100001d3c: ldr x8, [x8, #0x1c8] ; ivar _token 17 0x100001d40: str x20, [x19, x8] IMP recovered. The method stores the argument into _token. That is the analysis result.\nFigure 3. Frame I expect at the IMP: x29/x30 pair, then callee-saved.\rSanitized reproduction I call the lab UI, paste a 36-character stand-in token lab_ + 'A'*32.\n1(lldb) po [self-\u0026gt;_token length] 236 3(lldb) memory read -c 8 $x20 40x0000000283bb4a00: 6c 61 62 5f 41 41 41 41 lab_AAAA 5# remaining 28 bytes not copied into the note If I need to show a crash, I have a second lab method that memcpys the UTF8 of the token into a 16-byte stack buffer. Trigger:\n1// LabSession.m — intentional lab bug 2- (void)insecureCopy:(NSString *)token { 3 char buf[16]; 4 const char *u = token.UTF8String; 5 memcpy(buf, u, strlen(u) + 1); // no bound 6 _scratch = buf[0]; 7} 1* thread #1, queue = \u0026#39;com.apple.main-thread\u0026#39;, stop reason = EXC_BAD_ACCESS (code=2) 2 frame #0: 0x00000001890afc2c libsystem_platform.dylib`_platform_memmove + 204 3 frame #1: 0x0000000100001e10 LabSession`-[LabSession insecureCopy:] + 0x38 ASAN on the same file:\n1==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 2WRITE of size 37 at ... thread T0 3 #0 memcpy 4 #1 -[LabSession insecureCopy:] LabSession.m:41 5Shadow bytes around the buggy address: 6 00 00 00 00[f1]f1 f1 f1 00 00[f3]f3 That is a complete repro for the lab bug: source line, memcpy size 37, 16-byte buffer, ASAN shadow. It is not an iOS jailbreak, not a codesign bypass, and not a kernel bug.\nFrida variant (same IMP, truncated log) 1const m = Module.getExportByName(\u0026#39;libobjc.A.dylib\u0026#39;, \u0026#39;objc_msgSend\u0026#39;); 2Interceptor.attach(m, { 3 onEnter(args) { 4 const sel = (new ObjC.Object(args[1])).toString(); 5 if (sel !== \u0026#39;startWithToken:\u0026#39;) return; 6 const cls = new ObjC.Object(args[0]).$className; 7 const n = new ObjC.Object(args[2]).length(); 8 console.log(cls, sel, \u0026#39;arg2.len=\u0026#39; + n); 9 } 10}); 1LabSession startWithToken: arg2.len=36 I log length, not contents, unless the sample is my own and the value is a dummy.\nClosing Selector string → objc_msgSend site → IMP → ARM64. cryptid, entitlements, and whether I own the build decide if I even start. Argument dumps get truncated. The lab memcpy crash exists so the write-up has a reproduction that is a crash, not a payload.\nCommands appendix 1otool -l LabSession | egrep \u0026#39;cryptid|segname|LC_CODE\u0026#39; 2class-dump LabSession 3nm LabSession | grep msgSend 4xcrun lldb ./LabSession ","permalink":"https://blog.omiilgo.com/posts/ios-objc-msgsend-reversing/","summary":"otool load commands, class-dump selectors, lldb break on objc_msgSend, recover IMP for -[LabSession startWithToken:], redacted argument log. FairPlay-encrypted App Store bins are out of scope.","title":"Reversing objc_msgSend on a Self-Signed arm64 Mach-O"},{"content":"A checklist that has never been run on a file is a blog post. This one is filled against audit_lab, a 60-line PIE I compile with the bugs left in so the \u0026ldquo;dangerous API\u0026rdquo; row has addresses. The output is a one-pager. Deep dives (GOT bind, vtables, format leaks) live in the linked labs; here I only record what the first pass must not skip.\nFigure 1. First pass starts at JUMP_SLOT names: gets, sprintf, printf. Then mitigations, then one sink disassembly each.\r0. Identity 1/* audit_lab.c — intentionally messy, lab only */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;string.h\u0026gt; 4#include \u0026lt;stdlib.h\u0026gt; 5#include \u0026lt;unistd.h\u0026gt; 6 7static void greet(char *who) 8{ 9 char hi[32]; 10 sprintf(hi, \u0026#34;hi %s\u0026#34;, who); /* bounded format, unbounded dest */ 11 puts(hi); 12} 13 14static void log_raw(char *msg) 15{ 16 printf(msg); /* format sink */ 17 printf(\u0026#34;\\n\u0026#34;); 18} 19 20static void ingest(void) 21{ 22 char buf[16]; 23 gets(buf); /* unbounded */ 24 greet(buf); 25 if (buf[0] == \u0026#39;%\u0026#39;) 26 log_raw(buf); 27} 28 29int main(void) 30{ 31 ingest(); 32 return 0; 33} 1cc -O0 -fPIE -pie -fno-stack-protector -Wl,-z,lazy -g -o audit_lab audit_lab.c 2file audit_lab 3# audit_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped 4 5# identity block I paste into the ticket 6# name: audit_lab 7# sha256: 9f3c…[REDACTED] 8# build: gcc 9.3, -O0 -fPIE -pie -fno-stack-protector -Wl,-z,lazy 9# privilege: userland, not setuid (ls -l: -rwxr-xr-x 1000 1000) 10# scope: this ELF only; libc is inventory, not the audit target If the real target is setuid or a systemd unit, I stop and change the process. This one is a local CLI toy.\n1. Mitigation matrix (filled) 1$ checksec --file=audit_lab 2RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols 3Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 76 Symbols 4 5$ readelf -d audit_lab | egrep \u0026#39;NEEDED|BIND_NOW|FLAGS_1|RPATH|RUNPATH\u0026#39; 6 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 7 0x000000006ffffffb (FLAGS_1) Flags: PIE 8# no BIND_NOW, no RPATH 9 10$ readelf -l audit_lab | egrep \u0026#39;GNU_STACK|GNU_RELRO\u0026#39; 11 GNU_RELRO 0x0000000000000d80 0x000000000000fd80 0x000000000000fd80 12 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 13 0x0000000000000000 0x0000000000000000 RW 14 15$ readelf -s audit_lab | grep stack_chk 16# no __stack_chk_fail → canary row is \u0026#34;absent\u0026#34;, not \u0026#34;maybe inlined\u0026#34; Check Signal Result on audit_lab NX / GNU_STACK GNU_STACK RW (not RWE), NX enabled PASS ASLR / PIE Type: DYN, FLAGS_1 PIE PASS (runtime bits: see ASLR lab) Canary no __stack_chk_* FAIL RELRO GNU_RELRO yes, BIND_NOW no PARTIAL CFI / PAC no pacibsp in prologue absent (Linux aarch64 gcc 9) RPATH none PASS Stripped 76 symbols not stripped (easier sink naming; not a security pass) Partial RELRO means .got.plt stays rw-p. I do not call that \u0026ldquo;RELRO on.\u0026rdquo; Details in the RELRO note.\n2. Dangerous API inventory (filled, with VAs) 1$ readelf -r audit_lab | grep JUMP_SLOT 20000000000000fd8 ... R_AARCH64_JUMP_SLOT puts@GLIBC_2.17 + 0 30000000000000fe0 ... R_AARCH64_JUMP_SLOT printf@GLIBC_2.17 + 0 40000000000000fe8 ... R_AARCH64_JUMP_SLOT sprintf@GLIBC_2.17 + 0 50000000000000ff0 ... R_AARCH64_JUMP_SLOT gets@GLIBC_2.17 + 0 6 7$ nm -C audit_lab | egrep \u0026#39;ingest|greet|log_raw|main\u0026#39; 800000000000007a4 T greet 900000000000007f0 T log_raw 100000000000000828 T ingest 110000000000000880 T main Sink Call site Length / format story Ticket gets@plt ingest+0x10 bl 6e0 dest buf[16], no cap P0 unbounded copy sprintf@plt greet+0x18 dest hi[32], fmt \u0026quot;hi %s\u0026quot;, src = gets output P0 dest too small vs unbounded src printf@plt log_raw+0x8 x0 = msg = user, not a literal P1 format sink (see format lab) puts@plt greet+0x24 operand is hi after sprintf informational No system, popen, strcpy in this file. I still grep so the one-pager says \u0026ldquo;searched, absent\u0026rdquo;:\n1$ objdump -d audit_lab | grep -E \u0026#39;system@plt|popen@plt|strcpy@plt|strcat@plt\u0026#39; || echo none 2none 3. One sink, disassembled (not all of them) ingest is the entry from main. I dump it fully because the first pass must show where bytes enter.\n1$ objdump -d audit_lab | sed -n \u0026#39;/\u0026lt;ingest\u0026gt;:/,/ret/p\u0026#39; 20000000000000828 \u0026lt;ingest\u0026gt;: 3 828: a9be7bfd stp x29, x30, [sp, #-0x30]! 4 82c: 910003fd mov x29, sp 5 830: 910083e0 add x0, sp, #0x20 ; \u0026amp;buf[16] at [sp,#0x20] 6 834: 97ffffxx bl 6e0 \u0026lt;gets@plt\u0026gt; ; gets(buf) 7 838: 910083e0 add x0, sp, #0x20 8 83c: 97ffffxx bl 7a4 \u0026lt;greet\u0026gt; 9 840: 394083e0 ldrb w0, [sp, #0x20] ; buf[0] 10 844: 7100bc1f cmp w0, #0x25 ; \u0026#39;%\u0026#39; 11 848: 54000040 b.eq 850 12 84c: 14000004 b 85c 13 850: 910083e0 add x0, sp, #0x20 14 854: 97ffffxx bl 7f0 \u0026lt;log_raw\u0026gt; 15 85c: a8c37bfd ldp x29, x30, [sp], #48 16 860: d65f03c0 ret log_raw:\n100000000000007f0 \u0026lt;log_raw\u0026gt;: 2 7f0: a9be7bfd stp x29, x30, [sp, #-32]! 3 7f4: 910003fd mov x29, sp 4 7f8: f9000fe0 str x0, [sp, #24] 5 7fc: f9400fe0 ldr x0, [sp, #24] ; msg, still 6 800: 97ffffxx bl 6d0 \u0026lt;printf@plt\u0026gt; ; printf(msg) ← no adrp \u0026#34;%s\u0026#34; 7 804: 90000000 adrp x0, 0 8 808: 9120a000 add x0, x0, #0x828 ; \u0026#34;\\n\u0026#34; 9 80c: 97ffffxx bl 6d0 \u0026lt;printf@plt\u0026gt; 10 810: a8c27bfd ldp x29, x30, [sp], #32 11 814: d65f03c0 ret greet is the other P0. I dump it so the one-pager\u0026rsquo;s \u0026ldquo;sprintf dest 32, src unbounded\u0026rdquo; line has a VA.\n1$ objdump -d audit_lab | sed -n \u0026#39;/\u0026lt;greet\u0026gt;:/,/ret/p\u0026#39; 200000000000007a4 \u0026lt;greet\u0026gt;: 3 7a4: a9bd7bfd stp x29, x30, [sp, #-48]! 4 7a8: 910003fd mov x29, sp 5 7ac: f9000fe0 str x0, [sp, #24] ; who 6 7b0: 910083e0 add x0, sp, #0x20 ; \u0026amp;hi[32] 7 7b4: 90000001 adrp x1, 0 8 7b8: 91204021 add x1, x1, #0x810 ; \u0026#34;hi %s\u0026#34; ← format IS a literal 9 7bc: f9400fe2 ldr x2, [sp, #24] ; who 10 7c0: 97ffffxx bl 6f0 \u0026lt;sprintf@plt\u0026gt; ; sprintf(hi, \u0026#34;hi %s\u0026#34;, who) 11 7c4: 910083e0 add x0, sp, #0x20 12 7c8: 97ffffxx bl 6c0 \u0026lt;puts@plt\u0026gt; 13 7cc: a8c37bfd ldp x29, x30, [sp], #48 14 7d0: d65f03c0 ret Literal format, so this is not a format-string bug. It is a dest-size bug: \u0026quot;hi \u0026quot; + unbounded who into 32 bytes. The first pass must not conflate the two printf-family calls.\nTrust boundary: stdin → gets → 16-byte stack → sprintf 32-byte stack → optional printf as format. No auth. No length field even to distrust. Frame sizes from the stp immediates: ingest 0x30, greet 0x30, log_raw 0x20. I copy those numbers into the ticket so a later crash dump\u0026rsquo;s $sp math does not have to be redone.\n4. Control data near the overflow buf[16] sits in ingest\u0026rsquo;s frame. Saved x29/x30 are at [sp,#0] / [sp,#8] of a 0x30 frame; buf is at [sp,#0x20]. Sixteen bytes of overflow reach the saved lr. I do not need a vtable on this binary (no C++); I still write the sentence so the next sample that is C++ does not skip it.\nGOT: Partial RELRO, printf JUMP_SLOT at file 0xfe0. First-pass note: \u0026ldquo;writable GOT present; not required for the gets crash.\u0026rdquo;\n5. Sanitized reproduction 1$ python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39; | ./audit_lab 2hi AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 3Segmentation fault (core dumped) 4 5$ gdb -q ./audit_lab core 6(gdb) info registers pc x30 7pc 0x4141414141414141 8x30 0x4141414141414141 9(gdb) bt 10#0 0x4141414141414141 in ?? () Format path (no smash, leak / crash):\n1$ python3 -c \u0026#39;print(\u0026#34;%p.%p.%p.%p\u0026#34;)\u0026#39; | ./audit_lab 2hi %p.%p.%p.%p 30xffffffffe2d0.0xaaaaaaab0880.0x2.0xffffffffe458 4# buf[0]==\u0026#39;%\u0026#39; so log_raw runs. values are stack words; slide REDACTED in field notes ASan build of the same file:\n1$ cc -O0 -fPIE -pie -fsanitize=address -g -o audit_asan audit_lab.c 2$ python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39; | ./audit_asan 3================================================================= 4==4120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 5WRITE of size 41 at ... thread T0 6 #0 gets 7 #1 ingest audit_lab.c:20 8 #2 main audit_lab.c:29 9 This frame has 1 object(s): 10 [32, 48) \u0026#39;buf\u0026#39; (line 19) \u0026lt;== Memory access at offset 48 11HINT: gets() is unbounded; ASan reports the dest, not a \u0026#39;gadget\u0026#39;. I do not then retarget x30 into libc. The first pass ends at: P0 gets → 16-byte buf → SIGSEGV / ASan, plus P1 printf(msg).\n6. One-pager (what I actually file) 1audit_lab sha256=[REDACTED] aarch64 PIE gcc 9.3 not setuid 2mitigations: NX yes, PIE yes, canary NO, RELRO partial, GNU_STACK RW, no RPATH 3entry: stdin → ingest+0x10 gets@plt 4P0 gets ingest+0x10 dest buf[16] 5P0 sprintf greet+0x18 dest hi[32], src unbounded 6P1 printf log_raw+0x8 format = user (gated on buf[0]==\u0026#39;%\u0026#39;) 7imports searched, absent: system, popen, strcpy 8repro: 40 * \u0026#39;A\u0026#39; → pc=0x4141… ; ASan stack-buffer-overflow in ingest 9repro: \u0026#39;%p.%p.%p.%p\u0026#39; → log_raw leak of stack words 10non-goals this pass: libc internals, kernel, network framing 11next: patch gets→fgets, sprintf→snprintf, printf(msg)→printf(\u0026#34;%s\u0026#34;,msg); 12 rebuild with -fstack-protector-strong -Wl,-z,relro,-z,now That block is the deliverable. Everything above it is evidence.\nPatch 1static void greet(char *who) 2{ 3 char hi[32]; 4 snprintf(hi, sizeof hi, \u0026#34;hi %s\u0026#34;, who); 5 puts(hi); 6} 7 8static void log_raw(char *msg) 9{ 10 printf(\u0026#34;%s\\n\u0026#34;, msg); 11} 12 13static void ingest(void) 14{ 15 char buf[16]; 16 if (!fgets(buf, sizeof buf, stdin)) 17 return; 18 buf[strcspn(buf, \u0026#34;\\n\u0026#34;)] = 0; 19 greet(buf); 20 if (buf[0] == \u0026#39;%\u0026#39;) 21 log_raw(buf); 22} 1cc -O2 -fPIE -pie -fstack-protector-strong -Wl,-z,relro,-z,now \\ 2 -Wformat -Werror=format-security -o audit_lab_fixed audit_lab.c 3 4checksec --file=audit_lab_fixed 5# Full RELRO Canary found NX enabled PIE enabled 1$ python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39; | ./audit_lab_fixed 2hi AAAAAAAAAAAAAAA # 15 chars + NUL, no SIGSEGV 3$ python3 -c \u0026#39;print(\u0026#34;%p.%p\u0026#34;)\u0026#39; | ./audit_lab_fixed 4hi %p.%p 5%p.%p # literal percent, not leaked pointers Detection in CI (the checklist as a gate) 1# fail the build if the matrix regresses 2checksec --file=audit_lab_fixed | grep -q \u0026#39;Full RELRO\u0026#39; || exit 1 3checksec --file=audit_lab_fixed | grep -q \u0026#39;Canary found\u0026#39; || exit 1 4readelf -r audit_lab_fixed | grep -E \u0026#39;gets@|system@\u0026#39; \u0026amp;\u0026amp; exit 1 5# objdump: printf@plt call sites must load a literal in x0 (spot check in review) Human follow-through the CI cannot do: walk each remaining sprintf/printf as in section 3. The matrix is necessary, not sufficient.\nCommands appendix 1file audit_lab 2checksec --file=audit_lab 3readelf -d audit_lab | egrep \u0026#39;NEEDED|BIND_NOW|FLAGS_1|RPATH\u0026#39; 4readelf -l audit_lab | egrep \u0026#39;GNU_STACK|GNU_RELRO\u0026#39; 5readelf -r audit_lab | grep JUMP_SLOT 6objdump -d audit_lab | sed -n \u0026#39;/\u0026lt;ingest\u0026gt;:/,/ret/p\u0026#39; 7python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39; | ./audit_lab 8cc -fsanitize=address -g -o audit_asan audit_lab.c 9python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39; | ./audit_asan ","permalink":"https://blog.omiilgo.com/posts/binary-auditing-checklist/","summary":"First-pass checklist actually filled: checksec + readelf + objdump on a 60-line toy with gets/sprintf/printf(user). One-pager of mitigations, sinks, and the ASan crash.","title":"Binary Auditing Checklist, Filled on a Toy ELF"},{"content":"nm -D libfoo.so | grep Java_ returned empty. The Java side still had native void nInit(String);. That is RegisterNatives, not a missing .so. This note is the path I use every time: jadx → loadLibrary → JNI_OnLoad → JNINativeMethod[] → ARM64 function.\nFigure 1. RegisterNatives hides Java_* from the dynamic symbol table.\rLab layout (self-built APK, not a third-party app) 1com.lab.jni.hide 2 NativeBridge.java 3 lib/arm64-v8a/libhide.so 1package com.lab.jni.hide; 2 3public final class NativeBridge { 4 static { System.loadLibrary(\u0026#34;hide\u0026#34;); } 5 6 public static native void nInit(String token); 7 public static native int nAdd(int a, int b); 8} Java names will not appear as Java_com_lab_jni_hide_NativeBridge_nInit. Anyone grepping that string in the .so is already on the wrong path.\nAPK → .so 1unzip -l hide.apk | grep libhide 2# 18432 2021-06-09 lib/arm64-v8a/libhide.so 3 4unzip -p hide.apk lib/arm64-v8a/libhide.so \u0026gt; libhide.so 5file libhide.so 6# ELF 64-bit LSB shared object, ARM aarch64, dynamically linked, stripped 7 8readelf -s libhide.so | grep -E \u0026#39;Java_|JNI_OnLoad\u0026#39; 9# 8: 00000000000012a0 96 FUNC GLOBAL DEFAULT 12 JNI_OnLoad 10# no Java_* lines JNI_OnLoad is the only JNI-looking export. Good.\nGhidra / IDA decompile of JNI_OnLoad (cleaned) I renamed locals. This is the shape, not a dump of a random app.\n1jint JNI_OnLoad(JavaVM *vm, void *reserved) { 2 JNIEnv *env = NULL; 3 if ((*vm)-\u0026gt;GetEnv(vm, (void **)\u0026amp;env, JNI_VERSION_1_6) != JNI_OK) 4 return JNI_ERR; 5 6 jclass cls = (*env)-\u0026gt;FindClass(env, \u0026#34;com/lab/jni/hide/NativeBridge\u0026#34;); 7 if (cls == NULL) 8 return JNI_ERR; 9 10 /* table lives in .data; names are ordinary C strings */ 11 (*env)-\u0026gt;RegisterNatives(env, cls, gMethods, 2); 12 return JNI_VERSION_1_6; 13} Xrefs to RegisterNatives (JNIEnv slot 215 on this NDK) from JNI_OnLoad are the hunting needle in stripped samples.\nRecovering JNINativeMethod 1typedef struct { 2 const char *name; 3 const char *signature; 4 void *fnPtr; 5} JNINativeMethod; In the lab .so the table is at 0x21c00 (file offset). readelf -x .data plus string xref:\n1$ readelf -p .rodata libhide.so | grep -E \u0026#39;nInit|nAdd|I\u0026#39; 2 [ 1c] nInit 3 [ 22] (Ljava/lang/String;)V 4 [ 38] nAdd 5 [ 3d] (II)I 6 7$ # Ghidra Data → 3-field structure, 2 rows: 8# [0] ptr_name=0x1c \u0026#34;nInit\u0026#34; sig=\u0026#34;(Ljava/lang/String;)V\u0026#34; fn=0x13f0 9# [1] ptr_name=0x38 \u0026#34;nAdd\u0026#34; sig=\u0026#34;(II)I\u0026#34; fn=0x14a8 That is the map. nInit → 0x13f0. Open that address, ignore the Java name mangling fantasy.\nARM64 at nInit 1; libhide.so VA 0x13f0 JNI: void nInit(JNIEnv*, jclass, jstring) 213f0: a9be7bfd stp x29, x30, [sp, #-0x20]! 313f4: 910003fd mov x29, sp 413f8: a90153f3 stp x19, x20, [sp, #0x10] 513fc: aa0003f3 mov x19, x0 ; JNIEnv* 61400: aa0203f4 mov x20, x2 ; jstring token 71404: f9400260 ldr x0, [x19] ; *env 81408: f9417c01 ldr x1, [x0, #0x2f8] ; GetStringUTFChars @ slot 9140c: aa1303e0 mov x0, x19 101410: aa1403e1 mov x1, x20 111414: d2800002 mov x2, #0 ; isCopy = NULL 121418: d63f0020 blr x1 13141c: aa0003f3 mov x19, x0 ; const char *utf 14; ... copies utf into a 32-byte stack slot, then ReleaseStringUTFChars GetStringUTFChars is a JNIEnv function table lookup, not a PLT name. If you only follow PLT you will miss it. I keep a small table of JNIEnv slot indices for the NDK I actually see; slot numbers move across Android versions, so I confirm with the loaded libart.so / libnativehelper on the device image, not from memory.\nThe C string is copied to stack. Length is not checked against 32. That is a lab bug I planted so the next section has a crash, not a novel 0-day.\nSanitized reproduction (crash only) Device: userdebug emulator, app is the lab APK I signed with a debug key.\n1adb install -r hide.apk 2adb shell am start -n com.lab.jni.hide/.MainActivity 3# UI feeds nInit() from an EditText. I pasted 40 \u0026#39;A\u0026#39;s. 4 5adb logcat -s DEBUG:E hide:V 6# F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** 7# F DEBUG : Build fingerprint: \u0026#39;[REDACTED]\u0026#39; 8# F DEBUG : ABI: \u0026#39;arm64-v8a\u0026#39; 9# F DEBUG : pid: 4120, tid: 4120, name: lab.jni.hide 10# F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED] 11# F DEBUG : x19 0000007fd12a3c10 12# F DEBUG : backtrace: 13# F DEBUG : #00 pc 0000000000001428 /data/app/[REDACTED]/lib/arm64/libhide.so (nInit+0x38) I am not publishing a string that pops a shell. The point of the repro is: the native at 0x13f0 is reachable from Java nInit, and a long Java string reaches a 32-byte stack copy. ASAN-on-NDK in a later rebuild made it a loud stack-buffer-overflow; the shipping .so was not ASan-built, so tombstone is what I had.\nFrida, argument logging with truncation (no full token dump):\n1/* trace_ninit.js — lab package only */ 2const m = Module.getBaseAddress(\u0026#39;libhide.so\u0026#39;); 3const nInit = m.add(0x13f0); 4Interceptor.attach(nInit, { 5 onEnter(args) { 6 const env = args[0]; 7 const jstr = args[2]; 8 const utf = Java.vm.getEnv().getStringUtfChars(jstr); 9 const s = utf.readCString(); 10 const shown = s.length \u0026gt; 8 ? s.slice(0, 4) + \u0026#39;…[\u0026#39; + s.length + \u0026#39;]\u0026#39; : s; 11 console.log(\u0026#39;[nInit] len=\u0026#39; + s.length + \u0026#39; preview=\u0026#39; + shown); 12 } 13}); 1$ frida -U -f com.lab.jni.hide -l trace_ninit.js --no-pause 2[nInit] len=6 preview=labtok 3[nInit] len=40 preview=AAAA…[40] Production traces: drop the preview or hash it. I do not log values that look like eyJ, AKIA, sk-, or session cookies.\nWhat I file after this lab Map: NativeBridge.nInit (Ljava/lang/String;)V → libhide.so+0x13f0 Note: RegisterNatives, table in .data, 2 entries Bug class: unbounded GetStringUTFChars → 32-byte stack copy Repro: 40-byte Java string, SIGSEGV, pc in nInit+0x38 Fix: GetStringUTFLength + bound, or heap buffer with max length from the Java layer Commands appendix 1jadx hide.apk | less 2unzip -p hide.apk lib/arm64-v8a/libhide.so \u0026gt; libhide.so 3readelf -s libhide.so | grep JNI_OnLoad 4# Ghidra: xrefs to RegisterNatives / JNIEnv slot 5frida -U -f com.lab.jni.hide -l trace_ninit.js --no-pause 6adb logcat -b crash -d | tail -50 ","permalink":"https://blog.omiilgo.com/posts/android-jni-registernatives-lab/","summary":"APK where nm -D shows no Java_* exports. Walk JNI_OnLoad, recover the JNINativeMethod table, map Java names to ARM64 IMPs, Frida-trace the native with arguments redacted.","title":"Finding Hidden JNI Methods: RegisterNatives in a Lab APK"},{"content":"This is a flag-and-ticket lab, not a coerce-and-forward cookbook. Target is a fake lab forest LAB.INTERNAL with three deliberately boring service accounts I created: unconstrained print leftover, classic constrained web, RBCD on a file server. Goal: klist the tickets a user actually holds after hitting those services, setspn -L the accounts, and draw the three control planes in text. I do not coerce authentication, I do not write msDS-AllowedToActOnBehalfOfOtherIdentity from a low-priv principal, I do not S4U2Proxy toward a DC.\n1Figure 1. Three control planes. Direction of the allow-list is the whole confusion. 2Unconstrained: client TGT may be forwarded to a compromised mid-tier 3Constrained: mid-tier account lists target SPNs (optional protocol transition) 4RBCD: target computer lists who may inbound-delegate to it Lab layout 1labs/deleg_lab/ 2 klist-unconst.txt 3 klist-const.txt 4 setspn.txt 5 getad.txt Accounts (fake): LAB\\labuser (person), LAB\\WEB01$ (IIS, classic constrained), LAB\\PRINT01$ (unconstrained leftover), LAB\\FS01$ (file server, RBCD attribute present but empty-ish). SIDs [REDACTED].\nArtifact: setspn (who has which SPN) 1C:\\lab\u0026gt; setspn -L WEB01 2Registered ServicePrincipalNames for CN=WEB01,OU=servers,DC=lab,DC=internal: 3 HTTP/web01.lab.internal 4 HTTP/web01 5 WSMAN/web01.lab.internal 6 TERMSRV/web01 7 HOST/web01.lab.internal 8 HOST/web01 9 10C:\\lab\u0026gt; setspn -L PRINT01 11Registered ServicePrincipalNames for CN=PRINT01,OU=servers,DC=lab,DC=internal: 12 HOST/print01.lab.internal 13 HOST/print01 14 15C:\\lab\u0026gt; setspn -L FS01 16Registered ServicePrincipalNames for CN=FS01,OU=servers,DC=lab,DC=internal: 17 CIFS/fs01.lab.internal 18 CIFS/fs01 19 HOST/fs01.lab.internal 20 HOST/fs01 21 22C:\\lab\u0026gt; setspn -Q HTTP/web01.lab.internal 23Checking domain DC=lab,DC=internal 24CN=WEB01,OU=servers,DC=lab,DC=internal 25 HTTP/web01.lab.internal 26 HTTP/web01 27Existing SPN found! setspn -Q is the collision check. Two computer objects with the same HTTP SPN is a finding of its own. This lab is clean.\nArtifact: directory flags (the three planes) 1PS C:\\lab\u0026gt; Get-ADComputer WEB01,PRINT01,FS01 -Properties TrustedForDelegation, 2 TrustedToAuthForDelegation, msDS-AllowedToDelegateTo, 3 msDS-AllowedToActOnBehalfOfOtherIdentity | 4 fl Name, TrustedForDelegation, TrustedToAuthForDelegation, 5 \u0026#39;msDS-AllowedToDelegateTo\u0026#39;, \u0026#39;msDS-AllowedToActOnBehalfOfOtherIdentity\u0026#39; 1Name : PRINT01 2TrustedForDelegation : True # unconstrained 3TrustedToAuthForDelegation : False 4msDS-AllowedToDelegateTo : {} 5msDS-AllowedToActOnBehalfOfOtherIdentity : 6 7Name : WEB01 8TrustedForDelegation : False 9TrustedToAuthForDelegation : False # no protocol transition 10msDS-AllowedToDelegateTo : {CIFS/fs01.lab.internal, HTTP/api.lab.internal} 11msDS-AllowedToActOnBehalfOfOtherIdentity : 12 13Name : FS01 14TrustedForDelegation : False 15TrustedToAuthForDelegation : False 16msDS-AllowedToDelegateTo : {} 17msDS-AllowedToActOnBehalfOfOtherIdentity : # descriptor present, lab: WEB01$ allowed UserAccountControl bits I match against the dump (so a 4662/replication viewer can decode without PowerShell):\nBit Name Who has it here 0x80000 TRUSTED_FOR_DELEGATION PRINT01$ 0x1000000 TRUSTED_TO_AUTH_FOR_DELEGATION nobody in this lab (good) (attribute) msDS-AllowedToDelegateTo WEB01$ → CIFS/fs01, HTTP/api (attribute) msDS-AllowedToActOnBehalfOfOtherIdentity on FS01$, grant to WEB01$ Protocol transition (TrustedToAuthForDelegation) is off. That is the edge case I want people to look for: a constrained allow-list with that bit on means the front-end can S4U2Self without an inbound Kerberos TGS from the user. I do not turn it on to demonstrate.\nArtifact: klist after a normal user hits the services Interactive logon as labuser, then dir \\\\fs01.lab.internal\\labshare and a browser GET to https://web01.lab.internal/ (lab app). Tickets on LABPC01:\n1C:\\lab\u0026gt; klist 2Current LogonId is 0:0x[REDACTED] 3 4Cached Tickets: (3) 5 6#0\u0026gt; Client: labuser @ LAB.INTERNAL 7 Server: krbtgt/LAB.INTERNAL @ LAB.INTERNAL 8 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 9 Ticket Flags 0x40e10000 -\u0026gt; forwardable renewable initial pre_authent 10 Start Time: 5/17/2021 14:02:11 (local) 11 End Time: 5/18/2021 00:02:11 (local) 12 Renew Time: 5/24/2021 14:02:11 (local) 13 Session Key Type: AES-256-CTS-HMAC-SHA1-96 14 Cache Flags: 0x1 -\u0026gt; PRIMARY 15 Kdc Called: dc01.lab.internal 16 17#1\u0026gt; Client: labuser @ LAB.INTERNAL 18 Server: cifs/fs01.lab.internal @ LAB.INTERNAL 19 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 20 Ticket Flags 0x40a10000 -\u0026gt; forwardable renewable pre_authent 21 Kdc Called: dc01.lab.internal 22 23#2\u0026gt; Client: labuser @ LAB.INTERNAL 24 Server: HTTP/web01.lab.internal @ LAB.INTERNAL 25 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 26 Ticket Flags 0x40a10000 -\u0026gt; forwardable renewable pre_authent 27 Kdc Called: dc01.lab.internal forwardable on the TGT is default in this domain. It is not proof of unconstrained abuse. The unconstrained edge is on the service: PRINT01$ with TrustedForDelegation=True may receive a forwarded TGT when a client talks to it with delegation requested. I do not print a ok_as_delegate / forwarded TGT from a coercion. I print the flag on the computer object and stop. A ticket whose flags include forwarded (past tense) sitting in a mid-tier cache, for a user who never logged on to that host, is the IR artifact; this snapshot does not have one.\nOn PRINT01 itself, after a legitimate admin console logon (not a coerce), klist as the computer is empty of other users’ TGTs in this capture. That is what I want in IR: presence of someone else’s TGT in a mid-tier cache is the unconstrained smoking gun. Absence is not a proof the flag is off — check the directory.\n1C:\\lab\u0026gt; klist -li 0:0x3e7 2# SYSTEM logon on PRINT01 after idle 3Cached Tickets: (0) 4# good for this snapshot. the flag is still True — that is the finding, not a ticket Text diagrams (the overlay) 1 [ user: labuser ] 2 | 3 TGT krbtgt/LAB.INTERNAL flags: forwardable, initial 4 | 5 +---------------+----------------+ 6 | | 7 v v 8 HTTP/web01 HOST/print01 9 WEB01$ PRINT01$ 10 constrained unconstrained 11 allow: CIFS/fs01, HTTP/api (any service, if TGT forwarded) 12 | 13 v 14 CIFS/fs01 15 FS01$ 16 RBCD inbound: WEB01$ 1Who configures whom 2-------------------- 3Unconstrained : bit on the *mid-tier* computer/user 4Classic const.: msDS-AllowedToDelegateTo on the *mid-tier* 5RBCD : security descriptor on the *target* computer 6Protocol trans: TrustedToAuthForDelegation on the *mid-tier* (off here) 7Who can write RBCD / AllowedToDelegateTo : treat as tier-0 adjacent ACL Edge cases I record from this forest, still without a chain:\nPRINT01 unconstrained after the print role moved to a server OS that no longer needs it. Flag leftover. Finding. WEB01 constrained allow-list includes CIFS/fs01. That is a file-server hop, not a DC hop. Rank by target sensitivity; do not dump 200 SPNs unranked. FS01 RBCD grants WEB01$. Same hop, other control plane. If both classic and RBCD describe the same pair, IR should not count two incidents. TrustedToAuthForDelegation is False. If a change window flips it, 5136 on that attribute is the event, not a new CVE. Computer and user service accounts both take these flags. Filtering objectClass=computer misses svc-web unconstrained leftovers. Events I keep, not a playbook 1Event 5136 A directory object was modified. 2Object: CN=PRINT01,OU=servers,DC=lab,DC=internal 3Attribute: userAccountControl 4Value: [includes TRUSTED_FOR_DELEGATION] # or the reverse, a hardening 5 6Event 4769 A Kerberos service ticket was requested. 7Account Name: labuser@LAB.INTERNAL 8Service Name: HTTP/web01.lab.internal 9Ticket Encryption Type: 0x12 10# a storm of 4769 for many users\u0026#39; TGSes from WEB01$ is the S4U pattern 11# I do not generate that storm Mitigation Inventory all three planes quarterly; diff TrustedForDelegation, msDS-AllowedToDelegateTo, RBCD DACL. Remove unconstrained everywhere it is not a named exception with an owner. Print servers first. TrustedToAuthForDelegation default off. Exception list with expiry. Who can write msDS-AllowedToActOnBehalfOfOtherIdentity on servers in T0/T1 OUs: not Account Operators folklore, not “Authenticated Users”. Protected Users / Authentication Policy silos for people whose TGT must not be forwarded. Test before enforcing. 1PS C:\\lab\u0026gt; Get-ADComputer -Filter { TrustedForDelegation -eq $true } | 2 select Name 3PRINT01 4DC01 5# DC01 unconstrained is historic default for DCs. PRINT01 is the leftover. What I file after this lab setspn -L WEB01 / PRINT01 / FS01 as above Flags: PRINT01 unconstrained; WEB01 constrained to CIFS/fs01,HTTP/api, no protocol transition; FS01 RBCD inbound WEB01$ klist as labuser: TGT + cifs/fs01 + HTTP/web01, AES-256, forwardable (default) SYSTEM klist on PRINT01: 0 tickets this snapshot; flag still True Out of scope: coercion, S4U2Proxy to a DC, writing RBCD from a workstation account Commands appendix 1setspn -L WEB01 2setspn -Q HTTP/web01.lab.internal 3klist 4Get-ADComputer PRINT01,WEB01,FS01 -Properties TrustedForDelegation, 5 TrustedToAuthForDelegation, msDS-AllowedToDelegateTo, 6 msDS-AllowedToActOnBehalfOfOtherIdentity 7Get-ADComputer -Filter { TrustedForDelegation -eq $true } | select Name ","permalink":"https://blog.omiilgo.com/posts/windows-delegation-edge-cases/","summary":"Lab klist/setspn dumps redacted, unconstrained vs constrained vs RBCD text diagrams — flags and ACLs, not an abuse chain.","title":"Windows Delegation Edge Cases: Constrained, Unconstrained, and RBCD"},{"content":"This is a DOM lab, not a payload pack. Target is a single static HTML file I open as http://127.0.0.1:8000/lab.html. Goal: show location.hash reaching innerHTML, then show a \u0026lt;form name=...\u0026gt; clobbering a global the script thought was a config object. Payloads in this notebook are harmless: they write the text PWN into a \u0026lt;div\u0026gt;, or they rename a global. No javascript: URL that phones home, no cookie steal, no keylogger.\n1Figure 1. The server never sees the hash. The sink is still XSS. 2location.hash --\u0026gt; innerHTML(#out) [sink] 3\u0026lt;form name=config\u0026gt; --\u0026gt; window.config [clobber] Lab layout 1labs/dom_lab/ 2 lab.html # two bugs: hash sink, clobberable config 3 lab_fixed.html # textContent + id-based lookup 1\u0026lt;!-- lab.html — toy, served from 127.0.0.1 only --\u0026gt; 2\u0026lt;!doctype html\u0026gt; 3\u0026lt;html\u0026gt; 4\u0026lt;body\u0026gt; 5 \u0026lt;div id=\u0026#34;out\u0026#34;\u0026gt;empty\u0026lt;/div\u0026gt; 6 \u0026lt;form id=\u0026#34;search\u0026#34;\u0026gt; 7 \u0026lt;input name=\u0026#34;q\u0026#34;\u0026gt; 8 \u0026lt;/form\u0026gt; 9 \u0026lt;script\u0026gt; 10 /* BUG 1: hash → innerHTML */ 11 var raw = location.hash.slice(1); // source 12 document.getElementById(\u0026#34;out\u0026#34;).innerHTML = decodeURIComponent(raw); // sink 13 14 /* BUG 2: assume window.config is our object */ 15 window.config = window.config || { endpoint: \u0026#34;/api/me\u0026#34; }; 16 document.title = \u0026#34;lab:\u0026#34; + window.config.endpoint; 17 \u0026lt;/script\u0026gt; 18\u0026lt;/body\u0026gt; 19\u0026lt;/html\u0026gt; python3 -m http.server 8000 --bind 127.0.0.1 in that directory. No backend.\nSource → sink, measured in DevTools I open http://127.0.0.1:8000/lab.html#hello. Console:\n1location.hash 2// \u0026#34;#hello\u0026#34; 3document.getElementById(\u0026#34;out\u0026#34;).innerHTML 4// \u0026#34;hello\u0026#34; Harmless markup in the hash (redacted shape of a real payload — no event handler that runs code):\n1http://127.0.0.1:8000/lab.html#%3Cb%3EPWN%3C/b%3E 2# decodeURIComponent → \u0026lt;b\u0026gt;PWN\u0026lt;/b\u0026gt; 1# DevTools Elements, #out 2\u0026lt;div id=\u0026#34;out\u0026#34;\u0026gt;\u0026lt;b\u0026gt;PWN\u0026lt;/b\u0026gt;\u0026lt;/div\u0026gt; 3 4# Console 5document.getElementById(\u0026#34;out\u0026#34;).innerHTML 6// \u0026#34;\u0026lt;b\u0026gt;PWN\u0026lt;/b\u0026gt;\u0026#34; 7document.getElementById(\u0026#34;out\u0026#34;).innerText 8// \u0026#34;PWN\u0026#34; That is DOM XSS as a markup injection. I stop at \u0026lt;b\u0026gt;. A real attacker would reach innerHTML with a tag that executes. I am not pasting that tag. The file I attach to the ticket is this screenshot plus the two lines of JS.\nIf I need a \u0026ldquo;crash\u0026rdquo; analog in a browser, I use a huge hash and watch the tab:\n1# 200k of \u0026#39;A\u0026#39; in the hash — lab only, local file 2# Chromium task manager: tab CPU spike, then 3# \u0026#34;Aw, Snap! Error code: Out of Memory\u0026#34; [REDACTED session] 4# Not an exploit. Evidence the sink copies attacker-length data. No ASAN in JS. The OOM tab is the crash dump.\nClobber: name=config vs window.config HTML elements with id or name become properties of window / document in the old named-element namespace. If my script does window.config = window.config || {endpoint: ...} after the parser has already created window.config as a \u0026lt;form\u0026gt;, the || short-circuits and I keep the form.\nLab markup I add above the script (still in lab.html for the clobber run):\n1\u0026lt;form name=\u0026#34;config\u0026#34; id=\u0026#34;config\u0026#34;\u0026gt; 2 \u0026lt;input name=\u0026#34;endpoint\u0026#34; value=\u0026#34;[REDACTED-not-a-url]\u0026#34;\u0026gt; 3\u0026lt;/form\u0026gt; Reload http://127.0.0.1:8000/lab.html with that form present:\n1typeof window.config 2// \u0026#34;object\u0026#34; // actually an HTMLFormElement 3window.config.constructor.name 4// \u0026#34;HTMLFormElement\u0026#34; 5window.config.endpoint 6// \u0026lt;input name=\u0026#34;endpoint\u0026#34;\u0026gt; // NOT the string \u0026#34;/api/me\u0026#34; 7String(window.config.endpoint) 8// \u0026#34;[object HTMLInputElement]\u0026#34; 9document.title 10// \u0026#34;lab:[object HTMLInputElement]\u0026#34; The script never assigned {endpoint: \u0026quot;/api/me\u0026quot;}. The form won. Any later fetch(window.config.endpoint) would coerce the input element to a string that is not /api/me. In older browsers that stringification was surprising (http://... from name=... plus input values). I do not rely on a specific coerce; I file \u0026ldquo;global was clobbered, typeof check missing\u0026rdquo;.\nWhat the clobber is not: it is not XSS by itself. It is a trust bug next to XSS. Combined with the innerHTML sink, attacker-controlled HTML can plant the \u0026lt;form name=config\u0026gt; without a server round-trip if that HTML is written into the same document. In this lab I planted it statically so the trace is obvious.\nAnalysis steps List sources: location.hash, location.search, document.referrer, window.name, postMessage, sessionStorage. List sinks: innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, setTimeout(string), script.src, element.href on \u0026lt;a\u0026gt; / \u0026lt;script\u0026gt;. For each assignment, ask: does any source reach any sink without an allow-list / textContent? For each window.FOO || default, grep HTML for name=\u0026quot;FOO\u0026quot; and id=\u0026quot;FOO\u0026quot;. Confirm in a real browser, not in Node. Named properties are a browser DOM quirk. 1// grep-shaped review comment I leave on the PR 2// BAD: el.innerHTML = decodeURIComponent(location.hash.slice(1)) 3// GOOD: el.textContent = decodeURIComponent(location.hash.slice(1)) postMessage and window.name (same lab, extra sources) I added two more sources to lab.html behind a query flag so the hash sink stays the default.\n1window.addEventListener(\u0026#34;message\u0026#34;, function (ev) { 2 if (ev.origin !== \u0026#34;http://127.0.0.1:8000\u0026#34;) return; // origin check is mandatory 3 document.getElementById(\u0026#34;out\u0026#34;).textContent = String(ev.data).slice(0, 80); 4}); Without the origin check, any tab can postMessage into this page. I do not assign ev.data to innerHTML. The 80-char slice is a length bound, not encoding.\nwindow.name survives cross-origin navigation. A previous site can leave a string there. Reading it into innerHTML is the same sink with a quieter source. Lab check:\n1console.log(\u0026#34;name_len\u0026#34;, String(window.name).length); 2// do not: document.body.innerHTML = window.name Clobber of document.getElementById itself is the nasty sibling: an element with id=\u0026quot;getElementById\u0026quot; can shadow the function on some older trees when looked up via document.getElementById in a sloppy wrapper. I do not rely on that quirk. I use document.querySelector(\u0026quot;#out\u0026quot;) in the fixed file and I do not put attacker HTML into the same document before the script runs.\nSanitized reproduction Two URLs, both local:\n1# markup injection (harmless \u0026lt;b\u0026gt;) 2http://127.0.0.1:8000/lab.html#%3Cb%3EPWN%3C/b%3E 3# expected: #out contains \u0026lt;b\u0026gt;PWN\u0026lt;/b\u0026gt; 4 5# clobber (static form in the file) 6http://127.0.0.1:8000/lab.html 7# expected: document.title === \u0026#34;lab:[object HTMLInputElement]\u0026#34; Failed \u0026ldquo;auth\u0026rdquo; analog — I added a dummy fragment gate that some SPAs use (#token=). Logging it is the bug.\n1// do not do this 2console.log(\u0026#34;token\u0026#34;, location.hash); 3// lab console after #token=eyJhbGciOi[REDACTED] 4// token #token=eyJhbGciOi[REDACTED] I replace that with console.log(\u0026quot;token_len\u0026quot;, location.hash.length).\nMitigation 1\u0026lt;!-- lab_fixed.html --\u0026gt; 2\u0026lt;div id=\u0026#34;out\u0026#34;\u0026gt;empty\u0026lt;/div\u0026gt; 3\u0026lt;script\u0026gt; 4 (function () { 5 var out = document.getElementById(\u0026#34;out\u0026#34;); 6 var raw = location.hash.slice(1); 7 out.textContent = decodeURIComponent(raw); // not innerHTML 8 9 var cfg = Object.create(null); 10 cfg.endpoint = \u0026#34;/api/me\u0026#34;; 11 document.title = \u0026#34;lab:\u0026#34; + cfg.endpoint; // not window.config 12 })(); 13\u0026lt;/script\u0026gt; CSP on the lab server (header, not a meta tag I let HTML clobber):\n1Content-Security-Policy: default-src \u0026#39;none\u0026#39;; script-src \u0026#39;self\u0026#39;; 2 object-src \u0026#39;none\u0026#39;; base-uri \u0026#39;none\u0026#39;; 3# innerHTML \u0026lt;b\u0026gt; still injects markup; inline event handlers and \u0026lt;script\u0026gt; do not run textContent is the fix for the sink. CSP is defense in depth. For clobber: do not read window.* for config; use a module-scoped const; if you must, document.getElementById and instanceof HTMLFormElement checks.\nrel=noopener is a different bug (tabnabbing). I do not mix it into this ticket.\ndocument.write is the same sink family as innerHTML with a worse history. I grep it once per app and treat a hit that concatenates location as equivalent to this lab. insertAdjacentHTML(\u0026quot;beforeend\u0026quot;, raw) is innerHTML with extra steps. The fixed file uses textContent or document.createElement(\u0026quot;b\u0026quot;) + textContent if I actually need an element.\n1// GOOD — element, not HTML string 2var b = document.createElement(\u0026#34;b\u0026#34;); 3b.textContent = decodeURIComponent(raw); 4out.replaceChildren(b); That still lets an attacker pick the text, not the tag. Bold PWN in the lab becomes bold attacker text. No new element types.\nWhat I file after this lab Source: location.hash.slice(1) → sink: innerHTML on #out Repro: #%3Cb%3EPWN%3C/b%3E renders bold PWN Clobber: \u0026lt;form name=\u0026quot;config\u0026quot;\u0026gt; makes window.config an HTMLFormElement; title becomes lab:[object HTMLInputElement] Not included: executable payload, javascript: URL, cookie read Fix: textContent, module-scoped config, CSP script-src 'self', base-uri 'none' Commands appendix 1python3 -m http.server 8000 --bind 127.0.0.1 2# Chrome DevTools → Sources → Event Listener Breakpoints → DOM Mutation 3# Elements → #out after loading the hash URL ","permalink":"https://blog.omiilgo.com/posts/dom-xss-and-dom-clobbering/","summary":"Lab HTML+JS page: location.hash into innerHTML, name=form clobber of a config object, harmless redacted payload, CSP as the fix.","title":"DOM XSS and DOM Clobbering as Trust Problems"},{"content":"Stripped C++ still leaves a table of function pointers per class. The reversing job is to find that table, name the slots, and prove a call site loads [vptr+16]. This lab is a two-class hierarchy I compile myself: Animal / Dog, virtual destructor plus speak() and id(). No production binary, no RTTI-stripping rabbit hole beyond what g++ -O0 actually emits.\nFigure 1. Frame I expect at the virtual call: x29/x30 pair, this in x0, vptr load, then blr.\rLab binary 1/* vtable_lab.cpp — toy, no heap games in the happy path */ 2#include \u0026lt;cstdio\u0026gt; 3#include \u0026lt;cstdint\u0026gt; 4 5struct Animal { 6 int tag; 7 Animal() : tag(1) {} 8 virtual ~Animal() { std::puts(\u0026#34;~Animal\u0026#34;); } 9 virtual void speak() { std::puts(\u0026#34;animal\u0026#34;); } 10 virtual int id() { return 0xA1; } 11}; 12 13struct Dog : Animal { 14 int bark; 15 Dog() : bark(2) {} 16 ~Dog() override { std::puts(\u0026#34;~Dog\u0026#34;); } 17 void speak() override { std::puts(\u0026#34;woof\u0026#34;); } 18 int id() override { return 0xD0; } 19}; 20 21int main(void) { 22 Dog d; 23 Animal *p = \u0026amp;d; 24 p-\u0026gt;speak(); 25 return p-\u0026gt;id(); 26} 1# aarch64 box used for the dumps below; x86_64 twin at the end of the note 2g++ -O0 -fPIE -pie -g -o vtable_lab vtable_lab.cpp 3file vtable_lab 4# vtable_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped Itanium-style vtable (what libstdc++ emits here): the vptr stored in the object points at slot 0, which is the first virtual function. Two 8-byte words before that slot are offset-to-top and the RTTI pointer. I dump the whole 5-word window so the ctor math is visible.\nFile-level vtable dump nm still has the names because this is a lab build. On a stripped sample the same bytes live in .rodata / .data.rel.ro; only the labels vanish.\n1$ nm -C vtable_lab | egrep \u0026#39;vtable for|typeinfo\u0026#39; 20000000000002018 D vtable for Animal 30000000000002050 D vtable for Dog 40000000000002078 D typeinfo for Animal 50000000000002090 D typeinfo for Dog 6 7$ readelf -S vtable_lab | egrep \u0026#39;rodata|data.rel.ro|\\.data \u0026#39; 8 [16] .rodata PROGBITS 00000000000008c0 000008c0 9 [21] .data.rel.ro PROGBITS 0000000000001fd8 00000fd8 10 [23] .data PROGBITS 0000000000002108 00001108 On this toolchain the vtables landed in .data (writable until RELRO). Full RELRO later remaps .data.rel.ro; I still dump them as the compiler laid them out.\n1$ objdump -s -j .data vtable_lab 2Contents of section .data: 3 2108 00000000 00000000 00000000 00000000 ................ 4 ... 5# vtable for Animal @ 0x2018, shown with readelf so endianness is explicit: 6 7$ readelf -x .data vtable_lab | sed -n \u0026#39;/2010/,+12p\u0026#39; 8 0x00002010 00000000 00000000 00000000 00000000 ................ 9 0x00002020 88200000 00000000 b0070000 00000000 . .............. 10 0x00002030 e0070000 00000000 14080000 00000000 ................ 11 12$ nm vtable_lab | egrep \u0026#39;2018|07b0|07e0|0814\u0026#39; 13# closer: I dump with gdb-friendly x/ after load. File VAs: 14 15$ objdump -d vtable_lab | egrep \u0026#39;\u0026lt;_ZN6AnimalD|_ZN6Animal5speak|_ZN6Animal2id\u0026#39; 1600000000000007b0 \u0026lt;_ZN6AnimalD2Ev\u0026gt;: ; Animal::~Animal() 1700000000000007e0 \u0026lt;_ZN6Animal5speakEv\u0026gt;: 180000000000000814 \u0026lt;_ZN6Animal2idEv\u0026gt;: Reconstructed Animal vtable (little-endian aarch64, 8-byte slots):\n1VA 0x2018 offset-to-top = 0 2VA 0x2020 typeinfo = 0x2088 ; \u0026#34;typeinfo for Animal\u0026#34; 3VA 0x2028 slot0 ~Animal = 0x07b0 ; vptr points HERE at runtime 4VA 0x2030 slot1 speak = 0x07e0 5VA 0x2038 slot2 id = 0x0814 ; [vptr,#16] Dog\u0026rsquo;s table is the same shape, different IMPs:\n1$ nm -C vtable_lab | grep \u0026#39;Dog::\u0026#39; 20000000000000850 T Dog::~Dog() 300000000000008a4 T Dog::speak() 400000000000008d8 T Dog::id() 5 6# Dog vtable @ 0x2050 7# 0x2050 offset-to-top = 0 8# 0x2058 typeinfo for Dog 9# 0x2060 slot0 ~Dog = 0x0850 ; vptr 10# 0x2068 slot1 speak = 0x08a4 11# 0x2070 slot2 id = 0x08d8 ; [vptr,#16] That is the artifact. Everything after this is proving the ctor writes that pointer and main calls through slot 2.\nCtor writes the vptr Dog::Dog runs Animal::Animal first, then overwrites the vptr with Dog\u0026rsquo;s table. I want the store, not the C++ story.\n1$ objdump -d vtable_lab | sed -n \u0026#39;/\u0026lt;_ZN3DogC2Ev\u0026gt;/,/ret/p\u0026#39; 2000000000000090c \u0026lt;_ZN3DogC2Ev\u0026gt;: 3 90c: a9be7bfd stp x29, x30, [sp, #-0x20]! 4 910: 910003fd mov x29, sp 5 914: f9000bf3 str x19, [sp, #0x10] 6 918: aa0003f3 mov x19, x0 ; this 7 91c: 97ffffxx bl 7xx \u0026lt;_ZN6AnimalC2Ev\u0026gt; ; parent ctor 8 920: 90000080 adrp x0, 2000 9 924: 91018000 add x0, x0, #0x60 ; 0x2060 = Dog slot0 10 928: f9000260 str x0, [x19] ; this-\u0026gt;vptr = \u0026amp;Dog::vtable[0] 11 92c: 52800042 mov w2, #0x2 12 930: b9000e62 str w2, [x19, #0xc] ; this-\u0026gt;bark = 2 13 934: f9400bf3 ldr x19, [sp, #0x10] 14 938: a8c27bfd ldp x29, x30, [sp], #32 15 93c: d65f03c0 ret Animal::Animal did the same store with 0x2028. After the derived ctor returns, the object\u0026rsquo;s first qword is 0x2060 (plus load bias). tag sits at this+8, bark at this+0xc — layout I confirm with pahole or just the stores.\nParent ctor, trimmed:\n10000000000000780 \u0026lt;_ZN6AnimalC2Ev\u0026gt;: 2 780: a9bf7bfd stp x29, x30, [sp, #-16]! 3 784: 910003fd mov x29, sp 4 788: 90000081 adrp x1, 2000 5 78c: 9100a021 add x1, x1, #0x28 ; 0x2028 = Animal slot0 6 790: f9000001 str x1, [x0] ; this-\u0026gt;vptr 7 794: 52800021 mov w1, #0x1 8 798: b9000801 str w1, [x0, #8] ; this-\u0026gt;tag 9 79c: a8c17bfd ldp x29, x30, [sp], #16 10 7a0: d65f03c0 ret Two stores, two tables. If a write-up shows a single vptr write in a derived ctor, they compiled with a different ABI or inlined the parent.\nCall through [x0,#16] main calls id() virtually. After the vptr load, slot 2 is sixteen bytes in.\n10000000000000940 \u0026lt;main\u0026gt;: 2 940: a9be7bfd stp x29, x30, [sp, #-0x20]! 3 944: 910003fd mov x29, sp 4 948: 910043e0 add x0, sp, #0x10 ; \u0026amp;d (Dog on stack) 5 94c: 97ffffb0 bl 90c \u0026lt;_ZN3DogC1Ev\u0026gt; 6 950: 910043e0 add x0, sp, #0x10 ; this 7 954: f9400008 ldr x8, [x0] ; vptr 8 958: f9400508 ldr x8, [x8, #8] ; slot1 speak 9 95c: d63f0100 blr x8 10 960: 910043e0 add x0, sp, #0x10 11 964: f9400008 ldr x8, [x0] ; vptr again 12 968: f9400908 ldr x8, [x8, #16] ; slot2 id ← the hunt 13 96c: d63f0100 blr x8 14 970: 2a0003e1 mov w1, w0 ; return value 15 ... speak is [vptr,#8], id is [vptr,#16]. Destructor calls from ~Dog / complete-object dtor use [vptr,#0]. When I only have a stripped blob I still name slots by offset: +0 dtor-ish, +8, +16. Then I read the IMP at each slot and rename.\nx86_64 of the same id() call, for the other side of the notebook:\n1# cc -O0 x86_64 2 4011c2: mov rax, QWORD PTR [rbp-0x10] ; this 3 4011c6: mov rax, QWORD PTR [rax] ; vptr 4 4011c9: mov rax, QWORD PTR [rax+0x10] ; slot2 5 4011cd: mov rdi, QWORD PTR [rbp-0x10] 6 4011d1: call rax Same offset. Different register (rdi = this), same table.\ngdb: print the vptr, dump four slots set disable-randomization on so the numbers in this note repeat. Production traces get the slide [REDACTED].\n1$ gdb -q ./vtable_lab 2(gdb) set disable-randomization on 3(gdb) break main 4(gdb) run 5Breakpoint 1, main () at vtable_lab.cpp:24 6 7(gdb) break *main+0x28 ; ldr x8, [x0] before speak 8(gdb) continue 9(gdb) p/x $x0 10$1 = 0x0000ffffffffe2c0 ; \u0026amp;d on stack [REDACTED high bits in real ASLR] 11(gdb) x/4gx $x0 120xffffffffe2c0: 0x0000aaaaaaab2060 0x0000000200000001 130xffffffffe2d0: 0x0000000000000000 0x0000000000000000 14# qword0 = vptr → Dog slot0 15# qword1 low = tag=1, bark=2 (two ints) 16 17(gdb) x/4gx 0xaaaaaaab2060 180xaaaaaaab2060: 0x0000aaaaaaab0850 0x0000aaaaaaab08a4 190xaaaaaaab2070: 0x0000aaaaaaab08d8 0x0000000000000000 20# [0] ~Dog [8] speak [16] id 21 22(gdb) info symbol 0xaaaaaaab08d8 23Dog::id() in section .text of /home/[REDACTED]/vtable_lab 24 25(gdb) x/2i $pc 26=\u0026gt; 0xaaaaaaab0964: ldr x8, [x0] 27 0xaaaaaaab0968: ldr x8, [x8, #16] 28(gdb) stepi 29(gdb) stepi 30(gdb) p/x $x8 31$2 = 0x0000aaaaaaab08d8 ; Dog::id 32(gdb) finish 33(gdb) p $x0 34$3 = 208 ; 0xD0, Dog::id That is the proof: object vptr, four GOT-sized slots, call through #16 lands in Dog::id, return 0xD0. Animal\u0026rsquo;s table at 0xaaaaaaab2028 is still sitting in the image; nothing in main uses it after the derived ctor.\nPeek at the two header words behind the vptr, because people confuse \u0026ldquo;vtable symbol\u0026rdquo; with \u0026ldquo;vptr value\u0026rdquo;:\n1(gdb) x/6gx 0xaaaaaaab2050 20xaaaaaaab2050: 0x0000000000000000 0x0000aaaaaaab2090 ; top, typeinfo 30xaaaaaaab2060: 0x0000aaaaaaab0850 0x0000aaaaaaab08a4 ; ← vptr 40xaaaaaaab2070: 0x0000aaaaaaab08d8 0x0000000000000000 5(gdb) printf \u0026#34;%s\\n\u0026#34;, (char*)0xaaaaaaab2090 6# typeinfo name is mangled; abi::__cxa_demangle in a nicer session. 7# readelf -p .rodata | grep Dog → \u0026#34;3Dog\u0026#34; / \u0026#34;6Animal\u0026#34; Sanitized reproduction (crash only) I plant a 8-byte overflow into the stack Dog so the vptr becomes 0x4141414141414141. Next virtual call dies. This is a crash, not a vtable-hijack recipe.\n1/* extra lab path, compiled in with -DPLANT_BUG */ 2void smash(Animal *p) { 3 char *raw = reinterpret_cast\u0026lt;char *\u0026gt;(p); 4 for (int i = 0; i \u0026lt; 8; i++) 5 raw[i] = \u0026#39;A\u0026#39;; /* clobber vptr only */ 6 p-\u0026gt;id(); /* load [vptr,#16] of 0x4141… */ 7} 1$ g++ -O0 -fPIE -pie -DPLANT_BUG -g -o vtable_crash vtable_lab.cpp 2$ gdb -q ./vtable_crash 3(gdb) run 4Program received signal SIGSEGV, Segmentation fault. 50x0000aaaaaaab0968 in main () 6(gdb) x/i $pc 7=\u0026gt; 0xaaaaaaab0968: ldr x8, [x8, #16] 8(gdb) p/x $x8 9$1 = 0x4141414141414141 10(gdb) p/x $x0 11$2 = 0xffffffffe2c0 12(gdb) x/gx $x0 130xffffffffe2c0: 0x4141414141414141 ASan on the same smash, when I instead overflow a neighbor buffer into d:\n1$ g++ -O0 -fsanitize=address -g -o vtable_asan vtable_lab.cpp 2$ ./vtable_asan 3================================================================= 4==4120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 5WRITE of size 1 at 0x... thread T0 6 #0 smash vtable_lab.cpp:31 7 #1 main vtable_lab.cpp:27 8Address 0x... is located in stack of thread T0 at offset 32 in frame 9 main 10 This frame has 1 object(s): 11 [32, 48) \u0026#39;d\u0026#39; (line 24) \u0026lt;== Memory access at offset 32 12HINT: this is a stack-buffer-overflow, not a typed C++ bug. I am not publishing a fake vtable, a system slot, or a heap spray. The lab stops at: vptr clobber → SIGSEGV on [x8,#16], and ASan names the stack object.\nPatch / detection Source: keep virtual calls; do not memcpy into live objects. If a C API must fill a buffer, that buffer is not an Animal. Build: -D_GLIBCXX_ASSERTIONS, ASan/UBSan in CI. Optional -fno-rtti removes typeinfo words; the function slots stay. Binary triage: objdump -s on .data.rel.ro / .rodata looking for 8-byte-aligned pointer runs into .text. Cross-check ctor adrp+add+str of those addresses into [x0]. Runtime: gdb x/4gx *(void**)obj at any virtual call. If slot 2 is not in an r-x mapping, stop and dump maps. RELRO note: Full RELRO makes the table bytes read-only after load. That does not stop a vptr smash inside the object; it only stops rewriting the table itself. I still record RELRO in the same pass as the vtable dump — see the GOT lab for the BIND_NOW check.\nWhat I file after this lab Animal vtable file VA 0x2018, vptr 0x2028, slots ~ / speak / id Dog vtable file VA 0x2050, vptr 0x2060, id at [vptr,#16] = 0x08d8 Ctor store: str x0, [x19] with x0 = vtable+0x10 (Itanium skip of top/typeinfo) Repro: 8 As over vptr, SIGSEGV at ldr x8, [x8, #16] Fix: do not write through a char* into a live polymorphic object Commands appendix 1g++ -O0 -fPIE -pie -g -o vtable_lab vtable_lab.cpp 2nm -C vtable_lab | egrep \u0026#39;vtable for|::\u0026#39; 3readelf -x .data vtable_lab 4objdump -d vtable_lab | less +/_ZN3DogC2Ev 5gdb -q ./vtable_lab -ex \u0026#39;set disable-randomization on\u0026#39; -ex \u0026#39;b main\u0026#39; -ex \u0026#39;r\u0026#39; 6# inside gdb: x/4gx $x0 ; x/4gx *(void**)$x0 ","permalink":"https://blog.omiilgo.com/posts/cpp-vtable-recovery-from-disassembly/","summary":"Two-class toy hierarchy, virtual dtor plus two methods. objdump/readelf of the compiler vtable, ctor writing the vptr, virtual call through [x0,#16], gdb print of the slot.","title":"Recovering a C++ Vtable From ARM64/x64 Disassembly"},{"content":"NX stops \u0026ldquo;run my bytes.\u0026rdquo; What remains is reuse of bytes that are already executable. Two textbook labels sit on that axis: ret2libc (reuse a whole exported function) and ROP (reuse fragments that happen to end in ret). This note classifies the ret sites in a 50-line toy I compile myself, then crashes a stack smash into RIP=0x4141414141414141. I do not build a chain, I do not call system, and I do not put \u0026quot;/bin/sh\u0026quot; anywhere in the payload.\nFigure 1. The reuse primitive this lab talks about is the saved return slot — x30 on AArch64, [rsp] after ret on x86-64.\rI dumped the gadget classification on x86-64 because pop rdi; ret is the ABI fragment everyone names. The ARM64 twin is the same idea with ldp x29, x30, [sp], #N; ret.\nShared premise Both techniques need:\nA way to influence a return edge (saved rip / x30), or another transferable control slot. Addresses of reusable code. ASLR makes those addresses a leak problem; this lab disables randomization so the objdump VAs match gdb. Argument setup that the reused code will actually obey. ret2libc stops at (2)+(3) for function entries. ROP continues when a function entry is the wrong shape (wrong ABI, CFI on calls, missing symbol) and you instead consume epilogues and 2–3 instruction tails.\nLab binary Four small functions so the ret list is not the entire libc. bump is a classic frame; leaf_add is a leaf; sink has an unbounded gets so the smash is one command; main calls them.\n1/* ret_lab.c — toy, gets() is the planted bug */ 2#include \u0026lt;stdio.h\u0026gt; 3 4int leaf_add(int a, int b) 5{ 6 return a + b; 7} 8 9int bump(int x) 10{ 11 int y = x + 1; 12 return y; 13} 14 15void sink(void) 16{ 17 char buf[16]; 18 gets(buf); /* lab only */ 19 puts(buf); 20} 21 22int main(void) 23{ 24 int t = leaf_add(1, 2); 25 t = bump(t); 26 sink(); 27 return t; 28} 1cc -O1 -fno-stack-protector -no-pie -o ret_lab ret_lab.c 2# -O1 so epilogues are compact; no-pie so objdump VAs = runtime VAs in this lab 3# gets() needs a glibc that still exports it; otherwise I use fgets+strlen memcpy. 4 5file ret_lab 6# ret_lab: ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped 7 8checksec --file=ret_lab 9# RELRO STACK CANARY NX PIE 10# Partial RELRO No canary found NX enabled No PIE NX is on. The smash cannot run stack bytes as code. That is the only reason this note is about ret sites.\nobjdump: every ret in this module 1$ objdump -d ret_lab | grep -n $\u0026#39;\\tret$\u0026#39; 2# I also dump context, because a bare ret is not a classification. 3 4$ objdump -d ret_lab --no-show-raw-insn | sed -n \u0026#39;/\u0026lt;leaf_add\u0026gt;:/,/^$/p\u0026#39; 50000000000401130 \u0026lt;leaf_add\u0026gt;: 6 401130: lea eax,[rdi+rsi*1] 7 401133: ret 8 9$ objdump -d ret_lab --no-show-raw-insn | sed -n \u0026#39;/\u0026lt;bump\u0026gt;:/,/^$/p\u0026#39; 100000000000401140 \u0026lt;bump\u0026gt;: 11 401140: push rbp 12 401141: mov rbp,rsp 13 401144: lea eax,[rdi+0x1] 14 401147: pop rbp 15 401148: ret 16 17$ objdump -d ret_lab --no-show-raw-insn | sed -n \u0026#39;/\u0026lt;sink\u0026gt;:/,/^$/p\u0026#39; 180000000000401150 \u0026lt;sink\u0026gt;: 19 401150: push rbp 20 401151: mov rbp,rsp 21 401154: sub rsp,0x10 22 401158: lea rdi,[rbp-0x10] 23 40115c: call 401030 \u0026lt;gets@plt\u0026gt; 24 401161: lea rdi,[rbp-0x10] 25 401165: call 401040 \u0026lt;puts@plt\u0026gt; 26 40116a: nop 27 40116b: leave 28 40116c: ret 29 30$ objdump -d ret_lab --no-show-raw-insn | sed -n \u0026#39;/\u0026lt;main\u0026gt;:/,/^$/p\u0026#39; 310000000000401170 \u0026lt;main\u0026gt;: 32 401170: push rbp 33 401171: mov rbp,rsp 34 401174: mov esi,0x2 35 401179: mov edi,0x1 36 40117e: call 401130 \u0026lt;leaf_add\u0026gt; 37 401183: mov edi,eax 38 401185: call 401140 \u0026lt;bump\u0026gt; 39 40118a: call 401150 \u0026lt;sink\u0026gt; 40 40118f: mov eax,0x3 41 401194: pop rbp 42 401195: ret I also dump libc\u0026rsquo;s ret density without turning it into a catalog I would paste into a chain. Count only:\n1$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep -c $\u0026#39;\\tret$\u0026#39; 2# 18421 ; number moves by libc version. Order of magnitude: tens of thousands. 3$ objdump -d ret_lab | grep -c $\u0026#39;\\tret$\u0026#39; 44 Four in the toy, tens of thousands in libc. That is why \u0026ldquo;ROP on the main binary\u0026rdquo; and \u0026ldquo;ROP on libc\u0026rdquo; are different hunts. I still classify kinds, not addresses for a payload.\nClassification (the actual work) I tag each ret by what the CPU has just done to registers and the stack. This is the table I fill on every sample.\nSite Bytes before ret Class Useful as leaf_add+3 401133 lea eax,[rdi+rsi]; ret pure ret / arith tail fragment: computes rdi+rsi into eax, then returns. Not a function-reuse target unless you wanted leaf_add. bump+8 401148 pop rbp; ret pop-callee-saved ; ret fragment: pops one qword into rbp, then transfers. Classic \u0026ldquo;stack-aligned pop\u0026rdquo; gadget class. sink+0x1c 40116c leave; ret leave ; ret leave = mov rsp,rbp; pop rbp. Frame teardown. Different stack delta than a bare pop rbp; ret. main+0x25 401195 pop rbp; ret pop-callee-saved ; ret same class as bump\u0026rsquo;s epilogue, different address. puts@plt / .plt jmp QWORD PTR [rip+got] not a ret PLT is an indirect jump. ret2plt is a cousin; I do not file it under ret. x86-64 SysV argument setup, for the mental model only (still not a chain):\n1# what a \u0026#34;pop rdi; ret\u0026#34; *would* look like if gcc emitted one in this toy: 2# 5f c3 pop rdi / ret 3$ objdump -d ret_lab | grep -U $\u0026#39;\\tpop *rdi\u0026#39; 4# (no hits in this binary) 5$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep -c $\u0026#39;\\tpop *%rdi$\u0026#39; 6# hundreds — I do not list them. ret2libc-shaped reuse of this binary would mean: smash the saved rip in sink so ret lands on puts@plt or leaf_add at the symbol start, with rdi already whatever the smash left in it. The reused unit is a whole function.\nROP-shaped reuse would mean: smash so ret lands on 401148 (pop rbp; ret), then on 401133, then on something else — mid-function / epilogue addresses, each consuming one stack qword as a \u0026ldquo;return.\u0026rdquo; I am describing the shape so a crash dump that lands on bump+8 is not filed as \u0026ldquo;it jumped to bump().\u0026rdquo;\nARM64 equivalent classes, for the other notebook:\n1; leaf: ret 2; frame: ldp x29, x30, [sp], #32 ; ret 3; PAC frame: ldp x29, x30, [sp], #32 ; retab 4; load-arg: ldr x0, [sp], #16 ; ret ; rare in apps, common in handwritten asm retab dying is PAC, not a missing gadget. See the AArch64 PCS note.\ngdb: smash, crash, classify the landing pad 24 bytes of A fill buf[16] + saved rbp + saved rip.\n1$ python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*24)\u0026#39; | ./ret_lab 2AAAAAAAAAAAAAAA 3Segmentation fault (core dumped) 4 5$ gdb -q ./ret_lab core 6(gdb) info registers rip rbp rsp 7rip 0x4141414141414141 0x4141414141414141 8rbp 0x4141414141414141 0x4141414141414141 9rsp 0x7fffffffe2c8 0x7fffffffe2c8 [slide REDACTED in field notes] 10(gdb) x/i $rip 11Cannot access memory at address 0x4141414141414141 12(gdb) bt 13#0 0x4141414141414141 in ?? () That is the sanitized reproduction: saved rip overwritten, NX prevents running the As, CPU faults on the fetch of 0x4141…. I do not replace the 8-byte rip slot with system.\nASan on the same gets:\n1$ cc -O1 -fno-stack-protector -fsanitize=address -no-pie -g -o ret_asan ret_lab.c 2$ python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*24)\u0026#39; | ./ret_asan 3================================================================= 4==4120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 5WRITE of size 25 at ... thread T0 6 #0 gets 7 #1 sink ret_lab.c:16 8 #2 main ret_lab.c:23 9 This frame has 1 object(s): 10 [32, 48) \u0026#39;buf\u0026#39; (line 15) \u0026lt;== Memory access at offset 48 ASan names buf and stops before the ret. Two views of one bug: sanitizer in CI, 0x4141 rip in a production-like (no-ASan) build.\nIf I do stop at sink\u0026rsquo;s ret with a controlled slot and single-step, I can show what \u0026ldquo;landing on an epilogue\u0026rdquo; looks like without a chain. I set RIP to bump+7 (pop rbp; ret) under gdb, once, as a classification drill:\n1(gdb) set disable-randomization on 2(gdb) break *0x40116c ; sink\u0026#39;s ret 3(gdb) run \u0026lt;\u0026lt;\u0026lt; $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*16 + \u0026#34;BBBBBBBB\u0026#34; + \u0026#34;\\x48\\x11\\x40\\x00\\x00\\x00\\x00\\x00\u0026#34;)\u0026#39;) 4# 0x401148 = bump\u0026#39;s pop rbp; ret. I am inside gdb on my toy. 5Breakpoint 1, 0x000000000040116c in sink () 6(gdb) x/i $rip 7=\u0026gt; 0x40116c \u0026lt;sink+28\u0026gt;: ret 8(gdb) stepi 90x0000000000401148 in bump () 10(gdb) x/2i $rip 11=\u0026gt; 0x401148 \u0026lt;bump+8\u0026gt;: pop rbp 12 0x401149 : ret PC is mid-bump, at the epilogue, not at bump+0. That is the ROP-shaped landing. I kill the process here. I do not feed a second address. The drill exists so a tombstone with pc=bump+8 is read as fragment reuse, not as \u0026ldquo;the program called bump().\u0026rdquo;\nHow this shows up in crash dumps pc at puts / printf / another symbol start, with a smashed return slot in the frame below → ret2libc-shaped. Still not proof of a working exploit; libc ASLR may have been leaked elsewhere. pc at function+N where N is an epilogue (pop rbp; ret, leave; ret, ldp x29,x30; ret) and the next stack qwords look like addresses → ROP-shaped. pc=0x4141… → smash, no successful redirect. File as memory corruption, not as ROP. Mitigations map to edge types, not to labels:\nMitigation Hurts NX injected bytes as code ASLR / PIE both classes (need a leak) Full RELRO GOT-slot retarget (different class; see RELRO note) Shadow stack / PAC retab forged return edges (ROP and ret2libc equally) Coarse CFI on calls ret2libc into non-call-start; may still allow some returns Patch / detection 1void sink(void) 2{ 3 char buf[16]; 4 if (!fgets(buf, sizeof buf, stdin)) 5 return; 6 buf[strcspn(buf, \u0026#34;\\n\u0026#34;)] = 0; 7 puts(buf); 8} 1$ python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*24)\u0026#39; | ./ret_lab_fixed 2AAAAAAAAAAAAAAAAAAAAAAA 3# truncated, no SIGSEGV CI: -fstack-protector-strong, ASan, and a ban on gets. Binary audit: objdump -d | grep gets@plt is a finding; a list of ret sites is context, not a vulnerability.\nWhat I file after this lab Four ret sites in ret_lab, classified: 1 arith-tail, 2 pop rbp; ret, 1 leave; ret No pop rdi; ret in the toy; libc has many — counted, not listed Repro: 24 As, RIP=0x4141414141414141, ASan stack-buffer-overflow on buf[16] Drill (gdb only): sink\u0026rsquo;s ret → bump+8 epilogue, then kill Fix: fgets(buf, sizeof buf, stdin) Commands appendix 1cc -O1 -fno-stack-protector -no-pie -o ret_lab ret_lab.c 2objdump -d ret_lab --no-show-raw-insn | less 3objdump -d ret_lab | grep -n $\u0026#39;\\tret$\u0026#39; 4python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*24)\u0026#39; | ./ret_lab 5gdb -q ./ret_lab core -ex \u0026#39;info registers rip rbp\u0026#39; ","permalink":"https://blog.omiilgo.com/posts/rop-versus-ret2libc-mental-model/","summary":"Mental model plus objdump of a toy: ret2libc is whole-function reuse, ROP is fragment reuse. Classify epilogue rets vs pop;ret vs leave;ret. Overflow crash with RIP=0x4141… — no system(\u0026quot;/bin/sh\u0026quot;) chain.","title":"ROP vs ret2libc: Classifying `ret` Sites on a Toy Binary"},{"content":"This is a directory-reading lab, not a BloodHound attack path. Target is a fake lab forest LAB.INTERNAL on an isolated VM DC (dc01.lab.internal). Goal: keep one redacted ldapsearch, one PowerView-shaped listing, and the event IDs those reads generate, so a defender can baseline the same queries. I do not follow an ACL edge to DA, I do not Kerberoast, I do not dump NTDS, I do not chain this into an attack playbook.\n1Figure 1. Authenticated LDAP is a database read. The map is the product. Alert on who drew it. 2labuser -\u0026gt; ldapsearch (port 389/636) -\u0026gt; dc01.lab.internal 3events: 4624 (logon) 4662 (object access) 1644 (LDAP query, if enabled) Lab layout 1labs/ad_recon_lab/ # isolated lab DC, not a real tenant 2 ldap-rootdse.txt 3 ldap-users.txt # redacted 4 powerview-spn.txt # redacted, fake names 5 4662.txt 6 1644.txt Domain: LAB.INTERNAL. DC: dc01.lab.internal (Windows Server 2019, lab). Account: labuser (ordinary user, no extra rights). SIDs below have the unique infix replaced with [REDACTED]. No production names.\nArtifact: ldapsearch against the fake DC RootDSE first — this is what every join and every recon tool reads.\n1$ ldapsearch -H ldap://dc01.lab.internal -x -s base -b \u0026#39;\u0026#39; \\ 2 namingContexts defaultNamingContext dnsHostName 3dn: 4defaultNamingContext: DC=lab,DC=internal 5namingContexts: DC=lab,DC=internal 6namingContexts: CN=Configuration,DC=lab,DC=internal 7namingContexts: CN=Schema,CN=Configuration,DC=lab,DC=internal 8dnsHostName: dc01.lab.internal Authenticated user listing (password not in the note; bind as labuser). I ask for a short attribute set, not * with nTSecurityDescriptor.\n1$ ldapsearch -H ldaps://dc01.lab.internal \\ 2 -D \u0026#39;CN=labuser,CN=Users,DC=lab,DC=internal\u0026#39; -W \\ 3 -b \u0026#39;DC=lab,DC=internal\u0026#39; -s sub \\ 4 \u0026#39;(\u0026amp;(objectCategory=person)(objectClass=user))\u0026#39; \\ 5 sAMAccountName userPrincipalName userAccountControl servicePrincipalName memberOf 6 7dn: CN=labuser,CN=Users,DC=lab,DC=internal 8sAMAccountName: labuser 9userPrincipalName: labuser@lab.internal 10userAccountControl: 512 11memberOf: CN=Helpdesk,OU=T1,DC=lab,DC=internal 12 13dn: CN=alice,OU=T1,DC=lab,DC=internal 14sAMAccountName: alice 15userPrincipalName: alice@lab.internal 16userAccountControl: 512 17memberOf: CN=Helpdesk,OU=T1,DC=lab,DC=internal 18 19dn: CN=svc-web,OU=svc,DC=lab,DC=internal 20sAMAccountName: svc-web 21userPrincipalName: svc-web@lab.internal 22userAccountControl: 512 23servicePrincipalName: HTTP/web01.lab.internal 24servicePrincipalName: HTTP/web01 25 26dn: CN=svc-sql,OU=svc,DC=lab,DC=internal 27sAMAccountName: svc-sql 28userAccountControl: 66048 29servicePrincipalName: MSSQLSvc/sql01.lab.internal:1433 30servicePrincipalName: MSSQLSvc/sql01.lab.internal 31 32# 4 entries (lab is tiny on purpose) What this listing reveals, as inventory:\nObject Signal Why a defender cares labuser / alice UAC=512 (NORMAL_ACCOUNT) people svc-web SPN HTTP/web01 Kerberos service account; roastable class, not a how-to svc-sql UAC=66048 (NORMAL + DONT_EXPIRE) + MSSQL SPN long-lived service memberOf Helpdesk nested T1 role group, not DA I do not then request a TGS for those SPNs. Seeing the SPN in LDAP is the recon; roasting is a different ticket.\nGroup nest (still LDAP, still fake):\n1$ ldapsearch ... -b \u0026#39;CN=Domain Admins,CN=Users,DC=lab,DC=internal\u0026#39; member 2dn: CN=Domain Admins,CN=Users,DC=lab,DC=internal 3member: CN=Administrator,CN=Users,DC=lab,DC=internal 4member: CN=lab-da,OU=T0,DC=lab,DC=internal 5# labuser is NOT here. nested Helpdesk is not DA. file that, do not hunt a nest to make it true. Artifact: PowerView-style listing (same fake DC) I keep a PowerShell shape because that is what IR pastes. This is Get-ADUser / Get-ADComputer on the lab; the column layout matches the old PowerView one-liners people grep for. Data is fake.\n1PS C:\\lab\u0026gt; Get-ADUser -Filter * -Properties servicePrincipalName,userAccountControl | 2 ? { $_.servicePrincipalName } | 3 select SamAccountName, UserAccountControl, servicePrincipalName 4 5SamAccountName UserAccountControl servicePrincipalName 6-------------- ------------------ -------------------- 7svc-web 512 {HTTP/web01.lab.internal, HTTP/web01} 8svc-sql 66048 {MSSQLSvc/sql01.lab.internal:1433, MSSQLSvc/sql01.lab.internal} 9 10PS C:\\lab\u0026gt; Get-ADComputer -Filter * -Properties userAccountControl,servicePrincipalName | 11 select Name, IPv4Address, userAccountControl 12 13Name IPv4Address userAccountControl 14---- ----------- ------------------ 15DC01 10.0.10.10 532480 16WEB01 10.0.10.20 4096 17SQL01 10.0.10.21 4096 18# 532480 = SERVER_TRUST_ACCOUNT | TRUSTED_FOR_DELEGATION (DC default — expected) 19# 4096 = WORKSTATION_TRUST_ACCOUNT 1PS C:\\lab\u0026gt; Get-ADGroupMember \u0026#39;Domain Admins\u0026#39; | select SamAccountName 2Administrator 3lab-da 4 5PS C:\\lab\u0026gt; nltest /dclist:LAB 6Get list of DCs in domain \u0026#39;LAB\u0026#39; from \u0026#39;\\\\dc01.lab.internal\u0026#39;. 7 dc01.lab.internal [PDC] [DS] Site: Default-First-Site-Name 8The command completed successfully DNS SRV, same map, still fake:\n1$ nslookup -type=SRV _ldap._tcp.dc._msdcs.lab.internal 2_ldap._tcp.dc._msdcs.lab.internal SRV 0 100 389 dc01.lab.internal Computer UAC 532480 on DC01 is SERVER_TRUST_ACCOUNT | TRUSTED_FOR_DELEGATION. That combination is the default for domain controllers, not a leftover print server. I file it as expected-on-DC, unexpected-on-WEB01. The other note in this series covers the print leftover.\nThat is the whole recon product for this notebook: users, SPNs, computers, DA members, DC locator. No ACL bomb, no session hunter, no local-admin collector. A tool that also walks nTSecurityDescriptor on every object is the same LDAP class with a louder 4662 stream — I still do not run it here.\nEvent IDs on the DC (the defender half) I turned on Directory Service Access auditing for the lab OU and LDAP-query diagnostics (1644) for one hour. Then I ran the ldapsearch above.\n4624 — network logon from the workstation that bound:\n1Event 4624 An account was successfully logged on. 2Subject: SYSTEM 3Logon Type: 3 4Account Name: labuser 5Account Domain: LAB 6Logon ID: 0x[REDACTED] 7Workstation Name: LABPC01 8Source Network Address: 10.0.10.50 9Source Port: [REDACTED] 10Authentication Package: Kerberos 4662 — object access on a user object (one of many; this is svc-web):\n1Event 4662 An operation was performed on an object. 2Subject: 3 Security ID: S-1-5-21-[REDACTED]-1001 4 Account Name: labuser 5 Account Domain: LAB 6 Logon ID: 0x[REDACTED] 7Object: 8 Object Server: DS 9 Object Type: user 10 Object Name: CN=svc-web,OU=svc,DC=lab,DC=internal 11 Handle ID: 0x0 12Access Request Information: 13 Access Mask: 0x10 # Read Property 14 Properties: 15 -- %%{servicePrincipalName} 16 -- %%{userAccountControl} 17 -- %%{memberOf} A single 4662 is noise. Hundreds of 4662 from labuser on user objects, reading servicePrincipalName and nTSecurityDescriptor, from a workstation that is not an IdM sync engine, is the hunt.\n1644 — Field Engineering LDAP query log (noisy; lab only):\n1Event 1644 Internal event: ... LDAP search ... 2Starting node: DC=lab,DC=internal 3Filter: (\u0026amp;(objectCategory=person)(objectClass=user)) 4Scope: Subtree 5Attribute selection: sAMAccountName, userPrincipalName, userAccountControl, 6 servicePrincipalName, memberOf 7Visited entries: 12 8Returned entries: 4 9Client: 10.0.10.50: [REDACTED] 1644 is how I prove the filter, not something I leave on in production all year. Production equivalent: ETW Microsoft-Windows-ActiveDirectory_DomainService with a tighter subscription, or a DC-adjacent network tap that already records LDAP.\n4769 I do not expect from this lab step. A TGS for HTTP/web01 after the listing would be the next class. Absence of 4769 after a bulk 4662 is still recon.\nHunt questions I actually file:\nWhich non-server principals read servicePrincipalName / msDS-AllowedToDelegateTo / nTSecurityDescriptor this week? Did those hosts also 4624 from a new workstation the same day? Did 4769 for those SPNs follow from the same Logon ID? (If yes, that is a different ticket — still not a roast walkthrough.) Mitigation (shrink the map, notice the reader) Tiering: DA never logs on to LABPC01. Helpdesk is T1, not T0. SPNs on user accounts: inventory (svc-web, svc-sql above). Prefer gMSA. I do not “test roastability”. Pre-auth stays on (UAC bit 0x400000 absent in the dump — good). ACL review is a scheduled export, not a surprise from a red team LDAP scrape. Detect: baseline LDAP volume per account. Alert on bulk 4662 from interactive users. Do not alert on the DC’s own SAMR/LDAP. 1# defender grep of the lab exports 2findstr /i \u0026#34;servicePrincipalName nTSecurityDescriptor msDS-AllowedToDelegateTo\u0026#34; 4662.txt What I file after this lab Forest: LAB.INTERNAL / dc01.lab.internal (fake, isolated) ldapsearch 4 users; SPNs on svc-web and svc-sql only PowerView-style: same two SPN accounts; DC01 UAC 532480; DA = Administrator, lab-da Events: 4624 type 3 from 10.0.10.50; 4662 Read Property on svc-web; 1644 filter objectClass=user returned 4 No 4769 in this step Out of scope: Kerberoast, ACL-to-DA path, BloodHound collect, NTDS dump Commands appendix 1ldapsearch -H ldap://dc01.lab.internal -x -s base -b \u0026#39;\u0026#39; defaultNamingContext 2ldapsearch -H ldaps://dc01.lab.internal -D \u0026#39;CN=labuser,CN=Users,DC=lab,DC=internal\u0026#39; -W \\ 3 -b \u0026#39;DC=lab,DC=internal\u0026#39; \u0026#39;(\u0026amp;(objectCategory=person)(objectClass=user))\u0026#39; \\ 4 sAMAccountName servicePrincipalName userAccountControl 5# Windows 6# Get-ADUser -Filter * -Properties servicePrincipalName 7# wevtutil qe Security /q:\u0026#34;*[System[(EventID=4662)]]\u0026#34; /c:5 /f:text ","permalink":"https://blog.omiilgo.com/posts/active-directory-recon-notes/","summary":"Fake lab DC ldapsearch and PowerView-style listings, 4662/1644/4624 event IDs — inventory, not an attack playbook.","title":"Active Directory Recon Notes: What Enumerations Reveal"},{"content":"This is a sink taxonomy lab, not a shell-popping note. Target is a 20-line Flask wrapper around ping. One route builds a shell string (shell=True). One route passes an argv list. Goal: show the child command line in logs, show a metacharacter reaching /bin/sh on the bad path, and show the list path treating the same bytes as a hostname. I do not spawn a reverse shell, I do not call bash -i, and I do not paste \u0026amp; curl attacker. The dangerous call is in the snippet so reviewers can grep it; the fix sits next to it.\n1Figure 1. Metacharacters are a sh problem. argv lists still need a hostname allow-list. 2host= --\u0026gt; shell=True --\u0026gt; /bin/sh -c \u0026#34;ping -c 1 \u0026#34;+host 3host= --\u0026gt; argv list --\u0026gt; ping -c 1 -- host (no sh) Lab layout 1labs/cmdi_lab/ 2 app.py # two routes, ping -c 1 3 popen_wrap.c # optional C helper for ASAN 1# app.py — toy, 127.0.0.1 only 2import ipaddress 3import subprocess 4from flask import Flask, request, abort 5 6app = Flask(__name__) 7 8def _host(): 9 h = request.args.get(\u0026#34;host\u0026#34;, \u0026#34;\u0026#34;) 10 if not h or len(h) \u0026gt; 64: 11 abort(400) 12 return h 13 14@app.get(\u0026#34;/ping_bad\u0026#34;) 15def ping_bad(): 16 h = _host() 17 cmd = \u0026#34;ping -c 1 \u0026#34; + h # DANGEROUS 18 print(\u0026#34;SH\u0026#34;, cmd) 19 p = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=3) 20 return {\u0026#34;rc\u0026#34;: p.returncode, \u0026#34;out\u0026#34;: p.stdout[:200]} 21 22@app.get(\u0026#34;/ping_ok\u0026#34;) 23def ping_ok(): 24 h = _host() 25 try: 26 ipaddress.ip_address(h) # allow IPs only in this lab 27 except ValueError: 28 abort(400) 29 argv = [\u0026#34;ping\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;1\u0026#34;, h] 30 print(\u0026#34;ARGV\u0026#34;, argv) 31 p = subprocess.run(argv, shell=False, capture_output=True, text=True, timeout=3) 32 return {\u0026#34;rc\u0026#34;: p.returncode, \u0026#34;out\u0026#34;: p.stdout[:200]} ping is the child because it is in every training slide. The taxonomy does not care: convert, ffmpeg, dig, openssl s_client are the same sink class when the wrapper is a shell string.\nSink classes (what I write on the review comment) Shell-interpreted string — os.system, os.popen, subprocess(..., shell=True), popen(3), system(3), PowerShell Invoke-Expression. Word-splitting and metacharacters apply. Argv without a shell — subprocess list, execve, Go exec.Command(bin, args...). Metacharacters are literal. Injection moves to which binary and which flags the child implements. Implicit shell — CI script: blocks, Ansible shell: vs command:, Kubernetes command vs args plus a sh -c. Second-order — filename written now, cron later does backup.sh $FILE. Class 1 with request data is high until proven otherwise. Class 2 still needs an allow-list for hostnames, paths, and flags.\nArtifact: same HTTP, two child command lines Benign:\n1curl -s \u0026#39;http://127.0.0.1:5000/ping_bad?host=127.0.0.1\u0026#39; 2curl -s \u0026#39;http://127.0.0.1:5000/ping_ok?host=127.0.0.1\u0026#39; 1SH ping -c 1 127.0.0.1 2ARGV [\u0026#39;ping\u0026#39;, \u0026#39;-c\u0026#39;, \u0026#39;1\u0026#39;, \u0026#39;127.0.0.1\u0026#39;] 3# both: rc 0, one ICMP line, 127.0.0.1 Metacharacter probe. I use a comment so the extra command is inert. The point is that sh -c parsed it.\n1curl -sD - \u0026#39;http://127.0.0.1:5000/ping_bad?host=127.0.0.1%20%23%20lab\u0026#39; 2# host=127.0.0.1 # lab 1SH ping -c 1 127.0.0.1 # lab 2# ping still runs; sh ate the comment. Evidence: the shell parsed the string. 3 4$ curl -sD - \u0026#39;http://127.0.0.1:5000/ping_ok?host=127.0.0.1%20%23%20lab\u0026#39; 5HTTP/1.0 400 BAD REQUEST 6# ipaddress.ip_address rejected it; argv never built A second probe that would be dangerous in a real wrapper — I do not execute a useful payload. I show the log line the bad route would have built, then I do not send it:\n1# NOT SENT. Reviewer grep target only. 2# SH ping -c 1 127.0.0.1; id 3# If that line appears in an access/app log from production, the sink is live. 4# This lab never calls id, bash, nc, or curl as a sibling command. ps while the bad route pings:\n1$ ps -o pid,ppid,cmd -C ping 2 PID PPID CMD 3 4419 4410 ping -c 1 127.0.0.1 4$ ps -o pid,cmd -p 4410 5 PID CMD 6 4410 /bin/sh -c ping -c 1 127.0.0.1 # lab PPID is sh -c. That process table dump is the artifact I attach. On the ok route PPID is the Flask worker, CMD is ping -c 1 127.0.0.1 with four argv slots (/proc/PID/cmdline null-separated).\nSanitized reproduction (failed child / crash only) Timeouts and usage errors are enough.\n1curl -s \u0026#39;http://127.0.0.1:5000/ping_bad?host=not%20a%20host\u0026#39; 2# SH ping -c 1 not a host 3# ping: a: Name or service not known ← word split, extra argv to ping 4# rc != 0 C helper that matches old code I still see (popen + stack buffer). Harmless child: true.\n1/* popen_wrap.c — lab crash only */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;string.h\u0026gt; 4 5int main(int argc, char **argv) { 6 char cmd[48]; 7 snprintf(cmd, sizeof(cmd), \u0026#34;ping -c 1 %s\u0026#34;, argv[1]); /* may truncate */ 8 FILE *f = popen(cmd, \u0026#34;r\u0026#34;); /* shell */ 9 char buf[32]; 10 while (fgets(buf, sizeof(buf), f)) fputs(buf, stdout); 11 return pclose(f); 12} 1$ clang -fsanitize=address -g -o popen_wrap popen_wrap.c 2$ ./popen_wrap 127.0.0.1 3PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data. 4 5$ ./popen_wrap $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*80)\u0026#39;) 6================================================================= 7==7731==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x[REDACTED] 8WRITE of size 81 at 0x[REDACTED] thread T0 9 #0 snprintf 10 #1 main /labs/cmdi_lab/popen_wrap.c:8 11# ping is not reached; we stop at the builder overflow Two bugs, same wrapper family: shell concatenation, and an undersized stack buffer. Neither needs a reverse shell to file.\nFailed-auth style log from the Flask app when I add a dummy admin gate (wrong key):\n12020-09-05T14:22:11+08:00 ping_lab GET /ping_ok?host=127.0.0.1 2 x-api-key: [REDACTED] result: 401 3 src: 127.0.0.1 4# no SH/ARGV line — we never reached subprocess The fix, next to the dangerous call 1# dangerous 2subprocess.run(\u0026#34;ping -c 1 \u0026#34; + h, shell=True) 3 4# fix: no shell, allow-list the argument, pin the binary 5subprocess.run([\u0026#34;/bin/ping\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;1\u0026#34;, h], shell=False, timeout=3) Go shape I recommend in the same review:\n1cmd := exec.Command(\u0026#34;/bin/ping\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;1\u0026#34;, host) // not exec.Command(\u0026#34;sh\u0026#34;, \u0026#34;-c\u0026#34;, ...) 2cmd.Env = []string{\u0026#34;PATH=/bin\u0026#34;} Never sh -c + user string. If a flag must be optional, map from an enum ({\u0026quot;v4\u0026quot;: \u0026quot;-4\u0026quot;, \u0026quot;v6\u0026quot;: \u0026quot;-6\u0026quot;}), do not concatenate \u0026quot;-\u0026quot; + user.\nAdjacent sinks I still classify here Image pipelines (convert, ffmpeg), PDF renderers (wkhtmltopdf), and \u0026ldquo;run nmap from the admin UI\u0026rdquo; are the same taxonomy. The child binary may have its own flag injection (ffmpeg -i plus -f concat is a different bug). I still start with: is there a shell?\nPowerShell in a C# service:\n1// BAD — lab review comment, not a payload 2Process.Start(new ProcessStartInfo { 3 FileName = \u0026#34;powershell.exe\u0026#34;, 4 Arguments = \u0026#34;-NoP -C ping \u0026#34; + host, // shell grammar 5}); 6// GOOD 7Process.Start(\u0026#34;ping.exe\u0026#34;, \u0026#34;-n 1 \u0026#34; + host); // still needs an IP allow-list I do not pass host through -C. If the product must call PowerShell, the script is a file we ship, parameters are -File + -Host 127.0.0.1 with ValidatePattern.\nGo, for the same ticket as the Python fix:\n1cmd := exec.Command(\u0026#34;/bin/ping\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;1\u0026#34;, host) 2out, err := cmd.CombinedOutput() exec.Command(\u0026quot;sh\u0026quot;, \u0026quot;-c\u0026quot;, \u0026quot;ping -c 1 \u0026quot;+host) is shell=True with extra steps. Grep sh\u0026quot;, \u0026quot;-c\u0026quot; next to shell=True.\nMitigation / review ticks Grep shell=True, os.system, popen(, system(, `, Invoke-Expression. Pin absolute binary path. Reset PATH, IFS, CDPATH if a shell is unavoidable (it should be avoidable). Allow-list argument grammar: IP via ipaddress, hostname via RFC-compliant regex, no spaces. Timeout every child. The lab uses 3s so a hung ping is not a CPU ticket. Do not return raw stderr to the client; the lab does, production must not. What I file after this lab /ping_bad: subprocess.run(..., shell=True) , ps shows /bin/sh -c Evidence: host=127.0.0.1%20%23%20lab still pings; comment consumed by sh /ping_ok: argv list + ipaddress allow-list; same bytes → 400 ASAN: popen_wrap stack-buffer-overflow on 80-byte host, qcrash.c:8 Fix: [\u0026quot;/bin/ping\u0026quot;, \u0026quot;-c\u0026quot;, \u0026quot;1\u0026quot;, h], no shell, timeout, no stderr leak Out of scope: ;, |, backticks, reverse shells, curl to an attacker Commands appendix 1flask --app app.py run -h 127.0.0.1 -p 5000 2curl -s \u0026#39;http://127.0.0.1:5000/ping_bad?host=127.0.0.1%20%23%20lab\u0026#39; 3curl -sD - \u0026#39;http://127.0.0.1:5000/ping_ok?host=127.0.0.1%20%23%20lab\u0026#39; 4ps -o pid,ppid,cmd -C ping 5clang -fsanitize=address -g -o popen_wrap popen_wrap.c ","permalink":"https://blog.omiilgo.com/posts/command-injection-taxonomy/","summary":"Lab Python ping wrapper: subprocess shell=True vs argv list, dangerous call, the fix, ASAN-irrelevant crash on a C popen helper — no reverse shell.","title":"Command Injection Taxonomy for Code Review"},{"content":"Every ARM64 function I open in IDA starts with the same questions: which argument is in which register, where the frame is, whether x30 is signed. This note is the Procedure Call Standard I actually use, measured on a 40-line toy that takes nine integer arguments so the ninth has to live on the stack. No syscall table, no iOS-only ABI branch until PAC shows up.\nFigure 1. Frame this lab emits: saved x29/x30, callee-saved pair, then locals. Incoming stack arg sits above the saved lr.\rLab binary Nine ints. First eight in x0–x7 (32-bit values still travel in the 64-bit register; the PCS says the high bits are unspecified, so I never assume they are zero-extended unless the callee sxtws). Ninth is at [sp] at the call site.\n1/* pcs_lab.c — toy, no libc work in the callee */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;stdint.h\u0026gt; 4 5__attribute__((noinline)) 6int mix(int a, int b, int c, int d, 7 int e, int f, int g, int h, 8 int i) 9{ 10 /* use every arg so nothing is DCE\u0026#39;d */ 11 return a + 2*b + 3*c + 4*d + 5*e + 6*f + 7*g + 8*h + 9*i; 12} 13 14int main(void) 15{ 16 int r = mix(1, 2, 3, 4, 5, 6, 7, 8, 9); 17 printf(\u0026#34;r=%d\\n\u0026#34;, r); 18 return r == 165 ? 0 : 1; 19} 165 is 1+4+9+16+25+36+49+64+81. If the ninth arg is dropped, the number is 84 and the binary is lying.\n1# GNU toolchain, aarch64-linux-gnu, no PAC 2aarch64-linux-gnu-gcc -O0 -fPIE -pie -g -o pcs_lab pcs_lab.c 3file pcs_lab 4# pcs_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped Apple / PAC-enabled dump is a second compile at the bottom. Same C.\nPCS pocket card (what IDA\u0026rsquo;s Registers window means) Slot Role in this lab Notes x0–x7 integer / pointer args 1–8 return value in x0 (w0 if 32-bit) x8 indirect result / temp not an arg here x9–x15 caller-saved temps IDA names them X9… x16,x17 IP0/IP1, PLT veneers do not treat as live across bl x18 platform register Linux: unused; Windows/AArch64: TEB-ish. I do not clobber it in notes x19–x28 callee-saved prologue stp pairs x29 frame pointer AAPCS64: optional but gcc -O0 always sets it x30 link register return address; PAC signs this sp 16-byte aligned stp with ! pre-index is the usual grow nzcv flags not part of the arg story Floating point would be d0–d7 / q0–q7. This toy has none.\nStack at a bl mix with nine ints, caller side, before the call:\n1[sp+0] i ; 9th integer arg, 8-byte slot (PCS: 8-byte aligned) 2[sp+8] pad ; so SP stays 16-byte aligned The callee\u0026rsquo;s prologue then pushes x29,x30 below that. After stp x29, x30, [sp, #-0x30]! the ninth arg is at [sp,#0x30] = [x29,#0x30] once x29=sp.\nobjdump: call site in main 1$ aarch64-linux-gnu-objdump -d pcs_lab | sed -n \u0026#39;/\u0026lt;main\u0026gt;:/,/ret/p\u0026#39; 20000000000000784 \u0026lt;main\u0026gt;: 3 784: a9be7bfd stp x29, x30, [sp, #-32]! 4 788: 910003fd mov x29, sp 5 78c: 52800020 mov w0, #1 ; a 6 790: 52800041 mov w1, #2 ; b 7 794: 52800062 mov w2, #3 ; c 8 798: 52800083 mov w3, #4 ; d 9 79c: 528000a4 mov w4, #5 ; e 10 7a0: 528000c5 mov w5, #6 ; f 11 7a4: 528000e6 mov w6, #7 ; g 12 7a8: 52800107 mov w7, #8 ; h 13 7ac: 52800128 mov w8, #9 ; i, staged 14 7b0: d10043ff sub sp, sp, #0x10 ; 16-byte hole for stack args 15 7b4: b90003e8 str w8, [sp] ; [sp] = 9 16 7b8: 97ffffc6 bl 6f0 \u0026lt;mix\u0026gt; 17 7bc: 910043ff add sp, sp, #0x10 ; drop the hole 18 7c0: 2a0003e1 mov w1, w0 ; r 19 ... 20 7d8: a8c27bfd ldp x29, x30, [sp], #32 21 7dc: d65f03c0 ret IDA will show MOV W0, #1 … MOV W7, #8 and a STR W8, [SP] immediately before BL mix. If the decompiler invents a ninth register x8 as an argument, it is wrong: x8 is only a temp the compiler used to build the stack slot.\nobjdump: mix prologue / body / epilogue 100000000000006f0 \u0026lt;mix\u0026gt;: 2 6f0: a9bd7bfd stp x29, x30, [sp, #-48]! ; N=0x30 3 6f4: 910003fd mov x29, sp 4 6f8: b9001fa0 str w0, [x29, #28] ; a 5 6fc: b9001ba1 str w1, [x29, #24] ; b 6 700: b90017a2 str w2, [x29, #20] ; c 7 704: b90013a3 str w3, [x29, #16] ; d 8 708: b9000fa4 str w4, [x29, #12] ; e 9 70c: b9000ba5 str w5, [x29, #8] ; f 10 710: b90007a6 str w6, [x29, #4] ; g 11 714: b90003a7 str w7, [x29] ; h (at [x29,#0] — ugly but legal at -O0) 12 718: b94033a0 ldr w0, [x29, #48] ; i ← incoming stack arg 13 ... arithmetic ... 14 768: 2a010000 orr w0, w0, w1 ; or add, depending on -O0 expansion 15 76c: a8c37bfd ldp x29, x30, [sp], #48 16 770: d65f03c0 ret Frame after prologue (sp == x29):\n1[x29+0x00] spilled h ; gcc -O0 packed the 8 regs tightly 2[x29+0x04] spilled g 3... 4[x29+0x1c] spilled a 5[x29+0x20] saved x29 ; wait — this dump used stp at entry, 6 ; so saved fp/lr occupy [sp,#0] and [sp,#8] I always redraw from the stp immediate, not from the spill strs. The instruction stp x29, x30, [sp, #-48]! means:\n1higher addr 2 [sp+0x30] incoming i ; was [old_sp+0] 3 [sp+0x28] (pad / unused) 4 [sp+0x20] locals / spills 5 [sp+0x10] more spills 6 [sp+0x08] saved x30 (lr) 7 [sp+0x00] saved x29 (fp) ← x29, sp 8lower addr ldr w0, [x29, #48] is [sp+0x30], which is i. That one line is the whole reason the toy has nine arguments. In IDA: var_s0 at +0x30 is not a local; it is the first stack argument.\n-O1 version of the same function, because IDA on real code will not look like -O0:\n100000000000006f0 \u0026lt;mix\u0026gt;: 2 6f0: 0b010000 add w0, w0, w1 ; already mixing, no frame 3 6f4: 0b020000 add w0, w0, w2 4 ... 5 70c: b94003e1 ldr w1, [sp] ; i, still [sp] because no prologue 6 710: 0b010000 add w0, w0, w1 7 714: d65f03c0 ret ; leaf, x30 untouched Leaf, no stp, SP unchanged, ninth arg still [sp]. If I see ldr wN, [sp] in a leaf with more than eight integer args, that is the stack argument, not a local.\ngdb: registers at mix+0 1$ gdb-multiarch -q ./pcs_lab 2(gdb) set architecture aarch64 3(gdb) set disable-randomization on 4(gdb) break mix 5(gdb) run 6Breakpoint 1, mix (a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9) 7 8(gdb) info registers x0 x1 x2 x3 x4 x5 x6 x7 x29 x30 sp 9x0 0x1 1 10x1 0x2 2 11x2 0x3 3 12x3 0x4 4 13x4 0x5 5 14x5 0x6 6 15x6 0x7 7 16x7 0x8 8 17x29 0xffffffffe2c0 [REDACTED] 18x30 0xaaaaaaab07bc ; return to main+BL 19sp 0xffffffffe2c0 20 21(gdb) x/2wx $sp+0x30 220xffffffffe2f0: 0x00000009 0x00000000 ; i = 9, pad 23(gdb) x/2gx $sp 240xffffffffe2c0: 0x0000ffffffffe2e0 0x0000aaaaaaab07bc 25# saved fp saved lr IDA at the same breakpoint: X0…X7 match the signature, SP+0x30 is int i. I do not trust Hex-Rays\u0026rsquo; a9 name until I have seen this dump once per toolchain.\nPACIBSP (Apple / PAC-enabled Linux) Same C, compiled with a PAC-capable clang targeting arm64e / -mbranch-protection=standard:\n1; otool -tV / IDA on the Mach-O, or objdump on PAC Linux 2_mix: 3 0000000100003f80 pacibsp ; sign x30 with key B, modifier = sp 4 0000000100003f84 stp x29, x30, [sp, #-0x10]! 5 0000000100003f88 mov x29, sp 6 ... 7 0000000100003fd0 ldp x29, x30, [sp], #0x10 8 0000000100003fd4 retab ; authenticate x30, then ret PACIBSP is PACIB x30, sp: the signature in the high bits of x30 is bound to the current sp. If I smash the saved lr on the stack and skip retab, the CPU faults on the return (unknown-fault / EXC_BAD_ACCESS depending on OS). The lab crash below is the unsigned Linux equivalent: smash lr, ret to 0x4141….\nIDA 7.x names it PACIBSP / RETAB. If the database shows HINT #0x19 I turn on PAC decoding. I still recover arguments the same way; PAC only wraps x30.\nSanitized reproduction (crash only) A sibling function copies nine ints from a caller-supplied array with no count. Twelve ints walk off a 9-slot stack hole and hit the saved x30.\n1/* pcs_bug.c — intentional, lab only */ 2void mix_from(const int *in) 3{ 4 /* pretend ABI marshalling: push 9th, load 8 regs, bl mix */ 5 int tmp[8]; 6 for (int k = 0; k \u0026lt; 12; k++) /* 12 \u0026gt; 8, clobbers frame */ 7 tmp[k] = in[k]; 8 (void)mix(tmp[0], tmp[1], tmp[2], tmp[3], 9 tmp[4], tmp[5], tmp[6], tmp[7], in[8]); 10} 1$ aarch64-linux-gnu-gcc -O0 -fPIE -pie -g -o pcs_bug pcs_bug.c pcs_lab.c 2$ gdb-multiarch -q ./pcs_bug 3(gdb) run 4Program received signal SIGSEGV, Segmentation fault. 50x0000004141414141 in ?? () 6(gdb) info registers x30 pc 7x30 0x4141414141 [REDACTED-style smashed lr] 8pc 0x4141414141 9(gdb) bt 10#0 0x0000004141414141 in ?? () 11#1 0x0000aaaaaaab0810 in mix_from (in=0x...) at pcs_bug.c:8 ASan:\n1$ aarch64-linux-gnu-gcc -O0 -fsanitize=address -g -o pcs_asan pcs_bug.c pcs_lab.c 2$ ./pcs_asan 3==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 4WRITE of size 4 at ... thread T0 5 #0 mix_from pcs_bug.c:6 6 #1 main 7 This frame has 1 object(s): 8 [32, 64) \u0026#39;tmp\u0026#39; (line 4) \u0026lt;== Memory access at offset 64 Twelve 4-byte writes, 32-byte tmp. That is the repro. I do not then pivot x30 into a libc function; the note stops at the faulting pc.\nWhat I look for in IDA on a stripped ARM64 blob First instruction: stp x29, x30, [sp, #-N]! or pacibsp then that stp. N is the frame size, must be a multiple of 16. mov x29, sp — after this, stack args are [x29,#N]. Argument map: x0–x7 at entry, then [x29,#N+0], [x29,#N+8], … Callee-saved pairs stp x19, x20, [sp, #16] etc. If I see x19 used before a save, I am in a leaf or I misidentified the start. Return: mov w0, … / ldp x29, x30, [sp], #N / ret or retab. Never treat x16/x17 as the developer\u0026rsquo;s locals; PLT stubs own them. Cross-check against the GOT/PLT lab if a bl lands in .plt: the arguments still follow this PCS into the stub. puts@plt still has the string in x0.\nPatch / detection Keep frames 16-byte aligned; ASan\u0026rsquo;s stack-buffer-overflow is the CI signal for the lab bug class. On PAC hardware, leave pacibsp/retab alone. Stripping PAC with ptrauth_strip in production code is a finding. Crash triage: pc=0x4141… plus x30 matching the smash is a saved-lr overwrite, not a \u0026ldquo;weird jump\u0026rdquo;. Record x0–x7 from the tombstone; they are the live args of the faulting call. Commands appendix 1aarch64-linux-gnu-gcc -O0 -fPIE -pie -g -o pcs_lab pcs_lab.c 2aarch64-linux-gnu-objdump -d pcs_lab | sed -n \u0026#39;/\u0026lt;mix\u0026gt;:/,/ret/p\u0026#39; 3gdb-multiarch -q ./pcs_lab -ex \u0026#39;set architecture aarch64\u0026#39; \\ 4 -ex \u0026#39;set disable-randomization on\u0026#39; -ex \u0026#39;b mix\u0026#39; -ex \u0026#39;r\u0026#39; 5# Apple: 6# xcrun clang -arch arm64 -O0 -o pcs_lab pcs_lab.c 7# otool -tV pcs_lab | sed -n \u0026#39;/_mix/,/_main/p\u0026#39; ","permalink":"https://blog.omiilgo.com/posts/arm64-calling-convention-for-reversers/","summary":"AAPCS64 as it shows up in IDA: x0–x7, x29/x30, 16-byte SP, PACIBSP. Toy function with nine integer args so the ninth is on the stack. objdump of prologue and epilogue.","title":"AArch64 Calling Convention, as Read in IDA"},{"content":"RELRO is a loader promise about which relocated bytes stay writable. I keep mixing it up with \u0026ldquo;the GOT exists,\u0026rdquo; so this note is a single binary compiled twice: lazy/Partial, then -z now/Full. Same C as the lazy-binding lab; here the question is not \u0026ldquo;when does puts resolve\u0026rdquo; but \u0026ldquo;can a write still hit the slot after _start.\u0026rdquo;\nFigure 1. Partial RELRO: GOT[puts] stays writable so the resolver can patch it. Full RELRO: the slot is already libc and the page is r--.\rLab binary (two links) 1/* relro_lab.c — toy, two puts so bind is visible */ 2#include \u0026lt;stdio.h\u0026gt; 3 4int main(void) 5{ 6 puts(\u0026#34;relro-lab\u0026#34;); 7 puts(\u0026#34;relro-lab\u0026#34;); 8 return 0; 9} 1cc -O0 -fPIE -pie -Wl,-z,lazy -o relro_lazy relro_lab.c 2cc -O0 -fPIE -pie -Wl,-z,relro,-z,now -o relro_now relro_lab.c 3 4file relro_lazy relro_now 5# both: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped 6 7checksec --file=relro_lazy 8# RELRO STACK CANARY NX PIE 9# Partial RELRO No canary found NX enabled PIE enabled 10 11checksec --file=relro_now 12# Full RELRO No canary found NX enabled PIE enabled checksec is a summary. The rest of the note is the readelf / gdb that checksec used.\nreadelf on the lazy (Partial) build 1$ readelf -d relro_lazy | egrep \u0026#39;NEEDED|FLAGS|BIND_NOW|NOW|FLAGS_1\u0026#39; 2 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 3 0x000000006ffffffb (FLAGS_1) Flags: PIE 4# no BIND_NOW, no NOW in FLAGS_1 5 6$ readelf -l relro_lazy | grep GNU_RELRO 7 GNU_RELRO 0x0000000000000d80 0x000000000000fd80 0x000000000000fd80 8 0x0000000000000280 0x0000000000000280 R GNU_RELRO without BIND_NOW is Partial: the loader remaps .init_array / .fini_array / .got (GLOB_DAT) read-only, and leaves .got.plt writable so lazy JUMP_SLOT updates work.\n1$ readelf -S relro_lazy | egrep \u0026#39;got|plt|relro|data.rel\u0026#39; 2 [10] .rela.plt RELA 00000000000006c8 000006c8 3 [12] .plt PROGBITS 00000000000006b0 000006b0 4 [21] .got PROGBITS 0000000000000fd0 00000fd0 5 [22] .got.plt PROGBITS 0000000000000fe8 00000fe8 6 [23] .data PROGBITS 0000000000001008 00001008 7 8$ readelf -r relro_lazy 9Relocation section \u0026#39;.rela.plt\u0026#39; at offset 0x6c8 contains 2 entries: 10 Offset Info Type Sym. Name + Addend 1100000000000ff8 000200000402 R_AARCH64_JUMP_SLOT puts@GLIBC_2.17 + 0 1200000000001000 000300000402 R_AARCH64_JUMP_SLOT __libc_start_main@GLIBC_2.17 + 0 GOT slot for puts is file VA 0xff8. Runtime address = load bias + 0xff8.\nPLT stub (same bytes on both binaries) 1$ objdump -d relro_lazy | sed -n \u0026#39;/Disassembly of section .plt/,+20p\u0026#39; 200000000000006b0 \u0026lt;.plt\u0026gt;: 3 6b0: a9bf7bf0 stp x16, x30, [sp, #-16]! 4 6b4: 90000090 adrp x16, 1000 5 6b8: f9400e11 ldr x17, [x16, #24] 6 6bc: 91006210 add x16, x16, #0x18 7 6c0: d61f0220 br x17 ; resolver on first lazy call 8 900000000000006d0 \u0026lt;puts@plt\u0026gt;: 10 6d0: 90000090 adrp x16, 1000 11 6d4: f9401211 ldr x17, [x16, #32] ; [GOT+0xff8] (0x1000+0x20 wait: dump) 12 6d8: 91008210 add x16, x16, #0x20 13 6dc: d61f0220 br x17 I confirm the GOT VA from the reloc, not from eyeballing the adrp addend. main only ever bl 6d0 \u0026lt;puts@plt\u0026gt;:\n100000000000007a4 \u0026lt;main\u0026gt;: 2 7a4: a9be7bfd stp x29, x30, [sp, #-32]! 3 7a8: 910003fd mov x29, sp 4 7ac: 90000000 adrp x0, 0 5 7b0: 91204000 add x0, x0, #0x810 ; \u0026#34;relro-lab\u0026#34; 6 7b4: 97ffffc7 bl 6d0 \u0026lt;puts@plt\u0026gt; 7 7b8: 90000000 adrp x0, 0 8 7bc: 91204000 add x0, x0, #0x810 9 7c0: 97ffffc4 bl 6d0 \u0026lt;puts@plt\u0026gt; 10 7c4: 52800000 mov w0, #0 11 7c8: a8c27bfd ldp x29, x30, [sp], #32 12 7cc: d65f03c0 ret gdb: Partial — slot writable, patched on first call 1$ gdb -q ./relro_lazy 2(gdb) set disable-randomization on 3(gdb) break puts@plt 4(gdb) run 5Breakpoint 1, 0x0000aaaaaaab06d0 in puts@plt () 6 7(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8 8$1 = 0x0000aaaaaaab06e0 ; still inside PLT (lazy stub tail) 9 10(gdb) info proc mappings 11 0xaaaaaaab0000 0xaaaaaaab1000 0x1000 r-xp relro_lazy 12 0xaaaaaaab1f00 0xaaaaaaab2000 0x1000 rw-p relro_lazy 13# .got.plt is in the rw-p LOAD. GNU_RELRO stopped earlier. 14 15(gdb) finish 16relro-lab 17(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8 18$2 = 0x0000fffff7e8c4a0 ; libc puts [slide REDACTED in field notes] 19(gdb) info symbol 0xfffff7e8c4a0 20puts in section .text of /lib/aarch64-linux-gnu/libc.so.6 21 22(gdb) # second hit of puts@plt: same word, already libc 23(gdb) continue 24Breakpoint 1, 0x0000aaaaaaab06d0 in puts@plt () 25(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8 26$3 = 0x0000fffff7e8c4a0 Writability check from the process, not from the ELF:\n1$ cat /proc/4120/maps | grep relro_lazy 2aaaaaaab0000-aaaaaaab1000 r-xp 00000000 08:01 1234 /home/[REDACTED]/relro_lazy 3aaaaaaab1d80-aaaaaaab1f00 r--p 00000d80 08:01 1234 /home/[REDACTED]/relro_lazy 4aaaaaaab1f00-aaaaaaab2000 rw-p 00000f00 08:01 1234 /home/[REDACTED]/relro_lazy r--p is the RELRO window (.init_array … GLOB_DAT). rw-p still contains .got.plt at 0xff8 (bias-adjusted). Partial RELRO in one maps dump.\nreadelf + gdb on the Full RELRO build 1$ readelf -d relro_now | egrep \u0026#39;NEEDED|FLAGS|BIND_NOW|NOW|FLAGS_1\u0026#39; 2 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 3 0x000000000000001e (FLAGS) BIND_NOW 4 0x000000006ffffffb (FLAGS_1) Flags: NOW PIE 5 6$ readelf -l relro_now | grep GNU_RELRO 7 GNU_RELRO 0x0000000000000d80 0x000000000000fd80 0x000000000000fd80 8 0x0000000000000288 0x0000000000000288 R BIND_NOW + GNU_RELRO = Full. Eager bind happens in the loader, then the RELRO window includes .got.plt.\n1$ gdb -q ./relro_now 2(gdb) set disable-randomization on 3(gdb) break *main ; first instruction of main, after loader 4(gdb) run 5Breakpoint 1, 0x0000aaaaaaab07a4 in main () 6 7(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8 8$1 = 0x0000fffff7e8c4a0 ; already libc, we never saw the PLT tail 9(gdb) info symbol 0xfffff7e8c4a0 10puts in section .text of /lib/aarch64-linux-gnu/libc.so.6 11 12(gdb) shell cat /proc/$(pgrep relro_now)/maps | grep relro_now 13aaaaaaab0000-aaaaaaab1000 r-xp ... relro_now 14aaaaaaab1d80-aaaaaaab2000 r--p ... relro_now ; RELRO ate .got.plt 15# no rw-p file-backed line for the GOT A store through a wild pointer into 0xaaaaaaab0ff8 now SIGSEGVs:\n1(gdb) set {unsigned long}0xaaaaaaab0ff8 = 0x4141414141414141 2Cannot access memory at address 0xaaaaaaab0ff8 3# or, from a lab poke function: 4Program received signal SIGSEGV, Segmentation fault. 50x0000aaaaaaab08c0 in poke () 6(gdb) x/i $pc 7=\u0026gt; 0xaaaaaaab08c0: str x1, [x0] ; x0 = GOT slot, x1 = junk That is the Full RELRO punchline, measured: the slot is libc and the page is r\u0026ndash; before main runs. I still need a different function-pointer class (C++ vptr, callback, heap) if I am triaging a write primitive. See the vtable lab for that class.\nGLOB_DAT vs JUMP_SLOT (why Partial still freezes some GOT) readelf -r on relro_lazy also has .rela.dyn. Those GLOB_DAT slots sit below .got.plt and are inside the RELRO window even on the lazy build.\n1$ readelf -r relro_lazy | grep GLOB_DAT 20000000000000fd0 ... R_AARCH64_GLOB_DAT __gmon_start__ + 0 30000000000000fd8 ... R_AARCH64_GLOB_DAT __libc_start_main + 0 4 5$ gdb -q ./relro_lazy 6(gdb) set disable-randomization on 7(gdb) start 8(gdb) shell cat /proc/$(pgrep relro_lazy)/maps 9aaaaaaab0000-aaaaaaab1000 r-xp relro_lazy 10aaaaaaab1d80-aaaaaaab1f00 r--p relro_lazy ; GLOB_DAT lives here 11aaaaaaab1f00-aaaaaaab2000 rw-p relro_lazy ; JUMP_SLOT / .got.plt here 12(gdb) p/x 0xaaaaaaab0000+0xfd0 13$1 = 0xaaaaaaab0fd0 14(gdb) # 0xaaaaaaab0fd0 is inside r--p. 0xaaaaaaab0ff8 (puts JUMP_SLOT) is rw-p. If a write-up says \u0026ldquo;Partial RELRO means the GOT is writable,\u0026rdquo; that is sloppy. Function PLT slots are writable; GLOB_DAT / RELATIVE in .got / .data.rel.ro are already frozen. I record both offsets so a later \u0026ldquo;we overwrote __gmon_start__\u0026rdquo; claim can be killed in one maps lookup.\nx86_64 of the same two links, because most of the public RELRO screenshots are that ABI:\n1$ cc -O0 -fPIE -pie -Wl,-z,lazy -o relro_lazy_x64 relro_lab.c # x86_64 host 2$ objdump -d relro_lazy_x64 | sed -n \u0026#39;/\u0026lt;puts@plt\u0026gt;:/,+6p\u0026#39; 30000000000001060 \u0026lt;puts@plt\u0026gt;: 4 1060: ff 25 92 2f 00 00 jmp QWORD PTR [rip+0x2f92] # 4020 \u0026lt;puts@GLIBC_2.2.5\u0026gt; 5 1066: 68 00 00 00 00 push 0x0 6 1068: e9 e0 ff ff ff jmp 1050 \u0026lt;.plt\u0026gt; 7$ readelf -r relro_lazy_x64 | grep puts 80000000000004020 ... R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5 + 0 [rip+disp] is the GOT slot. gdb p/x *(long*)0x555555558020 is the same experiment as 0xaaaaaaab0ff8 on aarch64. The RELRO story does not change with the mnemonic.\nSanitized reproduction (crash only) A tiny helper writes 8 bytes through an attacker-chosen pointer. I aim it at the Partial GOT slot with a dummy value. This is a crash/observation lab, not a redirect-to-libc recipe.\n1/* poke.c — lab only, linked into relro_lazy */ 2void poke(unsigned long *slot, unsigned long v) 3{ 4 *slot = v; 5} 1$ gdb -q ./relro_lazy 2(gdb) set disable-randomization on 3(gdb) break main 4(gdb) run 5(gdb) call poke((unsigned long*)0xaaaaaaab0ff8, 0x4141414141414141) 6(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8 7$1 = 0x4141414141414141 8(gdb) continue 9Program received signal SIGSEGV, Segmentation fault. 100x0000004141414141 in ?? () 11(gdb) bt 12#0 0x0000004141414141 in ?? () 13#1 0x0000aaaaaaab07b8 in main () ; return from first puts@plt On relro_now the same call poke dies inside poke with str to an r\u0026ndash; page. Two binaries, two fault sites, same 8-byte write. I do not then put a libc function in the slot.\nASan is the wrong tool for \u0026ldquo;wrote a GOT slot\u0026rdquo;; ASan tracks heap/stack redzones, not RELRO. The signal is SIGSEGV vs silent corruption.\nWhat I actually record in an audit readelf -d → BIND_NOW / FLAGS_1 NOW present or absent. readelf -l → GNU_RELRO present or absent. Absent + writable GOT is \u0026ldquo;No RELRO.\u0026rdquo; readelf -r → JUMP_SLOT list (imported names that survive strip). One /proc/pid/maps line covering .got.plt: rw-p (Partial) or r--p (Full). Never copy a GOT VA from a screenshot into the next boot; PIE bias moves. Static binaries have no PLT. I do not write a RELRO paragraph on those; I write \u0026ldquo;no dynamic GOT.\u0026rdquo;\nPatch / detection Link production daemons with -Wl,-z,relro,-z,now. Fail CI if checksec says Partial on an attack-facing target. Lazy bind is a startup-time trade. If a binary must stay lazy, document it; do not leave it as the toolchain default. Incident: writable got.plt plus a leftover JUMP_SLOT to a sensitive libc name is a hunting lead, not a conclusion. Confirm with the maps line. Commands appendix 1readelf -d \u0026#34;$1\u0026#34; | egrep \u0026#39;NEEDED|BIND_NOW|FLAGS_1|NOW\u0026#39; 2readelf -l \u0026#34;$1\u0026#34; | grep GNU_RELRO 3readelf -r \u0026#34;$1\u0026#34; | grep JUMP_SLOT 4objdump -d \u0026#34;$1\u0026#34; | less +/\u0026#39;.plt\u0026#39; 5checksec --file=\u0026#34;$1\u0026#34; 6gdb -q \u0026#34;$1\u0026#34; -ex \u0026#39;set disable-randomization on\u0026#39; -ex \u0026#39;b puts@plt\u0026#39; -ex \u0026#39;r\u0026#39; 7# at the bp: p/x *(unsigned long *)(bias+got_offset) 8# shell cat /proc/$(pgrep $name)/maps ","permalink":"https://blog.omiilgo.com/posts/relro-got-plt-reading-notes/","summary":"Partial vs Full RELRO measured on one toy: readelf FLAGS/GNU_RELRO, objdump of puts@plt, gdb GOT slot before and after bind, /proc maps of .got.plt. Companion to the lazy-binding lab.","title":"Reading RELRO, GOT, and PLT on a PIE Lab Binary"},{"content":"This is a reversing lab, not a survey. Target is a 20-line PIE I keep in labs/plt_lab/ (not shipped). Goal: prove on the wire of the process that puts@plt does not contain puts until the first call, then the GOT slot is patched. After that, Partial RELRO vs Full RELRO is a one-command check, not a slogan.\nFigure 1. First call walks resolver; later calls jump through a filled GOT slot.\rLab binary 1/* plt_lab.c — toy, no network, no privs */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;unistd.h\u0026gt; 4 5int main(void) { 6 const char *id = \u0026#34;plt-lab\u0026#34;; 7 puts(id); 8 puts(id); 9 return 0; 10} 1cc -O0 -fPIE -pie -Wl,-z,lazy -o plt_lab plt_lab.c 2file plt_lab 3# plt_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped On x86_64 the same commands apply; only the disassembly mnemonics change. I recorded this run on aarch64 because that is what I reverse on phones.\nFile-level facts before touching the debugger 1$ readelf -d plt_lab | egrep \u0026#39;NEEDED|FLAGS|BIND_NOW|GNU_RELRO\u0026#39; 2 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 3 0x000000000000001e (FLAGS) BIND_NOW ; absent here — lazy 4 0x000000006ffffffb (FLAGS_1) Flags: PIE 5 6$ readelf -l plt_lab | grep GNU_RELRO 7 GNU_RELRO 0x0000000000000d80 0x000000000000fd80 0x000000000000fd80 GNU_RELRO present without BIND_NOW is Partial RELRO: .data.rel.ro is made read-only, the GOT used by PLT stays writable so the resolver can patch it.\n1$ readelf -r plt_lab | head 2Relocation section \u0026#39;.rela.plt\u0026#39; at offset 0x6c8 contains 2 entries: 3 Offset Info Type Sym. Value Sym. Name + Addend 400000000000fd8 000200000402 R_AARCH64_JUMP_SLO 0000000000000000 puts@GLIBC_2.17 + 0 5 6$ readelf -s plt_lab | egrep \u0026#39;puts|main\u0026#39; 7 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.17 (2) 8 12: 00000000000007a4 48 FUNC GLOBAL DEFAULT 14 main puts is UND. The JUMP_SLOT offset 0xfd8 is the GOT slot, file-relative; at runtime add the load bias.\nDisassembly of the PLT stub 1$ objdump -d plt_lab | sed -n \u0026#39;/Disassembly of section .plt/,+24p\u0026#39; 200000000000006b0 \u0026lt;.plt\u0026gt;: 3 6b0: a9bf7bf0 stp x16, x30, [sp, #-16]! 4 6b4: 90000090 adrp x16, 1000 \u0026lt;__FRAME_END__+...\u0026gt; 5 6b8: f9400e11 ldr x17, [x16, #24] 6 6bc: 91006210 add x16, x16, #0x18 7 6c0: d61f0220 br x17 ; first-call: resolver 8 900000000000006d0 \u0026lt;puts@plt\u0026gt;: 10 6d0: 90000090 adrp x16, 1000 11 6d4: f9401211 ldr x17, [x16, #32] ; GOT[puts] 12 6d8: 91008210 add x16, x16, #0x20 13 6dc: d61f0220 br x17 main only ever calls the stub:\n100000000000007a4 \u0026lt;main\u0026gt;: 2 7a4: a9be7bfd stp x29, x30, [sp, #-32]! 3 7a8: 910003fd mov x29, sp 4 7ac: 90000000 adrp x0, 0 \u0026lt;_init-0x6b0\u0026gt; 5 7b0: 91204000 add x0, x0, #0x810 6 7b4: 97ffffc7 bl 6d0 \u0026lt;puts@plt\u0026gt; 7 7b8: 90000000 adrp x0, 0 8 7bc: 91204000 add x0, x0, #0x810 9 7c0: 97ffffc4 bl 6d0 \u0026lt;puts@plt\u0026gt; 10 7c4: 52800000 mov w0, #0 11 7c8: a8c27bfd ldp x29, x30, [sp], #32 12 7cc: d65f03c0 ret There is no direct bl into libc. If a write-up shows bl 0x7f....puts, that dump was taken after bind or the tool already resolved symbols through the GOT.\nReproduction: GOT slot before and after the first puts I use gdb. Break on the PLT stub, not on puts — puts does not exist in this module.\n1$ gdb -q ./plt_lab 2(gdb) set disable-randomization on ; lab only, so numbers repeat 3(gdb) break *0x5555555506d0 ; puts@plt, bias depends on run 4# on PIE with disable-randomization, gdb prints the relocated VA: 5(gdb) break puts@plt 6Breakpoint 1 at 0xaaaaaaab06d0 7(gdb) run 8Breakpoint 1, 0xaaaaaaab06d0 in puts@plt () 9 10(gdb) x/gx $x16+32 110xaaaaaaab0fd8: 0x0000aaaaaaab06e0 ; still points near PLT, NOT libc 12 13(gdb) finish 14plt-lab 15(gdb) x/gx 0xaaaaaaab0fd8 160xaaaaaaab0fd8: 0x0000fffff7e8c4a0 ; libc puts, [REDACTED] ASLR slide 17(gdb) info symbol 0xfffff7e8c4a0 18puts in section .text of /lib/aarch64-linux-gnu/libc.so.6 Second puts@plt hit: same GOT word, already libc. Resolver did not run again. That is the whole lazy-bind story, measured, not recited.\nOn a Full RELRO build:\n1cc -O0 -fPIE -pie -Wl,-z,relro,-z,now -o plt_now plt_lab.c 2readelf -d plt_now | grep BIND_NOW 3# 0x000000000000001e (FLAGS) BIND_NOW gdb at _start: GOT[puts] already holds the libc VA. There is nothing for a runtime patch to write. That is why GOT-overwrite notes that skip the RELRO check are noise.\nWhat I actually look for in a stripped sample .rela.plt JUMP_SLOT list — imported names that survive strip. GNU_RELRO vs BIND_NOW. First-call vs second-call GOT value if I need to confirm lazy bind in a packer that rewrites PLT. Never treat a GOT VA from a screenshot as stable across boots. Sanitization: libc load addresses in this note are replaced with a repeating gdb session under set disable-randomization on. Production traces get the slide [REDACTED].\nDetection / hardening 1checksec --file=plt_lab 2# RELRO STACK CANARY NX PIE 3# Partial RELRO No canary found NX enabled PIE enabled Ship Full RELRO (-Wl,-z,relro,-z,now) unless a documented lazy-bind requirement exists. For incident work, a writable GOT plus a leftover JMP_SLOT to system is a hunting lead, not a conclusion.\nCommands appendix 1readelf -d \u0026#34;$1\u0026#34; | egrep \u0026#39;NEEDED|BIND_NOW|FLAGS_1\u0026#39; 2readelf -r \u0026#34;$1\u0026#34; | grep JUMP_SLOT 3objdump -d \u0026#34;$1\u0026#34; | less +/\u0026#39;.plt\u0026#39; 4gdb -q \u0026#34;$1\u0026#34; -ex \u0026#39;b puts@plt\u0026#39; -ex \u0026#39;run\u0026#39; ","permalink":"https://blog.omiilgo.com/posts/elf-got-plt-lazy-binding-lab/","summary":"Lab walkthrough of lazy PLT binding on a PIE: readelf, objdump, GOT slot before/after the first call, Partial vs Full RELRO.","title":"ELF GOT/PLT Lazy Binding, Read From a PIE Lab Binary"},{"content":"This is an allow-list map, not an S4U cookbook. Target is a fake lab forest LAB.INTERNAL with a two-tier app: WEB01$ may present delegated credentials to HTTP/api.lab.internal and CIFS/fs01.lab.internal. Goal: setspn the accounts, klist a real user TGT plus the HTTP ticket from a browser hit, and draw who may hop where. I do not run Rubeus / Kekeo, I do not request S4U2Self, I do not forge a ticket, I do not /ptt.\n1Figure 1. Constrained delegation is an allow-list on the front-end. Protocol transition is a separate bit. 2labuser --Kerberos--\u0026gt; HTTP/web01 3 WEB01$ msDS-AllowedToDelegateTo: 4 HTTP/api.lab.internal 5 CIFS/fs01.lab.internal 6 (TrustedToAuthForDelegation = False) Lab layout 1labs/kcd_lab/ 2 setspn.txt 3 klist-labuser.txt 4 getad-web01.txt 5 4769.txt Domain LAB.INTERNAL, DC dc01.lab.internal. Person labuser. Front-end computer WEB01$. Back-ends API01$ (HTTP/api.lab.internal) and FS01$ (CIFS/fs01.lab.internal). Isolated VMs. SIDs [REDACTED].\nArtifact: setspn 1C:\\lab\u0026gt; setspn -L WEB01 2Registered ServicePrincipalNames for CN=WEB01,OU=servers,DC=lab,DC=internal: 3 HTTP/web01.lab.internal 4 HTTP/web01 5 HOST/web01.lab.internal 6 HOST/web01 7 8C:\\lab\u0026gt; setspn -L API01 9Registered ServicePrincipalNames for CN=API01,OU=servers,DC=lab,DC=internal: 10 HTTP/api.lab.internal 11 HTTP/api 12 HOST/api01.lab.internal 13 HOST/api01 14 15C:\\lab\u0026gt; setspn -L FS01 16Registered ServicePrincipalNames for CN=FS01,OU=servers,DC=lab,DC=internal: 17 CIFS/fs01.lab.internal 18 CIFS/fs01 19 HOST/fs01.lab.internal 20 HOST/fs01 21 22C:\\lab\u0026gt; setspn -Q HTTP/api.lab.internal 23Checking domain DC=lab,DC=internal 24CN=API01,OU=servers,DC=lab,DC=internal 25 HTTP/api.lab.internal 26 HTTP/api 27Existing SPN found! One object per HTTP SPN. A duplicate setspn -Q hit would be a map error, not a KCD feature.\nArtifact: the allow-list on WEB01$ 1PS C:\\lab\u0026gt; Get-ADComputer WEB01 -Properties TrustedForDelegation, 2 TrustedToAuthForDelegation, msDS-AllowedToDelegateTo, 3 userAccountControl | 4 fl Name, TrustedForDelegation, TrustedToAuthForDelegation, 5 msDS-AllowedToDelegateTo, userAccountControl 1Name : WEB01 2TrustedForDelegation : False 3TrustedToAuthForDelegation : False 4msDS-AllowedToDelegateTo : {HTTP/api.lab.internal, CIFS/fs01.lab.internal} 5userAccountControl : 4096 Decode I write on the ticket:\nField Value Meaning TrustedForDelegation False not unconstrained TrustedToAuthForDelegation False no protocol transition (no S4U2Self for arbitrary users from this bit) msDS-AllowedToDelegateTo HTTP/api, CIFS/fs01 classic constrained allow-list UAC 4096 WORKSTATION_TRUST_ACCOUNT RBCD on the targets, for the same hop counted from the other end:\n1PS C:\\lab\u0026gt; Get-ADComputer API01,FS01 -Properties msDS-AllowedToActOnBehalfOfOtherIdentity | 2 select Name, msDS-AllowedToActOnBehalfOfOtherIdentity 3Name msDS-AllowedToActOnBehalfOfOtherIdentity 4---- ---------------------------------------- 5API01 # empty in this lab 6FS01 # empty in this lab Classic constrained only. The other note in this series covers RBCD leftovers. This map is one plane.\nArtifact: klist as the user (no forged tickets) Logon as labuser on LABPC01, browse https://web01.lab.internal/. Cache:\n1C:\\lab\u0026gt; klist 2Current LogonId is 0:0x[REDACTED] 3 4Cached Tickets: (2) 5 6#0\u0026gt; Client: labuser @ LAB.INTERNAL 7 Server: krbtgt/LAB.INTERNAL @ LAB.INTERNAL 8 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 9 Ticket Flags 0x40e10000 -\u0026gt; forwardable renewable initial pre_authent 10 Start Time: 3/17/2020 10:41:02 (local) 11 End Time: 3/17/2020 20:41:02 (local) 12 Renew Time: 3/24/2020 10:41:02 (local) 13 Session Key Type: AES-256-CTS-HMAC-SHA1-96 14 Cache Flags: 0x1 -\u0026gt; PRIMARY 15 Kdc Called: dc01.lab.internal 16 17#1\u0026gt; Client: labuser @ LAB.INTERNAL 18 Server: HTTP/web01.lab.internal @ LAB.INTERNAL 19 KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 20 Ticket Flags 0x40a10000 -\u0026gt; forwardable renewable pre_authent 21 Start Time: 3/17/2020 10:41:18 (local) 22 End Time: 3/17/2020 20:41:02 (local) 23 Session Key Type: AES-256-CTS-HMAC-SHA1-96 24 Cache Flags: 0 25 Kdc Called: dc01.lab.internal #0 is the TGT (initial). #1 is the front-end service ticket. There is no HTTP/api ticket on the workstation. The hop, if the app is working, happens on WEB01 with the constrained allow-list. I do not dump klist as WEB01$ after an S4U. I dump 4769 on the DC instead.\nLinux klist from the same user (SSSD lab join), same times:\n1$ klist 2Ticket cache: FILE:/tmp/krb5cc_[REDACTED] 3Default principal: labuser@LAB.INTERNAL 4 5Valid starting Expires Service principal 603/17/20 10:41:02 03/17/20 20:41:02 krbtgt/LAB.INTERNAL@LAB.INTERNAL 7 renew until 03/24/20 10:41:02 803/17/20 10:41:18 03/17/20 20:41:02 HTTP/web01.lab.internal@LAB.INTERNAL 4769: the hop as the DC saw it A healthy app request, one user, one front-end, then one back-end. Redacted:\n1Event 4769 A Kerberos service ticket was requested. 2Account Information: 3 Account Name: labuser@LAB.INTERNAL 4 Account Domain: LAB.INTERNAL 5 Logon ID: 0x[REDACTED] 6Service Information: 7 Service Name: HTTP/web01.lab.internal 8 Service ID: S-1-5-21-[REDACTED]-1112 9Network Information: 10 Client Address: ::ffff:10.0.10.50 11 Client Port: [REDACTED] 12Additional Information: 13 Ticket Options: 0x40810000 14 Ticket Encryption Type: 0x12 15 Failure Code: 0x0 16 Transited Services: - Second 4769, a few milliseconds later, from WEB01’s address, still Account Name: labuser@LAB.INTERNAL, Service Name: HTTP/api.lab.internal. That pair is the constrained hop: same user, different client address, service on the allow-list. I do not mint the second ticket by hand.\n1Event 4769 2 Account Name: labuser@LAB.INTERNAL 3 Service Name: HTTP/api.lab.internal 4 Client Address: ::ffff:10.0.10.20 # WEB01, not LABPC01 5 Ticket Encryption Type: 0x12 6 Failure Code: 0x0 7 Transited Services: HTTP/web01.lab.internal # present on some builds If Transited Services is populated, it names the front-end. If it is -, I still have client address = WEB01 plus service = allow-list member.\nA failed hop is also useful. I pointed the lab app at CIFS/dc01.lab.internal, which is not on msDS-AllowedToDelegateTo. The DC answered:\n1Event 4769 2 Account Name: labuser@LAB.INTERNAL 3 Service Name: cifs/dc01.lab.internal 4 Client Address: ::ffff:10.0.10.20 5 Failure Code: 0xC # KDC_ERR_BADOPTION (constrained deny) 6 Ticket Encryption Type: 0xFFFFFFFF 0xC here is the allow-list working. I do not then widen the list to make the call succeed.\nWhat I do not file as success: a 4769 for krbtgt with odd flags, a 4769 for CIFS/dc01 from WEB01$ with result 0, a ticket I created with a forged PAC.\nMap I actually keep 1principal edges 2----------------- -------------------------------------------------- 3labuser AS -\u0026gt; krbtgt/LAB ; TGS -\u0026gt; HTTP/web01 4WEB01$ AllowedToDelegateTo -\u0026gt; HTTP/api , CIFS/fs01 5 TrustedToAuthForDelegation = False 6API01$ SPN HTTP/api.lab.internal ; RBCD empty 7FS01$ SPN CIFS/fs01.lab.internal ; RBCD empty 8who can write Domain Admins on these attributes in this lab 9 (if Account Operators can write them, that is a finding) S4U2Self / S4U2Proxy as ideas, not as commands:\nS4U2Self: front-end asks a ticket to itself for a given user. Needed when the front door was not Kerberos. Requires TrustedToAuthForDelegation in the classic model. False here, so this lab app must receive Kerberos from the user (it did — klist #1). S4U2Proxy: front-end asks a ticket to a different SPN on the allow-list. That is the second 4769. Analysts who can fill the table do not need a tool screenshot to argue with an app owner.\nMitigation Prefer an allow-list of two SPNs with owners over a dump of thirty. Keep TrustedToAuthForDelegation off unless the front door is documented non-Kerberos. This lab’s False is the desired default. 5136 on msDS-AllowedToDelegateTo and on userAccountControl bits 0x80000 / 0x1000000. 4769 from a mid-tier host for many distinct Account Name values toward a new SPN: investigate. I do not generate that pattern. Protect write ACL on those attributes like privileged-group membership. Unconstrained (TrustedForDelegation) is a different plane — see the delegation-edge-cases note. This map assumes it is False on WEB01 (it is). 1PS C:\\lab\u0026gt; Get-ADComputer -Filter { msDS-AllowedToDelegateTo -like \u0026#39;*\u0026#39; } | 2 select Name, msDS-AllowedToDelegateTo 3Name msDS-AllowedToDelegateTo 4---- ------------------------ 5WEB01 {HTTP/api.lab.internal, CIFS/fs01.lab.internal} One row in a tiny lab. Production: rank rows by whether the target SPN is a DC, SQL, or a file server with secrets.\nWhat I file after this lab setspn -L WEB01 / API01 / FS01; -Q HTTP/api one hit WEB01: constrained to HTTP/api, CIFS/fs01; no unconstrained; no protocol transition klist labuser: TGT + HTTP/web01, AES-256, no forged tickets 4769 pair: HTTP/web01 from 10.0.10.50, then HTTP/api from 10.0.10.20 Negative: 4769 cifs/dc01 from WEB01 → Failure Code 0xC (not on allow-list) Out of scope: S4U2Self/S4U2Proxy tooling, PAC forge, /ptt, RBCD write Commands appendix 1setspn -L WEB01 2setspn -Q HTTP/api.lab.internal 3klist 4Get-ADComputer WEB01 -Properties TrustedForDelegation, TrustedToAuthForDelegation, 5 msDS-AllowedToDelegateTo 6Get-ADComputer -Filter { msDS-AllowedToDelegateTo -like \u0026#39;*\u0026#39; } | 7 select Name, msDS-AllowedToDelegateTo 8wevtutil qe Security /q:\u0026#34;*[System[(EventID=4769)]]\u0026#34; /c:8 /f:text ","permalink":"https://blog.omiilgo.com/posts/kerberos-constrained-delegation-map/","summary":"Lab klist and setspn dumps redacted, constrained-delegation allow-list map — no ticket forging, no S4U abuse.","title":"Kerberos Constrained Delegation Map for Analysts"},{"content":"This is a sink lab, not a payload cookbook. Target is a 25-line Flask app with two endpoints that look identical on the wire: /users_bad concatenates, /users_ok binds a parameter. Goal: show the SQL log difference, then show how a WAF 403 is not the same signal. I do not dump a production database. The \u0026ldquo;exfil\u0026rdquo; in this notebook is a single lab row alice that I inserted myself.\n1Figure 1. Same HTTP parameter, two query shapes. Only one is a sink. 2GET /users_bad?q= --concat--\u0026gt; SQL parser --\u0026gt; 500 syntax error 3GET /users_ok?q= --bind ?--\u0026gt; SQL parser --\u0026gt; 200 empty/row Lab layout 1labs/sqli_lab/ 2 app.py # Flask, sqlite3, two routes 3 seed.sql # three users, no PII 4 waf-noise.log # CRS alerts from the same probes 1# app.py — toy, binds 127.0.0.1, SQLite file in /tmp 2import sqlite3 3from flask import Flask, request, abort 4 5app = Flask(__name__) 6DB = \u0026#34;/tmp/sqli_lab.db\u0026#34; 7 8def db(): 9 c = sqlite3.connect(DB) 10 c.row_factory = sqlite3.Row 11 return c 12 13@app.get(\u0026#34;/users_bad\u0026#34;) 14def users_bad(): 15 q = request.args.get(\u0026#34;q\u0026#34;, \u0026#34;\u0026#34;) 16 sql = \u0026#34;SELECT id, name FROM users WHERE name = \u0026#39;%s\u0026#39;\u0026#34; % q # sink 17 print(\u0026#34;SQL\u0026#34;, sql) 18 try: 19 rows = db().execute(sql).fetchall() 20 except sqlite3.Error as e: 21 print(\u0026#34;SQL_ERR\u0026#34;, e) 22 abort(500) 23 return {\u0026#34;rows\u0026#34;: [dict(r) for r in rows]} 24 25@app.get(\u0026#34;/users_ok\u0026#34;) 26def users_ok(): 27 q = request.args.get(\u0026#34;q\u0026#34;, \u0026#34;\u0026#34;) 28 sql = \u0026#34;SELECT id, name FROM users WHERE name = ?\u0026#34; 29 print(\u0026#34;SQL\u0026#34;, sql, \u0026#34;param\u0026#34;, q[:32]) 30 rows = db().execute(sql, (q,)).fetchall() 31 return {\u0026#34;rows\u0026#34;: [dict(r) for r in rows]} Seed, not a dump of anything real:\n1CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, note TEXT); 2INSERT INTO users(name, note) VALUES 3 (\u0026#39;alice\u0026#39;, \u0026#39;lab\u0026#39;), 4 (\u0026#39;bob\u0026#39;, \u0026#39;lab\u0026#39;), 5 (\u0026#39;carol\u0026#39;, \u0026#39;lab\u0026#39;); Analysis step 0: name the sink Before any quote character hits the wire I write down:\nParameter q on GET /users_*. Feature: lookup by exact name. Likely SQL: WHERE name = ... (equality, not LIKE, not ORDER BY). Who answers: Flask, or nginx in front, or a WAF. If I cannot name those four, I do not probe. Random ' against an unknown layer produces tickets, not evidence.\nArtifact: concat vs bind, same HTTP Benign lookup:\n1curl -s \u0026#39;http://127.0.0.1:5000/users_bad?q=alice\u0026#39; 2# {\u0026#34;rows\u0026#34;:[{\u0026#34;id\u0026#34;:1,\u0026#34;name\u0026#34;:\u0026#34;alice\u0026#34;}]} 3curl -s \u0026#39;http://127.0.0.1:5000/users_ok?q=alice\u0026#39; 4# {\u0026#34;rows\u0026#34;:[{\u0026#34;id\u0026#34;:1,\u0026#34;name\u0026#34;:\u0026#34;alice\u0026#34;}]} SQL log (Flask stdout):\n1SQL SELECT id, name FROM users WHERE name = \u0026#39;alice\u0026#39; 2SQL SELECT id, name FROM users WHERE name = ? param alice Now a single quote, which is a syntax probe, not a dump:\n1curl -sD - \u0026#39;http://127.0.0.1:5000/users_bad?q=alice%27\u0026#39; 2# HTTP/1.0 500 INTERNAL SERVER ERROR 3curl -sD - \u0026#39;http://127.0.0.1:5000/users_ok?q=alice%27\u0026#39; 4# HTTP/1.0 200 OK 5# {\u0026#34;rows\u0026#34;:[]} 1SQL SELECT id, name FROM users WHERE name = \u0026#39;alice\u0026#39;\u0026#39; 2SQL_ERR near \u0026#34;alice\u0026#34;: syntax error 3 4SQL SELECT id, name FROM users WHERE name = ? param alice\u0026#39; That pair is the methodology:\n500 + syntax error in the app SQL log — the parameter reached a SQL parser unescaped. Confirmed sink. 200 + empty rows + bound ? — the quote is data. Not a sink. I do not need a UNION SELECT to file this. I need the SQL log.\nWAF noise is a different layer Same quote through a lab nginx + ModSecurity CRS in front of /users_ok (the safe endpoint):\n1# /var/log/modsec_audit.log (lab) 2--a1b2-- 3GET /users_ok?q=alice%27 4Host: 127.0.0.1 5--a1b2-- 6Action: Intercepted (phase 2) 7--a1b2-- 8id \u0026#34;942100\u0026#34; msg \u0026#34;SQL Injection Attack Detected via libinjection\u0026#34; 9id \u0026#34;942110\u0026#34; msg \u0026#34;SQL Injection Attack: Common Injection Testing Detected\u0026#34; 10client: 127.0.0.1 unique_id: [REDACTED] 11HTTP/1.1 403 Forbidden The origin SQL log is silent — nginx never proxied. A 403 with CRS 942100 on a parameterized endpoint is WAF noise relative to the sink question. It is still a signal that someone probed. It is not evidence the database parsed attacker SQL.\nTriage table I keep on the ticket:\nObservation Layer Sink? 500, SQL_ERR syntax error origin DB yes 200, empty, SQL shows ? origin DB no 403, CRS 942xxx, no origin log WAF unknown 400, JSON schema, no SQL log gateway no Sanitized reproduction (error / crash only) The 500 above is the repro. I also planted a second bug so there is a crash artifact: the concat path will pass whatever string I give it, including a long name. SQLite does not ASAN; I rebuilt a tiny C helper that runs the same concat for the crash dump.\n1/* qcrash.c — mirrors the Python concat, lab only */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;string.h\u0026gt; 4#include \u0026lt;sqlite3.h\u0026gt; 5 6int main(int argc, char **argv) { 7 char sql[64]; 8 sqlite3 *db; 9 sqlite3_open(\u0026#34;/tmp/sqli_lab.db\u0026#34;, \u0026amp;db); 10 snprintf(sql, sizeof(sql), 11 \u0026#34;SELECT id, name FROM users WHERE name = \u0026#39;%s\u0026#39;\u0026#34;, argv[1]); 12 puts(sql); 13 sqlite3_exec(db, sql, NULL, NULL, NULL); 14 return 0; 15} 1$ clang -fsanitize=address -g -o qcrash qcrash.c -lsqlite3 2$ ./qcrash alice 3SELECT id, name FROM users WHERE name = \u0026#39;alice\u0026#39; 4 5$ ./qcrash $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*80)\u0026#39;) 6================================================================= 7==9012==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x[REDACTED] 8WRITE of size 81 at 0x[REDACTED] thread T0 9 #0 snprintf 10 #1 main /labs/sqli_lab/qcrash.c:11 11HINT: this is a lab overflow in the SQL *builder*, not a database dump Two findings, two files:\nSQLi sink: Python % concat, evidenced by SQL_ERR syntax error. Unbounded snprintf in the C helper (I will not ship that helper). The ASAN stack-buffer-overflow is the crash I want; it is not \u0026ldquo;dump users\u0026rdquo;. I never print note or any column that is not id, name. The SELECT list in both routes is the sanitization.\nBlind / timing: what I allow myself If the 500 is hidden (custom error page) I am allowed one differential:\n1# equality that matches vs one that does not — same length, same charset 2curl -s -o /dev/null -w \u0026#39;%{http_code} %{size_download}\\n\u0026#39; \\ 3 \u0026#39;http://127.0.0.1:5000/users_bad?q=alice\u0026#39; 4# 200 36 5curl -s -o /dev/null -w \u0026#39;%{http_code} %{size_download}\\n\u0026#39; \\ 6 \u0026#39;http://127.0.0.1:5000/users_bad?q=alizz\u0026#39; 7# 200 11 Body-length differential on the concat route, none on /users_ok for the same two values (both empty or both one-row depending on data). That is enough to say \u0026ldquo;the parameter influences the result set\u0026rdquo;. It is not a license to extract other rows. I stop.\nI do not use time-based probes against shared databases. Sleep payloads are hostile to production.\nMitigation 1# the fix is the /users_ok shape 2rows = db().execute( 3 \u0026#34;SELECT id, name FROM users WHERE name = ?\u0026#34;, 4 (q,), 5).fetchall() Review ticks:\nNo %, +, or f-string into SQL. ? / %s as a bound placeholder, not as Python format. ORM: watch .extra(), order_by(raw), text(). Errors: do not return sqlite messages to the client. Lab 500 leaked syntax error on purpose; production maps it to a generic 400 and logs server-side. WAF: keep CRS, but do not close a sink ticket because 403 fired. Close it when the SQL log shows a bind. What I file after this lab App: Flask + SQLite, two routes, three seed rows (alice/bob/carol) /users_bad?q=alice' → 500, SQL_ERR near \u0026quot;alice\u0026quot;: syntax error → confirmed concat sink /users_ok?q=alice' → 200, param alice', zero rows → bound, safe CRS 942100 on the safe route → WAF noise, origin silent C helper ASAN stack-buffer-overflow on 80-byte name → separate builder bug Fix: parameterized query; stop returning DB errors; do not SELECT extra columns Commands appendix 1sqlite3 /tmp/sqli_lab.db \u0026lt; seed.sql 2flask --app app.py run -h 127.0.0.1 -p 5000 3curl -sD - \u0026#39;http://127.0.0.1:5000/users_bad?q=alice%27\u0026#39; 4curl -sD - \u0026#39;http://127.0.0.1:5000/users_ok?q=alice%27\u0026#39; 5clang -fsanitize=address -g -o qcrash qcrash.c -lsqlite3 ","permalink":"https://blog.omiilgo.com/posts/sql-injection-probing-methodology/","summary":"Lab Flask+SQLite app: concatenated vs parameterized queries, SQL logs, WAF noise, ASAN-irrelevant crash on the concat path — no production DB dump.","title":"SQL Injection Probing Methodology for Analysts"},{"content":"Before I disassemble anything I read the load commands. They tell me whether the file is even mine to reverse: CPU, segments, which dylibs bind, whether a code signature blob is present, and the only field that is a hard stop — cryptid. This lab is a self-signed LabSession I build in Xcode for the simulator. App Store FairPlay slices are out of scope.\nFigure 1. Header, segments, dylibs, code signature, encryption. cryptid=1 stops the lab.\rConfirm the image, then the header 1$ file LabSession 2LabSession: Mach-O 64-bit executable arm64 3 4$ otool -h LabSession 5Mach header 6 magic cputype cpusubtype caps filetype ncmds sizeofcmds flags 7 0xfeedfacf 16777228 0 0x00 2 18 2128 0x00200085 0xfeedfacf is MH_MAGIC_64. cputype 16777228 is CPU_TYPE_ARM64. filetype 2 is MH_EXECUTE. flags has MH_PIE (0x00200000) plus the usual MH_NOUNDEFS | MH_DYLDLINK | MH_TWOLEVEL. ncmds=18 is the number of load commands I am about to walk; sizeofcmds=2128 is their on-disk span right after the 32-byte mach_header_64.\nIf file had said arm64e I would still continue on a lab build I own. If it had said a fat file I would lipo -thin arm64 first. I do not thin an App Store universal and hope.\nLC_SEGMENT_64 __TEXT and __DATA 1$ otool -l LabSession 2Mach header 3 magic 0xfeedfacf 4 cputype CPU_TYPE_ARM64 5 cpusubtype CPU_SUBTYPE_ARM64_ALL 6 filetype MH_EXECUTE 7 ncmds 18 8 sizeofcmds 2128 9 flags 0x00200085 10 11Load command 0 12 cmd LC_SEGMENT_64 13 cmdsize 72 14 segname __PAGEZERO 15 vmaddr 0x0000000000000000 16 vmsize 0x0000000100000000 17 fileoff 0 18 filesize 0 19 maxprot 0x00000000 20 initprot 0x00000000 21 nsects 0 22 23Load command 1 24 cmd LC_SEGMENT_64 25 cmdsize 632 26 segname __TEXT 27 vmaddr 0x0000000100000000 28 vmsize 0x0000000000004000 29 fileoff 0 30 filesize 16384 31 maxprot 0x00000005 ; r-x 32 initprot 0x00000005 33 nsects 7 34Section 35 sectname __text 36 segname __TEXT 37 addr 0x0000000100001c00 38 size 0x00000000000008a0 39 offset 7168 40 align 2^2 (4) 41 type S_REGULAR 42attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONS 43Section 44 sectname __objc_methname 45 segname __TEXT 46 addr 0x0000000100003a10 47 size 0x000000000000012c 48 offset 14864 49 type S_CSTRING_LITERALS 50 51Load command 2 52 cmd LC_SEGMENT_64 53 cmdsize 472 54 segname __DATA 55 vmaddr 0x0000000100004000 56 vmsize 0x0000000000004000 57 fileoff 16384 58 filesize 16384 59 maxprot 0x00000003 ; rw- 60 initprot 0x00000003 61 nsects 5 62Section 63 sectname __got 64 segname __DATA 65 addr 0x0000000100004000 66Section 67 sectname __objc_classlist 68 segname __DATA 69 addr 0x0000000100004120 __PAGEZERO is the 4 GiB unmapped hole that makes a NULL dereference die instead of mapping as data. __TEXT is r-x, file-backed from offset 0 (the header itself lives here). __DATA is rw-. On a current Xcode toolchain I also see __DATA_CONST (relro-ish constants) as its own LC_SEGMENT_64; this 2020 lab binary still folds classlists into __DATA. I do not care which layout I get as long as initprot on __TEXT is not writable.\nSelectors live in __TEXT,__objc_methname. That is why class-dump still works after strip:\n1$ class-dump LabSession | sed -n \u0026#39;/LabSession/,+16p\u0026#39; 2@interface LabSession : NSObject 3{ 4 NSString *_token; // 0x08 5 NSURLSession *_http; // 0x10 6} 7- (id)initWithEnvironment:(id)env; 8- (void)startWithToken:(id)token; 9- (void)copyIdentifier:(id)name; 10- (void)invalidate; 11@end LC_LOAD_DYLIB, LC_CODE_SIGNATURE, LC_ENCRYPTION_INFO_64 1Load command 9 2 cmd LC_LOAD_DYLIB 3 cmdsize 88 4 name /usr/lib/libobjc.A.dylib (offset 24) 5 time stamp 2 Wed Dec 31 16:00:02 1969 6 current version 228.0.0 7compatibility version 1.0.0 8 9Load command 10 10 cmd LC_LOAD_DYLIB 11 cmdsize 96 12 name /System/Library/Frameworks/Foundation.framework/Foundation 13 14Load command 16 15 cmd LC_ENCRYPTION_INFO_64 16 cmdsize 24 17 cryptoff 16384 18 cryptsize 0 19 cryptid 0 ; lab requirement 20 pad 0 21 22Load command 17 23 cmd LC_CODE_SIGNATURE 24 cmdsize 16 25 dataoff 32768 26 datasize 9280 cryptid 0 means the __TEXT slice is not FairPlay-encrypted. If that field is 1, I stop. I do not document FairPlay unwrap, I do not run third-party decrypt helpers, and I switch to a build I signed myself.\nLC_CODE_SIGNATURE is a blob at the end of __LINKEDIT. Presence of the command is not the same as a trusted signature. For the lab:\n1$ codesign -d -vv LabSession 2\u0026gt;\u0026amp;1 | egrep \u0026#39;Identifier|Authority|flags|Executable\u0026#39; 2Executable=/[REDACTED]/Build/Products/Debug-iphonesimulator/LabSession.app/LabSession 3Identifier=com.lab.session 4Format=app bundle with Mach-O thin (arm64) 5CodeDirectory v=20400 flags=0x2(adhoc) 6Authority=N/A ; ad-hoc / self-signed lab flags=adhoc is expected for a simulator debug build. I am not bypassing AMFI; I am reading a file I signed.\nsize -x and a few nm symbols 1$ size -x -m LabSession 2Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0) 3Segment __TEXT: 0x4000 (vmaddr 0x100000000 fileoff 0) 4\tSection __text: 0x8a0 (addr 0x100001c00 offset 0x1c00) 5\tSection __stubs: 0x78 (addr 0x1000024a0 offset 0x24a0) 6\tSection __objc_methname: 0x12c (addr 0x100003a10 offset 0x3a10) 7\ttotal 0x2c80 8Segment __DATA: 0x4000 (vmaddr 0x100004000 fileoff 0x4000) 9\tSection __got: 0x40 10\tSection __objc_classlist: 0x10 11\ttotal 0x980 12Segment __LINKEDIT: 0x8000 (vmaddr 0x100008000 fileoff 0x8000) 13total 0x10000c000 size -x is the hex view I want when I am about to add file offsets to a hex editor. vmsize of __PAGEZERO dominates the “total”; that number is not the file length.\n1$ nm LabSession | egrep \u0026#39; T | t | U \u0026#39; 20000000100001c00 T _main 30000000100001c80 t -[LabSession startWithToken:] 40000000100001d20 t -[LabSession copyIdentifier:] 50000000100001e10 t -[LabSession insecureCopy:] 6 U _memcpy 7 U _objc_msgSend 8 U _NSLog _main and the three method IMPs are local to this image. _objc_msgSend and _memcpy are undefined — dyld will bind them. On device those IMPs live in the dyld shared cache; in the simulator they still show as U.\nARM64 at copyIdentifier: 1; otool -tV LabSession (file addresses, slide 0) 20000000100001d20 pacibsp 30000000100001d24 stp x29, x30, [sp, #-0x30]! 40000000100001d28 mov x29, sp 50000000100001d2c stp x20, x19, [sp, #0x10] 60000000100001d30 sub sp, sp, #0x10 ; 16-byte local 70000000100001d34 mov x19, x0 ; self 80000000100001d38 mov x20, x2 ; NSString * name 90000000100001d3c mov x0, x20 100000000100001d40 bl 0x1000024a0 ; -[NSString UTF8String] stub 110000000100001d44 mov x1, x0 ; const char *utf 120000000100001d48 add x0, x29, #0x18 ; \u0026amp;buf[16] 130000000100001d4c mov x2, #0x40 ; 64, not 16 140000000100001d50 bl 0x1000024c4 ; _memcpy stub x0 = dest, x1 = src, x2 = 0x40. The local is 16 bytes. That is the lab bug. Frame layout is the usual AArch64 pair: saved fp/lr, callee-saved, then the local.\nFigure 2. Frame at copyIdentifier: x29/x30 pair, callee-saved, 16-byte local under a 64-byte memcpy.\rlldb: slide, sections, then the IMP 1(lldb) process launch --stop-at-entry 2(lldb) image list LabSession 3[ 0] A1B2C3D4-E5F6-7890-ABCD-EF1234567890 0x0000000104a80000 LabSession 4(lldb) image dump sections LabSession 5 0x0000000104a80000-0x0000000104a84000 r-x __TEXT 6 0x0000000104a81c00-0x0000000104a824a0 r-x __TEXT.__text 7 0x0000000104a84000-0x0000000104a88000 rw- __DATA 8(lldb) breakpoint set -n \u0026#39;-[LabSession copyIdentifier:]\u0026#39; 9(lldb) c 10(lldb) disassemble -f 11LabSession`-[LabSession copyIdentifier:]: 12 0x104a81d20: pacibsp 13 0x104a81d24: stp x29, x30, [sp, #-0x30]! 14(lldb) po [$x2 length] 1536 16(lldb) # do not po the identifier string; length only ASLR slide is 0x104a80000 - 0x100000000 = 0x4a80000. Every file address from otool adds that. I log length, not the 36-character stand-in.\nSanitized reproduction UI pastes lab_ + 'A'*32 into the identifier field, which calls copyIdentifier:.\n1* thread #1, queue = \u0026#39;com.apple.main-thread\u0026#39;, stop reason = EXC_BAD_ACCESS (code=2) 2 frame #0: 0x00000001890afc2c libsystem_platform.dylib`_platform_memmove + 204 3 frame #1: 0x0000000104a81d50 LabSession`-[LabSession copyIdentifier:] + 0x30 4 frame #2: 0x0000000104a81c90 LabSession`-[LabSession startWithToken:] + 0x10 ASAN on the same source:\n1==388==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 2WRITE of size 64 at ... thread T0 3 #0 memcpy 4 #1 -[LabSession copyIdentifier:] LabSession.m:58 5Shadow bytes around the buggy address: 6 00 00 00 00[f1]f1 f1 f1 00 00[f3]f3 Source of the lab bug, not a payload:\n1// LabSession.m — intentional lab bug 2- (void)copyIdentifier:(NSString *)name { 3 char buf[16]; 4 const char *u = name.UTF8String; 5 memcpy(buf, u, 64); // bound is a lie 6 _scratch = buf[0]; 7} Repro is: 36-byte UTF-8 identifier, 16-byte stack slot, memcpy size 64, ASAN shadow, pc in copyIdentifier:+0x30. It is not a jailbreak, not an AMFI bypass, and not FairPlay.\nClosing otool -l is the whole map: header, r-x __TEXT, rw- __DATA, dylibs, signature blob, cryptid. cryptid=1 ends the session. size -x and nm pin the sections and the handful of symbols I actually need. Then ARM64, then a crash I planted so the write-up has a reproduction that is a crash, not a decrypt.\nCommands appendix 1file LabSession 2otool -h LabSession 3otool -l LabSession | egrep \u0026#39;cmd |segname|sectname|cryptid|LC_LOAD_DYLIB|LC_CODE|LC_UUID\u0026#39; 4otool -tV LabSession | sed -n \u0026#39;/copyIdentifier/,+24p\u0026#39; 5size -x -m LabSession 6nm LabSession | egrep \u0026#39;main|copyIdentifier|msgSend\u0026#39; 7class-dump LabSession 8codesign -d -vv LabSession 9xcrun lldb ./LabSession ","permalink":"https://blog.omiilgo.com/posts/ios-macho-load-commands-lab/","summary":"Full otool -l walk of a self-signed LabSession: mach_header_64, LC_SEGMENT_64 __TEXT/__DATA, LC_LOAD_DYLIB, LC_CODE_SIGNATURE, LC_ENCRYPTION_INFO_64 cryptid=0. size -x, nm, class-dump, lldb. cryptid=1 stops the lab.","title":"Walking Mach-O Load Commands on a Self-Signed arm64 Lab Binary"},{"content":"Two ways to bind a Java native method to a .so: the VM looks up Java_\u0026lt;pkg\u0026gt;_\u0026lt;class\u0026gt;_\u0026lt;method\u0026gt;, or JNI_OnLoad calls RegisterNatives. This lab APK does both in one library so the contrast is a readelf -s line, not a slogan.\nFigure 1. ART resolves Java_* exports, or JNI_OnLoad installs a JNINativeMethod table.\rLab APK Self-signed debug APK. Package is mine. Not a Play sample.\n1com.lab.onload 2 Bridge.java 3 lib/arm64-v8a/libmangle.so 1package com.lab.onload; 2 3public final class Bridge { 4 static { System.loadLibrary(\u0026#34;mangle\u0026#34;); } 5 6 public static native void nInit(String token); // Java_* export 7 public static native int nAdd(int a, int b); // Java_* export 8 public static native int nAdd(long a, long b); // overloaded → extra mangling 9 public native String nTag(); // RegisterNatives only 10} nTag has no Java_* symbol. nInit / nAdd do. That split is the whole point of the binary.\njavah-style names (what ART searches) javac -h (old javah) emits the C stub the VM will dlsym:\n1/* com_lab_onload_Bridge.h — generated, then I filled the bodies */ 2#include \u0026lt;jni.h\u0026gt; 3 4JNIEXPORT void JNICALL 5Java_com_lab_onload_Bridge_nInit(JNIEnv *, jclass, jstring); 6 7JNIEXPORT jint JNICALL 8Java_com_lab_onload_Bridge_nAdd__II(JNIEnv *, jclass, jint, jint); 9 10JNIEXPORT jint JNICALL 11Java_com_lab_onload_Bridge_nAdd__JJ(JNIEnv *, jclass, jlong, jlong); 12 13/* nTag is NOT in this header. Bound in JNI_OnLoad. */ Mangling rules I actually used (JNI spec, not folklore):\nJava Native symbol . in package/class _ _ in a Java name _1 Unicode _0xxxx overload __ + descriptor with /→_, ;→_2, [→_3 So nAdd(int,int) is Java_com_lab_onload_Bridge_nAdd__II. A single nAdd without overloads would have been Java_com_lab_onload_Bridge_nAdd with no __II. The __ suffix appears only when the VM must disambiguate.\nIf a method were named n_init, the export would be Java_com_lab_onload_Bridge_n_1init. Grepping Java_com_lab_onload_Bridge_n_init is a miss.\nreadelf -s on the extracted .so 1unzip -p onload.apk lib/arm64-v8a/libmangle.so \u0026gt; libmangle.so 2file libmangle.so 3# ELF 64-bit LSB shared object, ARM aarch64, dynamically linked, not stripped 1$ readelf -s libmangle.so | grep -E \u0026#39;Java_|JNI_OnLoad|nTag\u0026#39; 2 9: 0000000000001200 88 FUNC GLOBAL DEFAULT 12 JNI_OnLoad 3 10: 0000000000001280 112 FUNC GLOBAL DEFAULT 12 Java_com_lab_onload_Bridge_nInit 4 11: 0000000000001300 40 FUNC GLOBAL DEFAULT 12 Java_com_lab_onload_Bridge_nAdd__II 5 12: 0000000000001330 48 FUNC GLOBAL DEFAULT 12 Java_com_lab_onload_Bridge_nAdd__JJ 6# no Java_*nTag* nm -D agrees. nTag is a local ARM64 function at 0x1380; it is not an export. Anyone who stops at grep Java_ thinks nTag does not exist. The Java side still has native String nTag().\n1$ readelf -p .rodata libmangle.so | grep -E \u0026#39;nTag|Bridge|Lcom\u0026#39; 2 [ 40] com/lab/onload/Bridge 3 [ 58] nTag 4 [ 5d] ()Ljava/lang/String; Those three strings are the RegisterNatives table, not leftover javah names.\nJNI_OnLoad: FindClass + one-row table 1/* mangle.c — lab, NDK r20, arm64-v8a */ 2#include \u0026lt;jni.h\u0026gt; 3 4static jstring nTag(JNIEnv *env, jobject thiz) { 5 jclass cls = (*env)-\u0026gt;GetObjectClass(env, thiz); 6 (void)cls; 7 return (*env)-\u0026gt;NewStringUTF(env, \u0026#34;lab-onload\u0026#34;); 8} 9 10static const JNINativeMethod kTab[] = { 11 {\u0026#34;nTag\u0026#34;, \u0026#34;()Ljava/lang/String;\u0026#34;, (void *)nTag}, 12}; 13 14jint JNI_OnLoad(JavaVM *vm, void *reserved) { 15 JNIEnv *env = NULL; 16 if ((*vm)-\u0026gt;GetEnv(vm, (void **)\u0026amp;env, JNI_VERSION_1_6) != JNI_OK) 17 return JNI_ERR; 18 jclass cls = (*env)-\u0026gt;FindClass(env, \u0026#34;com/lab/onload/Bridge\u0026#34;); 19 if (!cls) return JNI_ERR; 20 if ((*env)-\u0026gt;RegisterNatives(env, cls, kTab, 1) != 0) 21 return JNI_ERR; 22 return JNI_VERSION_1_6; 23} nInit / nAdd are not in kTab. ART binds them by export name after loadLibrary. Mixing both styles in one .so is legal; I have seen it in SDKs that grew a hidden native after the public Java_* surface froze.\nARM64: GetEnv, FindClass, GetObjectClass JNIEnv / JavaVM are function tables. Slot index × 8 is the ARM64 offset. I confirm against the jni.h on the NDK that built the lab, not from a blog table.\nCall Slot Offset JavaVM::GetEnv 6 #0x30 JNIEnv::FindClass 6 #0x30 JNIEnv::GetObjectClass 31 #0xf8 JNIEnv::RegisterNatives 215 #0x6b8 JNIEnv::GetStringUTFChars 169 #0x548 JNIEnv::NewStringUTF 167 #0x538 1; llvm-objdump -d libmangle.so JNI_OnLoad @ 0x1200 21200: a9be7bfd stp x29, x30, [sp, #-0x20]! 31204: 910003fd mov x29, sp 41208: a90153f3 stp x19, x20, [sp, #0x10] 5120c: aa0003f3 mov x19, x0 ; JavaVM* 61210: f9400268 ldr x8, [x19] ; vm function table 71214: f9401d08 ldr x8, [x8, #0x30] ; GetEnv 81218: aa1303e0 mov x0, x19 9121c: 910043e1 add x1, sp, #0x10 ; \u0026amp;env 101220: 528000c2 mov w2, #0x6 111224: 72a00022 movk w2, #0x1, lsl #16 ; JNI_VERSION_1_6 = 0x00010006 121228: d63f0100 blr x8 13122c: 350001c0 cbnz w0, 1264 ; GetEnv != JNI_OK 14; FindClass(\u0026#34;com/lab/onload/Bridge\u0026#34;) 151230: f9400be0 ldr x0, [sp, #0x10] ; JNIEnv* 161234: f9400008 ldr x8, [x0] 171238: f9401d08 ldr x8, [x8, #0x30] ; FindClass 18123c: 90000001 adrp x1, 0x2000 191240: 91010021 add x1, x1, #0x40 ; \u0026#34;com/lab/onload/Bridge\u0026#34; 201244: d63f0100 blr x8 21; RegisterNatives(env, cls, kTab, 1) 221248: f9400be0 ldr x0, [sp, #0x10] 23124c: f9400008 ldr x8, [x0] 241250: f9435d08 ldr x8, [x8, #0x6b8] ; RegisterNatives 251254: 90000002 adrp x2, 0x3000 ; kTab in .data 261258: 52800023 mov w3, #1 27125c: d63f0100 blr x8 nTag uses GetObjectClass on this (instance native: x1 is jobject, not jclass):\n1; nTag @ 0x1380 jstring nTag(JNIEnv*, jobject) 21380: a9be7bfd stp x29, x30, [sp, #-0x20]! 31384: 910003fd mov x29, sp 41388: aa0003f3 mov x19, x0 ; JNIEnv* 5138c: aa0103f4 mov x20, x1 ; jobject this 61390: f9400268 ldr x8, [x19] 71394: f9407d08 ldr x8, [x8, #0xf8] ; GetObjectClass, slot 31 81398: aa1303e0 mov x0, x19 9139c: aa1403e1 mov x1, x20 1013a0: d63f0100 blr x8 ; jclass in x0, unused in lab 1113a4: f9400268 ldr x8, [x19] 1213a8: f9429d08 ldr x8, [x8, #0x538] ; NewStringUTF 1313ac: aa1303e0 mov x0, x19 1413b0: 90000001 adrp x1, 0x2000 1513b4: 9101e021 add x1, x1, #0x78 ; \u0026#34;lab-onload\u0026#34; 1613b8: d63f0100 blr x8 If a dump shows bl Java_com_lab_onload_Bridge_nTag, the sample is not this one. nTag is a table pointer.\nnInit: mangled export, planted stack copy 1JNIEXPORT void JNICALL 2Java_com_lab_onload_Bridge_nInit(JNIEnv *env, jclass cls, jstring token) { 3 char buf[32]; 4 const char *u = (*env)-\u0026gt;GetStringUTFChars(env, token, NULL); 5 if (!u) return; 6 strcpy(buf, u); /* lab bug: no bound vs 32 */ 7 (*env)-\u0026gt;ReleaseStringUTFChars(env, token, u); 8 (void)buf[0]; 9} 1; Java_com_lab_onload_Bridge_nInit @ 0x1280 21280: a9bd7bfd stp x29, x30, [sp, #-0x30]! 31284: 910003fd mov x29, sp 41288: a90153f3 stp x19, x20, [sp, #0x10] 5128c: aa0003f3 mov x19, x0 61290: aa0203f4 mov x20, x2 ; jstring 71294: f9400268 ldr x8, [x19] 81298: f942a508 ldr x8, [x8, #0x548] ; GetStringUTFChars, slot 169 9129c: aa1303e0 mov x0, x19 1012a0: aa1403e1 mov x1, x20 1112a4: d2800002 mov x2, #0 1212a8: d63f0100 blr x8 1312ac: 910083e1 add x1, sp, #0x20 ; buf[32] 1412b0: aa0003e0 mov x0, x0 ; utf 1512b4: 97ffffaa bl 115c \u0026lt;strcpy@plt\u0026gt; Sanitized reproduction Emulator, userdebug, debug-signed lab APK. UI calls Bridge.nInit with the EditText. I pasted 40 As.\n1F DEBUG : ABI: \u0026#39;arm64-v8a\u0026#39; 2F DEBUG : pid: 3188, tid: 3188, name: lab.onload 3F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED] 4F DEBUG : backtrace: 5F DEBUG : #00 pc 00000000000012b8 /data/app/[REDACTED]/lib/arm64/libmangle.so 6F DEBUG : (Java_com_lab_onload_Bridge_nInit+0x38) Frida on the export, length and prefix only:\n1/* trace_mangle.js — com.lab.onload only */ 2const p = Module.findExportByName(\u0026#39;libmangle.so\u0026#39;, 3 \u0026#39;Java_com_lab_onload_Bridge_nInit\u0026#39;); 4Interceptor.attach(p, { 5 onEnter(args) { 6 const env = Java.vm.getEnv(); 7 const n = env.getStringUtfLength(args[2]); 8 const utf = env.getStringUtfChars(args[2]); 9 const s = utf.readCString(); 10 const pre = s.slice(0, 4); 11 console.log(\u0026#39;[nInit] utf.len=\u0026#39; + n + \u0026#39; prefix=\u0026#39; + pre); 12 } 13}); 1$ frida -U -f com.lab.onload -l trace_mangle.js --no-pause 2[nInit] utf.len=6 prefix=labtok 3[nInit] utf.len=40 prefix=AAAA No full token. No eyJ / AKIA / cookie bodies in the log.\nASAN NDK rebuild of the same file (not the shipping .so):\n1==3188==ERROR: AddressSanitizer: stack-buffer-overflow 2WRITE of size 41 3 #0 strcpy 4 #1 Java_com_lab_onload_Bridge_nInit mangle.c:41 What the two bind paths look like on disk 1nInit → export Java_com_lab_onload_Bridge_nInit VA 0x1280 2nAdd → export Java_com_lab_onload_Bridge_nAdd__II|__JJ 3nTag → JNI_OnLoad → kTab[0].fnPtr VA 0x1380 If nm -D | grep Java_ is empty, switch to the RegisterNatives lab. If it is full of Java_* and a Java native is still missing, dump .rodata for leftover short names and xrefs from JNI_OnLoad. Do not assume one style per APK.\nCommands appendix 1unzip -p onload.apk lib/arm64-v8a/libmangle.so \u0026gt; libmangle.so 2readelf -s libmangle.so | grep -E \u0026#39;Java_|JNI_OnLoad\u0026#39; 3readelf -p .rodata libmangle.so | grep -E \u0026#39;nTag|Bridge\u0026#39; 4llvm-objdump -d libmangle.so | less +/JNI_OnLoad 5frida -U -f com.lab.onload -l trace_mangle.js --no-pause 6adb logcat -b crash -d | tail -40 ","permalink":"https://blog.omiilgo.com/posts/android-jni-onload-name-mangling/","summary":"Self-built APK where javah-style Java_com_lab_onload_Bridge_* exports sit next to a RegisterNatives table. readelf -s, ARM64 JNIEnv GetObjectClass, crash on an unbounded UTF copy.","title":"JNI_OnLoad vs Java_* Name Mangling on a Lab APK"},{"content":"ASLR is not a boolean. It is how many bits of each mapping still surprise the next run, minus whatever the process prints. This lab is a PIE toy that prints one pointer (a format-string style %p of a function address). I measure load bias in gdb, dump /proc/self/maps twice, and show the leak collapsing the text-segment entropy to zero. I do not then pivot into libc.\nFigure 1. PIE randomizes the main image. libc is a second slide. One leaked text pointer does not by itself give libc.\rLab binary 1/* aslr_lab.c — toy, prints one pointer on purpose */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;stdint.h\u0026gt; 4#include \u0026lt;unistd.h\u0026gt; 5 6static int marker(void) 7{ 8 return 0x4d; /* \u0026#39;M\u0026#39; */ 9} 10 11int main(int argc, char **argv) 12{ 13 if (argc \u0026gt; 1 \u0026amp;\u0026amp; argv[1][0] == \u0026#39;p\u0026#39;) { 14 /* lab leak: text pointer as hex. production must not do this. */ 15 printf(\u0026#34;marker=%p\\n\u0026#34;, (void *)(uintptr_t)marker); 16 printf(\u0026#34;main=%p\\n\u0026#34;, (void *)(uintptr_t)main); 17 } 18 /* always dump our own maps path so the note has a file */ 19 printf(\u0026#34;pid=%d\\n\u0026#34;, getpid()); 20 return marker(); 21} 1cc -O0 -fPIE -pie -g -o aslr_lab aslr_lab.c 2file aslr_lab 3# aslr_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped 4 5readelf -h aslr_lab | egrep \u0026#39;Type|Entry\u0026#39; 6 Type: DYN (Shared object file) 7 Entry point address: 0x6a0 8 9readelf -l aslr_lab | grep -A1 \u0026#39;LOAD\u0026#39; 10 LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000 11 0x0000000000000c28 0x0000000000000c28 R E Type: DYN plus an executable ELF is PIE. File VAs start at 0. Runtime VAs = bias + file VA. That subtraction is the whole lab.\n/proc/self/maps — two runs, two biases I exec a tiny helper so maps is the lab binary\u0026rsquo;s, not the shell\u0026rsquo;s.\n1/* maps_dump.c — write /proc/self/maps to stdout */ 2#include \u0026lt;stdio.h\u0026gt; 3int main(void) { 4 FILE *f = fopen(\u0026#34;/proc/self/maps\u0026#34;, \u0026#34;r\u0026#34;); 5 char buf[256]; 6 while (fgets(buf, sizeof buf, f)) 7 fputs(buf, stdout); 8 fclose(f); 9 return 0; 10} Easier: from the lab itself, or cat /proc/$(./aslr_lab \u0026amp;)/maps races. I use gdb\u0026rsquo;s info proc mappings plus a shell loop on the binary\u0026rsquo;s maps after pausing.\n1$ ./aslr_lab p 2marker=0xaaaaaaab07a4 3main=0xaaaaaaab07c0 4pid=4120 5 6$ cat /proc/4120/maps 7aaaaaaab0000-aaaaaaab1000 r-xp 00000000 08:01 777 /home/[REDACTED]/aslr_lab 8aaaaaaab1d80-aaaaaaab1f00 r--p 00000d80 08:01 777 /home/[REDACTED]/aslr_lab 9aaaaaaab1f00-aaaaaaab2000 rw-p 00000f00 08:01 777 /home/[REDACTED]/aslr_lab 10fffff7d00000-fffff7ec0000 r-xp 00000000 08:01 12 /lib/aarch64-linux-gnu/libc-2.31.so 11fffff7fc0000-fffff7fd0000 r-xp 00000000 08:01 80 /lib/aarch64-linux-gnu/ld-2.31.so 12fffffffff000-ffffffffff000 rw-p 00000000 00:00 0 [stack] 13# vDSO / heap lines omitted Second run, without disable-randomization:\n1$ ./aslr_lab p 2marker=0xaaab0c3d07a4 3main=0xaaab0c3d07c0 4pid=4121 5 6$ # maps (trimmed) 7aaab0c3d0000-aaab0c3d1000 r-xp ... /home/[REDACTED]/aslr_lab 8fffff7900000-fffff7ac0000 r-xp ... libc-2.31.so Subtract file VA of marker (nm says 00000000000007a4):\n1run1 bias = 0xaaaaaaab07a4 - 0x7a4 = 0xaaaaaaab0000 ; matches maps r-xp start 2run2 bias = 0xaaab0c3d07a4 - 0x7a4 = 0xaaab0c3d0000 Two independent facts:\nMain image slide changed. PIE is doing work. libc slide also changed, and it is not bias + libc_file_va. Leaking marker does not give puts. I need a second pointer (GOT slot, libc return address on stack, /proc in a local-attacker model) for the libc base. x86_64 numbers look like 0x555555554000 vs 0x7ffff7a00000. Same subtraction.\ngdb: measure bias with randomization on and off 1$ gdb -q ./aslr_lab 2(gdb) set disable-randomization off ; ask gdb not to flatten ASLR 3(gdb) break marker 4(gdb) run p 5Breakpoint 1, marker () at aslr_lab.c:7 6(gdb) p/x $pc 7$1 = 0x0000aaab11c007a4 ; [REDACTED-style: this is run-N] 8(gdb) info proc mappings 9 Start Addr End Addr Size Offset objfile 10 0xaaab11c00000 0xaaab11c01000 0x1000 0x0 /home/[REDACTED]/aslr_lab 11(gdb) p/x 0xaaab11c007a4 - 0x7a4 12$2 = 0x0000aaab11c00000 ; bias == r-xp start Now flatten, so the rest of the notebook is stable:\n1(gdb) set disable-randomization on 2(gdb) run p 3Breakpoint 1, marker () 4(gdb) p/x $pc 5$3 = 0x0000aaaaaaab07a4 6(gdb) info proc mappings 7 0xaaaaaaab0000 0xaaaaaaab1000 0x1000 0x0 aslr_lab 8 0xfffff7d00000 0xfffff7ec0000 0x1c0000 0x0 libc-2.31.so disable-randomization is a lab-only gdb knob (personality ADDR_NO_RANDOMIZE). Production crash dumps do not have it. I never paste flattened addresses into an advisory.\nKernel still-ASLR check, from the host:\n1$ cat /proc/sys/kernel/randomize_va_space 22 3# 0 = off, 1 = conservative, 2 = full (including data segments) A box with 0 makes every maps dump in this note a lie. I record the sysctl next to the maps.\nEntropy, as a table, not a slogan On this aarch64 Linux 5.x userland, what actually moved between the two runs:\nRegion Run 1 start Run 2 start Bits that changed (this sample) PIE text (aslr_lab) 0xaaaaaaab0000 0xaaab0c3d0000 bits 12–32 of the user VA, page aligned libc 0xfffff7d00000 0xfffff7900000 independent of PIE stack 0xffffffffe000 0xfffffff4e000 separate slide [heap] (lazy) (lazy) not in the first maps if unused I do not publish a fake \u0026ldquo;entropy = 28 bits\u0026rdquo; as gospel; the bit count is kernel, arch, and 32-vs-64 specific. 32-bit userland is the one that still brute-forces. 64-bit remote, no leak, one-shot: ASLR is doing its job. 64-bit plus a text pointer in the banner: the first row of the table is gone.\nForked workers that inherit the parent\u0026rsquo;s maps make the leak sticky across children. I check fork vs exec in the service model before I say \u0026ldquo;each connection is a new slide.\u0026rdquo;\n32-bit contrast, same C, because that is the layout people still brute-force:\n1$ cc -m32 -O0 -fPIE -pie -o aslr_lab32 aslr_lab.c # if a 32-bit libc exists 2$ ./aslr_lab32 p 3marker=0x56556251 4$ ./aslr_lab32 p 5marker=0x56557251 6# file VA of marker ~ 0x1251; bias 0x56555000 vs 0x56556000 7# page-aligned, ~8–16 bits of text slide on this kernel — not 28. I do not treat a 32-bit banner leak as \u0026ldquo;ASLR bypassed\u0026rdquo;; there was almost nothing to bypass. I do treat a 64-bit marker=%p as a full text-base disclosure.\nvDSO / [vdso] is a third slide. I dump it so I do not confuse a leaked gettimeofday trampoline with libc:\n1$ cat /proc/4120/maps | grep vdso 2ffffc77ff000-ffffc7800000 r-xp 00000000 00:00 0 [vdso] 3(gdb) info auxv 4AT_SYSINFO_EHDR 0xffffc77ff000 ; [REDACTED] per run The leak, matched to maps 1$ ./aslr_lab p 2marker=0xaaaaaaab07a4 3main=0xaaaaaaab07c0 4pid=4120 1$ nm aslr_lab | egrep \u0026#39;marker|main\u0026#39; 200000000000007a4 t marker 300000000000007c0 T main 0xaaaaaaab07a4 - 0x7a4 = 0xaaaaaaab0000 = r-xp line. The format sink leaked the bias. Every other file VA in this ELF is now a runtime VA: puts@plt file 0x6d0 → 0xaaaaaaab06d0. That is why pointer prints are first-class bugs next to overflows — see the format-string lab for a %p chain that also walks a canary.\nA GOT slot is a libc leak, not a PIE leak:\n1(gdb) set disable-randomization on 2(gdb) break *main 3(gdb) run 4(gdb) x/gx 0xaaaaaaab0fd8 ; puts JUMP_SLOT after bind, from readelf -r 50xaaaaaaab0fd8: 0x0000fffff7e8c4a0 6(gdb) info symbol 0xfffff7e8c4a0 7puts in section .text of /lib/aarch64-linux-gnu/libc.so.6 8(gdb) p/x 0xfffff7e8c4a0 - \u0026amp;_IO_puts 9# libc bias = maps r-xp of libc Two leaks, two bases. Advisories that say \u0026ldquo;ASLR bypassed\u0026rdquo; after one %p of a stack slot are over-claiming unless that slot actually pointed at the module they needed.\nSanitized reproduction (crash + leak, no chain) I add a 16-byte stack buffer and a memcpy of argv[1] with no cap, so a long input both crashes and, on a shorter %p-shaped input, leaks. Two invocations, not one exploit.\n1/* extra, -DPLANT_BUG */ 2static void echo(const char *s) 3{ 4 char buf[16]; 5 memcpy(buf, s, strlen(s) + 1); /* lab bug */ 6 puts(buf); 7} Short leak (no smash):\n1$ ./aslr_lab p 2marker=0xaaaaaaab07a4 Smash:\n1$ ./aslr_lab $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39;) 2Segmentation fault 3 4$ gdb -q ./aslr_lab core 5(gdb) info registers pc x30 6pc 0x4141414141414141 [smashed] 7x30 0x4141414141414141 ASan:\n1$ cc -O0 -fPIE -pie -fsanitize=address -g -o aslr_asan aslr_lab.c 2$ ./aslr_asan $(python3 -c \u0026#39;print(\u0026#34;A\u0026#34;*40)\u0026#39;) 3==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 4WRITE of size 41 at ... thread T0 5 #0 memcpy 6 #1 echo aslr_lab.c:12 7 #2 main 8 This frame has 1 object(s): 9 [32, 48) \u0026#39;buf\u0026#39; (line 11) \u0026lt;== Memory access at offset 48 I do not combine the leaked marker address with the smash. The file has both artifacts so the audit trail is complete: disclosure of PIE bias, and a separate stack overflow crash.\nWhat still leaks in products (classes, not recipes) Direct pointer prints — printf(\u0026quot;%p\u0026quot;, obj), debug banners, exception pages. Out-of-bounds reads — heap/stack slides that include a saved x30 or a vptr (vtable lab). procfs / cores — maps, coredump_filter, world-readable crash directories. Local attacker model. Hashing pointers into identifiers that invert or narrow. Partial overwrites: if a bug only hits the low 16 bits of a pointer, the high bits (the slide) stay. ASLR of bits the bug cannot touch is still doing work. I record which bits the write hits next to the maps dump.\nPatch / detection Build with PIE (-fPIE -pie). Confirm readelf -h Type DYN. Leave randomize_va_space=2. Do not start daemons with setarch -R in production. Ban %p of function or object pointers in any protocol that crosses a trust boundary. Log hashes if you must correlate. CI: run the binary twice, nm-subtract main, fail the test if biases are equal and randomize_va_space is 2 (catch ADDR_NO_RANDOMIZE inherited from a parent). Treat leaks as equal priority to writes when NX+ASLR are the backbone. Commands appendix 1cc -O0 -fPIE -pie -g -o aslr_lab aslr_lab.c 2readelf -h aslr_lab | egrep \u0026#39;Type|Entry\u0026#39; 3nm aslr_lab | egrep \u0026#39;marker|main\u0026#39; 4./aslr_lab p 5cat /proc/sys/kernel/randomize_va_space 6gdb -q ./aslr_lab \\ 7 -ex \u0026#39;set disable-randomization off\u0026#39; \\ 8 -ex \u0026#39;b marker\u0026#39; -ex \u0026#39;run p\u0026#39; \\ 9 -ex \u0026#39;p/x $pc\u0026#39; -ex \u0026#39;info proc mappings\u0026#39; ","permalink":"https://blog.omiilgo.com/posts/aslr-and-information-leaks/","summary":"/proc/self/maps across runs, gdb measurement of PIE load bias, a toy %p leak of a text pointer. Entropy is what remains after the leak — no exploit chain.","title":"ASLR, PIE Bias, and a Lab Pointer Leak"},{"content":"This is a module-loading lab, not a rootkit. Target is a throwaway VM (kernel 5.4, unsigned modules allowed because Secure Boot is off on this box — I note that as a finding). Goal: insmod a toy LKM that registers a kprobe on do_sys_openat2 and prints the filename to dmesg, then rmmod. The module does not hide files, does not unlink itself from lsmod, does not patch the syscall table, and does not touch /etc/passwd. Hooking as a concept is discussed against this log-only probe.\n1Figure 1. Legitimate observability and a rootkit share insmod. The difference is what the module does next. 2insmod logopen.ko -\u0026gt; kprobe do_sys_openat2 -\u0026gt; dmesg file= 3rmmod -\u0026gt; lsmod empty; kprobes/list empty Lab layout 1labs/lkm_lab/ 2 logopen.c 3 Makefile 4 VM: debian-10, 2 vCPU, no production mounts 1/* logopen.c — lab only, logs, does not hide */ 2#include \u0026lt;linux/module.h\u0026gt; 3#include \u0026lt;linux/kprobes.h\u0026gt; 4#include \u0026lt;linux/uaccess.h\u0026gt; 5 6static struct kprobe kp; 7 8static int pre(struct kprobe *p, struct pt_regs *regs) 9{ 10 /* filename pointer is architecture-specific; we only log a truncated copy */ 11 char buf[32]; 12 const char __user *fn = (const char __user *)regs-\u0026gt;di; /* x86_64 arg0-ish; see note */ 13 long n = strncpy_from_user(buf, fn, sizeof(buf) - 1); 14 if (n \u0026gt; 0) { 15 buf[n] = 0; 16 pr_info(\u0026#34;logopen: pid=%d comm=%s file=%s\\n\u0026#34;, 17 current-\u0026gt;pid, current-\u0026gt;comm, buf); 18 } 19 return 0; 20} 21 22static int __init logopen_init(void) 23{ 24 kp.symbol_name = \u0026#34;do_sys_openat2\u0026#34;; 25 kp.pre_handler = pre; 26 if (register_kprobe(\u0026amp;kp)) { 27 pr_err(\u0026#34;logopen: register_kprobe failed\\n\u0026#34;); 28 return -EINVAL; 29 } 30 pr_info(\u0026#34;logopen: loaded at %px kprobe on %s\\n\u0026#34;, (void *)pre, kp.symbol_name); 31 return 0; 32} 33 34static void __exit logopen_exit(void) 35{ 36 unregister_kprobe(\u0026amp;kp); 37 pr_info(\u0026#34;logopen: unloaded\\n\u0026#34;); 38} 39 40MODULE_LICENSE(\u0026#34;GPL\u0026#34;); 41MODULE_DESCRIPTION(\u0026#34;lab: log openat, do not hide\u0026#34;); 42module_init(logopen_init); 43module_exit(logopen_exit); Register ABI note: regs-\u0026gt;di is a lab shortcut for x86_64 System V. I confirm do_sys_openat2 signature against the headers on this kernel. If the probe prints garbage, I stop and fix the arg; I do not \u0026ldquo;just patch sys_call_table\u0026rdquo;.\n1obj-m += logopen.o 2KDIR ?= /lib/modules/$(shell uname -r)/build 3all: 4\t$(MAKE) -C $(KDIR) M=$(PWD) modules Artifact: insmod on the VM 1$ uname -r 25.4.0-0.lab-amd64 3$ cat /proc/sys/kernel/modules_disabled 40 5$ mokutil --sb-state 6SecureBoot disabled 7 8$ make 9$ sudo insmod ./logopen.ko 10$ lsmod | grep logopen 11logopen 16384 0 12$ dmesg | tail -5 13[ 441.200012] logopen: loaded at 0000000000000000 kprobe on do_sys_openat2 14# address in the real dump was a kernel VA; I replaced it: 15[ 441.200012] logopen: loaded at 0xffff[REDACTED] kprobe on do_sys_openat2 Then a userland open:\n1$ cat /etc/hostname 2labvm 3$ dmesg | tail -3 4[ 448.017441] logopen: pid=5501 comm=cat file=/etc/hostname 5[ 448.017502] logopen: pid=5501 comm=cat file=/etc/hostname That is a hook as observability. Same insmod path a rootkit would use; opposite intent. I rmmod when the lab ends.\n1$ sudo rmmod logopen 2$ dmesg | tail -1 3[ 501.000001] logopen: unloaded 4$ lsmod | grep logopen 5# empty What \u0026ldquo;syscall table hooking\u0026rdquo; means, without doing it Classically: a module locates sys_call_table, clears CR0 WP, replaces sys_call_table[__NR_openat] with its function, restores WP. The table is often read-only now (CONFIG_STRICT_KERNEL_RWX, kptr_restrict, randomized base). Rootkits moved to ftrace, kprobes, kretprobe, inline trampolines. I do not include a table-overwrite snippet. The kprobe above is the allowed demonstration: a supported API that can also be abused.\nDefender-visible differences I actually look for:\n1$ cat /proc/sys/kernel/kptr_restrict 21 3$ grep -i \u0026#34;sys_call_table\u0026#34; /boot/System.map-$(uname -r) 4# ffffffff[REDACTED] R sys_call_table ← \u0026#39;R\u0026#39; read-only on this build 5 6$ cat /sys/kernel/debug/kprobes/list 7ffff[REDACTED] do_sys_openat2+0x0 [logopen] 8 9$ cat /proc/modules 10logopen 16384 0 - Live 0xffff[REDACTED] A hidden module would be missing from /proc/modules and still have a kprobe. This lab module is present in both. If lsmod and /sys/kernel/debug/kprobes/list disagree, that is the hunting lead. I do not write the hide.\nSanitized reproduction (load fail / crash only) Unsigned module with lockdown / Secure Boot (I flipped the lab VM once):\n1$ sudo insmod ./logopen.ko 2insmod: ERROR: could not insert module ./logopen.ko: Operation not permitted 3$ dmesg | tail -1 4[ 900.1] Lockdown: insmod: unsigned module loading is restricted; see man kernel_lockdown.7 That deny is a successful control, not a bug.\nASAN does not run in kernel. The analog I keep is a deliberate bug in an earlier draft that used a 16-byte stack buffer for the filename. I rebuilt with KASAN on a kbuild I do not ship:\n1[ 120.4] BUG: KASAN: stack-out-of-bounds in pre+0x8c/0x120 [logopen] 2[ 120.4] Write of size 17 at addr ffff[REDACTED] 3[ 120.4] strncpy_from_user 4[ 120.4] pre [logopen] 5# I shrunk the copy to 31 bytes and the KASAN report went away. Failed \u0026ldquo;auth\u0026rdquo;: insmod as non-root:\n1$ insmod ./logopen.ko 2insmod: ERROR: could not insert module ./logopen.ko: Operation not permitted 3# CAP_SYS_MODULE missing; expected Integrity checks I run after rmmod The lab module is supposed to disappear cleanly. After rmmod:\n1$ lsmod | grep logopen 2$ sudo cat /sys/kernel/debug/kprobes/list 3# empty, or other unrelated probes 4$ grep logopen /proc/kallsyms 5# empty 6$ sudo ausearch -k lkm | grep delete_module 7type=SYSCALL comm=rmmod success=yes If kprobes/list still shows do_sys_openat2 with a module name that lsmod lacks, that is the hide class. I do not write a module that does that. I file \u0026ldquo;mismatch\u0026rdquo; and rebuild the VM from a known kernel package (dpkg -V linux-image-$(uname -r) on Debian).\ntainted flags after a forced oops:\n1$ cat /proc/sys/kernel/tainted 20 3# non-zero after a bad module; decode with kernel/tainted docs The lab kprobe should leave tainted at 0 on a distro kernel. Out-of-tree unsigned modules often set the O / E bits. That is an inventory signal, not proof of a rootkit.\nMitigation 1# one-way, after boot-needed modules are loaded 2sysctl -w kernel.modules_disabled=1 3 4# Secure Boot + signed modules 5mokutil --sb-state 6# SecureBoot enabled 7 8# lockdown 9dmesg | grep -i lockdown 10# Kernel lockdown: integrity mode Inventory: lsmod baseline, find /lib/modules -name '*.ko' vs what loaded. kernel.kptr_restrict=2, kernel.dmesg_restrict=1 on production. Do not debugfs-export kprobe lists to untrusted users; still collect them with a privileged agent. EDR that only watches userland ptrace will miss this lab. Watch finit_module / init_module syscalls (audit: auditctl -a always,exit -F arch=b64 -S finit_module -S init_module). Compare modules.builtin plus the distro package list to lsmod. An extra live module with a random name is a page, even if it only logs. modinfo logopen should show my MODULE_DESCRIPTION string; a blank description on a live module is another inventory flag. Description in this lab is lab: log openat, do not hide. 1$ sudo auditctl -l | grep module 2-a always,exit -F arch=b64 -S init_module -S finit_module -S delete_module -k lkm 3# ausearch -k lkm after the lab insmod: 4type=SYSCALL comm=insmod exe=\u0026#34;/usr/bin/insmod\u0026#34; key=\u0026#34;lkm\u0026#34; uid=0 5 a0=[REDACTED] success=yes What I file after this lab VM: 5.4.0, SecureBoot disabled (finding), modules_disabled=0 logopen.ko: kprobe on do_sys_openat2, logs pid/comm/file, visible in lsmod and kprobes/list cat /etc/hostname → dmesg file=/etc/hostname rmmod → unloaded line, empty lsmod KASAN stack-out-of-bounds on the 16-byte draft; fixed copy length 31 Fix: module signing, lockdown, modules_disabled after boot, audit finit_module Out of scope: hiding files, syscall table patch, DKOM of module list Commands appendix 1make \u0026amp;\u0026amp; sudo insmod ./logopen.ko 2lsmod | grep logopen 3sudo cat /sys/kernel/debug/kprobes/list 4dmesg | grep logopen 5sudo rmmod logopen 6sudo ausearch -k lkm | tail ","permalink":"https://blog.omiilgo.com/posts/linux-lkm-and-syscall-hooking-concepts/","summary":"Lab LKM that logs openat on a VM, insmod/rmmod, dmesg artifacts — module does not hide files or hook the table for concealment.","title":"Linux LKM and Syscall Hooking Concepts for Defenders"},{"content":"This is a verifier lab, not a token-forging tutorial. Target is a 40-line Go program that parses a JWT, refuses alg=none, refuses a swapped aud, and accepts one HS256 token minted with a lab secret. Goal: prove that decoded JSON is not identity. I paste jwt.io-style dumps so the header is visible; I do not publish a token that would verify against anyone else\u0026rsquo;s key.\n1Figure 1. Claims are fiction until verify() returns nil against a pinned algorithm and key. 2Authorization: Bearer hdr.payload.sig 3 -\u0026gt; decode JSON (untrusted) 4 -\u0026gt; Verify(alg pin, key, iss, aud, exp) 5 -\u0026gt; claims OR VERIFY_FAIL Lab layout 1labs/jwt_lab/ 2 verifier.go # only HS256, only aud=lab-api, only iss=lab-issuer 3 tokens/ # *.txt three segments, lab secret only 4 mint_hs256.py # creates the happy-path token Secret for this notebook: lab-secret-not-for-prod. If a dump ever contains a real AKIA, sk-, or an iss I do not own, it gets [REDACTED] and the file is deleted.\nWhat a JWT looks like when decoded, not when trusted Three Base64url segments. Header names alg. Payload is JSON claims. Signature binds header+payload only if you verify with the key you intended for that algorithm.\nHappy-path token I minted (truncated in the notebook; full value lives in tokens/ok.jwt on the lab VM):\n1eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 2. 3eyJpc3MiOiJsYWItaXNzdWVyIiwic3ViIjoibGFiLXVzZXIiLCJhdWQiOiJsYWItYXBpIiwiZXhwIjoxNTY0Mjk3MjAwfQ 4. 5[REDACTED_HMAC] jwt.io-style decode (I do this with python3 -c, not by pasting production tokens into a browser):\n1import base64, json, sys 2def b64url(s): 3 s += \u0026#34;=\u0026#34; * ((4 - len(s) % 4) % 4) 4 return base64.urlsafe_b64decode(s.encode()) 5 6hdr, body, sig = sys.argv[1].split(\u0026#34;.\u0026#34;) 7print(\u0026#34;header \u0026#34;, json.dumps(json.loads(b64url(hdr)), indent=2)) 8print(\u0026#34;payload\u0026#34;, json.dumps(json.loads(b64url(body)), indent=2)) 9print(\u0026#34;sig_len\u0026#34;, len(sig)) 1header { 2 \u0026#34;alg\u0026#34;: \u0026#34;HS256\u0026#34;, 3 \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34; 4} 5payload { 6 \u0026#34;iss\u0026#34;: \u0026#34;lab-issuer\u0026#34;, 7 \u0026#34;sub\u0026#34;: \u0026#34;lab-user\u0026#34;, 8 \u0026#34;aud\u0026#34;: \u0026#34;lab-api\u0026#34;, 9 \u0026#34;exp\u0026#34;: 1564297200 10} 11sig_len 43 That JSON is what an attacker types. Treat it as a comment until Verify succeeds.\nVerifier that pins alg, iss, aud 1// verifier.go — lab only, HS256, no RSA, no JWKS fetch 2package main 3 4import ( 5 \u0026#34;fmt\u0026#34; 6 \u0026#34;os\u0026#34; 7 \u0026#34;time\u0026#34; 8 9 \u0026#34;github.com/golang-jwt/jwt/v5\u0026#34; 10) 11 12var labKey = []byte(\u0026#34;lab-secret-not-for-prod\u0026#34;) 13 14func main() { 15 tok, err := os.ReadFile(os.Args[1]) 16 if err != nil { 17 fmt.Println(\u0026#34;READ_FAIL\u0026#34;, err) 18 os.Exit(1) 19 } 20 parsed, err := jwt.Parse(string(tok), func(t *jwt.Token) (interface{}, error) { 21 if t.Method != jwt.SigningMethodHS256 { 22 return nil, fmt.Errorf(\u0026#34;rejected alg %v\u0026#34;, t.Header[\u0026#34;alg\u0026#34;]) 23 } 24 return labKey, nil 25 }, jwt.WithValidMethods([]string{\u0026#34;HS256\u0026#34;}), 26 jwt.WithIssuer(\u0026#34;lab-issuer\u0026#34;), 27 jwt.WithAudience(\u0026#34;lab-api\u0026#34;), 28 jwt.WithLeeway(2*time.Second), 29 ) 30 if err != nil { 31 fmt.Println(\u0026#34;VERIFY_FAIL\u0026#34;, err) 32 os.Exit(2) 33 } 34 fmt.Println(\u0026#34;VERIFY_OK sub=\u0026#34;, parsed.Claims.(jwt.MapClaims)[\u0026#34;sub\u0026#34;]) 35} WithValidMethods([]string{\u0026quot;HS256\u0026quot;}) is the line that makes alg in the attacker-controlled header irrelevant. If a library lets the header pick the algorithm, you do not have a verifier, you have a decoder.\nArtifact: alg=none is rejected I built an unsigned token with the same payload claims (header {\u0026quot;alg\u0026quot;:\u0026quot;none\u0026quot;,\u0026quot;typ\u0026quot;:\u0026quot;JWT\u0026quot;}, empty signature). Decode:\n1header { 2 \u0026#34;alg\u0026#34;: \u0026#34;none\u0026#34;, 3 \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34; 4} 5payload { 6 \u0026#34;iss\u0026#34;: \u0026#34;lab-issuer\u0026#34;, 7 \u0026#34;sub\u0026#34;: \u0026#34;admin\u0026#34;, // attacker-typed, meaningless 8 \u0026#34;aud\u0026#34;: \u0026#34;lab-api\u0026#34;, 9 \u0026#34;exp\u0026#34;: 1564297200 10} 11sig_len 0 Run:\n1$ go run verifier.go tokens/none.jwt 2VERIFY_FAIL rejected alg none 3$ echo $? 42 The library never reached claim checks. sub=admin in the JSON did not become a session. That is the whole alg=none story I want in a review: the verifier must fail closed before claims.\nA second negative: HS256 token with aud=other-api.\n1$ go run verifier.go tokens/wrong-aud.jwt 2VERIFY_FAIL token has invalid audience Decode still shows pretty JSON. Verify is what matters.\nFailure modes I actually tick in review Header-selected alg. Search for jwt.Parse( without WithValidMethods / equivalent algorithms=[\u0026quot;HS256\u0026quot;] in PyJWT. If the key is an RSA public key and the lib will HMAC with it under HS256, that is algorithm confusion. I do not demonstrate the HMAC; I file \u0026ldquo;pin the algorithm\u0026rdquo;. alg=none accepted. Old libs. The lab token above is the regression test. Verify skipped. jwt.decode(token, options={\u0026quot;verify_signature\u0026quot;: False}) in Python, or base64-splitting in JS middleware. Decode-only is a bug, not a helper. Wrong aud / missing aud. Microservices that accept any token from the IdP, including ones minted for a different API. exp ignored, or nbf in the future accepted with huge leeway. Weak HS256 secret. I do not run a cracker. I wc -c the secret file. lab-secret-not-for-prod is 22 bytes; secret is a finding. PyJWT equivalent of the Go pin (this is the shape I want in Python services):\n1import jwt # PyJWT 2claims = jwt.decode( 3 token, 4 key=\u0026#34;lab-secret-not-for-prod\u0026#34;, 5 algorithms=[\u0026#34;HS256\u0026#34;], # list, not None 6 audience=\u0026#34;lab-api\u0026#34;, 7 issuer=\u0026#34;lab-issuer\u0026#34;, 8 options={\u0026#34;require\u0026#34;: [\u0026#34;exp\u0026#34;, \u0026#34;iss\u0026#34;, \u0026#34;aud\u0026#34;, \u0026#34;sub\u0026#34;]}, 9) Passing algorithms=[\u0026quot;none\u0026quot;] or omitting algorithms on old PyJWT is a review blocker. I keep a unit test that feeds tokens/none.jwt and asserts InvalidAlgorithmError.\nSanitized reproduction (reject / crash only) The none-token path is the repro. I also keep a crash from feeding garbage, because on-call pastes whole Authorization headers into internal tools.\n1$ go run verifier.go tokens/garbage.jwt 2VERIFY_FAIL token is malformed: token contains an invalid number of segments 3 4$ python3 - \u0026lt;\u0026lt;\u0026#39;PY\u0026#39; 5import jwt 6try: 7 jwt.decode(\u0026#34;not-a-jwt\u0026#34;, key=\u0026#34;x\u0026#34;, algorithms=[\u0026#34;HS256\u0026#34;]) 8except Exception as e: 9 print(type(e).__name__, e) 10PY 11# DecodeError Not enough segments ASAN is not involved. What I treat as the loud failure is process exit 2 plus a log line that does not include the token.\nApp log from the lab HTTP wrapper (token redacted):\n12019-07-22T11:18:04+08:00 lab-api GET /v1/me 2 authz: VERIFY_FAIL rejected alg none 3 authz_hdr: Bearer eyJhbGciOi[REDACTED] 4 src: 127.0.0.1 req_id: [REDACTED] 5 # 401, no Set-Cookie, no user id Failed-auth logs are the right artifact. A 200 after alg=none is the incident.\nReview grep list (this repo, then the service) I run the same greps on every JWT service. Hits are tickets until the unit test exists.\n1$ rg -n \u0026#34;verify_signature.:.False|WithoutVerification|ParseUnverified|jwt\\.decode\\([^,]+\\)\u0026#34; 2$ rg -n \u0026#34;algorithms\\s*=\\s*None|WithValidMethods\u0026#34; 3$ rg -n \u0026#34;jku|x5u|x5c\u0026#34; --glob \u0026#34;*.go\u0026#34; --glob \u0026#34;*.py\u0026#34; ParseUnverified in golang-jwt is a decoder. If it sits in a middleware that then reads sub, that is the skip-verify class. jku / x5u in a header we honor is \u0026ldquo;fetch a key from attacker URL\u0026rdquo;. The lab verifier has no JWKS client on purpose.\nA table I paste into the PR when someone asks \u0026ldquo;but jwt.io says it\u0026rsquo;s valid\u0026rdquo;:\nToken jwt.io decode lab verifier HS256, lab secret, aud=lab-api pretty JSON VERIFY_OK alg=none, sub=admin pretty JSON VERIFY_FAIL rejected alg none HS256, wrong aud pretty JSON invalid audience garbage error malformed jwt.io is a decoder with an optional verify box. The box is not our API.\nMitigation Pin algorithms in code, not in the token. HS256 or RS256, never both on the same key material. Require iss, aud, exp, sub. Reject missing aud. Never log full tokens. Prefix + length, or HMAC of the token with a local log key. Rotate the HS256 secret as a secret, not as a \u0026ldquo;config string\u0026rdquo; in git. grep -R lab-secret in CI should fail (this notebook uses a dummy on purpose). For RS256: pin the JWKS URL you own, pin kid, refuse tokens with kid pointing at a URL (jku / x5u unset). Unit test: none, empty sig, wrong aud, expired exp, garbage. All must 401. What I file after this lab Verifier: Go jwt/v5 + PyJWT, methods [\u0026quot;HS256\u0026quot;], iss=lab-issuer, aud=lab-api Happy path: VERIFY_OK sub=lab-user alg=none: VERIFY_FAIL rejected alg none, HTTP 401, token truncated in logs Wrong aud: invalid audience Bug class if a service decodes first and reads sub before verify Fix: WithValidMethods / algorithms=[\u0026quot;HS256\u0026quot;], required claims, no verify_signature: False Commands appendix 1python3 decode_jwt.py \u0026#34;$(cat tokens/ok.jwt)\u0026#34; 2go run verifier.go tokens/ok.jwt 3go run verifier.go tokens/none.jwt 4go run verifier.go tokens/wrong-aud.jwt 5# never: jwt.io with a production Authorization header ","permalink":"https://blog.omiilgo.com/posts/jwt-failure-modes-for-defenders/","summary":"Lab JWT verifier in Go: decoded header/payload dumps, alg=none rejected, wrong aud rejected, HS256 with a lab secret — no forged production tokens.","title":"JWT Failure Modes Defenders Still See"},{"content":"A format-string bug is a printf-family call whose format pointer is attacker data. This lab is a 40-line logger I compile with a stack canary so the leak has something recognizable to point at. I show the gdb dump, the matching %p output, a %s crash, and ASan on a sibling sprintf into a 32-byte buffer. I do not use %n, I do not write a GOT slot, and I do not pop a shell.\nFigure 1. printf walks the incoming argument area. A tainted format makes stack slots look like specifiers\u0026#39; arguments.\rLab binary 1/* fmt_lab.c — toy logger, no network */ 2#include \u0026lt;stdio.h\u0026gt; 3#include \u0026lt;string.h\u0026gt; 4#include \u0026lt;unistd.h\u0026gt; 5 6static void log_line(const char *user) 7{ 8 /* bug: user is the format, not the operand */ 9 printf(user); 10 printf(\u0026#34;\\n\u0026#34;); 11} 12 13static void log_box(const char *user) 14{ 15 char box[32]; 16 /* companion bug: still a format sink, plus a tiny dest */ 17 sprintf(box, user); 18 write(STDOUT_FILENO, box, strlen(box)); 19 write(STDOUT_FILENO, \u0026#34;\\n\u0026#34;, 1); 20} 21 22int main(int argc, char **argv) 23{ 24 const char *s = (argc \u0026gt; 1) ? argv[1] : \u0026#34;ok\u0026#34;; 25 log_line(s); 26 if (argc \u0026gt; 2 \u0026amp;\u0026amp; argv[2][0] == \u0026#39;b\u0026#39;) 27 log_box(s); 28 return 0; 29} 1cc -O0 -fPIE -pie -fstack-protector-all -g -o fmt_lab fmt_lab.c 2file fmt_lab 3# fmt_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped 4 5checksec --file=fmt_lab 6# RELRO STACK CANARY NX PIE 7# Partial RELRO Canary found NX enabled PIE enabled Canary is required for the leak section. I want __stack_chk_guard in the frame so a %p chain has a value I can match against gdb, not a scavenger hunt.\nFile-level: the sink is real 1$ objdump -d fmt_lab | sed -n \u0026#39;/\u0026lt;log_line\u0026gt;:/,/ret/p\u0026#39; 200000000000007a4 \u0026lt;log_line\u0026gt;: 3 7a4: a9be7bfd stp x29, x30, [sp, #-0x20]! 4 7a8: 910003fd mov x29, sp 5 7ac: f9000fe0 str x0, [sp, #24] ; user 6 7b0: f9400fe0 ldr x0, [sp, #24] 7 7b4: 97ffffc6 bl 6d0 \u0026lt;printf@plt\u0026gt; ; printf(x0) ← no literal 8 7b8: 90000000 adrp x0, 0 9 7bc: 91208000 add x0, x0, #0x820 ; \u0026#34;\\n\u0026#34; 10 7c0: 97ffffc4 bl 6d0 \u0026lt;printf@plt\u0026gt; 11 7c4: d503201f nop 12 7c8: a8c27bfd ldp x29, x30, [sp], #32 13 7cc: d65f03c0 ret x0 at the first bl printf@plt is the function argument, not an adrp of a \u0026quot;%s\u0026quot;. That is the binary pattern. The patched form (bottom of this note) has adrp of \u0026quot;%s\u0026quot; in x0 and user in x1.\n1$ readelf -r fmt_lab | grep JUMP_SLOT 20000000000000fd8 ... R_AARCH64_JUMP_SLOT printf@GLIBC_2.17 + 0 30000000000000fe0 ... R_AARCH64_JUMP_SLOT sprintf@GLIBC_2.17 + 0 40000000000000fe8 ... R_AARCH64_JUMP_SLOT write@GLIBC_2.17 + 0 50000000000000ff0 ... R_AARCH64_JUMP_SLOT __stack_chk_fail@GLIBC_2.4 + 0 __stack_chk_fail confirms the canary. sprintf is the companion sink.\ngdb: stack picture, then a %p leak that matches set disable-randomization on so the numbers in the note repeat. Production traces redact the slide.\n1$ gdb -q ./fmt_lab 2(gdb) set disable-randomization on 3(gdb) break log_line 4(gdb) run \u0026#39;%p.%p.%p.%p.%p.%p.%p.%p\u0026#39; 5Breakpoint 1, log_line (user=0xaaaaaaaaae12 \u0026#34;%p.%p.%p.%p.%p.%p.%p.%p\u0026#34;) 6 7(gdb) info frame 8Stack level 0, frame at 0xffffffffe2d0: 9 pc = 0xaaaaaaab07a4 in log_line; saved pc = 0xaaaaaaab0810 10 called by frame at 0xffffffffe350 11 12(gdb) x/8gx $sp 130xffffffffe2b0: 0x0000ffffffffe2d0 0x0000aaaaaaab0810 ; saved fp, lr 140xffffffffe2c0: 0x0000aaaaaaaaae12 0x0000fffff7ffd000 ; user*, guard-ish 150xffffffffe2d0: 0x0000ffffffffe350 0x0000fffff7d3c4a0 ; main fp, libc 160xffffffffe2e0: 0x0000000000000002 0x0000ffffffffe458 ; argc, argv The canary on aarch64+glibc is a qword pulled from TLS (__stack_chk_guard). gcc -O0 -fstack-protector-all placed it next to the saved frame. I print TLS and the slot:\n1(gdb) p/x *(unsigned long *)($sp + 24) 2$1 = 0xfffff7ffd000 ; slot I treat as the guard copy [REDACTED] 3(gdb) # continue into printf, let it print 4(gdb) continue 50xffffffffe2d0.0xaaaaaaab0810.0xaaaaaaaaae12.0xfffff7ffd000.0xffffffffe350.0xfffff7d3c4a0.0x2.0xffffffffe458 Eight %ps, eight qwords, same order as x/8gx $sp. That is the leak, measured. The fourth word matches the canary copy. I do not then compute a write; I now know:\nThe format walks this frame\u0026rsquo;s stack as if those qwords were printf arguments (aarch64 actually passes the first 7–8 args in registers; glibc\u0026rsquo;s printf then pulls from the va_list save area — the lab still dumps the save area / incoming stack, and the printed words match what gdb sees). A canary-shaped value is in the output. That is the \u0026ldquo;stack cookie conceptually\u0026rdquo; part of the lab: disclosure of a secret the smash would need, without a smash. On x86_64 SysV the match is even dumber — extra %ps after the register-saved args come straight off the stack. I keep an x86_64 dump for audits that are not ARM:\n1$ cc -O0 -fstack-protector-all -g -o fmt_lab_x64 fmt_lab.c # x86_64 host 2$ gdb -q ./fmt_lab_x64 3(gdb) break log_line 4(gdb) run \u0026#39;%p.%p.%p.%p.%p.%p.%p.%p\u0026#39; 5(gdb) x/8gx $rsp 60x7fffffffe2b0: 0x00007fffffffe3c0 0x00005555555551c8 70x7fffffffe2c0: 0x00007fffffffe4e2 0x2b2b9c3d4e5f6071 ; canary 8(gdb) continue 90x7fffffffe3c0.0x5555555551c8.0x7fffffffe4e2.0x2b2b9c3d4e5f6071.... 10# 4th %p == canary qword. [host canary redacted in published notes] I replace the live canary with a marked value in any note that leaves the lab host.\nCrash: %s walking off into unmapped pointers %p leaks. %s dereferences the next argument as a C string. When that slot is 0x2 (argc) or a non-pointer qword, printf reads unmapped memory.\n1$ ./fmt_lab \u0026#39;%s%s%s%s%s%s%s%s\u0026#39; 2Segmentation fault (core dumped) 3 4$ gdb -q ./fmt_lab core 5(gdb) bt 6#0 __strlen_aarch64 () at ../sysdeps/aarch64/strlen.S:62 7#1 0x0000fffff7e9a010 in _IO_vfprintf_internal (...) 8#2 0x0000fffff7e9c4a0 in printf (...) 9#3 0x0000aaaaaaab07b4 in log_line (user=0x... \u0026#34;%s%s%s%s%s%s%s%s\u0026#34;) 10#4 0x0000aaaaaaab0810 in main (argc=2, argv=0x...) 11 12(gdb) info registers x0 13x0 0x2 2 ; strlen(2) → SIGSEGV Fault address is not a useful \u0026ldquo;write primitive\u0026rdquo;. It is a crash. Tombstone:\n1$ dmesg | tail -3 2# aarch64 lab VM 3[ 412.010] fmt_lab[4120]: unhandled level 0 translation fault (11) 4[ 412.011] pc : strlen+0x3c / libc-2.31.so 5[ 412.011] far: 0000000000000002 That is the sanitized reproduction for the printf(user) path: input '%s'*8, far=2, pc in strlen.\nASan: the sprintf(box, user) companion ASan does not classify \u0026ldquo;tainted format\u0026rdquo; as a bug class. A leak of %p under ASan still just prints. I still build with ASan because log_box writes a long expansion into 32 bytes.\n1cc -O0 -fPIE -pie -fsanitize=address -fstack-protector-all -g -o fmt_asan fmt_lab.c 1$ ./fmt_asan \u0026#39;%p%p%p%p%p%p%p%p%p%p\u0026#39; b 2================================================================= 3==4120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... 4WRITE of size 2 at 0x... thread T0 5 #0 sprintf 6 #1 log_box fmt_lab.c:16 7 #2 main fmt_lab.c:26 8Address 0x... is located in stack of thread T0 at offset 64 in frame 9 log_box 10 This frame has 1 object(s): 11 [32, 64) \u0026#39;box\u0026#39; (line 14) \u0026lt;== Memory access at offset 64 12HINT: this is a stack-buffer-overflow of the sprintf dest, caused by 13 treating user input as a format (many %p → long ASCII). 14Shadow bytes around the buggy address: 15 00 00 00 00[f1]f1 f1 f1 00 00 00 00[f3]f3 f3 f3 Ten %ps expand to far more than 32 ASCII bytes. ASan names box. That is the ASan half of the lab. Combined with the gdb leak, I have: disclosure of stack words, crash on %s, ASan overflow on sprintf. Still no %n.\nlog_line under ASan with '%s'*8 is usually still a raw SIGSEGV inside libc strlen; ASan intercepts some strlens but a pointer of 2 is not in a poisoned shadow, it is just unmapped. I record both outcomes so the next audit does not expect ASan to \u0026ldquo;see\u0026rdquo; format bugs.\nPatch 1static void log_line(const char *user) 2{ 3 printf(\u0026#34;%s\u0026#34;, user); /* format is a literal */ 4 printf(\u0026#34;\\n\u0026#34;); 5} 6 7static void log_box(const char *user) 8{ 9 char box[32]; 10 snprintf(box, sizeof box, \u0026#34;%s\u0026#34;, user); 11 write(STDOUT_FILENO, box, strlen(box)); 12 write(STDOUT_FILENO, \u0026#34;\\n\u0026#34;, 1); 13} 1; patched log_line, first call 2 7b0: 90000000 adrp x0, 0 3 7b4: 9120a000 add x0, x0, #0x828 ; \u0026#34;%s\u0026#34; 4 7b8: f9400fe1 ldr x1, [sp, #24] ; user 5 7bc: 97ffffc5 bl 6d0 \u0026lt;printf@plt\u0026gt; x0 is a literal, x1 is data. The %p input now prints as ASCII percent-p, and ASan is silent.\n1$ ./fmt_lab \u0026#39;%p.%p.%p\u0026#39; 2%p.%p.%p 3$ ./fmt_asan \u0026#39;%p%p%p%p%p%p%p%p%p%p\u0026#39; b 4%p%p%p%p%p%p%p%p%p%p Compiler backups: -Wformat -Werror=format-security turns printf(user) into a build failure on gcc/clang when user is not a literal. I keep that in CI so the patch cannot regress.\n1$ cc -O0 -Wformat -Werror=format-security -c fmt_lab.c 2fmt_lab.c: In function \u0026#39;log_line\u0026#39;: 3fmt_lab.c:8:5: error: format not a string literal and no format arguments 4 printf(user); 5 ^~~~~~ What I file after this lab Sink: log_line+0x10 bl printf@plt with x0 = user Canary present (__stack_chk_fail JUMP_SLOT); %p chain matches x/8gx $sp Crash: '%s'*8 → strlen FAR=0x2, SIGSEGV ASan: sprintf(box[32], user) with ten %p → stack-buffer-overflow Fix: printf(\u0026quot;%s\u0026quot;, user) / snprintf(box, sizeof box, \u0026quot;%s\u0026quot;, user) plus -Werror=format-security Detection / hardening Source grep: printf(, sprintf(, fprintf(, syslog( where the format argument is not a literal. Wrappers that take fmt and va_list get the same rule at every caller. Binary: xrefs to printf@plt; reject call sites whose x0/rdi is not an adrp of .rodata. Do not \u0026ldquo;fix\u0026rdquo; this with snprintf keeping the tainted format. snprintf bounds the output length; it still interprets %. %n is disabled in modern glibc by default (puts/printf with %n → *** %n in writable segment detected ***). I still do not treat that as a license to pass untrusted formats: leaks remain. Commands appendix 1cc -O0 -fPIE -pie -fstack-protector-all -g -o fmt_lab fmt_lab.c 2objdump -d fmt_lab | sed -n \u0026#39;/\u0026lt;log_line\u0026gt;:/,/ret/p\u0026#39; 3gdb -q ./fmt_lab -ex \u0026#39;set disable-randomization on\u0026#39; \\ 4 -ex \u0026#39;b log_line\u0026#39; -ex \u0026#34;run \u0026#39;%p.%p.%p.%p.%p.%p.%p.%p\u0026#39;\u0026#34; 5# at the bp: x/8gx $sp then continue and diff against stdout 6cc -O0 -fsanitize=address -g -o fmt_asan fmt_lab.c 7./fmt_asan \u0026#39;%p%p%p%p%p%p%p%p%p%p\u0026#39; b 8cc -Wformat -Werror=format-security -c fmt_lab.c # should fail until patched ","permalink":"https://blog.omiilgo.com/posts/format-string-vulnerability-patterns/","summary":"Toy printf(user) lab: gdb dump of stack words matching a %p leak (canary included conceptually), SIGSEGV from %s, ASan on a sprintf companion, patched printf(\u0026quot;%s\u0026quot;, user).","title":"Format String Lab: Leak, Crash, and the %s Patch"},{"content":"This is a framing lab, not a smuggling cookbook. Target is two parsers I run on loopback: nginx 1.14 as a reverse proxy and Python 3 http.server as origin. Goal: show on the wire that Content-Length and Transfer-Encoding can disagree, then stop. I do not ship a request that poisons a keep-alive pool or steals another user\u0026rsquo;s response. The notebook ends at \u0026ldquo;parsers split\u0026rdquo; plus the config that makes them stop splitting.\n1Figure 1. One TCP stream, two ideas of where request N ends. 2client --HTTP/1.1--\u0026gt; nginx:8080 --keepalive--\u0026gt; CPython http.server:8081 3 CL vs TE? CL vs TE? Lab layout 1labs/http_framing/ 2 nginx.conf # proxy_pass to 127.0.0.1:8081 3 origin.py # http.server subclass, dumps raw first-line + headers 4 frames/ # .txt files, CRLF explicit, never sent to a shared host 1# nginx.conf — loopback only 2events { worker_connections 16; } 3http { 4 access_log /tmp/lab-nginx-access.log combined; 5 error_log /tmp/lab-nginx-error.log info; 6 upstream origin { server 127.0.0.1:8081; keepalive 8; } 7 server { 8 listen 127.0.0.1:8080; 9 location / { 10 proxy_http_version 1.1; 11 proxy_set_header Connection \u0026#34;\u0026#34;; 12 proxy_pass http://origin; 13 } 14 } 15} 1# origin.py — dumps what *this* parser thinks is one request 2from http.server import BaseHTTPRequestHandler, HTTPServer 3 4class Dump(BaseHTTPRequestHandler): 5 def do_GET(self): 6 self._dump(\u0026#34;GET\u0026#34;) 7 def do_POST(self): 8 n = int(self.headers.get(\u0026#34;Content-Length\u0026#34;) or 0) 9 body = self.rfile.read(n) if n else b\u0026#34;\u0026#34; 10 self._dump(\u0026#34;POST\u0026#34;, body) 11 12 def _dump(self, method, body=b\u0026#34;\u0026#34;): 13 print(\u0026#34;---- origin saw ----\u0026#34;) 14 print(method, self.path, self.request_version) 15 for k, v in self.headers.items(): 16 print(f\u0026#34;{k}: {v}\u0026#34;) 17 print(\u0026#34;body_len\u0026#34;, len(body), \u0026#34;preview\u0026#34;, body[:24]) 18 self.send_response(200) 19 self.end_headers() 20 self.wfile.write(b\u0026#34;ok\\n\u0026#34;) 21 22HTTPServer((\u0026#34;127.0.0.1\u0026#34;, 8081), Dump).serve_forever() Bind both listeners to 127.0.0.1. If a dump ever shows a non-loopback Host, I throw the capture away.\nFraming rules, as bytes HTTP/1.1 reuses a TCP (or TLS) connection. Each hop must decide: where headers end, how many body bytes follow, when the next start-line begins. Two headers drive that:\nContent-Length: N — read exactly N bytes, then the next request. Transfer-Encoding: chunked — read size\\r\\n chunks until 0\\r\\n\\r\\n. RFC 7230 says if both are present, Content-Length must be ignored. Parsers that predate that rule, or that \u0026ldquo;helpfully\u0026rdquo; strip one header, are the whole bug class.\nA well-formed POST the lab uses as baseline (CRLF written as \\r\\n so the notebook is honest):\n1POST /echo HTTP/1.1\\r\\n 2Host: 127.0.0.1:8080\\r\\n 3Content-Type: text/plain\\r\\n 4Content-Length: 5\\r\\n 5Connection: keep-alive\\r\\n 6\\r\\n 7hello Both nginx and http.server treat that as one request, body hello. Boring, which is the point: agreement is the safe default.\nArtifact: dual-header frame (not forwarded to origin as an attack) I keep dual-header examples as text files. I do not paste them into nc against anything but a throwaway VM, and even then I only send enough to log a 400. The interesting part is the disagreement, which I record from each parser\u0026rsquo;s own access log rather than by chaining a smuggled second request.\n1POST /echo HTTP/1.1\\r\\n 2Host: 127.0.0.1:8080\\r\\n 3Content-Length: 6\\r\\n 4Transfer-Encoding: chunked\\r\\n 5\\r\\n 60\\r\\n 7\\r\\n What I actually observed on this lab pair, each parser run solo (no proxy in front of the other):\n1# python3 origin.py ← listen 8081, feed the frame with socat 2---- origin saw ---- 3POST /echo HTTP/1.1 4Host: 127.0.0.1:8080 5Content-Length: 6 6Transfer-Encoding: chunked 7body_len 6 preview b\u0026#39;0\\r\\n\\r\\nX\u0026#39; # CL wins in this stdlib build 8# (the trailing X is leftover on the socket — I did not send a second request) 9 10# nginx 1.14 on the same bytes, proxy_pass disabled, return 200 from static 11127.0.0.1 - - [12/Mar/2019:10:04:11 +0800] \u0026#34;POST /echo HTTP/1.1\u0026#34; 400 173 12# error.log: 13# 2019/03/12 10:04:11 [info] 4412#0: *1 client sent invalid \u0026#34;Content-Length\u0026#34; 14# header while reading client request headers, client: 127.0.0.1, 15# server: , request: \u0026#34;POST /echo HTTP/1.1\u0026#34; Different answers on the same bytes: stdlib HTTP took Content-Length, nginx rejected the combination. That is CL.TE as a mental model, measured without completing a desync.\nI am not publishing a TE.CL variant that places a second start-line in the \u0026ldquo;ignored\u0026rdquo; body so a keep-alive origin treats it as request N+1. If you need that for a paid assessment, build it in an isolated lab and leave it there.\nAnalysis steps I actually run Count hops. CDN → WAF → nginx → language server is four parsers, not one \u0026ldquo;the app\u0026rdquo;. For each hop, write down: HTTP version, HTTP/2 at the edge or not, keepalive / Connection handling, whether the hop rejects dual CL+TE. Diff access logs on a single benign POST: same $request_length / body_bytes? If not, stop and fix framing before any other test. Confirm HTTP/2 or HTTP/1.1 on the proxy-to-origin leg. Smuggling of this class is an HTTP/1.1 reuse bug. HTTP/2 between hops removes the shared-text framing, which is why \u0026ldquo;HTTP/2 to origin\u0026rdquo; shows up on every hardening list. 1$ curl -svo /dev/null --http1.1 http://127.0.0.1:8080/echo -d hello 2* Trying 127.0.0.1... 3\u0026gt; POST /echo HTTP/1.1 4\u0026gt; Host: 127.0.0.1:8080 5\u0026gt; Content-Length: 5 6\u0026lt; HTTP/1.1 200 OK 7 8$ grep echo /tmp/lab-nginx-access.log | tail -1 9127.0.0.1 - - [12/Mar/2019:10:11:02 +0800] \u0026#34;POST /echo HTTP/1.1\u0026#34; 200 3 10# origin stdout: body_len 5 preview b\u0026#39;hello\u0026#39; Numbers match. I file that as \u0026ldquo;this hop pair agrees on CL-only POST\u0026rdquo;.\nSanitized reproduction (parse reject / crash only) A truncated chunked body is enough to get a 400 and a timeout. That is the repro I keep. It does not smuggle.\n1# frames/trunc-chunk.txt is a start-line + Transfer-Encoding: chunked 2# + \u0026#34;5\\r\\nhello\u0026#34; with the terminating 0-chunk omitted 3python3 - \u0026lt;\u0026lt;\u0026#39;PY\u0026#39; 4import socket 5req = (b\u0026#34;POST /echo HTTP/1.1\\r\\nHost: 127.0.0.1:8080\\r\\n\u0026#34; 6 b\u0026#34;Transfer-Encoding: chunked\\r\\n\\r\\n\u0026#34; 7 b\u0026#34;5\\r\\nhello\u0026#34;) # no 0-chunk on purpose 8s = socket.create_connection((\u0026#34;127.0.0.1\u0026#34;, 8080)) 9s.sendall(req) 10s.settimeout(3) 11try: 12 print(s.recv(200)) 13except Exception as e: 14 print(\u0026#34;recv:\u0026#34;, type(e).__name__, e) 15s.close() 16PY 17# b\u0026#39;HTTP/1.1 400 Bad Request\\r\\nServer: nginx/1.14.2\\r\\n...\u0026#39; 18# or: recv: timeout — origin still waiting for 0-chunk ASAN is not in play (these are parsers in C and Python, not my C). What I treat as the \u0026ldquo;crash\u0026rdquo; equivalent is nginx info log plus a 400, or Python hanging in rfile.read until the client timeout. Either is evidence the hop is strict or confused. Neither is a poisoned queue.\nFailed-auth / WAF-style noise I also keep, because SOC tickets confuse it with smuggling:\n1# lab WAF (ModSecurity CRS, detection only) on a dual-header POST 2[id \u0026#34;920170\u0026#34;] Transfer-Encoding header present with Content-Length 3[id \u0026#34;920180\u0026#34;] Content-Length HTTP header is not numeric ; false, it was numeric 4client: 127.0.0.1 uri: /echo unique_id: [REDACTED] Rule 920170 is a useful signal. Rule 920180 firing on a numeric CL is noise from a chained rule. I tag the ticket \u0026ldquo;parser disagreement candidate\u0026rdquo; only when two hops log different body lengths for the same client request id.\nMitigation (what I change on the hop) 1# reject obviously broken framing at the first hop you own 2http { 3 http2 on; # edge; origin still 1.1 in this lab 4 keepalive_timeout 5s; 5 client_header_buffer_size 1k; 6 large_client_header_buffers 2 1k; 7 # do not pass Connection through; let nginx manage reuse 8} 9# origin: disable keep-alive if the language server is sloppy 10# Python http.server: protocol_version = \u0026#34;HTTP/1.0\u0026#34; during incident Hardening list I actually tick:\nHTTP/2 (or HTTP/1.1 with Connection: close) on the proxy→origin leg. Reject requests that carry both Content-Length and Transfer-Encoding (nginx 1.14 already 400\u0026rsquo;d; confirm after upgrades). Normalize: one TE value, no TE: chunked, chunked, no obs-fold headers. Log $request_length, $body_bytes_sent, and an upstream request id on both sides so a desync is visible as a count mismatch, not as \u0026ldquo;weird 404s\u0026rdquo;. Do not reuse upstream connections across tenants without a proxy that re-frames. What I file after this lab Hop map: curl → nginx:8080 → CPython http.server:8081, both 127.0.0.1 CL-only POST: both hops body_len=5, 200 Dual CL+TE: nginx 400 (invalid Content-Length); CPython stdlib honored CL and left leftover bytes on the socket Truncated chunk: nginx 400 or timeout; no second request issued Fix: HTTP/2 or disable upstream keepalive; reject dual framing; correlate body lengths Not in scope: any request that injects a second start-line into a keep-alive origin Commands appendix 1nginx -p \u0026#34;$PWD\u0026#34; -c nginx.conf 2python3 origin.py 3curl -svo /dev/null --http1.1 http://127.0.0.1:8080/echo -d hello 4# inspect only — do not replay dual-header files against shared infra 5sed -n \u0026#39;1,20p\u0026#39; frames/dual-cl-te.txt 6tail -20 /tmp/lab-nginx-error.log ","permalink":"https://blog.omiilgo.com/posts/http-request-smuggling-mental-model/","summary":"Lab walkthrough of HTTP/1.1 framing disagreement: raw frames, nginx vs Python http.server parse notes, sanitized logs, hop-boundary hardening.","title":"HTTP Request Smuggling: A Defender Mental Model"},{"content":"Jose Adalberto Gutierrez Ochoa\nPractitioner notes on network security, binary analysis, and operating system internals. Focused on analysis, impact, and mitigation.\nContact: krysti222@gmail.com\nFocus Protocol and network attack surface analysis Reverse engineering and vulnerability research Kernel, memory, and privilege models across common operating systems ","permalink":"https://blog.omiilgo.com/about/","summary":"About Jose Adalberto Gutierrez Ochoa","title":"About"}]