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.
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.exeandNoREpls2.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 andstringson 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:
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:
- Reads the edit control (ID
0x3E9) withGetDlgItemTextA. - Calls
sub_401330, which runs the input throughCryptBinaryToStringAwith flag0x1,CRYPT_STRING_BASE64. 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:
- Breakpoint (F2) on the
jzat0x0040148A. - Run (F9), enter
12345. - At the break,
AL = 0, so ZF = 1 and the jump will be taken. - Double-click ZF in the registers pane to flip it to 0.
- 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 |
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).