Category: Reverse Engineering
Flag: texsaw{VVhYd_U_M4k3_mE_s0_4n6ry}
Challenge Description
Hopefully this doesn’t frustrate you too much…
Analysis
ragebait is a stripped 64-bit ELF for x86-64, so the interesting parts were going to be in the control flow rather than in friendly symbols. The first trap was that the binary happily printed several completely plausible texsaw{...} strings, which made it look solved long before it actually was. Sampling the dispatcher dynamically showed exactly how much bait was sitting in the binary.
from pathlib import Path
import struct, random, string, subprocess
data = Path('ragebait').read_bytes()
table_off = 0x4e080
size = 1009
addrs = [struct.unpack_from('<Q', data, table_off + i * 8)[0] for i in range(size)]
chars = (string.ascii_letters + string.digits + '_{}!@#$%^&*()-=+[];:,.<>?/').encode()
def fnv(s):
h = 0x811c9dc5
for b in s:
h = ((h ^ b) * 0x1000193) & 0xffffffff
return h
found = {}
while len(found) < len(set(addrs)):
s = bytes(random.choice(chars) for _ in range(9))
idx = fnv(s) % size
if idx not in found:
found[idx] = s
interesting = []
flags = {}
for idx, s in found.items():
arg = (s + b'A' * (32 - len(s))).decode('latin1')
try:
cp = subprocess.run(['./ragebait', arg], capture_output=True, timeout=0.35, text=True)
out = (cp.stdout + cp.stderr).strip()
except subprocess.TimeoutExpired:
out = '<timeout>'
if 'texsaw{' in out:
interesting.append((idx, addrs[idx], out))
flags[out] = flags.get(out, 0) + 1
print('unique texsaw outputs:', len(flags))
for k, v in sorted(flags.items(), key=lambda kv: (kv[0], kv[1])):
print(v, k)python scan_dispatcher.pyunique texsaw outputs: 3
28 [SUCCESS] Flag: texsaw{fake_flag_do_not_submit}
39 [SUCCESS] Flag: texsaw{maybe_the_real_fake_flag_was_the_friends_we_made}
52 [SUCCESS] Flag: texsaw{n0t_th3_fl4g_lol}That is where the challenge earns its name.
The fake outputs made it clear that any path based only on seeing a green success message was untrustworthy, so the next step was to look for a function that constrained the full input instead of just reaching a flashy print. Decompiling the hidden validator at 0x0042e7ec showed a wrapper that eventually jumps into the real checker.
r2 -A -q -c 'pdg @ 0x0042e7ec' ./ragebait 2>/dev/nullvoid fcn.0042e7ec(int64_t arg1)
{
...
for (i = 0; i < 0x1f; i++) {
acStack_38[i] = encoded[i] + (encoded[i] & 0x32) * -2 + '2';
}
...
exit(1);
}The useful part was nearby. Decompiling the success wrapper at 0x0042fec7 exposed the real check: every byte of the input is folded into one of four accumulators based on i & 3, and each accumulator is updated with state = state * 131 + c. That matters because it turns the verification into four independent base-131 numbers.
r2 -A -q -c 'af @ 0x0042fec7; pdg @ 0x0042fec7' ./ragebait 2>/dev/nullvoid fcn.0042fec7(void)
{
...
uVar1 = strlen(input);
while (i < uVar1) {
c = input[i];
lane = state[i & 3];
state[i & 3] = c + lane * 131;
i++;
}
if (state[0] == 0x112996d9ae479fd &&
state[1] == 0xefb70b2a601818 &&
state[2] == 0x11c799cc5063ac2 &&
state[3] == 0x1100d35eadc1177) {
printf("\x1b[0;32m[SUCCESS] Flag: %s\x1b[0m\n", input);
return;
}
exit(1);
}To confirm this was the right branch, I located the dispatcher entry that reaches the validator. The result showed that index 692 was the one pointing at 0x42e7ec, which matched the hidden validation path rather than any of the decoy handlers.
python -c "from pathlib import Path; import struct; data=Path('ragebait').read_bytes(); off=0x4e080; addrs=[struct.unpack_from('<Q',data,off+i*8)[0] for i in range(1009)]; print([i for i,a in enumerate(addrs) if a==0x42e7ec])"[692]Once the four lane constants were known, the inversion was pleasantly direct. Because each lane is just an 8-character base-131 expansion, dividing repeatedly by 131 recovers the original characters for that lane.
consts = [0x112996d9ae479fd, 0x00efb70b2a601818, 0x11c799cc5063ac2, 0x1100d35eadc1177]
for i, c in enumerate(consts):
vals = []
x = c
for _ in range(8):
vals.append(x % 131)
x //= 131
vals = vals[::-1]
print(i, vals, ''.join(chr(v) for v in vals))python invert_lanes.py0 [116, 97, 86, 95, 52, 109, 48, 54] taV_4m06
1 [101, 119, 104, 85, 107, 69, 95, 114] ewhUkE_r
2 [120, 123, 89, 95, 51, 95, 52, 121] x{Y_3_4y
3 [115, 86, 100, 77, 95, 115, 110, 125] sVdM_sn}Interleaving those four recovered lane strings reconstructed the full 32-byte candidate, and recomputing the lane hashes matched the constants exactly. That was the real moment the binary stopped being ragebait and started being a straightforward reversible check.
l0 = 'taV_4m06'
l1 = 'ewhUkE_r'
l2 = 'x{Y_3_4y'
l3 = 'sVdM_sn}'
out = ''.join(a + b + c + d for a, b, c, d in zip(l0, l1, l2, l3))
print(out)
print(len(out))
def lane_hash(s):
acc = [0, 0, 0, 0]
for i, ch in enumerate(s.encode()):
acc[i & 3] = acc[i & 3] * 131 + ch
return acc
print([hex(x) for x in lane_hash(out)])python reconstruct_flag.pytexsaw{VVhYd_U_M4k3_mE_s0_4n6ry}
32
['0x112996d9ae479fd', '0xefb70b2a601818', '0x11c799cc5063ac2', '0x1100d35eadc1177']Solution
The final solve was to run the binary with the reconstructed 32-byte flag candidate and confirm that the real validator accepted it.
./ragebait 'texsaw{VVhYd_U_M4k3_mE_s0_4n6ry}'[SUCCESS] Flag: texsaw{VVhYd_U_M4k3_mE_s0_4n6ry}