← Back to Writeups
CUSTOMMediumReversing

TwoBruter

XESXOR9/10/20264 min read
#reversing#custom#medium#key_brute_force#rc4_keystream_recovery#sprintf_nul_key_length#static_disassembly_analysis#xor_decryption

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:

SymbolDemangledAddressRole
_Z7hooyeahPhjhooyeah(unsigned char*, unsigned int)0x40074dRC4 KSA
_Z6yahooovyahoo()0x40086dRC4 PRGA (one keystream byte)
main—0x40091bdriver / brute-force loop
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Global state confirms RC4:

  • 256-byte S-box at 0x6010a0
  • index i at 0x6011a0, index j at 0x6011a4

hooyeah — RC4 Key Scheduling Algorithm

c
for (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
i = (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/null with 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 from movabs/mov constants:
    • 0x1d17778f34727ca7
    • 0xc4403788b46dfbab (the movabs of -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):
    1. malloc(0x14) output buffer.
    2. sprintf("%d", counter) into a 6-byte stack buffer at [rbp-0x80]; key length 6 is passed to hooyeah (KSA).
    3. For 19 bytes: call yahoo() for one keystream byte, XOR with the fixed ciphertext byte, store in the output buffer.
    4. NUL-terminate, fputc each 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
#!/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. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