NX stops “run my bytes.” 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 "/bin/sh" anywhere in the payload.
I 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.
Shared premise
Both techniques need:
- A 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
objdumpVAs 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.
Lab 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.
1/* ret_lab.c — toy, gets() is the planted bug */
2#include <stdio.h>
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.
objdump: every ret in this module
1$ objdump -d ret_lab | grep -n $'\tret$'
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 '/<leaf_add>:/,/^$/p'
50000000000401130 <leaf_add>:
6 401130: lea eax,[rdi+rsi*1]
7 401133: ret
8
9$ objdump -d ret_lab --no-show-raw-insn | sed -n '/<bump>:/,/^$/p'
100000000000401140 <bump>:
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 '/<sink>:/,/^$/p'
180000000000401150 <sink>:
19 401150: push rbp
20 401151: mov rbp,rsp
21 401154: sub rsp,0x10
22 401158: lea rdi,[rbp-0x10]
23 40115c: call 401030 <gets@plt>
24 401161: lea rdi,[rbp-0x10]
25 401165: call 401040 <puts@plt>
26 40116a: nop
27 40116b: leave
28 40116c: ret
29
30$ objdump -d ret_lab --no-show-raw-insn | sed -n '/<main>:/,/^$/p'
310000000000401170 <main>:
32 401170: push rbp
33 401171: mov rbp,rsp
34 401174: mov esi,0x2
35 401179: mov edi,0x1
36 40117e: call 401130 <leaf_add>
37 401183: mov edi,eax
38 401185: call 401140 <bump>
39 40118a: call 401150 <sink>
40 40118f: mov eax,0x3
41 401194: pop rbp
42 401195: ret
I also dump libc’s ret density without turning it into a catalog I would paste into a chain. Count only:
1$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep -c $'\tret$'
2# 18421 ; number moves by libc version. Order of magnitude: tens of thousands.
3$ objdump -d ret_lab | grep -c $'\tret$'
44
Four in the toy, tens of thousands in libc. That is why “ROP on the main binary” and “ROP on libc” are different hunts. I still classify kinds, not addresses for a payload.
Classification (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.
| Site | 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 “stack-aligned pop” 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’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):
1# what a "pop rdi; ret" *would* look like if gcc emitted one in this toy:
2# 5f c3 pop rdi / ret
3$ objdump -d ret_lab | grep -U $'\tpop *rdi'
4# (no hits in this binary)
5$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep -c $'\tpop *%rdi$'
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.
ROP-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 “return.” I am describing the shape so a crash dump that lands on bump+8 is not filed as “it jumped to bump().”
ARM64 equivalent classes, for the other notebook:
1; 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.
gdb: smash, crash, classify the landing pad
24 bytes of A fill buf[16] + saved rbp + saved rip.
1$ python3 -c 'print("A"*24)' | ./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.
ASan on the same gets:
1$ cc -O1 -fno-stack-protector -fsanitize=address -no-pie -g -o ret_asan ret_lab.c
2$ python3 -c 'print("A"*24)' | ./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) 'buf' (line 15) <== 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.
If I do stop at sink’s ret with a controlled slot and single-step, I can show what “landing on an epilogue” looks like without a chain. I set RIP to bump+7 (pop rbp; ret) under gdb, once, as a classification drill:
1(gdb) set disable-randomization on
2(gdb) break *0x40116c ; sink's ret
3(gdb) run <<< $(python3 -c 'print("A"*16 + "BBBBBBBB" + "\x48\x11\x40\x00\x00\x00\x00\x00")')
4# 0x401148 = bump's pop rbp; ret. I am inside gdb on my toy.
5Breakpoint 1, 0x000000000040116c in sink ()
6(gdb) x/i $rip
7=> 0x40116c <sink+28>: ret
8(gdb) stepi
90x0000000000401148 in bump ()
10(gdb) x/2i $rip
11=> 0x401148 <bump+8>: 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 “the program called bump().”
How this shows up in crash dumps
pcatputs/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.pcatfunction+NwhereNis 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:
| Mitigation | 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, "\n")] = 0;
7 puts(buf);
8}
1$ python3 -c 'print("A"*24)' | ./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.
What I file after this lab
- Four
retsites inret_lab, classified: 1 arith-tail, 2pop rbp; ret, 1leave; ret - No
pop rdi; retin the toy; libc has many — counted, not listed - Repro: 24
As,RIP=0x4141414141414141, ASan stack-buffer-overflow onbuf[16] - Drill (gdb only):
sink’sret→bump+8epilogue, 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 $'\tret$'
4python3 -c 'print("A"*24)' | ./ret_lab
5gdb -q ./ret_lab core -ex 'info registers rip rbp'