Step 218. 10 crackmes (Medium Difficulty) — Reading Relations, Opening Gates One by One

Step 218. 10 crackmes (Medium Difficulty) — Reading Relations, Opening Gates One by One

Level 3 — Reversing Track | Difficulty ★★★★☆ | Estimated time: 8 hours

Prerequisites: you’ve finished Step 217 (easy crackmes). The three-type recognition comes automatically, and you can capture the comparison moment with gdb.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. Keygen writing is analysis training limited to legal crackmes.

  • What you need: WSL Ubuntu (measured: Ubuntu 24.04, gcc 13.3.0, gdb 15.1), Python 3 (for writing solvers and keygens). Collecting problems from external platforms is shown as screen examples.
  • Caution: from medium difficulty on, "the answer in one shot" stops happening. Encountering problems you can’t solve is normal, and consulting solutions per the rules (decided later in this chapter) is also part of this chapter’s learning method.

If yesterday’s easy difficulty was "where is the answer hiding," today’s medium difficulty is "can you read the structure of the verification." Deriving the relation between a name and a serial to build a keygen, opening three layers of verification gates one by one, switching strategies in front of a verification that changes on every run. The judgment to mix in dynamic analysis when static analysis alone isn’t enough — that’s today’s core skill.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Distinguish the three hallmarks of medium difficulty (relational verification, multi-stage checks, runtime-dependent verification)
  • Read a relation from the disassembly and port it into a Python keygen
  • Read compiler optimizations (shl+sub = multiplication) back as the original operation
  • Break multi-stage verification down with a checkpoint-by-checkpoint observation tactic
  • Build the habit of deciding a static-vs-dynamic strategy before solving

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment C (for building problems) + Python (keygens & solvers) + WSL Ubuntu bash (measured: gcc 13.3.0, gdb 15.1)
Today’s commands Reading shl/sub/imul/xor in objdump -d, gdb checkpoint breakpoints, Python itertools.product (brute force)
Concepts needed Hash-style relations (name→serial), keygens, multi-stage verification, compiler multiplication optimization, symmetric transforms
Today’s deliverables 1 keygen + 1 multi-stage solver + 10 strategy notes

2-1. The Three Hallmarks of Medium Difficulty

The difference from easy is "is the answer in the file?" The answer to a medium problem mostly isn’t in the file — because it’s computed.

  1. Relational verification (keygen type) — the answer isn’t fixed; it’s determined by a relation between a name (or ID) and a serial. The goal is to produce a pair satisfying serial == f(name), so instead of finding one answer, you must restore f. A program that reimplements this f is a keygen.
  2. Multi-stage checks — the verification is split into several gates. Each gate must be passed before the next becomes visible, so you need "observation at every gate," not "one capture."
  3. Runtime-dependent verification — it uses ingredients that change every run, like time, PID, or random values. The answer string may not exist at all, making dynamic analysis mandatory.

2-2. Compilers Hate Multiplication

The first wall of intermediate analysis is assembly that doesn’t look like the source. The classic case is multiplication — since multiplication is a slow instruction on the CPU, compilers rewrite constant multiplication as shifts and subtraction:

s * 31  →  (s << 5) - s        # shl $0x5 followed by sub
s * 33  →  (s << 5) + s

You’ll meet exactly this in today’s hands-on work. When shl $0x5 is followed by sub, practice reading it as "×31" — this substitution table is where intermediate disassembly reading begins.

2-3. The Keygen Mindset — Transplant the Verification Function Whole

The textbook solution for relational problems is a Python port of the verification function. Read the computation order from the disassembly, transcribe it as-is, feed in a name, and compute the serial. The point is not "understand and rewrite" but "transcribe exactly" — if the port is faithful, the result is automatically correct. This is why machine-level details like integer overflow (32-bit masking) must be ported too.

2-4. Decomposing Multi-Stage Verification — Stop at Every Gate

Don’t try to understand three layers of verification at once. Set a breakpoint at each check (checkpoint) and observe one gate at a time. It’s a progressive siege: "stage 1 is a length check — so I’ll view stage 2 with a length-matching input." Step 216’s conditional breakpoints shine here — you can stop only on the iteration you want inside a loop that spins hundreds of times.

2-5. The "Same Input Twice" Experiment

