algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

one_gadget — a shell in a single address

2026-01-223 min readpwn

You have an arbitrary jump — a hook to overwrite, a saved return address, a function pointer — but not enough room or leaks for a clean system("/bin/sh") call. A one-gadget is the shortcut: a single libc address that, when executed, spawns a shell. No argument setup, no chain. The catch is that each one only fires if the surrounding register/stack state already satisfies its constraints.

target./svc + libc.so.6
arch
amd64-64-little
have
one arbitrary jump (a hook / saved RIP) + a libc leak
want
a shell without building a full ROP chain

what one_gadget finds#

These addresses live inside libc's do_system/exec_comm path, where /bin/sh is already staged — but reaching execve cleanly needs the argument registers to be NULL. one_gadget enumerates them and prints the constraints that must hold at the jump:

one_gadget ./libc.so.6txt
0x4f2a5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

0x4f302 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a2fc execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

The address is the easy part — libc_base + 0x4f302. The constraint is the whole game: at the instant control reaches it, the listed register or stack slot has to already be zero.

picking the one that fits your context#

You don't get to choose your register state — the call site that hands you control does. The trick is knowing what's NULL there:

  • right after free(p) — on many glibc builds rax/some args are predictable; the [rsp+0x40] == NULL variants often hold;
  • overwriting __free_hook (pre-2.34) — free(p) calls hook(p), so rdi=p; a gadget that doesn't care about rdi and only needs a NULL stack slot is ideal;
  • a saved return address — you control the stack around rsp, so a [rsp+0x40] == NULL constraint is something you can arrange by placing a zero there.
og.pypython
1
2
3
4
5
6
7
from pwn import *
libc = ELF("./libc.so.6")
base = leak_libc()                       # one_gadget addresses are libc-relative

og = base + 0x4f302                      # the [rsp+0x40] == NULL variant
# we control the stack here, so make sure rsp+0x40 holds a 0 when we land:
overwrite_saved_rip(og, and_zero_at="rsp+0x40")
when one fires, it's a shell in one write

No args, no chain — the gadget did execve("/bin/sh", …) because the one slot it cared about was zero:

txt
segfault{one_address_one_shell}

when none of them match#

It happens — every gadget needs a slot you can't zero. Options, roughly in order:

  • Arrange the constraint. Pivot the stack so the required [rsp+X] lands on memory you control, then write NULL there. Most "impossible" one_gadgets become possible after a small pivot.
  • Pick a different jump site. The same one_gadget that fails from __free_hook may satisfy its constraint from a saved return address, where you own the stack.
  • Fall back to the long way. No matching gadget → set up rdi = "/bin/sh" and call system with a real (if longer) chain. one_gadget is a convenience, not a dependency.

The mental model: system("/bin/sh") is the reliable workhorse; a one_gadget is the same destination reached in a single write, if the calling context already did the argument setup for you. Read the constraints first — they tell you instantly whether this shortcut is open from where you're standing.