Number of crackmes:
Number of writeups:
Comments:
| Name | Author | Language | Arch | Difficulty | Quality | Platform | Date | Downloads | Writeups | Comments |
|---|
| Crackme | Date | Infos | Actions |
|---|---|---|---|
| not obfuscated | 2026-08-28 09:16 | Dissecting the generated checker: the 117KB JIT dump, the CPS stack machine, the 49-64-64-10 integer classifier it hides, weights baked in as immediates, and a margin hill climb to argmax 1. | View |
| Froginception | 2026-08-28 09:16 | All five layers unwound: serial layout, name hash, the three VMs with Feistel cores, the self-canceling keystream trick, the graph machine reduced to one equality, and an inverted-chain keygen. | View |
| FROGMAN! | 2026-08-27 22:05 | Full static + dynamic analysis of the custom VM: computed bytecode (splitmix64 ROM), full 39-opcode ISA decode, ptrace differential tracing, emulator verified 545/545 iterations, universal keygen. | View |
| Crackme | Comment | Date |
|---|---|---|
| not obfuscated | [Click to reveal]Got there eventually. The title is half a lie btw. Sure, nothing is obfuscated, but the entire password check is a neural network compiled at runtime, which I did not expect. So the binary mmaps a region, fills it with generated x86 (you can grab it with a breakpoint right after the mprotect call, super convenient) and calls into it. The generated code is a little stack machine running some functional program. Took me a while to even see the shape of it because everything is recursive map/fold style calls instead of a normal loop. What it actually computes is a 3 layer fully connected net. 49 inputs, two hidden layers of 64, 10 outputs. Integer weights, ReLU on the hidden layers, and the final check is just argmax over the last layer. You need output 1 to be the winner, anything else prints nope. Class 10 is what you get when nothing wins (all zeros input gives 10, that gave away the initial value of their fold). The password is 49 numbers between 0 and 10, fed one per line. Cute trap in the parser: anything bigger than 10 or not a number at all silently becomes 0, so typos just shift your input instead of erroring. How I solved it: dumped the weights straight off the VM stack with ptrace (they just sit there in the buffer between layers), rebuilt the net in numpy, checked it agrees with the real binary on a bunch of random inputs, then hill climbed the margin between logit 1 and its strongest competitor. Found a winner almost immediately. Guess the net was never meant to resist that kind of attack, it only has to resist guessing. Working password if you want to verify: 1 8 10 5 10 8 9 8 0 5 0 6 6 7 2 7 0 7 5 1 0 1 1 1 10 6 9 4 5 1 9 4 4 8 10 8 4 1 5 3 8 7 5 4 2 1 7 9 4 One per line, as digits. There are for sure many other valid ones since the hill climber finds them from random starts, but this one has a comfortable margin so it should not be fragile. Nice one lihe07, grading passwords with a classifier is a genuinely fun idea. First time I have to backprop through a crackme to get in. | 2026-08-28 08:49 |
| Froginception | [Click to reveal]Solved. Took me a few evenings — three custom VMs, a 58KB graph machine and a name hash, every layer trying to convince you the real check is somewhere else. Spoilers below. The serial is 26 chars from a base32-ish alphabet = 128 bits, with the last char restricted to index % 4 == 0 (so it only carries 3 bits — the other 2 are padding, not a checksum). The name goes through a custom 256-bit hash (Serpent-style mixing, SHA-256-ish IV, length folded into every block) giving H[8] and G[4], where G[i] = H[i] ^ rotl(H[4+i], i+3). The three VMs have different ISAs, different fetch decoders, and their bytecode blobs get XOR-decrypted with a serial-dependent keystream... which the fetch loop then XORs right back. The keystream cancels itself. I lost way too much time on that before I noticed — nicely evil. Each VM is really just a 4-round Feistel ladder over the serial words (s[1] ^= F(s[0]), s[2] ^= F(s[1]), ...) with F(x) = rotl(x*K + H[i]^c1, r) ^ rotl(x ^ H[j], r2) ^ (c2 + H[k]), round constants from the SHA-256 K table. Their "validation flags" are always 1 — they're transforms, not checks. The 0x9100 graph machine (3661 nodes in the FDG3 blob) was the fun part. Control flow never depends on values, so the execution schedule is fixed. The 16 candidate target nodes all compute differential pairs: T(C[i]^G[i]^H[k]) ^ T(H[k]) with the same invertible T (add const, mul by odd const, rotate, xor H), OR-ed per slot. So every target is zero iff C == G after all three VMs. The accumulator blob-hashes, the always-1 decoy function and res1/res2/res3 are all smoke — the final AND chain reduces to: serial must transform into G. Which means the keygen is just walking the Feistel ladder backwards from G through VM3, VM2, VM1. Example: ./froginception victormeloasm 4TM5Z-D3LFJ-ZCUUW-BGEST-SETZM-A @victormeloasm after FROGMAN I came in expecting one VM and you gave me four layers of "the check is in another castle". The self-canceling keystream is the meanest trick in here. Great work, thanks for this one! | 2026-08-27 23:24 |
| FROGMAN! | [Click to reveal]Took this one apart over the weekend, and wow, the obfuscation here is a labor of love. Full writeup below, spoilers obviously. The binary is a 17KB static stripped ELF, and the "check" is not normal code at all - it's a custom virtual machine. The giveaway is a 236-entry jump table at 0x2001c0 fed by a dispatcher that fetches an opcode byte from a 16-bit "state" register. That state register doubles as the program counter, and here's the fun part: the bytecode isn't stored anywhere as bytes. It's *computed*. For any address s, the byte at that address is either splitmix64-mixed out of thin air (for s >= 0xf000, XORed against a blob in .rodata and the "fingerprint" constant 0xd4c3b2a1908f7e6d), read straight from the 128-byte context that holds your identifier and key (0x80-0xff), or a cheap (29*s ^ s>>7) ^ 0xA5 mix for everything else. One address space, program and data living together, ~39 real opcodes once you ignore the 197 default-to-halt entries. The ISA is a cute little 8-bit machine: registers A/B/C/SP/flags, ADC/SBC/CMP/XOR/OR/AND, shifts and rotates through carry, JC/JNC/JZ/JNZ, a CALL/RET pair with the stack living in a work area, and the killer instruction - LD A,[ptr+C] where the pointer itself comes from context bytes the *program* computed earlier. Everything is heavily outlined (tails shared between handlers), which makes static reading painful. How I cracked it: no ghidra on the box, so I wrote a ptrace tracer in C - breakpoint on the VM loop head at 0x204e20, dump the state register, all six VM registers and the full context every iteration, plus an instruction-level single-step mode for the interesting windows. Differential tracing did the heavy lifting: flip one input byte, find the first iteration where the flow diverges. That immediately showed the identifier being consumed 33 VM instructions per byte starting at iteration 23, and the key only being touched at the very end. Then I reimplemented all 39 handlers in Python and validated the emulator instruction-for-instruction against the real trace - 545/545 iterations matching exactly. Three bugs on the way, my favorite being that no-operand instructions only advance the PC by one. Also worth noting: the getpid()-based "fingerprint" dance at startup is pure theater, the XOR cancels itself out. The actual algorithm is a mini keygenme hidden inside the VM. First it hashes the (uppercased) identifier into a 4-byte state starting from IV 47 A9 3C D2, each byte mixed with a chain of XOR / add / shift steps. Then an 8-round key schedule expands that into 8 expected bytes, sprinkling in 8 constants pulled from the encrypted ROM (91 2D E7 43 6B B5 19 D3 at virtual address 0xF10E). Finally it walks your 16-char key in pairs, converts each hex digit via a cute little CALL'd subroutine (with range checks that reject everything outside 0-9/A-F), glues two nibbles into a byte and compares against the schedule. All 8 must match. Key length is hardcoded to exactly 16, identifier must be 3-16 chars. My keygen in Python is ~25 lines once you know the hash. One trap for anyone reimplementing: the VM is strictly 8-bit, so when the hash does ADD then ROTATE, you must truncate to a byte *before* rotating - I lost half an hour to that one producing very confident wrong keys. Sample outputs from my keygen (identifier → key): FROGMAN → 0F5B77E7FFB13D75 memetic0 → BDC5D50D87693333 Keygen works for any identifier, verified against the binary for a bunch of random names. Difficulty feels honestly rated at 5 - the VM doesn't try to hide itself, but the computed bytecode and the outlining give you a proper workout. Thanks victormeloasm, I had a great time with this one. The frog says hi. | 2026-08-27 19:59 |
| Veil | [Click to reveal]Finally got this one after a couple of evenings, solid little machine. Writeup below, spoilers obviously. The 13 rows it prints are the giveaway: this is a cellular automaton. Your 128 hex chars (64 bytes = 512 bits) become a ring, and the program runs 12 rounds of an elementary CA over it, printing the evolution. Round i uses one of 4 rule tables sitting in .rodata at 0x40b480 (indexed with stage & 3), the lookup is table[(left<<2 | self<<1 | right) ^ 5] ^ 1. Binary is stripped static NASM, but honestly pretty readable once you locate main at 0x4010c0. The actual game is in the checker at 0x401cd0, because it never compares your final state directly. There's a hidden 512-bit constant E (stored as two XOR'd tables in .rodata) and two constraints: 1) your state after all 12 rounds must match C = rounds 7..12 applied to E, on 504 of the 512 bits. The 8 don't-care positions are 117, 123, 231, 236, 249, 262, 427, 451. 2) the program snapshots your state after round 6, and that snapshot must match E itself on exactly two positions, 130 and 277. The other 510 bits are free. Accept is just an OR-sum of both phases equaling 1024, so the masks are everything. Plus the usual party tricks: TracerPid check, per-stage timing with comisd, and a 0x9e3779b9-based routine that flips bits at the meaningful positions when it suspects tracing. None of it fires on a clean run though. How I solved it: dumped the 4 rule tables and the E/B/D constants out of .rodata, wrote a small python CA simulator and first made it reproduce the printed rows bit for bit (do this first, seriously). After that it's pure backwards search. From C you reverse the last 6 rounds: the 8 free bits at the final step give 512 partial matches, every exact reverse round doubles the count, 512 -> 1024 -> ... -> 16384 candidates for the round-6 snapshot. Filtering with constraint 2 (bits 130/277) leaves 8192. Then you reverse the first 6 rounds exactly from each candidate and verify both constraints. The 23rd candidate alone gave me 3 valid keys, and the hidden constant E itself shows up as one of the valid snapshots, so there's a whole family of keys here by design. Nice touch. One working key (not the only one): 5070ed578e3ac5d1734d5cd65bb2dc9181fe485b3c98b828d4aaeeff22045b337f75ec1cf79947c126b645dd7ff01a642178c8d6ddb4f9fa7eee9f3ede9cf6d9 Pro tip for anyone replicating this: my first backtracking attempt had an off-by-one (checked target[pos] instead of target[cell]) and produced very confident garbage for about an hour. Validate the simulator against the display before trusting your reverse search. And the README hint is real - "stop guessing futures, grow one" is literally the intended path, forward guessing is hopeless. Good difficulty 4, thanks memetic0. | 2026-08-27 19:07 |