The first experiment when you can’t tell whether a verification is arithmetic or cryptographic. Feed the same input twice: if the results (internal values, failure point) are identical, it’s a deterministic transform — back-calculation is likely possible. If they differ per run, it depends on time or randomness — you must watch, with dynamic analysis, where those ingredients are made. Only once the judgment stands can you pick the tool.

2-6. Rules for Consulting Solutions

From medium difficulty on, problems you can’t solve will appear. Today’s rule: consulting a write-up is allowed for up to 2 of the 10. But there’s a condition — don’t just read and stop: ① reproduce it in your own environment, and ② record "the clue I missed" in one line. The list of missed clues is the map of your skill.


3. Follow Along

3-1. Training Ground — Two Intermediate Specimens

Build the textbook specimens of the two hallmarks: relational and multi-stage. As yesterday, you know the source while making them, then cover it and become the analyst.

Input (m1_keygen.c — relational verification)

#include <stdio.h>
#include <string.h>

/* A verifier that computes a serial from a name — you must crack the "relation" between name and serial */
unsigned int calc_serial(const char *name) {
    unsigned int s = 0x1357;
    int i;
    for (i = 0; name[i]; i++)
        s = s * 31 + (unsigned char)name[i];
    return s ^ 0x2468ace;
}

int main(void) {
    char name[64];
    unsigned long serial;
    printf("name: ");
    scanf("%63s", name);
    printf("serial: ");
    scanf("%lu", &serial);
    if (serial == (unsigned long)calc_serial(name))
        puts("correct! FLAG{m1_r3l4t10n_br0k3n}");
    else
        puts("wrong.");
    return 0;
}

Input (m2_stages.c — multi-stage check)

#include <stdio.h>
#include <string.h>

int main(void) {
    char pw[64];
    int i, s = 0;
    printf("key: ");
    scanf("%63s", pw);
    if (strlen(pw) != 8) goto fail;                /* stage 1: length */
    for (i = 0; i < 8; i++)
        s += (pw[i] ^ 0x20) + i;                   /* stage 2: transformed sum */
    if (s != 462) goto fail;
    if (pw[0] != 0x6b || pw[7] != 0x79) goto fail; /* stage 3: anchors k??????y */
    puts("correct! FLAG{m2_thr33_g4t3s}");
    return 0;
fail:
    puts("wrong.");
    return 0;
}
cd ~/lab214_218
gcc -O1 -o m1_keygen m1_keygen.c
gcc -O1 -o m2_stages m2_stages.c
printf "testn123n" | ./m1_keygen
echo abcd | ./m2_stages
name: serial: wrong.
key: wrong.

(Measured 2026-09-09. Both quietly reject wrong answers — the analysis starts here.)

3-2. Analyzing m1 — Reading the Relation from the Disassembly

Open the verification function:

objdump -d m1_keygen | sed -n "/<calc_serial>:/,/ret/p"
00000000000011a9 <calc_serial>:
    11ad:	0f b6 17             	movzbl (%rdi),%edx          ; read one character
    11b0:	84 d2                	test   %dl,%dl
    11b2:	74 28                	je     11dc                 ; exit if null
    11b8:	b8 57 13 00 00       	mov    $0x1357,%eax         ; initial value 0x1357
    11bd:	89 c1                	mov    %eax,%ecx
    11bf:	c1 e1 05             	shl    $0x5,%ecx            ; ecx = s << 5
    11c2:	29 c1                	sub    %eax,%ecx            ; ecx = (s<<5) - s = s*31
    11c4:	0f b6 d2             	movzbl %dl,%edx
    11c7:	8d 04 0a             	lea    (%rdx,%rcx,1),%eax   ; s = char + s*31
    11d2:	84 d2                	test   %dl,%dl
    11d4:	75 e7                	jne    11bd                 ; loop
    11d6:	35 ce 8a 46 02       	xor    $0x2468ace,%eax      ; final XOR
    11db:	c3                   	ret

(Measured 2026-09-09. The comments are mine, and some lines are omitted.)

How to read the output: here’s today’s first wall — the source’s s * 31 appears nowhere. Instead, shl $0x5 (times 32) followed by sub (subtract once). 32s − s = 31s — the compiler’s multiplication optimization (2-2). Read through it and the relation is restored:

s starts at 0x1357 → for each character, s = s*31 + char → finally s XOR 0x2468ace

