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 “dangerous API” 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.
0. Identity
1/* audit_lab.c — intentionally messy, lab only */
2#include <stdio.h>
3#include <string.h>
4#include <stdlib.h>
5#include <unistd.h>
6
7static void greet(char *who)
8{
9 char hi[32];
10 sprintf(hi, "hi %s", who); /* bounded format, unbounded dest */
11 puts(hi);
12}
13
14static void log_raw(char *msg)
15{
16 printf(msg); /* format sink */
17 printf("\n");
18}
19
20static void ingest(void)
21{
22 char buf[16];
23 gets(buf); /* unbounded */
24 greet(buf);
25 if (buf[0] == '%')
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.
1. 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 'NEEDED|BIND_NOW|FLAGS_1|RPATH|RUNPATH'
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 'GNU_STACK|GNU_RELRO'
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 "absent", not "maybe inlined"
| 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 “RELRO on.” Details in the RELRO note.
2. 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 'ingest|greet|log_raw|main'
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 "hi %s", 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 “searched, absent”:
1$ objdump -d audit_lab | grep -E 'system@plt|popen@plt|strcpy@plt|strcat@plt' || 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.
1$ objdump -d audit_lab | sed -n '/<ingest>:/,/ret/p'
20000000000000828 <ingest>:
3 828: a9be7bfd stp x29, x30, [sp, #-0x30]!
4 82c: 910003fd mov x29, sp
5 830: 910083e0 add x0, sp, #0x20 ; &buf[16] at [sp,#0x20]
6 834: 97ffffxx bl 6e0 <gets@plt> ; gets(buf)
7 838: 910083e0 add x0, sp, #0x20
8 83c: 97ffffxx bl 7a4 <greet>
9 840: 394083e0 ldrb w0, [sp, #0x20] ; buf[0]
10 844: 7100bc1f cmp w0, #0x25 ; '%'
11 848: 54000040 b.eq 850
12 84c: 14000004 b 85c
13 850: 910083e0 add x0, sp, #0x20
14 854: 97ffffxx bl 7f0 <log_raw>
15 85c: a8c37bfd ldp x29, x30, [sp], #48
16 860: d65f03c0 ret
log_raw:
100000000000007f0 <log_raw>:
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 <printf@plt> ; printf(msg) ← no adrp "%s"
7 804: 90000000 adrp x0, 0
8 808: 9120a000 add x0, x0, #0x828 ; "\n"
9 80c: 97ffffxx bl 6d0 <printf@plt>
10 810: a8c27bfd ldp x29, x30, [sp], #32
11 814: d65f03c0 ret
greet is the other P0. I dump it so the one-pager’s “sprintf dest 32, src unbounded” line has a VA.
1$ objdump -d audit_lab | sed -n '/<greet>:/,/ret/p'
200000000000007a4 <greet>:
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 ; &hi[32]
7 7b4: 90000001 adrp x1, 0
8 7b8: 91204021 add x1, x1, #0x810 ; "hi %s" ← format IS a literal
9 7bc: f9400fe2 ldr x2, [sp, #24] ; who
10 7c0: 97ffffxx bl 6f0 <sprintf@plt> ; sprintf(hi, "hi %s", who)
11 7c4: 910083e0 add x0, sp, #0x20
12 7c8: 97ffffxx bl 6c0 <puts@plt>
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: "hi " + unbounded who into 32 bytes. The first pass must not conflate the two printf-family calls.
Trust 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’s $sp math does not have to be redone.
4. Control data near the overflow
buf[16] sits in ingest’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.
GOT: Partial RELRO, printf JUMP_SLOT at file 0xfe0. First-pass note: “writable GOT present; not required for the gets crash.”
5. Sanitized reproduction
1$ python3 -c 'print("A"*40)' | ./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):
1$ python3 -c 'print("%p.%p.%p.%p")' | ./audit_lab
2hi %p.%p.%p.%p
30xffffffffe2d0.0xaaaaaaab0880.0x2.0xffffffffe458
4# buf[0]=='%' so log_raw runs. values are stack words; slide REDACTED in field notes
ASan build of the same file:
1$ cc -O0 -fPIE -pie -fsanitize=address -g -o audit_asan audit_lab.c
2$ python3 -c 'print("A"*40)' | ./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) 'buf' (line 19) <== Memory access at offset 48
11HINT: gets() is unbounded; ASan reports the dest, not a 'gadget'.
I do not then retarget x30 into libc. The first pass ends at: P0 gets → 16-byte buf → SIGSEGV / ASan, plus P1 printf(msg).
6. 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]=='%')
7imports searched, absent: system, popen, strcpy
8repro: 40 * 'A' → pc=0x4141… ; ASan stack-buffer-overflow in ingest
9repro: '%p.%p.%p.%p' → log_raw leak of stack words
10non-goals this pass: libc internals, kernel, network framing
11next: patch gets→fgets, sprintf→snprintf, printf(msg)→printf("%s",msg);
12 rebuild with -fstack-protector-strong -Wl,-z,relro,-z,now
That block is the deliverable. Everything above it is evidence.
Patch
1static void greet(char *who)
2{
3 char hi[32];
4 snprintf(hi, sizeof hi, "hi %s", who);
5 puts(hi);
6}
7
8static void log_raw(char *msg)
9{
10 printf("%s\n", 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, "\n")] = 0;
19 greet(buf);
20 if (buf[0] == '%')
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 'print("A"*40)' | ./audit_lab_fixed
2hi AAAAAAAAAAAAAAA # 15 chars + NUL, no SIGSEGV
3$ python3 -c 'print("%p.%p")' | ./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 'Full RELRO' || exit 1
3checksec --file=audit_lab_fixed | grep -q 'Canary found' || exit 1
4readelf -r audit_lab_fixed | grep -E 'gets@|system@' && 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.
Commands appendix
1file audit_lab
2checksec --file=audit_lab
3readelf -d audit_lab | egrep 'NEEDED|BIND_NOW|FLAGS_1|RPATH'
4readelf -l audit_lab | egrep 'GNU_STACK|GNU_RELRO'
5readelf -r audit_lab | grep JUMP_SLOT
6objdump -d audit_lab | sed -n '/<ingest>:/,/ret/p'
7python3 -c 'print("A"*40)' | ./audit_lab
8cc -fsanitize=address -g -o audit_asan audit_lab.c
9python3 -c 'print("A"*40)' | ./audit_asan