Post

Two Versions of a Crackme: Base64, MD5, and the Two Bytes That Never Changed

Reversing two versions of the NoREpls crackme in IDA Pro: recovering the serial key from v1.1, why v2.0's switch to MD5 stops key recovery but not patching, and the single jz both versions depend on.

Two Versions of a Crackme: Base64, MD5, and the Two Bytes That Never Changed

Crackmes are small programs built to be reversed. This one, NoREpls (“Nothing Really Epic; Pretty Lousy Software”), comes in two versions. The developer “fixed” the first version’s protection in the second one, and comparing the two shows what that fix actually changed.

For each version I tried to (1) find the validation routine, (2) recover the real serial if possible, (3) bypass the check live in a debugger, and (4) patch the binary so it accepts any key.

I’ve left the actual serial key and stored hash values out of this post so it doesn’t spoil the crackme. The method is all here.

Setup

  • Targets: NoREpls1.1.exe and NoREpls2.0.exe, both 32-bit x86 MFC GUI apps
  • Analysis VM: Windows 10 in VirtualBox, on the isolated lab network from my lab setup post
  • Tools: IDA Pro 9.1 (disassembler + debugger), plus readpe, objdump, pefile and strings on the Linux host

The First Trap: Missing Strings

Running v1.1 and trying a fake key (12345) under About → Register gives a message box: “Registration Failed – Invalid serial number!” That error string is the obvious place to start in IDA.

But IDA’s Strings window didn’t show it. The app is MFC and all its UI strings are UTF-16LE, which IDA’s default string scan leaves out. In the Strings window, Ctrl+U (Setup) → enable Unicode C-style (16 bits), and it shows up at .rdata:00410C48. On the host, strings -e l (little-endian 16-bit) finds it too. Plain strings doesn’t.

v1.1: Finding the Check

Ctrl+X on the string gives one xref, inside the dialog handler at 0x00401460. The graph view shows the whole decision:

Dialog handler graph with the test al,al jz fork One call, one test al, al, one jz. Left branch is success, right branch is the failure message box.

1
2
3
4
.text:00401481  mov  ecx, esi          ; dialog handle
.text:00401483  call sub_4013B0        ; validate serial
.text:00401488  test al, al            ; 1 = valid, 0 = invalid
.text:0040148A  jz   short loc_4014B9  ; 0 -> "Registration Failed"

Recovering the key

sub_4013B0 does the actual validation:

  1. Reads the edit control (ID 0x3E9) with GetDlgItemTextA.
  2. Calls sub_401330, which runs the input through CryptBinaryToStringA with flag 0x1, CRYPT_STRING_BASE64.
  3. strncmps the result against a hardcoded 36-byte (24h) string in .rdata.

Base64 is an encoding, not encryption. Decoding the stored string gives the plaintext serial (format NOREPLS-XXXX-XXXX-XXXX-XXXX). Entering it gets “Registration Successful”, and the app sets its registered flag at byte_4132FC = 1.

Bypassing it in the debugger

Without the key, and without changing the file:

  1. Breakpoint (F2) on the jz at 0x0040148A.
  2. Run (F9), enter 12345.
  3. At the break, AL = 0, so ZF = 1 and the jump will be taken.
  4. Double-click ZF in the registers pane to flip it to 0.
  5. Continue. Execution falls through to the success path.

Patching it permanently

The jz is two bytes at file offset 0x88A:

  Bytes Instruction
Original 74 2D jz short loc_4014B9
Patched 75 2D jnz short loc_4014B9

Patched jnz in IDA graph view After the patch: invalid input now skips the failure jump.

Edit → Patch program → Apply patches to input file, then run the patched copy with TEST123, and it registers. Inverting the condition does make the real key fail now. If you want both to work, 90 90 (two NOPs) removes the branch entirely.

v2.0: The “Fix”

v2.0 looks the same and fails the same way. Following the Unicode string (now at .rdata:00413E8C) leads to a dialog handler at 0x00402AB0 with the same structure:

1
2
3
.text:00402AD3  call sub_402840        ; new validator
.text:00402AD8  test al, al
.text:00402ADA  jz   short loc_402B02  ; -> "Registration Failed"

The validator is what changed. sub_402840 passes the input through an internal routine (sub_402230) and strncmps the result against a hardcoded 32-character hex string: 128 bits, which is an MD5 digest. The key is no longer stored, only its hash. You can’t reverse MD5, so the key can’t be read out of the binary. You’d have to guess candidates and hash them.

Patching works exactly as before. 74 26 → 75 26 (or 90 90) at file offset 0x1EDA, and TEST123 registers. v2.0 also persists registration now: on success it writes License.lic containing SERIAL=TEST123 via CreateFileW/WriteFile. The patched build happily writes a license for a key that was never valid.

Side by Side

  v1.1 v2.0
Validation Base64 (Windows API CryptBinaryToStringA) MD5 (internal routine)
Stored value Encoded key, reversible Hash, one-way
Recover the key statically? Yes, decode it No, would need brute force
Registration state In-memory flag License.lic on disk
Decision point One test al, al / jz One test al, al / jz
Anti-tamper / integrity checks None None
Patch effort 1 byte 1 byte

The Lesson

v2.0 is much harder to keygen and no harder to crack. The developer improved what gets compared but left how the answer is used exactly the same: a boolean in AL, one conditional jump, no checksum over .text, no second check somewhere else, no obfuscation. Since the program runs on the attacker’s own machine, the whole protection comes down to two bytes the attacker can change.

That carries over to anything that makes a security decision on a machine you don’t control. Stronger crypto doesn’t help if the result of the check ends up in a single branch. Real protections spread the check out, verify their own code, or move the decision somewhere the user can’t patch (a server).

This post is licensed under CC BY-NC-ND 4.0 by the author.