Skip to main content
A stack buffer overflow is the oldest trick in binary exploitation, and it remains one of the most common vulnerability classes in beginner-to-intermediate CTF pwn challenges. The idea is simple: a fixed-size buffer lives on the stack, and the program writes more data into it than it can hold. The excess bytes spill into adjacent stack memory — overwriting the saved frame pointer, then the saved return address — and when the function returns, the CPU jumps to wherever you told it to go. Your job is to control that destination with surgical precision.

Challenge Setup

This writeup follows a 32-bit Linux ELF binary compiled with a vulnerable gets() call. The same concepts apply to 64-bit binaries and strcpy(); differences are called out explicitly where they matter. Start with your recon commands:
Sample output for a deliberately vulnerable challenge binary:
No canary, no PIE, NX disabled. This is a classic beginner stack smash — you can inject shellcode or jump directly to a win() function that the binary already contains.
NX disabled means the stack is executable, so shellcode injection is possible. No canary found means you can overwrite the return address without needing to leak anything first. No PIE means every address in the binary is fixed and predictable across runs.

Finding the Vulnerability

Open the binary in GDB or a decompiler. The vulnerable code typically looks like one of these patterns:
In GDB, disassemble the vulnerable function to confirm the buffer size and find the exact layout:
Look for the sub esp, 0xNN instruction at the function prologue — that tells you how much stack space is reserved for locals. The buffer starts somewhere in that space, and the saved return address sits just above it (at a higher address on x86, meaning it’s reached by writing past the end of the buffer).

Determining the Offset

You need to know exactly how many bytes of padding to send before your controlled return address begins. The fastest way is a cyclic pattern — a non-repeating sequence where every 4-byte (or 8-byte) chunk is unique, so you can look up the crash address and immediately know the offset.
1

Generate and send the pattern

In pwntools, cyclic(200) generates 200 bytes of De Bruijn sequence. Send it as input and let the program crash.
Or do it interactively in GDB:
2

Calculate the offset from the crash address

Feed the crashed EIP value back into cyclic_find():
For 64-bit binaries, use cyclic_find(crashed_rip, n=8). If GDB shows only a partial overwrite (e.g., 0x00007f6161616166), strip the upper bytes first.
3

Verify the offset

Confirm by sending exactly offset A-bytes followed by four B-bytes and checking that EIP lands on 0x42424242:

Technique 1 — Ret2Win (Jump to a Hidden Function)

Many CTF challenges include a win() function — never called by normal program flow — that either prints the flag directly or spawns a shell. All you need to do is overwrite the return address with win’s address.
1

Find the win function address

Or in GDB:
2

Build and send the payload

p32() packs the address as a 4-byte little-endian integer, matching x86 memory layout. Use p64() for 64-bit binaries.
3

Run against remote

Swap process('./challenge') for remote('challenge.ctf.site', 1337) and send the same payload. Remote offsets and addresses are identical as long as the binary matches.

Technique 2 — Ret2Libc (64-bit, NX Enabled)

When there’s no win() function and NX is enabled (you can’t run shellcode), you pivot to ret2libc: call system("/bin/sh") using the libc that the binary already loads. On 64-bit, this requires a two-stage exploit — first leak a libc address to defeat ASLR, then make the actual system() call.
On 64-bit x86-64, function arguments are passed in registers (rdi, rsi, rdx, …), not on the stack. To call system("/bin/sh"), you must control rdi. You do this with a pop rdi; ret gadget — see the ROP Chains writeup for full detail. The pwntools ROP class handles this automatically.
1

Stage 1 — Leak a libc address via puts

Call puts(puts@got) to print the runtime address of puts in libc, then return to main so you get a second input opportunity.
After sending, read the leaked address:
2

Stage 2 — Call system('/bin/sh')

With libc_base known, compute the runtime addresses of system and the /bin/sh string. Because system is a raw integer address (not a named symbol in the binary), build the payload manually with flat() so that the pop rdi gadget is inserted explicitly — this guarantees rdi is set to the /bin/sh pointer before system is called:

GDB Workflow Reference

These are the GDB commands you reach for most often during a buffer overflow investigation:
Always test your exploit locally against a copy of the binary before pointing it at the remote server. Offsets computed from a different binary version, a different libc, or a different compiler will be wrong. If the challenge provides a Dockerfile or libc.so.6, use those exact files.

Complete Exploit Reference

Last modified on July 23, 2026