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.

Saved lr / return slot in the frame
Figure 1. The reuse primitive this lab talks about is the saved return slot — x30 on AArch64, [rsp] after ret on x86-64.

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:

  1. A way to influence a return edge (saved rip / x30), or another transferable control slot.
  2. Addresses of reusable code. ASLR makes those addresses a leak problem; this lab disables randomization so the objdump VAs match gdb.
  3. 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.

SiteBytes before retClassUseful as
leaf_add+3 401133lea eax,[rdi+rsi]; retpure ret / arith tailfragment: computes rdi+rsi into eax, then returns. Not a function-reuse target unless you wanted leaf_add.
bump+8 401148pop rbp; retpop-callee-saved ; retfragment: pops one qword into rbp, then transfers. Classic “stack-aligned pop” gadget class.
sink+0x1c 40116cleave; retleave ; retleave = mov rsp,rbp; pop rbp. Frame teardown. Different stack delta than a bare pop rbp; ret.
main+0x25 401195pop rbp; retpop-callee-saved ; retsame class as bump’s epilogue, different address.
puts@plt / .pltjmp QWORD PTR [rip+got]not a retPLT 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

  • 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:

MitigationHurts
NXinjected bytes as code
ASLR / PIEboth classes (need a leak)
Full RELROGOT-slot retarget (different class; see RELRO note)
Shadow stack / PAC retabforged return edges (ROP and ret2libc equally)
Coarse CFI on callsret2libc 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 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’s retbump+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 $'\tret$'
4python3 -c 'print("A"*24)' | ./ret_lab
5gdb -q ./ret_lab core -ex 'info registers rip rbp'