Description
The developers learned their lesson from unsafe input functions and tried to secure things using fgets(). Unfortunately, they didn't use it correctly. Can you still find a way to read the flag? Download the program file and source code.
Setup
Download vuln and its source code.
Read the source code to understand how input is handled.
cat vuln.cchmod +x vulnSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Find the fgets() bugObservationI noticed the challenge description said fgets() was used but 'not correctly', which suggested inspecting the size argument against the actual buffer declaration to find a mismatch that still allows a stack overflow.Read vuln.c - the developer switched from gets() to fgets() but passed the wrong size argument (128 instead of 32). The result is that fgets still reads far more bytes than the buffer can hold, creating a stack overflow. This is a 32-bit binary.bashcat vuln.cbashchecksec --file=./vulnbashfile vulnExpected output
08049276 <win>: vuln: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, not stripped
What didn't work first
Tried: Assume the binary is safe because it uses fgets() instead of gets()
fgets() is only safe when its size argument matches the actual buffer allocation. Here the developer passed 128 while the buffer is 32 bytes, so fgets still reads 96 bytes past the end of the buffer. The switch to fgets fixes nothing when the size literal is wrong.
Tried: Skip checksec and assume no canary is present based on the source code alone
checksec reads the actual binary ELF headers for SSP, NX, PIE, and RELRO flags - the source code does not tell you the compiler flags used. If a canary were present, a naive overflow would crash with a stack smashing error rather than redirecting execution, and you would need a leak step first.
Learn more
fgets(buf, size, stdin)is the safe replacement forgets()precisely because it accepts a size limit. But the safety guarantee only holds when the size argument accurately reflects the buffer's actual allocation. A common developer mistake is passing a size that is larger than the buffer - for example, writingfgets(buf, 256, stdin)whenbufwas declared aschar buf[64]. The result is thatfgetshappily reads up to 256 bytes into a 64-byte region, overflowing 192 bytes past the end.This is a false-fix pattern: the developer knows
gets()is dangerous and switches tofgets(), but copies the wrong size constant. It appears in real codebases regularly - particularly when refactoring legacy code where the buffer size and the read size were originally the same constant name but later diverged. Static analysis tools like-D_FORTIFY_SOURCE=2and AddressSanitizer catch this at runtime;clang-tidycan catch it statically.checksecshows which mitigations the binary was compiled with: stack canaries (SSP), non-executable stack (NX), ASLR/PIE, and RELRO. If the stack canary is absent, a straightforward overflow of the return address succeeds. If a canary is present, you need to first leak its value (via an information leak) before overwriting past it.Step 2
Find the offset to the return addressObservationI noticed the binary is 32-bit (confirmed by checksec and file), which meant I needed to find the exact number of padding bytes by reading the disassembly for the buffer's offset from EBP and adding 4 bytes for the saved EBP to reach the return address.In Ghidra, look at the vuln function. The fgets call reads into a buffer that is hex 28 (40 decimal) bytes below EBP. The old EBP is 4 bytes. So the total padding before the return address is 40 + 4 = 44 bytes.bashobjdump -d vuln | grep -A 30 '<vuln>'bash# In Ghidra: look at the sub esp instruction to find buffer offset from EBPbash# Buffer is at EBP - 0x28 (40 bytes), old EBP is 4 bytes => 44 bytes to return addressWhat didn't work first
Tried: Use a cyclic pattern and look at $rip after a crash to find the offset
This is a 32-bit binary, so the register to inspect after a crash is $eip (not $rip). Using cyclic_find() on the $rip value from a 64-bit GDB session will return a wrong offset or no match at all because GDB reports a zero-extended 64-bit register. Run the binary under a 32-bit-aware GDB or read the disassembly directly: the buffer is at EBP - 0x28, giving 44 bytes of padding.
Tried: Guess the offset is 32 because the source code declares char buf[32]
The buffer size alone does not equal the offset to the return address. The stack frame also contains the saved EBP (4 bytes on a 32-bit binary) between the end of the buffer and the return address. Using 32 bytes of padding overwrites only the end of the buffer and leaves the saved EBP and return address untouched, so the program returns normally without hijacking control flow.
Learn more
A cyclic (De Bruijn) pattern is a sequence of bytes where every subsequence of length n appears exactly once. When this pattern overwrites the saved return address and the program crashes, the value in the instruction pointer register (
$ripon x86-64,$eipon x86-32) is a unique 4- or 8-byte substring of the pattern.cyclic_find()searches the pattern for that substring and returns its offset from the start, giving you the exact number of padding bytes needed.A core dump is produced when the process crashes with an unhandled signal (SIGSEGV). Pwntools'
Corefileclass parses the core and exposes register state, memory maps, and stack contents. Alternatively, GDB'spattern create/pattern offset(from the PEDA or GEF extension) achieves the same result interactively. Runningulimit -c unlimitedbefore the program ensures core dumps are written.This binary is 32-bit, so the saved return address is 4 bytes wide and there is no 16-byte alignment requirement. The stack layout from the top of the frame is: local variables, then the saved base pointer (
EBP, 4 bytes), then the return address (4 bytes). The disassembly shows the buffer atEBP - 0x28(40 bytes below EBP), so padding is40 + 4 = 44bytes before the return address. The cyclic approach confirms this empirically without needing to read the assembly manually.Step 3
Redirect execution to win()ObservationI noticed objdump showed a non-stripped binary with a win() function at a fixed address, and checksec confirmed no PIE and no stack canary, which meant a straightforward ret2win using 44 bytes of padding followed by p32(win_addr) would redirect execution.Find win's address (around 0x8049276 in this binary), pad 44 bytes, then append the 4-byte little-endian address. In 32-bit there is no SSE alignment issue, so no ret gadget is needed.bashobjdump -d vuln | grep '<win>'pythonpython3 << 'EOF' from pwn import * e = ELF("./vuln") p = remote("<HOST>", <PORT_FROM_INSTANCE>) win_addr = e.sym["win"] # should be around 0x8049276 payload = b"A" * 44 payload += p32(win_addr) # 32-bit binary uses p32, not p64 p.sendlineafter(b"key", payload) print(p.recvall(timeout=3)) EOFWhat didn't work first
Tried: Use p64(win_addr) instead of p32(win_addr) when crafting the payload
p64() packs the address as 8 bytes, but this is a 32-bit binary where the saved return address slot is only 4 bytes wide. Sending 8 bytes causes the extra 4 bytes to overwrite the next word on the stack, so execution jumps to garbage rather than win(). Always match the pack function to the binary architecture: p32 for 32-bit, p64 for 64-bit.
Tried: Add a ret gadget before win_addr to fix stack alignment, as is required on 64-bit binaries
The 16-byte SSE alignment requirement enforced by 64-bit System V ABI does not apply to 32-bit binaries. Inserting a ret gadget between the padding and win_addr shifts the return address by 4 bytes, so the function receives a corrupted frame and likely segfaults or misreads its local variables. On 32-bit x86 the payload is simply 44 bytes of padding followed immediately by p32(win_addr).
Learn more
A classic ret2win exploit overwrites the saved return address with the address of a "win" function that already exists in the binary - here
win()at around0x8049276. The payload is simply padding bytes (to fill the buffer and the saved EBP up to the return address) followed by the 4-byte little-endian address of the target function. When the vulnerable function executes itsretinstruction, it pops the overwritten address off the stack and jumps there.p32(addr)in pwntools packs a 32-bit integer into 4 little-endian bytes, which is what this binary expects (the 64-bit equivalent isp64(addr), 8 bytes). Getting the endianness right is critical - submitting the address in big-endian order will jump to garbage memory and crash instead.This technique is the simplest form of control-flow hijacking. More advanced variants include ret2libc (jumping to
system()/bin/shinstead of a win function), ret2plt (calling a PLT stub to invoke library functions), and ROP (Return-Oriented Programming) which chains small code snippets ("gadgets") together to execute arbitrary logic even when no win function exists. This challenge is a clean introduction to the core concept before those complications arise.See Buffer Overflow Binary Exploitation and Pwntools for CTF for the broader workflow.
Interactive tools
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
Flag
Reveal flag
picoCTF{3ch0_3sc4p3_2_...}
The developer switched to fgets() but passed 128 instead of 32 - the buffer still overflows. Buffer is 40 bytes below EBP in a 32-bit binary, so 44 bytes of padding then p32(win address) redirects execution.
Key takeaway
How to prevent this
How to prevent this
fgets() is safe only when the size matches the actual buffer. A wrong literal here is exactly as bad as gets().
- Use
sizeof(buf), never a hardcoded number.fgets(buf, sizeof(buf), stdin)is correct under any future buffer-size change;fgets(buf, 256, stdin)rots when someone shrinksbuf. - Build with
-fstack-protector-strong. The canary catches the overflow at function epilogue regardless of which API got the size wrong. - Add
-D_FORTIFY_SOURCE=3on glibc. It replacesfgetswith a fortified version that knows the actual__bos(buffer-object-size) and aborts on out-of-bounds writes when the destination size is known at compile time.