Hasher 1
Hasher 1
Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: call_state_single_function, hashcat_mask_attack, md5_preimage_bruteforce, multi_constraint_solving, pie_rebasing, static_reverse_engineering, symbolic_execution_angr
Summary
Task: Linux x86-64 PIE crackme (hasher1.elf) validating a 41-char serial through two gates — an MD5 check on 6 specific chars plus an obfuscated xor state machine. Solution: extract the hardcoded MD5 target, brute-force its 6-char preimage with hashcat, then solve the flattened check_serial with angr using those chars as fixed constraints (rebasing PIE addresses to base 0x400000).
Hasher 1 — spbctf
Description
hasher1.elf — validate the serial. (keygen crackme)
File: hasher1.elf, downloaded from https://rev-kids20.forkbomb.ru/files/rev/new8/hasher1.elf.
English summary: A Linux x86-64 PIE crackme takes a serial as argv[1] and prints VALID!
if it is accepted. The goal is to find any valid serial. The binary has two independent
gates — an MD5 check on 6 specific characters and an obfuscated (control-flow-flattened)
xor state machine over the whole string. The intended solution combines a small hashcat
brute-force with an angr symbolic solve.
Analysis
Binary facts (from file / objdump):
- ELF 64-bit LSB PIE executable, x86-64, dynamically linked, not stripped.
- BuildID (sha1):
66e4ed6dd22773dfd42aff58a531bfe0db3dbee2. - angr load base:
0x400000(main_object.min_addr == 0x400000).
Relevant functions (file offset → angr-rebased address = offset + 0x400000):
| Function | File offset | Rebased | Purpose |
|---|---|---|---|
main | 0xe8b | 0x400e8b | driver |
check_format | 0x1024 | 0x401024 | validate serial layout |
check_serial | 0x10f0 | 0x4010f0 | obfuscated xor state machine |
valid | 0xe18 | 0x400e18 | prints VALID!, exit(0) |
non_valid | 0xe32 | 0x400e32 | prints failure, exit(0) |
debug | 0xe4c | — | unused decoy (fgets into a global) |
md5_* | — | — | standard MD5 implementation |
| |
Program flow in main
-
Length gate:
strlen(argv[1])must equal0x29 = 41. -
Format gate (
check_format): for each indexiin0..40:- if
i % 7 == 6→ the char must be'-'(0x2d), - otherwise → the char must be in
[0-9A-Z](0x30-0x39or0x41-0x5A).
So the serial layout is six groups of six separated by dashes:
XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX(In asm the
i % 7is computed with the classic magic-multiplyimul $0x92492493reciprocal trick.) - if
-
MD5 gate:
md5_init, then a loop over indices wherei % 7 == 5— that is indices 5, 12, 19, 26, 33, 40, i.e. the last character of each group — callingmd5_updatewith 1 byte each. It MD5-hashes the 6-char string made of those last chars, runsmd5_final, and compares the first 16 bytes of the digest against a target embedded inmainas a sequence ofmovbstores: target = fb0068b143eeae108e8b875e7a9a0c1eAny byte mismatch →
non_valid(exit). -
Serial gate (
check_serial): a large control-flow-flattened dispatcher. A state variable at-0x80(%rbp)is compared against many constants to jump to per-character handler blocks. Each block typically does:buf[i] ^= buf[i+k] ; k a small offset (1..5), sometimes no neighbor buf[i] ^= const cmp buf[i], expected ; match -> set "good" next state ; else -> "bad" -> non_valid
Because each char depends on its neighbours via xor, this is tedious by hand but
trivial for a symbolic solver. If every handler matches, execution reaches valid().
The two gates are independent: the MD5 gate constrains only the 6 group-final chars, the serial gate constrains the whole string. Both must be satisfied at once.
Solution
Step 1 — Identify structure
bash read-only • copy allowedobjdump -d -M intel hasher1.elf > full_disasm.txt # inspect main, check_format, the MD5 loop, and check_serial
Step 2 — Extract the MD5 target
From main's movb sequence, the 16-byte comparison target is:
fb0068b143eeae108e8b875e7a9a0c1e
Step 3 — Brute-force the 6-char MD5 preimage with hashcat
The preimage charset is [0-9A-Z], length 6 → 36^6 ≈ 2.18e9 candidates.
bash read-only • copy allowedecho fb0068b143eeae108e8b875e7a9a0c1e > target.hash hashcat -m 0 -a 3 -1 '?d?u' target.hash '?1?1?1?1?1?1' \ --potfile-disable -o cracked.txt # cracked in ~1 second: # fb0068b143eeae108e8b875e7a9a0c1e:FR1E1E
Result: FR1E1E → the last char of each group:
| Index | 5 | 12 | 19 | 26 | 33 | 40 |
|---|---|---|---|---|---|---|
| char | F | R | 1 | E | 1 | E |
Step 4 — Solve check_serial with angr (fixing the 6 MD5 chars)
Call check_serial directly with a symbolic 41-byte buffer, constrain the format,
fix the six MD5-derived chars, then find valid() / avoid non_valid().
Gotchas:
- The binary is PIE, loaded at base
0x400000. Using the raw file offset0x10f0givesNo bytes in memory for block starting at 0x10f0; you must rebase to0x4010f0/0x400e18/0x400e32. - Solving
check_serialwithout fixing the 6 MD5 chars yields a serial that passescheck_serialbut fails the MD5 gate (e.g.SPBCTF-ZGOSPM-DBNNM0-IFOZNM-EPQVA5-HAPWJA). You must constrain both gates simultaneously.
python read-only • copy allowed#!/usr/bin/env python3 import angr, claripy, logging logging.getLogger('angr').setLevel(logging.ERROR) proj = angr.Project('hasher1.elf', auto_load_libs=False) # base 0x400000 LEN = 41 buf = claripy.BVS('buf', 8 * (LEN + 1)) # +1 for the null terminator BUF_ADDR = 0x700000 # check_serial @ 0x4010f0 takes rdi = pointer to the 41-char buffer state = proj.factory.call_state(0x4010f0, BUF_ADDR) # MD5-cracked last char of each group: indices 5,12,19,26,33,40 -> "FR1E1E" FIXED = {5: 'F', 12: 'R', 19: '1', 26: 'E', 33: '1', 40: 'E'} for i in range(LEN): b = buf.get_byte(i) state.memory.store(BUF_ADDR + i, b) if i % 7 == 6: # dash separators state.solver.add(b == ord('-')) elif i in FIXED: # MD5 gate constraints state.solver.add(b == ord(FIXED[i])) else: # format: [0-9A-Z] state.solver.add(claripy.Or( claripy.And(b >= ord('0'), b <= ord('9')), claripy.And(b >= ord('A'), b <= ord('Z')), )) state.memory.store(BUF_ADDR + LEN, claripy.BVV(0, 8)) # null terminator simgr = proj.factory.simgr(state) simgr.explore(find=0x400e18, avoid=0x400e32) # valid() / non_valid() assert simgr.found, "no solution" sol = simgr.found[0].solver.eval(buf, cast_to=bytes)[:LEN] print("SERIAL:", sol.decode())
angr returns the unique meaningful serial:
REDACTED
which reads as "SPBCTF EXPLORE COOL ANGR FEATURE LETS NE(XT)".
Step 5 — Verify
The binary is a Linux x86-64 ELF; on macOS run it under Docker linux/amd64:
bash read-only • copy alloweddocker run --rm --platform linux/amd64 -v "$PWD":/w -w /w ubuntu:22.04 \ ./hasher1.elf "REDACTED" # => VALID!
Also confirmed in Python that md5("FR1E1E")[:16].hex() == fb0068b143eeae108e8b875e7a9a0c1e.