Checking main as well shows call calc_serial followed by cmp 0x8(%rsp),%rax + je — the structure of comparing the computed result against your serial is visible (measured 2026-09-09).

3-3. Solving m1 — A Python Keygen

Port the relation exactly:

Input (keygen_m1.py)

import sys
name = sys.argv[1]
s = 0x1357
for ch in name.encode():
    s = (s * 31 + ch) & 0xFFFFFFFF   # imitate 32-bit unsigned — port the overflow too
print(s ^ 0x2468ace)
python3 keygen_m1.py daimon
3264632025

(Measured 2026-09-09.)

How to read it: & 0xFFFFFFFF is the key detail. C’s unsigned int discards overflow beyond 32 bits, but Python integers grow without bound, so you must apply a mask to make it behave like the same machine. Remove this one line and the keygen breaks on long names.

Verify:

printf "daimonn3264632025n" | ./m1_keygen
name: serial: correct! FLAG{m1_r3l4t10n_br0k3n}

(Measured 2026-09-09.)

Summary: you didn’t "find" an answer — you "possess" the relation. This keygen produces a valid serial for any name — this is the advanced form of solving a crackme.

3-4. Analyzing m2 — A Map of Three Gates

objdump -d m2_stages | sed -n "/<main>:/,/ret/p" | grep -E "cmp|call|jne|xor"
    120d:	call   10d0 <strlen@plt>
    1212:	cmp    $0x8,%rax              ; stage 1: length == 8
    1216:	jne    124c <fail>
    1226:	xor    $0x20,%eax             ; stage 2 loop: accumulate (char ^ 0x20) + index
    1231:	cmp    $0x8,%rdx
    1235:	jne    1222 <loop>
    1237:	cmp    $0x1ce,%ecx            ; stage 2: sum == 0x1ce (=462)
    123d:	jne    124c <fail>
    123f:	cmpb   $0x6b,(%rsp)           ; stage 3: first char == 'k'
    1243:	jne    124c <fail>
    1245:	cmpb   $0x79,0x7(%rsp)        ; stage 3: last char == 'y'

(Measured 2026-09-09. The comments are mine.)

How to read the output: three jnes — three gates leading to failure. List each gate’s condition:

  1. Length exactly 8
  2. The total of each character XORed with 0x20 plus its index equals 462 (0x1ce)
  3. First character ‘k’ (0x6b), last character ‘y’ (0x79)

There’s more than one answer — any key satisfying the conditions works. Problems like this call not for back-calculation but constraint-satisfying search.

3-5. Solving m2 — A Constraint Solver

Transcribe the three conditions into formulas and let Python search:

Input (solve_m2.py)

from itertools import product

# Conditions read from the disassembly:
# stage 1 len==8 / stage 2 sum((pw[i]^0x20)+i)==462 / stage 3 pw[0]=='k', pw[7]=='y'
target = 462
fixed = (ord("k") ^ 0x20) + 0 + (ord("y") ^ 0x20) + 7
need = target - fixed          # the sum positions i=1..6 must fill
print("sum the middle 6 characters must contribute:", need)

found = None
for mid in product("abcde3fg", repeat=6):
    pw = "k" + "".join(mid) + "y"
    s = sum((ord(c) ^ 0x20) + i for i, c in enumerate(pw))
    if s == target:
        found = pw
        break
print("key found:", found)
sum the middle 6 characters must contribute: 291
key found: k333gggy

(Measured 2026-09-09.)

echo k333gggy | ./m2_stages
key: correct! FLAG{m2_thr33_g4t3s}

(Measured 2026-09-09.)

Summary: trying to solve three gates at once would have been hard. Write out each gate’s condition (3-4), fill the fixed gates first (stages 1 and 3), then search the remaining freedom (the middle 6 characters) — this decomposition is the textbook approach to multi-stage verification.

3-6. Strategy Notes — Criteria for Choosing Static vs Dynamic

Settle today’s two problems and the criteria for tool choice emerge:

Situation Choice Why
Relation fully readable in disassembly Static → Python port Read it once, get a permanent keygen
Conditions split across several gates Static map → solver search Listing the conditions is half the solve
Ingredients change per run Dynamic required The answer doesn’t exist in the file at all
Transform too complex to read Dynamic capture to build an input→output table Even a black box reveals its I/O

