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.

PLT imports are the sink list
Figure 1. First pass starts at JUMP_SLOT names: gets, sprintf, printf. Then mitigations, then one sink disassembly each.

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"
CheckSignalResult on audit_lab
NX / GNU_STACKGNU_STACK RW (not RWE), NX enabledPASS
ASLR / PIEType: DYN, FLAGS_1 PIEPASS (runtime bits: see ASLR lab)
Canaryno __stack_chk_*FAIL
RELROGNU_RELRO yes, BIND_NOW noPARTIAL
CFI / PACno pacibsp in prologueabsent (Linux aarch64 gcc 9)
RPATHnonePASS
Stripped76 symbolsnot 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
SinkCall siteLength / format storyTicket
gets@pltingest+0x10 bl 6e0dest buf[16], no capP0 unbounded copy
sprintf@pltgreet+0x18dest hi[32], fmt "hi %s", src = gets outputP0 dest too small vs unbounded src
printf@pltlog_raw+0x8x0 = msg = user, not a literalP1 format sink (see format lab)
puts@pltgreet+0x24operand is hi after sprintfinformational

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