kernel1 took me way longer than I want to admit, and I got a lot of it wrong on the way — wrong pointer offsets, signed-vs-unsigned mismatches, the usual ways a decompiler dump lies to you if you're tired enough. Near the end of it sits one small, dumb, four-byte hash gate. It's the one part of this box I can actually show my work on without also showing you three hours of me being wrong about a struct layout. The fastest way through it turned out to have nothing to do with how fast anything could hash.
Sitting alongside the kernel exploit is a tiny, unassuming checker binary. Compared to everything else in this box, it's almost polite: it reads four bytes from stdin, runs them through a hand-rolled hash, and compares the result against a constant baked into the binary at compile time.
unsigned long hash(char *input) {
unsigned int hashval = 0;
for (int i = 0; i < strlen(input); i++) {
unsigned int tmp = (hashval + input[i]) * 0x401;
hashval = tmp ^ (tmp >> 6) ^ input[i];
}
return hashval;
}
int main(void) {
read(0, buffer, 4);
if (hash(buffer) == 0x03319f75)
puts("Flag");
else
puts("Not flag");
}
Four input bytes means a 32-bit search space: 4,294,967,296 candidates. Small enough to brute-force outright. After days of getting the actual kernel exploit wrong in increasingly humbling ways, a problem I could just throw compute at instead of thinking my way through felt like a vacation. So instead of picking whichever method got me the flag fastest and moving on, I made myself actually measure the three ways people reach for when a hash needs cracking.
The obvious move, and the one I trust most precisely because there's so little to get wrong: try every 32-bit value, hash it the same way the checker does, stop when it matches.
for (uint64_t i = 0; i <= 0xffffffff; i++) {
*(uint32_t*)buf = i;
if (hash(buf) == 0x03319f75)
printf("match: %08x\n", (uint32_t)i);
}
Two preimages turn up in the full 232 space — expected for a birthday-ish spread of a 32-bit hash over 4 billion inputs. Sharding the same loop across the box's 4 threads brings it down further:
Same algorithm, same search order, ported line for line:
def h(buf):
r = 0
for c in buf:
tmp = (r + c) * 0x401 & 0xffffffff
r = (tmp ^ (tmp >> 6) ^ c) & 0xffffffff
return r
3,000,000 candidates — about 0.07% of the space — was enough to clock a stable rate. At ~228K hashes/sec, the full sweep would run somewhere north of five hours. I let it run just long enough to be sure of the number, then killed it. Life's too short, and so was my patience by that point in the box.
The third approach is the one I almost skipped, mostly out of stubbornness — I'd already gotten the hash "working" twice by then and didn't love the idea of a tool proving me wrong a third way. It doesn't touch the hash function at all. Instead of asking "what input produces this hash," it asks the binary directly: "what stdin makes you print Flag?"
project = angr.Project("./hash", load_options={'auto_load_libs': False})
flag = claripy.BVS('flag', 32)
state = project.factory.entry_state(stdin=flag)
simulation = project.factory.simgr(state)
simulation.explore(
find=lambda s: b'Flag' in s.posix.dumps(1),
avoid=lambda s: b'Not flag' in s.posix.dumps(1))
angr walks the actual instructions the binary executes, turns the byte-by-byte hash loop into a set of constraints over four symbolic bytes, and hands the whole thing to an SMT solver. It doesn't enumerate 4 billion candidates — it reasons about the one path that reaches puts("Flag") and asks Z3 for an input satisfying it. Eight seconds, and it agrees with the brute force.
Porting a four-line hash function from a decompiler dump should be the safe part. It wasn't. The original operates on a plain char — signed, on x86-64 Linux — so any byte ≥ 0x80 sign-extends to a negative value before it's folded into the accumulator. My first C port and my first Python port both defaulted to unsigned bytes, independently, without me noticing either time:
Both versions compiled clean, ran fine, and produced plausible, self-consistent, wrong hashes — a full 4-billion-candidate sweep that would never find the real preimages, because it wasn't computing the real hash. Nothing crashes on this bug. Nothing warns you. You just get a confident, silent wrong answer, which is somehow worse than a crash because a crash at least tells you to go look. The multithreaded C brute force is what actually caught it: it disagreed with the single-threaded run on the exact matches found, which is what a correctness bug hiding inside a "just split the loop" refactor is supposed to look like, but rarely does this cleanly.
I'd like to say this was a one-off, but it wasn't even the first pointer-arithmetic mistake this box pulled out of me. Reversing mysu.ko earlier — the actual kernel exploit this hash gate sits next to — my first pass at the driver's hash call had it reading from buf + 1 instead of buf + 4. One digit off, and it would have quietly hashed three bytes of the uid field along with garbage instead of the four bytes it was supposed to check, and I wouldn't have known until nothing worked and I had no idea why. Different offset, same shape of mistake: a decompiler or a disassembly listing hands you something that looks like a clean pointer expression, and your brain fills in "this is obviously right" faster than it checks. angr never has this problem, in either form — it executes the compiled bytes, not my re-typed understanding of them.
| method | wall time | preimage found | exhaustive? |
|---|---|---|---|
| C ×1 (−O2) | 126.7s | 1c45dfe0, 897b636e | yes — full 2³² |
| C ×4 (pthreads) | 63.0s | 1c45dfe0, 897b636e | yes — full 2³² |
| Python (naive) | ~18,854s† | not run to completion | would be, at ~314 min |
| angr | 7.9s | 897b636e | no — first solution only |
† extrapolated from a measured 227,797 hashes/sec over a 3,000,000-candidate sample.
Raw hash-rate explains most of the chart, and none of it is surprising if you've felt it before: C is roughly 150× faster than an interpreted Python loop doing the identical arithmetic, because every +, *, ^, and >> in CPython pays for bytecode dispatch, a dynamic type check, and a heap-allocated int object instead of costing one instruction on real silicon. Threading the C version buys another ~2× — less than the 4× the core count promises, because this hash is small and branchy enough that four threads spend some of their time contending rather than computing.
angr isn't a faster hasher — it never hashes at all. Two things about ./hash conspire to make it the wrong problem to brute-force:
That's the actual mechanism, not just a speed difference: brute force is O(2ⁿ) enumeration with zero information reuse between tries. Symbolic execution only wins this decisively because ./hash has no data-dependent branching inside the hash loop itself — the only branch in the whole binary is the final comparison. angr never has to fork on the input, so all of the work lands on Z3, and Z3 treats simple arithmetic like this as a constraint-propagation problem rather than a guessing game. Give the checker real byte-by-byte branching instead of straight-line arithmetic, and angr would start forking on every one of those branches too — at which point the advantage shrinks, and can even reverse.
But the number that actually decided which method I'd reach for next time isn't in that table: it's zero, the number of hash-function bugs angr had to avoid, because it never had one to get right. Both of my brute forces shipped the same signed-char mistake independently, and I'd already gotten a pointer offset wrong once earlier in this exact box. angr doesn't reimplement anything — it reasons about the bytes the CPU actually runs, which is a nice way of saying it doesn't get tired at 2am and mistype a struct offset. For a hash this simple, brute force in C is still the pragmatic choice: fastest wall-clock, trivial to verify, and it enumerates every preimage instead of stopping at the first one a solver happens to find. But the moment the check is more convoluted, obfuscated, or simply not worth re-deriving by hand — and after this box, I'm inclined to assume I'll get it wrong at least once — the fastest path to an answer is the one that never asks you to be right about the algorithm in the first place.