← Back to Writeups
CUSTOMMediumReversing

Hasher 1

XESXOR9/2/20265 min read
#reversing#custom#medium#call_state_single_function#hashcat_mask_attack#md5_preimage_bruteforce#multi_constraint_solving#pie_rebasing#static_reverse_engineering#symbolic_execution_angr

Hasher 1

Platform: CUSTOM | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: call_state_single_function, hashcat_mask_attack, md5_preimage_bruteforce, multi_constraint_solving, pie_rebasing, static_reverse_engineering, symbolic_execution_angr

Summary

Walkthrough of the CUSTOM Reversing Hasher 1. 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

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):

FunctionFile offsetRebasedPurpose
main0xe8b0x400e8bdriver
check_format0x10240x401024validate serial layout
check_serial0x10f00x4010f0obfuscated xor state machine
valid0xe180x400e18prints VALID!, exit(0)
non_valid0xe320x400e32prints failure, exit(0)
debug0xe4c—unused decoy (fgets into a global)
md5_*——standard MD5 implementation
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Program flow in main

  1. Length gate: strlen(argv[1]) must equal 0x29 = 41.

  2. Format gate (check_format): for each index i in 0..40:

    • if i % 7 == 6 → the char must be '-' (0x2d),
    • otherwise → the char must be in [0-9A-Z] (0x30-0x39 or 0x41-0x5A).

    So the serial layout is six groups of six separated by dashes:

    XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX
    

    (In asm the i % 7 is computed with the classic magic-multiply imul $0x92492493 reciprocal trick.)

  3. MD5 gate: md5_init, then a loop over indices where i % 7 == 5 — that is indices 5, 12, 19, 26, 33, 40, i.e. the last character of each group — calling md5_update with 1 byte each. It MD5-hashes the 6-char string made of those last chars, runs md5_final, and compares the first 16 bytes of the digest against a target embedded in main as a sequence of movb stores: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    target = fb0068b143eeae108e8b875e7a9a0c1e
    

    Any byte mismatch → non_valid (exit).

  4. 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
objdump -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
echo 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:

Index51219263340
charFR1E1E

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 offset 0x10f0 gives No bytes in memory for block starting at 0x10f0; you must rebase to 0x4010f0 / 0x400e18 / 0x400e32.
  • Solving check_serial without fixing the 6 MD5 chars yields a serial that passes check_serial but fails the MD5 gate (e.g. SPBCTF-ZGOSPM-DBNNM0-IFOZNM-EPQVA5-HAPWJA). You must constrain both gates simultaneously. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/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
docker 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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering call_state_single_function, hashcat_mask_attack, md5_preimage_bruteforce, multi_constraint_solving, pie_rebasing, static_reverse_engineering, symbolic_execution_angr was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-07-02 • CUSTOM • Hasher 1 • Difficulty: Medium