In the main training (10 external problems), before opening each problem, write down first which row of this table it is. The habit of writing a strategy cuts wandering time.


4. Missions & Exercises

Mission — 10 Medium Problems + Strategy Notes

  1. Pick 10 crackmes around difficulty 3 (mix in platforms beyond crackmes.one. No external access? Substitute with self-made variants of m1/m2 — change the initial value, the XOR constant, the number of gates)
  2. Before starting each problem, write one line: "this problem is ___ type, so I’ll solve it with ___"
  3. For relational types, aim to write a keygen (Python); for multi-stage types, aim to list each gate’s conditions
  4. Aim to solve at least 8. For unsolved ones (max 2), consult a solution, reproduce it, and record "the clue I missed"
  5. At the end, settle the strategy notes: were there problems where your written prediction differed from the actual solution?

Exercises

Exercise 1. In m1’s disassembly, shl $0x5 followed by sub appeared. Why is this ×31, and what would ×33 have looked like?

Exercise 2. Why is & 0xFFFFFFFF needed in the keygen? In which cases does removing it break things?

Exercise 3. Explain the difference between "verification with many valid answers" like m2 and "verification where the answer is a relation" like m1, and match each with its fitting solution form (solver/keygen).

Exercise 4. If a crackme’s verification result differs on every run, what ingredients should you suspect, and why is static analysis alone insufficient?


5. Model Answers & Completion Criteria

Mission Model Answer

Example strategy notes:

#01 serial_me     | predicted: relational → actual: relational ✓ | wrote keygen, 25 min
#02 triple_gate   | predicted: multi-stage → actual: multi-stage ✓ | listed gate conditions then solver, 31 min
#03 time_bomb     | predicted: multi-stage → actual: runtime-dependent ✗ | time-based — observed ingredient creation with gdb, 40 min
#04 hashy         | predicted: relational → actual: relational ✓ | burned 10 min reading the shl+sub substitution, 35 min
...
Summary: 8 of 10 solved, 2 with solution reference
Missed clue 1: #06 had two verification functions but I only read one — need the habit of "counting all calls"
Missed clue 2: #09 was shl $0x5 + add but I read it as sub — need the habit of checking the sign

How to verify: ① is there evidence (flag or correct output) for at least 8 successes? ② do the solution-referenced problems include reproduction and a "missed clue"? ③ for keygen types, did a serial for an arbitrary new name pass? Matching only one is coincidence, not a keygen — verifying once more with a different name is the criterion.

Exercise Answers

Answer 1. shl $0x5 is 2⁵ = 32x, and the following sub %eax subtracts the original value once, giving 32s − s = 31s. By the same logic, ×33 is 32s + s, so shl $0x5 followed by add would have appeared. Reading constant multiplication through the combination of shift amount and add/subtract is the key, and confusing subtraction with addition twists the whole relation — lines 11bf and 11c2 in today’s measurement are exactly that spot.

Answer 2. Because C’s unsigned int is 32 bits, so results of multiplication and addition that overflow lose their upper bits, while Python integers grow without bound. With short names there’s no overflow and it accidentally matches, but as the name grows, from the moment s exceeds 2³² the Python value and the real binary’s value diverge. The mask is a device that imitates "the machine’s overflow" — a port must carry over not just the operations but the overflow to be accurate.

Answer 3. m2 is a constraint-satisfaction problem — satisfy the conditions (length, sum, anchors) and you’re done — so there are infinitely many answers and a solver (a searcher) that finds any one of them fits. m1 is a functional relation where the answer differs per name, so you need a keygen that restores not a specific answer but the function itself. How to tell them apart: does external input (the name) enter the verification as a computation ingredient? If yes, it’s likely keygen type; if not, likely constraint-satisfaction.

Answer 4. Suspect runtime ingredients like time, process ID, and randomness (/dev/urandom). In such verifications the answer exists nowhere in the file and is freshly computed each run, so no amount of reading the file yields an answer. What’s needed is dynamic analysis — set a breakpoint at the call that creates the ingredient (time, etc.), read that run’s ingredient value, and change strategy toward passing the verification within the same run or bypassing it.

