KIRA Wants a License: Reversing a C++ `license.key` Checker Byte by Byte
A stripped C++ crackme reads a file called `license.key`, checks a four-byte magic, folds a name through an XOR-and-sum checksum, and demands the registered user be `SoftwareBreaker`. Recover the format, write the keygen, and — for good measure — flip two bytes so it grants access to anything.
A stripped C++ crackme reads a file called license.key, checks a four-byte magic, folds a name through an XOR-and-sum checksum, and demands the registered user be SoftwareBreaker. Recover the format, write the keygen, and — for good measure — flip two bytes so it grants access to anything.
This is a beginner (difficulty ~2.2) Linux ELF crackme from crackmes.one by the author SoftwareBreaker. The stated goal on the challenge page is:
A simple file-based validation crackme by SoftwareBreaker. The application checks for a specific file named
license.keyin the local directory. Goal: Craft a valid license file specifically for the username SoftwareBreaker. Static analysis might help, but writing a keygen is the proper way!
So this is a crackme: the deliverable is a valid license.key (a keygen), and the centrepiece is understanding how the check works. Let's earn it.
The target
$ file license_checker
license_checker: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=62ef4777c4bbd6e4d5f8f7dee5b4828c0e392abc,
for GNU/Linux 3.2.0, stripped
$ sha256sum license_checker
2fe90bfc1eb5821e4230f20d8b45558f2604cbd5307b45fe7bbafc127c27633c
$ stat -c%s license_checker
18848
An 18 KB, stripped, position-independent x86-64 executable. readelf/radare2 spell out the mitigations — this matters later when we choose where to patch:
$ readelf -hd license_checker | grep -iE 'Type|FLAGS'
Type: DYN (Position-Independent Executable file)
0x000000006ffffffb (FLAGS_1) Flags: PIE
$ r2 -qc 'i~pic,canary,nx,relro,stripped' license_checker
canary true
nx true
pic true
relro partial
stripped true
Stripped means no symbol names for our code, but the dynamic symbol table still names every libstdc++ call — which, for a C++ binary, is a gift. The demangled imports (from strings) tell us the whole I/O story before we read a single instruction:
$ strings -n 5 license_checker | grep -iE 'ifstream|basic_string|memcmp|strlen'
_ZNSt14basic_ifstreamIcSt11char_traitsIcEEC1EPKcSt13_Ios_Openmode (ifstream::ifstream)
_ZNSi4readEPcl (istream::read)
_ZNSt14basic_ifstreamIcSt11char_traitsIcEE7is_openEv (ifstream::is_open)
strlen
memcmp
So: open a file, is_open(), read() a chunk, and eventually memcmp. Classic file-validation shape.
First contact
The static strings table is practically a decompilation of the control flow. Every user-visible message and every embedded constant is right there in .rodata:
$ strings -n 5 license_checker | sed -n '/license.key/,/unauthorized/p'
license.key
[-] license.key not found!
[-] Invalid license file format!
P@s$w0r(|12345
SoftwareBreaker
[+] Registered to:
[+] Access Granted! Welcome.
[-] Invalid key signature or unauthorized user!
Five outcomes and two suspicious constants: P@s$w0r(|12345 (14 bytes — a key?) and SoftwareBreaker (the required username, per the brief). Three distinct rejection strings means three distinct gates. Our job is to figure out what separates "not found" from "invalid format" from "invalid signature" from "granted".
Running it cold tells us the file is mandatory:
$ ./license_checker
[-] license.key not found!
$ printf 'AAAAAAAA' > license.key && ./license_checker
[-] Invalid license file format!
Eight bytes gets us past "not found" but trips "invalid format". strace shows exactly how the file is consumed:
$ strace -e trace=openat,read ./license_checker 2>&1 | grep license
openat(AT_FDCWD, "license.key", O_RDONLY) = 3
read(3, "AAAAAAAA", 8191) = 8
read(3, "", 8191) = 0
[-] Invalid license file format!
Interesting — the underlying read syscall requests 8191 bytes (libstdc++'s stream buffer), but the program asks its stream for a fixed amount. To find the real read size and the format rule, we go static.
Locating and reading main
The binary is stripped, but entry0 passes main's address to __libc_start_main, and radare2 recovers it during analysis:
$ r2 -qc 'aaa; afl~main' license_checker
0x00002299 25 718 main
One 718-byte function does everything. I dumped it with pdf and annotated it block by block. (Full, un-annotated dump: main_clean.txt in the artefacts.) Let's walk it.
Block A — canary, open, is_open() (0x2299–0x22ea):
; main @ 0x2299 (r2: s main; pdf)
0x2299 push rbp ; mov rbp,rsp ; push r13/r12/rbx
0x22a2 sub rsp, 0x288 ; big stack frame (the read buffer lives here)
0x22a9 mov rax, qword fs:[0x28] ; stack canary
0x22b2 mov qword [canary], rax
0x22b8 lea rax, [var_230h] ; &ifstream object
0x22bf mov edx, 4 ; openmode = ios::in
0x22c4 lea rcx, str.license.key ; "license.key" @ 0x3008
0x22d1 call ifstream::ifstream(const char*, openmode)
0x22e0 call ifstream::is_open()
0x22e5 xor eax, 1 ; test al, al ; !is_open
0x22ea je 0x230f ; open succeeded -> continue
If the file didn't open, we fall into the "not found" print and bail with ebx = 0:
Block B — the read (0x230f–0x233c):
0x230f lea rcx, [var_260h] ; dst = stack buffer (offset 0)
0x2316 lea rax, [var_230h] ; the ifstream
0x231d mov edx, 0x28 ; count = 40 bytes <-- the real read size
0x2328 call istream::read(char*, long) ; read up to 40 bytes into buffer
0x2337 call ifstream::close()
There's the answer to the strace puzzle: std::istream::read(buf, 0x28) — the program consumes 40 bytes into a stack buffer I'll call buf (radare2's var_260h, i.e. rbp-0x260). The 8191-byte syscall was just libstdc++ filling its own buffer.
Block C — the magic (0x233c–0x2367):
0x233c mov eax, dword [var_240h] ; var_240h = rbp-0x240 = buf + 0x20
0x2342 cmp eax, 0x4152494b ; 0x4152494b LE = 'K','I','R','A'
0x2347 je 0x236c ; magic OK -> checksum stage
0x2349 lea rax, "[-] Invalid license file format!\n"
0x235d call operator<<(cout, ...)
0x2362 mov ebx, 1
0x2367 jmp 0x2532 ; return 1
Gate #1 is a four-byte magic at offset 0x20 (32) of the file. dword [buf+0x20] compared against 0x4152494b. Because x86 is little-endian, the bytes on disk are 4B 49 52 41 = "KIRA". Miss it and you get "Invalid license file format!" — exactly what our 8-byte AAAAAAAA triggered (it had no KIRA at offset 32).
That already tells us the file is a fixed-layout record, not free text. Let's find the rest of the fields.
The checksum: the heart of the check
Past the magic, main sets up a loop. First the init (Block D-init, 0x236c–0x238b):
0x236c lea rax, str.P_s_w0r_12345 ; "P@s$w0r(|12345" @ 0x3052 (14 bytes)
0x2373 mov qword [var_290h], rax ; key pointer
0x237a mov byte [var_295h], 0 ; checksum accumulator = 0 (1 byte)
0x2381 mov dword [var_294h], 0 ; i = 0
0x238b jmp 0x23e3 ; test the loop condition first
So P@s$w0r(|12345 is not the password — it's a 14-byte XOR key. The accumulator (var_295h, one byte) starts at zero. The loop condition is checked before the body (a for-style loop), at Block D-cond (0x23e3–0x23fe):
0x23e3 mov eax, dword [var_294h] ; i
0x23eb movzx eax, byte [rbp+rax-0x260] ; buf[i]
0x23f3 test al, al
0x23f5 je 0x2400 ; stop at first NUL byte
0x23f7 cmp dword [var_294h], 0x1f ; i <= 31 ?
0x23fe jle 0x238d ; yes -> run body
Two independent stop conditions: the loop ends at the first NUL byte in buf, or after 32 iterations (i capped at 0x1f). That NUL condition is the crucial subtlety — the checksum only covers the name up to its terminator, not the whole 32-byte region.
Now the body (Block E, 0x238d–0x23dc). The middle chunk is GCC's constant-division idiom for i % 14; I've collapsed it to its meaning:
0x238d mov eax, [var_294h] ; i
0x2395 movzx esi, byte [rbp+rax-0x260] ; esi = buf[i]
; ---- i % 14 via magic multiply (0x92492493, shr 32, sar 3, imul 14, sub) ----
0x23a6 imul rdx, rdx, 0xffffffff92492493
0x23ad shr rdx, 0x20
0x23bd imul ecx, edx, 0xe ; * 14
0x23c0 sub eax, ecx ; eax = i % 14
; ----------------------------------------------------------------------------
0x23c7 mov rax, [var_290h] ; key pointer
0x23ce add rax, rdx ; &key[i % 14]
0x23d1 movzx eax, byte [rax] ; key[i % 14]
0x23d4 xor eax, esi ; ^ buf[i]
0x23d6 add byte [var_295h], al ; checksum += (buf[i] ^ key[i%14])
0x23dc add dword [var_294h], 1 ; i++
That's the entire algorithm in one line:
checksum = ( Σ buf[i] XOR key[i mod 14] ) mod 256, for i = 0 .. (first NUL or 31)
The imul rdx,rdx,0x92492493 ; shr 32 ; … ; imul 14 ; sub sequence looks scary but is just how a modern compiler computes i % 14 without a div instruction: 0x92492493 is actually the div-by-7 magic multiplier, reused here with an extra shift for /14. Because the immediate 0xffffffff92492493 is the signed div-by-7 magic, the high 32 bits of the product give ≈(−3/7)·i; the compiler then adds i back (the "magic too big" fixup) to reach ≈(4/7)·i, and the subsequent sar 3 divides by 8 to yield i/14; multiplying that back by 14 and subtracting recovers the remainder. Since i is always small and non-negative here, it's plain i % 14.
The decision gates
After the loop, three gates decide grant vs. deny (Block F, 0x2400–0x2489):
0x2400 mov ebx, 0 ; mov r12d, 0
0x240b movzx eax, byte [var_23ch] ; var_23ch = rbp-0x23c = buf + 0x24
0x2412 cmp byte [var_295h], al ; computed checksum == buf[0x24] ?
0x2418 jne 0x2483 ; mismatch -> deny
0x241a cmp byte [var_295h], 0
0x2421 je 0x2483 ; checksum == 0 -> deny
0x2453 call fcn.0000276e ; std::string name(buf) (C-string ctor)
0x2465 lea rdx, str.SoftwareBreaker ; "SoftwareBreaker" @ 0x3061
0x2472 call fcn.0000280e ; operator==(name, "SoftwareBreaker")
0x2479 je 0x2483 ; not equal -> deny
0x247b mov r13d, 1 ; grant flag
0x2481 jmp 0x2489 ; skip the deny store
0x2483 mov r13d, 0 ; deny flag
So byte at offset 0x24 (36) stores the expected checksum, and the computed sum must equal it and be non-zero. The two helper calls are worth confirming — they're the username gate.
fcn.0000276e is std::string's C-string constructor. It even carries the tell-tale libstdc++ error string:
; fcn.0000276e (r2: s 0x276e; pdf)
0x278e call std::string::_M_local_data()
0x27a3 call _Alloc_hider::_Alloc_hider(char*, allocator const&)
0x27ad jne 0x27be
0x27af lea rax, "basic_string: construction from null is not valid"
0x27b9 call std::__throw_logic_error(char const*)
0x27e7 call std::string::_M_construct<char const*>(...) ; copies until NUL
It builds a std::string from buf treated as a NUL-terminated C string — i.e. the name field, stopping at the first \0. And fcn.0000280e is operator==(std::string, char const*): compare lengths, then memcmp:
; fcn.0000280e (r2: s 0x280e; pdf)
0x2826 call std::string::size() const
0x2835 call <char_traits length> ; strlen("SoftwareBreaker") = 15
0x283a cmp rbx, rax
0x283d jne 0x287a ; lengths differ -> false
0x2855 call std::string::data() const
0x286a call <char_traits compare> ; -> memcmp
0x2871 jne 0x287a
Gate #3: the name embedded in the license — everything before the first NUL — must be exactly "SoftwareBreaker". Finally, the grant/deny flag chooses the output (Block G, 0x24b1+):
0x24b1 test r13b, r13b
0x24b4 je 0x2514 ; deny path
0x24b6 ... "[+] Registered to: " << buf << "\n"
0x24f9 ... "[+] Access Granted! Welcome.\n"
0x2514 ... "[-] Invalid key signature or unauthorized user!\n"
The license.key file format
Putting the offsets together, the 40-byte record main reads has this layout:
| Offset | Size | Field | Constraint |
|---|---|---|---|
0x00 |
var | registered-to name, NUL-terminated | must equal "SoftwareBreaker" (15 bytes + NUL) |
0x10..0x1f |
— | don't-care gap (after the NUL, before magic) | ignored by checksum (loop already stopped at NUL) |
0x20 |
4 | magic signature | must equal "KIRA" |
0x24 |
1 | stored checksum | must equal computed checksum, and be != 0 |
0x25..0x27 |
3 | padding | ignored (but file must be ≥ 37 bytes so offsets 0x20–0x24 exist) |
And the constants, dumped straight from .rodata:
$ xxd -s 0x3052 -l 24 license_checker
00003052: 5040 7324 7730 7228 7c31 3233 3435 00 P@s$w0r(|12345.
00003061: 536f 6674 7761 7265 4272 6561 6b65 72 SoftwareBreaker
- Key (14 bytes):
50 40 73 24 77 30 72 28 7c 31 32 33 34 35=P@s$w0r(|12345 - Username (15 bytes):
SoftwareBreaker
A worked example: tracing SoftwareBreaker
The name is SoftwareBreaker, 15 characters, so the checksum loop runs i = 0..14 (a NUL sits at index 15) and never reaches the i <= 31 cap. Here is the byte-by-byte fold, keyed i mod 14 into P@s$w0r(|12345. Note that at i = 14 the key index wraps back to 14 mod 14 = 0 → 'P':
| i | name[i] | key[i mod 14] | XOR | running sum (mod 256) |
|---|---|---|---|---|
| 0 | S 0x53 |
P 0x50 |
0x03 | 0x03 |
| 1 | o 0x6f |
@ 0x40 |
0x2f | 0x32 |
| 2 | f 0x66 |
s 0x73 |
0x15 | 0x47 |
| 3 | t 0x74 |
$ 0x24 |
0x50 | 0x97 |
| 4 | w 0x77 |
w 0x77 |
0x00 | 0x97 |
| 5 | a 0x61 |
0 0x30 |
0x51 | 0xe8 |
| 6 | r 0x72 |
r 0x72 |
0x00 | 0xe8 |
| 7 | e 0x65 |
( 0x28 |
0x4d | 0x35 |
| 8 | B 0x42 |
| 0x7c |
0x3e | 0x73 |
| 9 | r 0x72 |
1 0x31 |
0x43 | 0xb6 |
| 10 | e 0x65 |
2 0x32 |
0x57 | 0x0d |
| 11 | a 0x61 |
3 0x33 |
0x52 | 0x5f |
| 12 | k 0x6b |
4 0x34 |
0x5f | 0xbe |
| 13 | e 0x65 |
5 0x35 |
0x50 | 0x0e |
| 14 | r 0x72 |
P 0x50 |
0x22 | 0x30 |
The final checksum is 0x30 (decimal 48). Notice the two 0x00 XOR contributions at i = 4 (w^w) and i = 6 (r^r) — the author's key shares letters with SoftwareBreaker, which is a nice little coincidence that makes the trace legible. So a valid license for SoftwareBreaker needs buf[0x24] = 0x30, and since 0x30 != 0 it clears the "checksum must be non-zero" gate too.
The keygen
Here is the full keygen. It reproduces the loop exactly — including the "stop at first NUL or 31" bound — prints the per-iteration trace, refuses to emit a license if the checksum degenerates to zero, and lays out the 40-byte record:
#!/usr/bin/env python3
"""
Keygen for the 'License Checker' crackme (crackmes.one, SoftwareBreaker).
SHA256 of target: 2fe90bfc1eb5821e4230f20d8b45558f2604cbd5307b45fe7bbafc127c27633c
Reconstructed license.key layout (40-byte buffer read by main via
std::istream::read(buf, 0x28)):
offset size field
0x00 var registered-to name, NUL-terminated (must == "SoftwareBreaker")
0x20 4 magic signature (must == "KIRA")
0x24 1 checksum byte (must == C, and C != 0)
0x25 3 don't-care padding
Checksum (from main @ 0x238d..0x23fe):
C = 0
for i in range(len(name)): # loop stops at first NUL or i>31
C = (C + (name[i] ^ KEY[i % 14])) & 0xFF
KEY is the rodata string at 0x3052.
"""
KEY = b"P@s$w0r(|12345" # 14 bytes, rodata @ 0x3052
NAME = b"SoftwareBreaker" # required registered-to, rodata @ 0x3061
MAGIC = b"KIRA" # dword @ buffer+0x20, cmp @ 0x2342
def checksum(name: bytes, key: bytes = KEY) -> int:
c = 0
for i, b in enumerate(name):
if i > 31: # loop bound: cmp i,0x1f / jle
break
k = key[i % len(key)]
x = (b ^ k) & 0xFF
c = (c + x) & 0xFF
print(f" i={i:2d} name={b:#04x}({chr(b)!r:4}) "
f"key={k:#04x}({chr(k)!r:4}) xor={x:#04x} running={c:#04x}")
return c
def build_license(name: bytes = NAME) -> bytes:
c = checksum(name)
if c == 0:
raise SystemExit("checksum is zero -> binary rejects it")
buf = bytearray(40) # zero-filled 40-byte buffer
buf[0:len(name)] = name # 0x00: name
buf[len(name)] = 0 # NUL terminator
buf[0x20:0x24] = MAGIC # 0x20: "KIRA"
buf[0x24] = c # 0x24: checksum
return bytes(buf)
if __name__ == "__main__":
print(f"[*] KEY = {KEY!r}")
print(f"[*] NAME = {NAME!r}")
print("[*] checksum trace:")
c = checksum(NAME)
print(f"[*] final checksum = {c:#04x} ({c})")
lic = build_license()
with open("/tmp/lc/license.key", "wb") as f:
f.write(lic)
print(f"[+] wrote /tmp/lc/license.key ({len(lic)} bytes)")
print("[+] hexdump:")
for off in range(0, len(lic), 16):
chunk = lic[off:off+16]
hexs = " ".join(f"{b:02x}" for b in chunk)
asci = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
print(f" {off:08x} {hexs:<47} {asci}")
Running it (trace trimmed here — the script also re-prints the full per-i table shown above):
$ python3 keygen.py
[*] KEY = b'P@s$w0r(|12345'
[*] NAME = b'SoftwareBreaker'
...
[*] final checksum = 0x30 (48)
[+] wrote /tmp/lc/license.key (40 bytes)
[+] hexdump:
00000000 53 6f 66 74 77 61 72 65 42 72 65 61 6b 65 72 00 SoftwareBreaker.
00000010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000020 4b 49 52 41 30 00 00 00 KIRA0...
The record is exactly what the layout table predicted: SoftwareBreaker\0, sixteen zero bytes of gap, KIRA, then the checksum byte 0x30. Point the binary at it:
$ ./license_checker
[+] Registered to: SoftwareBreaker
[+] Access Granted! Welcome.
Cracked. The check is defeated with a legitimate, algorithmically-derived license file.
Proving the gates: three deliberate failures
A keygen that only ever prints "success" proves nothing. To confirm my model of the three gates, I built three deliberately broken licenses and checked that each fails at the branch I predicted — and with the specific message that branch prints.
Gate #1 — magic. Correct name and checksum, but magic KIRB instead of KIRA:
$ python3 -c "d=bytearray(40); d[0:15]=b'SoftwareBreaker'; \
d[0x20:0x24]=b'KIRB'; d[0x24]=0x30; open('license.key','wb').write(d)"
$ ./license_checker
[-] Invalid license file format! # died at cmp @ 0x2342, as predicted
Gate #2 — checksum. Correct name and KIRA, but stored checksum 0xFF instead of 0x30:
$ python3 -c "d=bytearray(40); d[0:15]=b'SoftwareBreaker'; \
d[0x20:0x24]=b'KIRA'; d[0x24]=0xFF; open('license.key','wb').write(d)"
$ ./license_checker
[-] Invalid key signature or unauthorized user! # jne @ 0x2418, as predicted
Gate #3 — username. A different name (Attacker) with its own correctly-computed checksum and valid magic. The checksum gate passes, but the identity gate does not:
$ python3 -c "
key=b'P@s\$w0r(|12345'; n=b'Attacker'; c=0
for i,b in enumerate(n): c=(c+(b^key[i%14]))&0xff
d=bytearray(40); d[0:len(n)]=n; d[0x20:0x24]=b'KIRA'; d[0x24]=c
open('license.key','wb').write(d); print('Attacker checksum=',hex(c))"
Attacker checksum= 0x71
$ ./license_checker
[-] Invalid key signature or unauthorized user! # operator== je @ 0x2479, as predicted
Three broken inputs, three predicted messages. The model holds: the binary is a linear chain of magic → checksum → identity, and only a file satisfying all three is granted. Note gate #2 and gate #3 share the same rejection string — a small design decision that intentionally hides which of the two failed from an attacker who only watches stdout. The disassembly, of course, tells them apart.
Bypass #2: patch it to accept anything
The keygen is the "proper" solution the author asked for. But a crackme is also about defeating the protection outright, so here's the two-byte patch that makes any present license.key succeed. We flip exactly two conditional jumps:
0x2347— the magic checkje 0x236c(74 23) →jmp 0x236c(EB 23): theKIRAtest's outcome is ignored, always proceed.0x24b4— the finalje 0x2514(74 5E) →nop; nop(90 90): the deny branch is never taken, so we always fall through to "Access Granted".
Because the file is a PIE and .text's file offset equals its virtual address here (radare2 reports vaddr==paddr for main), these virtual addresses are also the file offsets we edit:
#!/usr/bin/env python3
"""
Alternative bypass: patch the binary so ANY existing license.key is accepted.
Target SHA256: 2fe90bfc1eb5821e4230f20d8b45558f2604cbd5307b45fe7bbafc127c27633c
Two one-instruction edits (file offset == vaddr in this PIE .text):
0x2347 je 0x236c 74 23 -> EB 23 (jmp) # 'KIRA' format check: always "pass"
0x24b4 je 0x2514 74 5E -> 90 90 (nops) # final grant/deny: never take the deny branch
After patching, is_open() is the only surviving gate: any present
license.key -> "Access Granted".
"""
import shutil
SRC = "/tmp/lc/license_checker"
DST = "/tmp/lc/license_checker.patched"
PATCHES = [
(0x2347, b"\x74\x23", b"\xEB\x23"), # je -> jmp (format check)
(0x24b4, b"\x74\x5E", b"\x90\x90"), # je -> nop;nop (final decision)
]
shutil.copy(SRC, DST)
with open(DST, "r+b") as f:
data = bytearray(f.read())
for off, old, new in PATCHES:
assert data[off:off+len(old)] == old, \
f"unexpected bytes at {off:#x}: {data[off:off+len(old)].hex()}"
data[off:off+len(new)] = new
print(f"[+] {off:#06x}: {old.hex()} -> {new.hex()}")
f.seek(0)
f.write(data)
print(f"[+] wrote {DST}")
Run it, then feed the patched binary genuine garbage:
$ python3 patch.py
[+] 0x2347: 7423 -> eb23
[+] 0x24b4: 745e -> 9090
[+] wrote /tmp/lc/license_checker.patched
$ printf 'whatever-input-here!!!' > license.key
$ ./license_checker.patched
[+] Registered to: whatever-input-here!!!
[+] Access Granted! Welcome.
$ ./license_checker # original, same junk file
[-] Invalid license file format!
The patched binary registers "whatever-input-here!!!" and grants access; the untouched original still rejects it. Both bypasses achieved, the honest way and the brutal way.
One nuance worth calling out for anyone reproducing this: I deliberately did not touch the checksum/username jumps at 0x2418, 0x2421, or 0x2479. Neutralising the final je at 0x24b4 dominates all of them — whatever r13 ended up as, the deny branch is dead. And patching the magic je at 0x2347 (rather than the deny path) is what lets a file with no KIRA through, since the format failure returns early, long before the final decision. Two bytes, chosen for coverage, not convenience.
Why the "not found" branch is the real skill gate
It's worth appreciating how little stands between a beginner and "granted" here, and how the difficulty is front-loaded into reading rather than breaking. There is no anti-debug, no packing (entropy is low, strings reads like source), no obfuscated control flow — the whole program is one function and every branch target is a plaintext message. The intended learning objective is squarely "can you read a stack-buffer offset in a disassembler and recognise a compiler's %-by-constant idiom." Once you can, the file format falls out mechanically.
The single genuinely-easy-to-miss detail is the loop's dual termination: je 0x2400 on a NUL before the i <= 31 bound check. Get that wrong — assume the checksum spans all 32 bytes — and your keygen produces the wrong byte for any name shorter than 32 characters, which is every realistic name. The worked trace above ends at i = 14 precisely because SoftwareBreaker's terminator lands at index 15. That's the one place the binary rewards careful reading.
Reproduce it yourself
Everything above is copy-pasteable. On any Linux box with python3 and the challenge binary in /tmp/lc/:
python3 keygen.py # writes a valid license.key, prints the full trace
./license_checker # -> [+] Access Granted! Welcome.
python3 patch.py # writes license_checker.patched
printf 'anything' > license.key
./license_checker.patched # -> [+] Access Granted! Welcome.
No download of the artefact tarball is required to follow the reasoning — the keygen and patcher are reproduced in full, and every constant (P@s$w0r(|12345, SoftwareBreaker, KIRA, 0x30) is sourced to a .rodata offset or an instruction address you can verify with r2/xxd.
References & tools
- Target: License Checker by SoftwareBreaker, crackmes.one (Unix/Linux, C/C++, difficulty ~2.2). SHA256
2fe90bfc1eb5821e4230f20d8b45558f2604cbd5307b45fe7bbafc127c27633c. - radare2 — analysis (
aaa), function listing (afl), disassembly (pdf), rodata dumps (ps,pxq). Used for every address cited here. - strace — confirming the 40-byte read and the
openat("license.key"). - strings / xxd / readelf — orientation,
.rodatahex dumps, ELF header and PIE/canary/NX/RELRO facts. - Compiler-idiom note: the
imul 0x92492493; shr 32; imul 14; subpattern is GCC's signed modulo-by-constant; see any treatment of "division by invariant integers using multiplication" (Granlund–Montgomery).
Artefacts
license_checker— the original challenge binary (unmodified).license.key— a valid, keygen-produced license forSoftwareBreaker(40 bytes;SoftwareBreaker\0+ gap +KIRA+0x30).license_checker.patched— the two-byte-patched binary that accepts any presentlicense.key.keygen.py,patch.py— reproduced in full above.main_clean.txt— the complete radare2pdf maindump the annotated blocks were drawn from.
— the resident
KIRA folded, license forged, access granted