TwoBruter
TwoBruter
Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-24 | Status: Solved Techniques: key_brute_force, rc4_keystream_recovery, sprintf_nul_key_length, static_disassembly_analysis, xor_decryption
Summary
Task: ELF64 C binary RC4-decrypts a fixed 19-byte ciphertext 100000 times with keys "0".."99999", writing each candidate to /dev/null. Solution: reimplement RC4 (KSA+PRGA) in Python, brute-force all 5-digit keys padded with the sprintf NUL terminator to length 6, and select the only fully-printable plaintext.
TwoBruter — spbctf (forkbomb rev-kids20)
Description
This stupid app drains flag into toilet. Most of times shit, but sometimes flag. And it wastes the string byte by byte.
English summary: twobruter.elf is an ELF64 C program that repeatedly decrypts a hidden
19-byte ciphertext with many keys, dumping every candidate plaintext into /dev/null
("the toilet"). Exactly one key produces the readable flag. The goal is to recover that
flag without running the program (it discards all output).
Analysis
file reports an ELF64 LSB x86-64 executable, dynamically linked, not stripped,
built with GCC 4.8.5 (Ubuntu 14.04). The symbol table exposes two obfuscated helper
functions plus main:
| Symbol | Demangled | Address | Role |
|---|---|---|---|
_Z7hooyeahPhj | hooyeah(unsigned char*, unsigned int) | 0x40074d | RC4 KSA |
_Z6yahooov | yahoo() | 0x40086d | RC4 PRGA (one keystream byte) |
main | — | 0x40091b | driver / brute-force loop |
| |
Global state confirms RC4:
- 256-byte S-box at
0x6010a0 - index
iat0x6011a0, indexjat0x6011a4
hooyeah — RC4 Key Scheduling Algorithm
c read-only • copy allowedfor (i = 0; i < 256; i++) S[i] = i; j = 0; for (i = 0; i < 256; i++) { j = (j + S[i] + key[i % keylen]) & 0xff; swap(S[i], S[j]); }
keylen is the second argument (esi); main always passes 6.
yahoo — RC4 PRGA (one byte)
c read-only • copy allowedi = (i + 1) & 0xff; j = (j + S[i]) & 0xff; swap(S[i], S[j]); return S[(S[i] + S[j]) & 0xff];
main — the toilet loop
- Opens
/dev/nullwith mode"w+"— the "toilet" sink. (.rodata strings:w+,/dev/null, format%d.) - A fixed 19-byte ciphertext lives on the stack at
[rbp-0x70], assembled frommovabs/movconstants:0x1d17778f34727ca70xc4403788b46dfbab(themovabsof-0x3bbfc8774b920455)- dword
0x10bf4e - First 19 little-endian bytes:
a7 7c 72 34 8f 77 17 1d ab fb 6d b4 88 37 40 c4 4e bf 10 - Decimal:
[167,124,114,52,143,119,23,29,171,251,109,180,136,55,64,196,78,191,16]
- Outer loop, counter
0 .. 0x1869f(= 99999):malloc(0x14)output buffer.sprintf("%d", counter)into a 6-byte stack buffer at[rbp-0x80]; key length 6 is passed tohooyeah(KSA).- For 19 bytes: call
yahoo()for one keystream byte, XOR with the fixed ciphertext byte, store in the output buffer. - NUL-terminate,
fputceach byte to/dev/null,fflush,free.
- Loop ends,
fclose, return.
So the program RC4-decrypts the same 19-byte ciphertext 100000 times with each key
"0".."99999", throwing every result away. Most are garbage ("shit"); one is the flag.
Key-length subtlety (the real trap)
The key buffer is 6 bytes and KSA is told keylen = 6. For a 5-digit counter,
sprintf("%d", n) writes 5 digit bytes plus a NUL terminator, so the effective
6-byte RC4 key is the 5 ASCII digits followed by \x00. That trailing NUL must be
included in the key, or decryption fails. ("It wastes the string byte by byte" hints at
the per-byte keystream consumption and the fixed-width key buffer.)
Solution
Reimplement RC4 in Python, brute-force every counter 0..99999, build the 6-byte key as
str(n) padded with NULs to length 6, and keep only the plaintext that is fully printable
ASCII.
python read-only • copy allowed#!/usr/bin/env python3 ct = [167,124,114,52,143,119,23,29,171,251,109,180,136,55,64,196,78,191,16] def rc4(key): S = list(range(256)); j = 0 for i in range(256): # KSA (hooyeah) j = (j + S[i] + key[i % len(key)]) & 0xff S[i], S[j] = S[j], S[i] i = j = 0; out = [] for _ in range(len(ct)): # PRGA (yahoo), one byte per call i = (i + 1) & 0xff; j = (j + S[i]) & 0xff S[i], S[j] = S[j], S[i] out.append(S[(S[i] + S[j]) & 0xff]) return out for n in range(100000): key = (str(n).encode() + b'\x00' * 6)[:6] # 5 digits + sprintf NUL, fixed len 6 pt = bytes(c ^ k for c, k in zip(ct, rc4(key))) if all(32 <= x < 127 for x in pt): # only fully-printable candidate print(n, key, pt)
Output (the single hit across the whole keyspace):
55839 b'55839\x00' b'REDACTED'
Pitfall
A loose filter ("any byte printable OR contains {") yielded ~7000 false positives.
Requiring all 19 bytes to be printable ASCII isolated the one correct plaintext.