Completion Criteria Checklist

  • [ ] I can distinguish and state the three hallmarks of medium difficulty (relational / multi-stage / runtime-dependent)
  • [ ] I can read a shl+sub combination back as multiplication
  • [ ] I ported m1’s relation into a Python keygen and verified it with another name
  • [ ] I listed m2’s three gates as conditions and found a key with a solver
  • [ ] I built the habit of writing "type prediction and tool choice" before opening a problem
  • [ ] I solved at least 8 of 10 (or reproduced after consulting solutions)
  • [ ] I recorded the "missed clues" for the problems I couldn’t solve

6. Common Pitfalls & Fixes

Wall 1. I made a keygen, but it fails for some names

Symptom: short names pass, but serials for long names get rejected.
Cause: missing 32-bit overflow mask — the Python integer kept growing past 2³² (see Exercise 2).
Fix: apply & 0xFFFFFFFF every loop iteration. If it still fails, re-check sign extension in the disassembly (movzbl = zero extension, movsbl = sign extension) — if the upper-bit handling of characters differs, results diverge.

Wall 2. I read shl+sub as a number other than ×31

Symptom: the keygen outputs all wrong answers.
Cause: mistaking the shift amount (shl $0x5 = ×32 baseline) or confusing add/sub.
Fix: run a contrast experiment with small input — the expected value for one-character name "A" (0x41) is ((0x1357 * multiplier) + 0x41) ^ XOR constant. Vary the multiplier across 31, 33, etc. and compare against the real intermediate value read in gdb — which one it is gets settled immediately. Not guesses — a one-character experiment is the standard.

Wall 3. In a multi-stage verification I can’t pass the first gate, so I never see the second

Symptom: you keep failing stage 1 (length) and never get a chance to observe stage 2’s conditions.
Cause: this is the essence of sequential verification — you must pass the front gate for the back gate to execute.
Fix: there are two paths. Statically, the disassembly doesn’t hide gates, so (as in 3-4) just read them all. Dynamically, make a temporary input that passes stage 1 (any 8 characters) and enter stage 2. Opening a gate and knowing its condition are separate jobs.

Wall 4. The brute-force solver never finishes

Symptom: the search space of product is too large (e.g., 26 letters⁶).
Cause: you’re searching the entire freedom.
Fix: shrinking the space with conditions is the solver’s craft. In m2, stage 3 (anchors) fixes 2 characters and stage 2 (sum) fixes the rest’s total, so the actual freedom is far smaller. Narrow the character set ("abcde3fg"), arrange conditions algebraically first, then search — formulas first, brute force later.

Wall 5. I read a solution but "why did they think of that" doesn’t stick

Symptom: you reproduced the referenced solution, but you can’t apply it to the next problem.
Cause: you only reproduced and didn’t record the "missed clue." Reproduction is training for the hands; clue recording is training for the eyes.
Fix: cover the solution and ask yourself — "where in the file was this solution’s first clue? Where was I looking during my first 30 minutes?" That one line of difference is what the rule in 2-6 is after.


7. Summary

Today’s Concepts

Concept One-line explanation
Relational verification (keygen type) Answer = f(name) — a program that restores f is a keygen
Multi-stage check List each gate’s conditions → fill fixed gates first → search the rest
Multiplication optimization shl+sub = ×31, shl+add = ×33 — the compiler’s constant-multiplication substitution
32-bit mask & 0xFFFFFFFF — a device for porting the machine’s overflow into Python
Runtime-dependent verification Time/random ingredients — no answer in the file, dynamic analysis required
Strategy notes The training of writing "type prediction + tool choice" before opening a problem

Today’s Commands

Command What it does
objdump -d | grep -E "shl|sub|imul|xor" Collect the relation’s ingredients (shifts, operations, constants)
python3 keygen.py name Compute a serial with the ported relation
itertools.product (Python) Constraint-satisfying search — with a narrowed character set
gdb checkpoint breakpoints + conditions Observe each gate of a multi-stage verification

An Instinct More Important Than Commands

At medium difficulty, skill lies not in "reading" but in translation. Assembly into formulas, formulas into Python — if the two translations are accurate, the answer is a byproduct. And the translation is always verified by small experiments (one character, a temporary 8 characters). The reason big problems don’t yield is mostly that you skipped the small experiments.

Lastly, cherish the two problems you couldn’t solve. Today’s "missed clues" list is the most information-dense document in this entire track — because it isn’t a problem set; it’s a map of your own eyes.


Once every box is checked, Step 218 is complete.