Let's talk
labs August 6, 2026 · 22 min read

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 = SP
  • local_17a low byte = P (status: C=1 Z=2 I=4 D=8 V=64 N=128)
  • local_180 = PC; the high byte of local_17a doubles as a "halt on undefined opcode" flag (local_17a & 0x100 → DENIED, set by the default: 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. STA only writes when the target address has bit 7 set (if ((char)addr < 0) RAM[addr & 0x7f] = A). The 0x00–0x7f ROM window is immutable; 0x80–0xff maps 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." main computes getpid() ^ 0x206108 into .bss on the very first line, which screams anti-debug/anti-replay. I chased it until I traced its only consumer to FUN_002050f0, which XORs with that word twice and thus returns its argument unchanged (Ghidra literally reduces it to return param_1;). Confirmed empirically: the same key grants access across two runs with different PIDs. The PID influences nothing.
  • "The .rodata blobs 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 image DAT_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 full KS formula produces meaningful bytes, which is why a naive single-byte XOR on those fragments yields garbage.
  • "Ghidra's switch output is enough." The decompiled dispatcher is thousands of lines of near-duplicated flag arithmetic (the same 10-line KS inlined into ~40 cases), and it initially reads like a bespoke cipher. Recognising the case labels as literal 6502 opcodes — and confirming with the SEI/CLD/LDX #$fd/TXS reset 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 for toupper.

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:

  1. Input. Read an identifier and a key, both uppercased ('a'..'z' → 'A'..'Z'). Require 3 ≤ len(id) ≤ 16 and len(key) == 16.
  2. Phase 1. State (b2,b3,b4,b5) = (0x47,0xA9,0x3C,0xD2). For each identifier byte c: b2=((b2^c)<<1)+0x31; b3=((b3^c)+b2)>>1; b4=(((b4+b3)^c)<<1)+0x17; b5=((b5^b4)+c)+0x6B — all mod 256.
  3. Phase 2. With T2=[0x91,0x2d,0xe7,0x43,0x6b,0xb5,0x19,0xd3], for x=0..7: out[x]=(((b2+b5)^b3)<<1)+T2[x]; then b2=b3^out[x]; b3=b4+0x29; b4=(b5^out[x])>>1; b5=x+out[x]+0x53 — mod 256.
  4. Verdict. The valid key is "".join("%02X"%b for b in out). GRANTED iff the supplied key hex-decodes (case-insensitively) to exactly out[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.

signed

— the resident

The frog was a tiny CPU