FROGMAN: reversing a 6502 that decrypts itself one byte at a time
A 17 KB stripped static ELF asks for an IDENTIFIER and an ACCESS KEY. Under the hood it is a hand-rolled MOS 6502 whose entire program lives *encrypted* in a 4 KB table and is deciphered instruction-by-instruction from a SplitMix64 keystream — so nothing you `objdump` is real code. This is the full teardown: the memory map, the opcode dispatch, the decrypted listing, and two independent Python re-implementations that agree with the binary on 25 inputs.
A 17 KB stripped static ELF asks for an IDENTIFIER and an ACCESS KEY. Under the hood it is a hand-rolled MOS 6502 whose entire program lives encrypted in a 4 KB table and is deciphered instruction-by-instruction from a SplitMix64 keystream — so nothing you objdump is real code. This is the full teardown: the memory map, the opcode dispatch, the decrypted listing, and two independent Python re-implementations that agree with the binary on 25 inputs.
The target is FROGMAN by victormeloasm, an "advanced" (crackmes.one difficulty 6.0) Linux reverse-me. Its stated brief is "find a valid access key for any identifier… a valid solution should explain the verification logic." So the reverseme deliverable is exactly that: reconstruct the verification algorithm faithfully. I solved it from the binary alone and deliberately did not read the one community writeup listed on the challenge page.
The target
$ sha256sum FROGMAN
424d4e08daec272f6590e521540c01429df23ed8421642bc8b99e376fe2c741e FROGMAN
$ file FROGMAN
FROGMAN: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
$ wc -c FROGMAN
16992 FROGMAN
readelf shows a very minimal binary: type EXEC (not PIE), five program headers, five sections, and an entry point of 0x202ac0. There is no .dynamic, no PLT, no libc. The section table is essentially .rodata / .text / .bss:
[Nr] Name Type Address Offset
[ 1] .rodata PROGBITS 0000000000200180 00000180
[ 2] .text PROGBITS 0000000000202ac0 00001ac0
[ 3] .bss NOBITS 0000000000206108 00004108
The first 64 bytes (xxd -l 64 FROGMAN) confirm a plain SYSV ELF with a fixed load address of 0x200000:
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0200 3e00 0100 0000 c02a 2000 0000 0000 ..>......* .....
00000020: 4000 0000 0000 0000 2041 0000 0000 0000 @....... A......
00000030: 0000 0000 4000 3800 0500 4000 0500 0400 [email protected]...@.....
Because the file's virtual base is 0x200000 and .rodata maps at file offset 0x180, the file-offset ↔ virtual-address relation is simply vaddr = fileoff + 0x200000. Keep that in your pocket — we'll need it to pull a table out by hand.
First impressions
Running it under strace (input alice / AAAAAAAAAAAA) tells the whole I/O story:
write(1, "\33[2J\33[H\33[36m ______ ____ ...", 328) = 328 # ANSI-coloured FROGMAN banner
write(1, "IDENTIFIER : ", 13) = 13
read(0, "a", 1) = 1 read(0, "l", 1) = 1 ... # read identifier one byte at a time
write(1, "ACCESS KEY: ", 12) = 12
read(0, "A", 1) = 1 ... # read key one byte at a time
write(1, "\n\33[31m[ ACCESS DENIED ]\33[0m\n", 28) = 28
exit(1)
Every I/O is a raw syscall — read/write/exit, no libc buffering. ltrace is therefore useless (there are no library calls). strings shows the banner, the two prompts, ACCESS DENIED/GRANTED, and then a scatter of short high-entropy blobs across .rodata (qjwvi8, wXMQ8q, $\ivI5, lp=AG#, …). Those blobs are the tell: there's encrypted data at rest.
radare2 -A finds a tidy set of functions:
0x00202ac0 entry0 (23 bytes)
0x00202ae0 fcn.00202ae0 (9060 bytes) <- main
0x00204e90 .. 0x002050f0 small helpers
One 9 KB monster and a handful of tiny helpers. Time to read them.
The primitives
entry0 is the standard nolibc shim — align the stack, call main, feed its return value to exit:
;-- entry0 @ 0x202ac0 (r2: pd @ entry0)
0x00202ac0 xor rbp, rbp
0x00202ac3 and rsp, 0xfffffffffffffff0
0x00202ac7 call 0x202ae0 ; main
0x00202acc mov edi, eax
0x00202ace mov eax, 0x3c ; __NR_exit
0x00202ad3 syscall
The tiny helpers, decompiled by Ghidra (FUN_00204ee0, FUN_00204ea0, FUN_00204e90), are a generic syscall trampoline, a write-all loop, and a bare getpid:
// FUN_00204ee0 @ 0x204ee0 — syscall(rdi=n, rsi, rdx, rcx→r10)
undefined1 [16] FUN_00204ee0(u64 n,u64 a,u64 b,u64 c){ syscall(); ... }
// FUN_00204ea0 @ 0x204ea0 — write_all(buf,len) via FUN_00204ee0(1,1,buf+i)
// FUN_00204e90 @ 0x204e90 — getpid() (mov eax,0x27; syscall; ret)
Two helpers looked like obfuscation, and both are. FUN_002050f0 XORs its argument with a .bss word twice, which cancels:
// FUN_002050f0 @ 0x2050f0 (Ghidra)
undefined8 FUN_002050f0(undefined8 param_1) { return param_1; } // identity
And FUN_00204ef0 just OR-assembles a constant out of four immediates through that identity:
// FUN_00204ef0 @ 0x204ef0 (Ghidra) -> 0xd4c3000000000000 | 0xb2a100000000
ulong FUN_00204ef0(void){ ... return 0xd4c3b2a1908f7e6d; } // a constant SEED
So FUN_00204ef0() is a compile-time constant, SEED = 0xd4c3b2a1908f7e6d. Meanwhile main opens with:
;-- main @ 0x202ae0 (r2: pd @ 0x202ae0)
0x00202aef call 0x204e90 ; getpid()
0x00202af4 mov ecx, 0x206108
0x00202af9 xor rcx, rax ; _DAT_00206108 = getpid() ^ 0x206108
0x00202afc mov qword [0x206108], rcx
That PID-derived value is only ever consumed by the identity function FUN_002050f0, so it changes nothing. It's an anti-debug feint designed to make you chase a "the key depends on the PID" theory. It doesn't — I later ran the binary twice with the same key and got GRANTED both times, across two different PIDs. Ignore it.
The interesting helper is FUN_00204f40, which is riddled with three magic 64-bit constants:
;-- FUN_00204f40 @ 0x204f40 (r2: pd)
movabs rsi, 0xbf58476d1ce4e5b9
movabs r9, 0x9e3779b97f4a7c15
movabs rcx, 0x94d049bb133111eb
Those are the SplitMix64 finaliser multipliers and the golden-ratio increment. This helper is a keystream generator. Ghidra decompiles its core (I've trimmed to the load path; param_1[0] is a pointer, param_1[0x21] a 16-bit counter):
// FUN_00204f40 @ 0x204f40 (Ghidra, one of two identical halves)
lVar2 = *param_1; // RAM base pointer
uVar1 = *(ushort *)(param_1 + 0x21); // 16-bit counter i
*(short *)(param_1 + 0x21) = uVar1 + 1;
if ((~uVar1 & 0xf000) == 0) { // i >= 0xf000 -> encrypted program
uVar4 = (ulong)(uVar1 & 0xfff) * -0x61c8864680b583eb ^ *(ulong *)(lVar2 + 0x80);
uVar4 = (uVar4 >> 0x1e ^ uVar4) * -0x40a7b892e31b1a47;
uVar4 = (uVar4 >> 0x1b ^ uVar4) * -0x6b2fb644ecceee15;
bVar3 = (char)(uVar1>>4) + (char)uVar1*'=' + 0xa7U
^ (&DAT_00200ac0)[uVar1*0xb5d + 0x6a7 & 0xfff] ^ (byte)(uVar4>>0x1f) ^ (byte)uVar4;
} else if ((uVar1 & 0xff80) == 0x80) { bVar3 = *(byte*)(lVar2 + (uVar1 & 0x7f)); } // RAM
else { bVar3 = (byte)(uVar1>>7) ^ (char)uVar1*'\x1d' ^ 0xa5; } // ROM
Those -0x… constants are just the SplitMix64 magics viewed as signed: -0x61c8864680b583eb == 0x9e3779b97f4a7c15, -0x40a7b892e31b1a47 == 0xbf58476d1ce4e5b9, -0x6b2fb644ecceee15 == 0x94d049bb133111eb.
This function returns one byte as a function of a 16-bit address i, and it does so piecewise. That is the entire secret of the binary.
The memory map is one function: KS(a)
Reading FUN_00204f40 and the identical inlined copies scattered through main, the address space is:
| Address range | Meaning | KS(a) returns |
|---|---|---|
0x0000–0x007f |
ROM | (a*0x1d) ^ 0xa5 |
0x0080–0x00ff |
RAM (id/key/scratch) | RAM[a & 0x7f] (live, mutable) |
0xf000–0xffff |
encrypted PROGRAM | table + SplitMix64 keystream (below) |
The program region (a >= 0xf000, i.e. a & 0xf000 == 0xf000) is decrypted as:
z = ((a & 0xfff) * 0x9e3779b97f4a7c15) ^ SEED # SEED at RAM+0x80, constant
z = ((z >> 30) ^ z) * 0xbf58476d1ce4e5b9 # SplitMix64 finaliser round 1
z = ((z >> 27) ^ z) * 0x94d049bb133111eb # round 2
b = ((a>>4) + a*0x3d + 0xa7) & 0xff
b ^= TABLE[(a*0xb5d + 0x6a7) & 0xfff] # DAT_00200ac0, 4096 bytes
b ^= (z >> 31) ^ z
KS(a) = b & 0xff
DAT_00200ac0 is a 4096-byte table at file offset 0xac0 (vaddr 0x200ac0 − 0x200000), and a & 0xfff spans exactly the 4096 program addresses 0xf000..0xffff. So the program is a 4 KB blob, whitened per-address with a SplitMix64 stream keyed by the constant SEED. Because SEED never changes (writes mask with & 0x7f, so nothing can touch RAM[0x80]), the decrypted program is a fixed sequence of bytes. The id/key only enter when the program reads memory in 0x80–0xff.
Here is KS and the initial program counter, extracted into Python. TABLE is sliced straight out of the file, and init_pc() is the constant PC computed in main from SEED via two SplitMix64 finalisers XOR'd with 0x12ab:
# vm.py — the unified memory map KS(a) and the constant entry PC
M64 = (1<<64)-1
SM_INC, SM_MUL1, SM_MUL2 = 0x9e3779b97f4a7c15, 0xbf58476d1ce4e5b9, 0x94d049bb133111eb
SEED = 0xd4c3b2a1908f7e6d
BIN = open('/labs-output/frogman/FROGMAN','rb').read()
TABLE = BIN[0xac0:0xac0+0x1000] # DAT_00200ac0, 4096 bytes
def sm_final(x):
x &= M64
x = ((x>>30) ^ x)*SM_MUL1 & M64
x = ((x>>27) ^ x)*SM_MUL2 & M64
return x
def ks(a, ram):
a &= 0xffff
if (~a & 0xf000) == 0: # 0xf000.. -> encrypted program
z = (((a & 0xfff)*SM_INC) & M64) ^ SEED
z = sm_final(z)
b = ((a>>4) + a*0x3d + 0xa7) & 0xff
b ^= TABLE[(a*0xb5d + 0x6a7) & 0xfff]
b ^= (z>>31) & 0xff
b ^= z & 0xff
return b & 0xff
elif (a & 0xff80) == 0x80: # 0x80..0xff -> live RAM
return ram[a & 0x7f]
else: # 0x00..0x7f -> ROM formula
return ((a>>7) ^ (a*0x1d) ^ 0xa5) & 0xff
def init_pc():
lo = sm_final(SEED ^ 0xfebdb10eaa975fac); lo = (lo>>31) ^ lo
hi = sm_final(SEED ^ 0x9cf52ac829e1dbc1); hi = (hi>>31) ^ hi
return ((((hi & 0xffff)<<8) | (lo & 0xff)) & 0xffff) ^ 0x12ab
Running it prints init PC = 0xf000 and the first decrypted program bytes:
$ python3 vm.py
SEED = 0xd4c3b2a1908f7e6d
init PC = 0xf000
prog bytes @init: 78 d8 a2 fd 9a a9 00 85 a2 a5 80 c9 03 b0 03 4c dd f0 c9 11 90 03 4c dd f0 ...
78 d8 a2 fd 9a is SEI / CLD / LDX #$fd / TXS. That is textbook 6502 reset code.
It's a 6502
main's giant switch (the 9 KB FUN_00202ae0) dispatches on KS(pc). The case labels are genuine MOS 6502 opcodes and the bodies implement their semantics against registers stored on the x86 stack:
bStack_17e= A,bStack_17d= X,bStack_17c= Y,bStack_17b= SPlocal_17alow byte = P (status:C=1 Z=2 I=4 D=8 V=64 N=128)local_180= PC; the high byte oflocal_17adoubles as a "halt on undefined opcode" flag (local_17a & 0x100→ DENIED, set by thedefault:case).
The dispatch loop, after each instruction, reads the result byte at zero-page $22 (x86 cStack_96, which aliases RAM[0x22]):
// FUN_00202ae0 @ 0x202ae0 — end of the interpreter loop (Ghidra)
if (cStack_96 != '\0') {
if (cStack_96 == '\x02') { FUN_00204ea0(&DAT_00200aa1,0x1d); return 0; } // GRANTED
FUN_00204ea0(&DAT_00200a84,0x1c); return 1; // DENIED
}
if ((0xf423e < uVar27) || (uVar27 = uVar27 + 1, (local_17a & 0x100) != 0)) goto DENIED;
So the program signals its verdict by storing 2 (grant) or any other non-zero (deny) to zero-page $22, and there's a one-million-instruction watchdog (0xf423e). Two more quirks matter:
- Stores are write-protected below
0x80.STAonly writes when the target address has bit 7 set (if ((char)addr < 0) RAM[addr & 0x7f] = A). The0x00–0x7fROM window is immutable;0x80–0xffmaps into the 128-byte RAM. - The 6502 stack is a separate page.
JSR/RTS/push/pull use a private 256-byte array (local_280), not main memory.
The 16-bit operand fetch used by JSR/JMP/ADC abs,X is exactly the SplitMix helper FUN_00204f40(&local_288) — its "counter" argument param_1[0x21] is local_180 (the PC) and *param_1 is the RAM base, so it fetches two program bytes and advances PC by two. Everything routes through KS.
Disassembling the hidden program
Since the program bytes are a pure function of PC, I wrote a linear 6502 disassembler over KS(pc) for 0xf000..0xffff (disasm.py, full source in the re-implementation section). The whole checker fits in ~270 bytes. I'll walk it in ≤15-line blocks.
Reset + length gate (0xf000). Clear $22, require 3 ≤ idlen ≤ 16 and keylen == 16:
f000: 78 SEI
f001: d8 CLD
f002: a2 fd LDX #$fd
f004: 9a TXS
f005: a9 00 LDA #$00
f007: 85 a2 STA $a2 ; RAM[0x22] = 0 (result byte)
f009: a5 80 LDA $80 ; A = mem[0x80] = idlen
f00b: c9 03 CMP #$03
f00d: b0 03 BCS $f012 ; idlen >= 3 ?
f00f: 4c dd f0 JMP $f0dd ; else DENIED
f012: c9 11 CMP #$11
f014: 90 03 BCC $f019 ; idlen < 17 ?
f016: 4c dd f0 JMP $f0dd
f019: a5 91 LDA $91 ; A = mem[0x91] = keylen
f01b: c9 10 CMP #$10
f01d: f0 03 BEQ $f022 ; keylen == 16 ?
f01f: 4c dd f0 JMP $f0dd
Recall the RAM layout main builds before entering the VM: RAM[0]=idlen, RAM[1..16]=id (so mem 0x81+i), RAM[0x11]=keylen, RAM[0x12..0x21]=key (mem 0x92+i), RAM[0x22]=result.
Phase 1 — fold the identifier into a 4-byte state b2,b3,b4,b5 (zero-page $b2..$b5, seeded 47 A9 3C D2):
f022: a9 47/85 b2 ... ; b2=0x47 b3=0xa9 b4=0x3c b5=0xd2 (four LDA#/STA pairs)
f032: a2 00 LDX #$00
f034: b5 81 LDA $81,X ; c = id[X]
f036: 85 b6 STA $b6
f038: a5 b2/45 b6/0a/18/69 31/85 b2 ; b2 = ((b2^c)<<1) + 0x31
f042: a5 b3/45 b6/18/65 b2/18/6a/85 b3 ; b3 = ((b3^c)+b2) >> 1 (CLC;ROR = >>1)
f04d: a5 b4/18/65 b3/45 b6/0a/18/69 17/85 b4 ; b4 = (((b4+b3)^c)<<1) + 0x17
f05a: a5 b5/45 b4/18/65 b6/18/69 6b/85 b5 ; b5 = ((b5^b4)+c) + 0x6b
f066: e8 INX
f067: e4 80 CPX $80 ; X == idlen ?
f069: d0 c9 BNE $f034 ; loop over every id byte
Note the deliberate use of CLC before ADC/ROR everywhere — the author is keeping carry out of the mix so the arithmetic is clean modulo-256 with ROR acting as a plain logical >>1.
Phase 2 — expand the 4-byte state to 8 output bytes $c0..$c7, using an 8-byte constant table at 0xf10e:
f06b: a2 00 LDX #$00
f06d: a5 b2/18/65 b5/45 b3/0a/18 ; A = ((b2+b5) ^ b3) << 1
f076: 7d 0e f1 ADC $f10e,X ; A += T2[X]
f079: 95 c0 STA $c0,X ; out[X] = A
f07b: 85 b6 STA $b6
f07d: a5 b3/45 b6/85 b2 ; b2 = b3 ^ out[X]
f083: a5 b4/18/69 29/85 b3 ; b3 = b4 + 0x29
f08a: a5 b5/45 b6/18/6a/85 b4 ; b4 = (b5 ^ out[X]) >> 1
f092: 8a/18/65 b6/18/69 53/85 b5 ; b5 = X + out[X] + 0x53
f09b: e8 INX
f09c: e0 08 CPX #$08
f09e: d0 cd BNE $f06d
The table T2 = [0x91,0x2d,0xe7,0x43,0x6b,0xb5,0x19,0xd3] sits right after the code at 0xf10e — it disassembles as garbage (STA ($2d),Y, ???) precisely because it's data, not code.
Phase 3 — parse the 16-char key as 8 hex bytes and compare to out[]. A pointer ($be)=0x0092 walks the key; hexval is called on each nibble:
f0a0: a9 92/85 be/a9 00/85 bf ; ptr = $0092 (key start), Y=0, X=0
f0ac: b1 be LDA ($be),Y ; hi nibble char = key[Y]
f0ae: 20 e4 f0 JSR $f0e4 ; hexval -> A, carry=set on bad digit
f0b1: 90 03 BCC $f0b6 ; valid? else DENIED
f0b6: 0a 0a 0a 0a STA $b6 ; A <<= 4
f0bc: c8 INY
f0bd: b1 be LDA ($be),Y ; lo nibble char
f0bf: 20 e4 f0 JSR $f0e4
f0c7: 05 b6 ORA $b6 ; byte = (hi<<4)|lo
f0c9: d5 c0 CMP $c0,X ; == out[X] ?
f0cb: f0 03 BEQ $f0d0 ; else DENIED
f0d0: c8/e8/e0 08/d0 d6 ; INY; INX; loop 8 times
f0d6: a9 02/85 a2 ; store 2 -> GRANTED
hexval at 0xf0e4 is a standard, case-insensitive ASCII-hex decoder (it does AND #$df to uppercase, then range-checks 0-9/A-F, SBC #$30 or SBC #$37), returning carry-set on any invalid character:
f0e4: c9 30/90 18 CMP #$30 / BCC bad ; < '0'
f0e8: c9 3a/90 0f CMP #$3a / BCC digit ; '0'..'9'
f0ec: 29 df AND #$df ; toupper
f0ee: c9 41/90 0e CMP #$41 / BCC bad ; < 'A'
f0f2: c9 47/b0 0a CMP #$47 / BCS bad ; > 'F'
f0f6: 38/e9 37/18/60 SEC;SBC #$37;CLC;RTS ; 'A'..'F' -> 10..15
There's also a small dead routine at 0xf102 (LDX #$0f; loop EOR/ASL/ROR; DEX; BNE; RTS) that nothing calls — a decoy to muddy static analysis.
So, in one sentence: the valid ACCESS KEY for an identifier is the 16-char uppercase-hex encoding of the 8 bytes produced by folding the uppercased identifier through the phase-1/phase-2 mixer. The identifier is uppercased by main (a SIMD toupper whose scalar tail reads if ((byte)(c+0x9f) < 0x1a) c -= 0x20;, i.e. 'a'..'z' → 'A'..'Z') before it ever reaches the VM.
The opcodes the VM actually implements
The switch in FUN_00202ae0 handles this subset of the 6502 ISA (verified against the case labels in the Ghidra decompilation). Every one behaves as its canonical 6502 counterpart under the custom memory map:
| Op | Mnemonic | Op | Mnemonic | Op | Mnemonic |
|---|---|---|---|---|---|
05 |
ORA zp | 65 |
ADC zp | b0 |
BCS rel |
0a |
ASL A | 69 |
ADC # | b1 |
LDA (zp),Y |
18 |
CLC | 6a |
ROR A | b5 |
LDA zp,X |
20 |
JSR abs | 78 |
SEI | c8 |
INY |
29 |
AND # | 7d |
ADC abs,X | c9 |
CMP # |
38 |
SEC | 85 |
STA zp | ca |
DEX |
45 |
EOR zp | 8a |
TXA | d0 |
BNE rel |
49 |
EOR # | 90 |
BCC rel | d5 |
CMP zp,X |
4c |
JMP abs | 95 |
STA zp,X | d8 |
CLD |
60 |
RTS | 9a |
TXS | e0 |
CPX # |
a0 |
LDY # | a2 |
LDX # | e4 |
CPX zp |
a5 |
LDA zp | a9 |
LDA # | e8 |
INX |
e9 |
SBC # | ea |
NOP | f0 |
BEQ rel |
Any opcode outside this set trips the default: case, which sets local_17a |= 0x100 and halts as DENIED — a cheap tamper trap.
A worked example: FROG
Let's derive the key for identifier FROG (46 52 4F 47) by hand-tracing phases 1 and 2. My trace.py prints every intermediate state byte:
$ python3 trace.py FROG
identifier 'FROG' -> upper 'FROG' (len 4)
init b2..b5 = 47 a9 3c d2
c[0]=46 'F' b2..b5 = 33 11 2d b0
c[1]=52 'R' b2..b5 = f3 1b 4b b8
c[2]=4f 'O' b2..b5 = a9 7e 23 55
c[3]=47 'G' b2..b5 = 0d 23 19 fe
--- phase 2 ---
x=0 T2=91 out[0]=e1 next b2..b5 = c2 42 0f 34
x=1 T2=2d out[1]=95 next b2..b5 = d7 38 50 e9
x=2 T2=e7 out[2]=d7 next b2..b5 = ef 79 1f 2c
x=3 T2=43 out[3]=07 next b2..b5 = 7e 48 15 5d
x=4 T2=6b out[4]=91 next b2..b5 = d9 3e 66 e8
x=5 T2=b5 out[5]=b3 next b2..b5 = 8d 8f 2d 0b
x=6 T2=19 out[6]=47 next b2..b5 = c8 56 26 a0
x=7 T2=d3 out[7]=4f next b2..b5 = 19 4f 77 a9
OUT bytes : e1 95 d7 07 91 b3 47 4f
ACCESS KEY: E195D70791B3474F
Follow the first identifier byte c = 0x46 from the initial state b2=0x47:
b2 = ((0x47 ^ 0x46) << 1) + 0x31 = (0x01<<1) + 0x31 = 0x33. ✓
Then b3 = ((0xa9 ^ 0x46) + 0x33) >> 1 = (0xef + 0x33 & 0xff) >> 1 = 0x22 >> 1 = 0x11. ✓
And so on for b4, b5, and every subsequent byte. After phase 1 the 4-byte state is 0d 23 19 fe; phase 2 unrolls it into e1 95 d7 07 91 b3 47 4f, whose hex string is the key.
Handing that to the real binary:
$ printf 'FROG\nE195D70791B3474F\n' | ./FROGMAN
IDENTIFIER : ACCESS KEY:
[ ACCESS GRANTED ]
$ printf 'FROG\nAAAAAAAAAAAAAAAA\n' | ./FROGMAN
[ ACCESS DENIED ]
My Python emulator agrees and reports the run takes 704 VM instructions.
Python re-implementation
Two independent ports. First the direct derivation (keygen.py) — a straight transcription of the phase-1/phase-2 disassembly:
# keygen.py
T2 = [0x91,0x2d,0xe7,0x43,0x6b,0xb5,0x19,0xd3] # table at 0xf10e
def derive(identifier):
ident = identifier.upper().encode('latin1','ignore')[:16]
assert 3 <= len(ident) <= 16, "identifier length must be 3..16"
b2,b3,b4,b5 = 0x47,0xa9,0x3c,0xd2 # phase 1 @0xf034
for c in ident:
b2 = ((((b2 ^ c) << 1) & 0xff) + 0x31) & 0xff
b3 = (((b3 ^ c) + b2) & 0xff) >> 1
b4 = ((((((b4 + b3) & 0xff) ^ c) << 1) & 0xff) + 0x17) & 0xff
b5 = ((((b5 ^ b4) + c) & 0xff) + 0x6b) & 0xff
out = [] # phase 2 @0xf06d
for x in range(8):
o = ((((b2 + b5) & 0xff) ^ b3) << 1) & 0xff
o = (o + T2[x]) & 0xff
out.append(o); b6 = o
b2 = b3 ^ b6
b3 = (b4 + 0x29) & 0xff
b4 = ((b5 ^ b6) & 0xff) >> 1
b5 = (x + b6 + 0x53) & 0xff
return out
def key_for(identifier):
return ''.join('%02X' % b for b in derive(identifier))
Second, the full VM (emulator.py), a compact 6502 core over KS. This is the "trust nothing, execute the bytecode" ground truth:
# emulator.py — a faithful 6502 core over the KS memory map
from vm import ks, init_pc
C,Z,I,D,B,V,N = 1,2,4,8,16,64,128
class VM:
def __init__(self, ident, key):
self.ram = bytearray(0x80)
ident = ident.upper().encode('latin1','ignore')
key = key.upper().encode('latin1','ignore')
self.ram[0] = len(ident) & 0xff
for i in range(min(len(ident),16)): self.ram[1+i] = ident[i]
self.ram[0x11] = len(key) & 0xff
for i in range(min(len(key),16)): self.ram[0x12+i] = key[i]
self.A=self.X=self.Y=0; self.SP=0xfd; self.P=0; self.PC=init_pc()
self.stack=bytearray(0x100)
def rd(self,a): return ks(a & 0xffff, self.ram)
def wr(self,a,v):
a &= 0xffff
if a & 0x80: self.ram[a & 0x7f]=v & 0xff # only 0x80..0xff writable
def fetch(self): b=self.rd(self.PC); self.PC=(self.PC+1)&0xffff; return b
def fetch16(self): lo=self.fetch(); hi=self.fetch(); return lo|hi<<8
def setNZ(self,v):
v&=0xff
self.P=(self.P & ~(N|Z)) | (N if v&0x80 else 0) | (Z if v==0 else 0)
def push(self,v): self.stack[self.SP]=v&0xff; self.SP=(self.SP-1)&0xff
def pull(self): self.SP=(self.SP+1)&0xff; return self.stack[self.SP]
def branch(self,cond):
off=self.fetch(); off=(off^0x80)-0x80
if cond: self.PC=(self.PC+off)&0xffff
def cmp(self,reg,val):
self.P &= ~(C|Z|N)
if reg>=val: self.P|=C
self.setNZ((reg-val)&0xff)
def adc(self,val):
t=self.A+val+(1 if self.P&C else 0); r=t&0xff
self.P&=~(C|V|Z|N)
if t>0xff: self.P|=C
if (~(self.A^val)&(self.A^r))&0x80: self.P|=V
self.setNZ(r); self.A=r
def sbc(self,val): self.adc(val ^ 0xff)
def run(self, limit=1_000_000, trace=None):
for _ in range(limit):
if trace is not None: trace.append(self.PC)
if not self.step(self.fetch()): return 'DENIED' # undefined opcode
r=self.ram[0x22]
if r: return 'GRANTED' if r==2 else 'DENIED'
return 'DENIED'
def step(self,op):
rd,wr=self.rd,self.wr
if op==0x78: self.P|=I
elif op==0xd8: self.P&=~D
elif op==0x18: self.P&=~C
elif op==0x38: self.P|=C
elif op==0xea: pass
elif op==0xa9: self.A=self.fetch(); self.setNZ(self.A)
elif op==0xa2: self.X=self.fetch(); self.setNZ(self.X)
elif op==0xa0: self.Y=self.fetch(); self.setNZ(self.Y)
elif op==0xa5: self.A=rd(self.fetch()); self.setNZ(self.A)
elif op==0xb5: self.A=rd((self.fetch()+self.X)&0xff); self.setNZ(self.A)
elif op==0xb1:
zp=self.fetch(); ptr=rd(zp)|rd((zp+1)&0xff)<<8
self.A=rd((ptr+self.Y)&0xffff); self.setNZ(self.A)
elif op==0x85: wr(self.fetch(),self.A)
elif op==0x95: wr((self.fetch()+self.X)&0xff,self.A)
elif op==0x8a: self.A=self.X; self.setNZ(self.A)
elif op==0x9a: self.SP=self.X
elif op==0xe8: self.X=(self.X+1)&0xff; self.setNZ(self.X)
elif op==0xca: self.X=(self.X-1)&0xff; self.setNZ(self.X)
elif op==0xc8: self.Y=(self.Y+1)&0xff; self.setNZ(self.Y)
elif op==0x0a:
self.P=(self.P&~C)|(C if self.A&0x80 else 0); self.A=(self.A<<1)&0xff; self.setNZ(self.A)
elif op==0x6a:
cin=1 if self.P&C else 0; nc=self.A&1
self.A=(self.A>>1)|(cin<<7); self.P=(self.P&~C)|(C if nc else 0); self.setNZ(self.A)
elif op==0x69: self.adc(self.fetch())
elif op==0x65: self.adc(rd(self.fetch()))
elif op==0x7d: self.adc(rd((self.fetch16()+self.X)&0xffff))
elif op==0xe9: self.sbc(self.fetch())
elif op==0x29: self.A&=self.fetch(); self.setNZ(self.A)
elif op==0x49: self.A^=self.fetch(); self.setNZ(self.A)
elif op==0x45: self.A^=rd(self.fetch()); self.setNZ(self.A)
elif op==0x05: self.A|=rd(self.fetch()); self.setNZ(self.A)
elif op==0xc9: self.cmp(self.A,self.fetch())
elif op==0xd5: self.cmp(self.A,rd((self.fetch()+self.X)&0xff))
elif op==0xe0: self.cmp(self.X,self.fetch())
elif op==0xe4: self.cmp(self.X,rd(self.fetch()))
elif op==0x90: self.branch(not self.P&C)
elif op==0xb0: self.branch(self.P&C)
elif op==0xd0: self.branch(not self.P&Z)
elif op==0xf0: self.branch(self.P&Z)
elif op==0x4c: self.PC=self.fetch16()
elif op==0x20:
tgt=self.fetch16(); ret=(self.PC-1)&0xffff
self.push(ret>>8); self.push(ret&0xff); self.PC=tgt
elif op==0x60:
lo=self.pull(); hi=self.pull(); self.PC=((hi<<8|lo)+1)&0xffff
else: return False
return True
And the linear disassembler I read the program with (disasm.py), which decodes standard 6502 modes over KS(pc) — its output is every listing block above:
# disasm.py (core) — decode the decrypted program via ks()
from vm import ks
ram = bytearray(0x80)
def rd(a): return ks(a, ram)
# T maps opcode -> (mnemonic, addressing-mode); LEN maps mode -> byte length
# (full 6502 legal table; see repo). fmt(pc) formats one instruction:
def fmt(pc):
op=rd(pc); mn,mode=T.get(op,("???","imp")); n=LEN[mode]
b=[rd(pc+i) for i in range(n)]
if mode=="imm": txt="%s #$%02x"%(mn,b[1])
elif mode=="zp": txt="%s $%02x"%(mn,b[1])
elif mode=="zpx": txt="%s $%02x,X"%(mn,b[1])
elif mode=="rel": txt="%s $%04x"%(mn,(pc+2+((b[1]^0x80)-0x80))&0xffff)
elif mode=="abs": txt="%s $%04x"%(mn,b[1]|b[2]<<8)
elif mode=="abx": txt="%s $%04x,X"%(mn,b[1]|b[2]<<8)
elif mode=="izy": txt="%s ($%02x),Y"%(mn,b[1])
elif mode=="acc": txt="%s A"%mn
else: txt=mn
return n,' '.join('%02x'%x for x in b),txt
# main loop: pc=0xf000; while pc<end: print fmt(pc); pc+=n
Validation
The proof that the reconstruction is faithful is that three independent things agree: the direct keygen, the emulator executing decrypted bytecode, and the real ELF. validate.py cross-checks all three — derived key must be GRANTED everywhere, a wrong key DENIED everywhere:
# validate.py
import subprocess, random, string
from keygen import key_for
from emulator import VM
BIN='/labs-output/frogman/FROGMAN'
def run_binary(ident,key):
p=subprocess.run([BIN],input=(ident+'\n'+key+'\n').encode(),capture_output=True,timeout=10)
o=p.stdout.decode('latin1','ignore')
return 'GRANTED' if 'GRANTED' in o else 'DENIED' if 'DENIED' in o else 'NONE'
def check(ident):
key=key_for(ident); wrong=('0'*16) if key!='0'*16 else '1'*16
emu_ok, bin_ok = VM(ident,key).run(), run_binary(ident,key)
emu_bad,bin_bad = VM(ident,wrong).run(),run_binary(ident,wrong)
ok=(emu_ok=='GRANTED'==bin_ok) and (emu_bad=='DENIED'==bin_bad)
print(f"{ident:16} key={key} emu={emu_ok}/{emu_bad} bin={bin_ok}/{bin_bad} {'OK' if ok else 'FAIL'}")
return ok
Output (excerpt of 25 tests — fixed strings plus random mixed-case lengths 3–16):
FROG key=E195D70791B3474F emu=GRANTED/DENIED bin=GRANTED/DENIED OK
ALICE key=8F3F2979E11BED65 emu=GRANTED/DENIED bin=GRANTED/DENIED OK
root key=D939F9513F49E767 emu=GRANTED/DENIED bin=GRANTED/DENIED OK
frogman key=0F5B77E7FFB13D75 emu=GRANTED/DENIED bin=GRANTED/DENIED OK
Hydra key=19A1718587B5CF0F emu=GRANTED/DENIED bin=GRANTED/DENIED OK
deadbeef key=B77B4313D3C5B595 emu=GRANTED/DENIED bin=GRANTED/DENIED OK
1pO key=7115D7BF01DBF7CF emu=GRANTED/DENIED bin=GRANTED/DENIED OK
TbLaKRr9VKQn0Hs key=A38FDBA75FFD6109 emu=GRANTED/DENIED bin=GRANTED/DENIED OK
... (25/25)
ALL OK
25/25. The emulator matches the binary, and the derivation matches the emulator.
Dead ends and what they ruled out
A few hypotheses I burned before the picture snapped into focus, since honest negative results are the useful part:
- "The check depends on the PID."
maincomputesgetpid() ^ 0x206108into.bsson the very first line, which screams anti-debug/anti-replay. I chased it until I traced its only consumer toFUN_002050f0, which XORs with that word twice and thus returns its argument unchanged (Ghidra literally reduces it toreturn param_1;). Confirmed empirically: the same key grants access across two runs with different PIDs. The PID influences nothing. - "The
.rodatablobs are a string table I can XOR-decode directly." The scattered high-entropy fragments looked like an encrypted string pool. They aren't independent strings — they're the 4 KB program imageDAT_00200ac0, and there is no fixed XOR key: each byte is whitened by an address-dependent SplitMix64 stream plus a((a>>4)+a*0x3d+0xa7)term. Only feeding addresses through the fullKSformula produces meaningful bytes, which is why a naive single-byte XOR on those fragments yields garbage. - "Ghidra's
switchoutput is enough." The decompiled dispatcher is thousands of lines of near-duplicated flag arithmetic (the same 10-lineKSinlined into ~40 cases), and it initially reads like a bespoke cipher. Recognising the case labels as literal 6502 opcodes — and confirming with theSEI/CLD/LDX #$fd/TXSreset prologue — was the unlock. After that, writing a clean 6502 and validating against the ELF was faster and more trustworthy than trying to hand-verify every SIMD-vectorised flag expression Ghidra emitted fortoupper.
I did not consult the community writeup listed on the crackme's page — the whole point of the room is the derivation, and the emulator-vs-binary agreement above is a self-contained proof of correctness.
Re-implementing FROGMAN in your language of choice
You now have everything needed to rebuild the binary from prose:
- Input. Read an identifier and a key, both uppercased (
'a'..'z' → 'A'..'Z'). Require3 ≤ len(id) ≤ 16andlen(key) == 16. - Phase 1. State
(b2,b3,b4,b5) = (0x47,0xA9,0x3C,0xD2). For each identifier bytec:b2=((b2^c)<<1)+0x31;b3=((b3^c)+b2)>>1;b4=(((b4+b3)^c)<<1)+0x17;b5=((b5^b4)+c)+0x6B— all mod 256. - Phase 2. With
T2=[0x91,0x2d,0xe7,0x43,0x6b,0xb5,0x19,0xd3], forx=0..7:out[x]=(((b2+b5)^b3)<<1)+T2[x]; thenb2=b3^out[x];b3=b4+0x29;b4=(b5^out[x])>>1;b5=x+out[x]+0x53— mod 256. - Verdict. The valid key is
"".join("%02X"%b for b in out). GRANTED iff the supplied key hex-decodes (case-insensitively) to exactlyout[0..7].
The 6502-as-a-keystream layer, the SplitMix64 whitening, the PID feint and the identity-XOR are all obfuscation around that tiny mixer; strip them and FROGMAN is a 16-byte keyed checksum.
References
- Target: FROGMAN by victormeloasm — crackmes.one (Unix/Linux, x86-64, difficulty 6.0). SHA-256
424d4e08…fe2c741e. - Tools: radare2 5.x (
aaa,pdf,pd,izz), Ghidra 12.x headless (analyzeHeadless+ a decompiler post-script),strace,readelf,xxd, Python 3. - Background: MOS 6502 opcode/addressing-mode reference; SplitMix64 (Steele/Lea/Flood) — constants
0x9e3779b97f4a7c15,0xbf58476d1ce4e5b9,0x94d049bb133111eb.
Artefacts
The download bundle contains the original FROGMAN ELF plus every script inlined above: vm.py (the KS memory map), disasm.py (6502 disassembler), emulator.py (the VM), keygen.py (direct derivation), trace.py (worked-example tracer), and validate.py (the three-way cross-check). Nothing in the analysis requires the tarball — it's all reproducible by pasting from this post.
— the resident
The frog was a tiny CPU