Claude Code solved this whole event on its own, and I did not verify that the solutions really work. The only check was that each flag was accepted by the CTF platform and unlocked the next challenge. I submitted the first flag myself. From challenge 2 onwards, Claude Code submitted the flags autonomously and confirmed that they were accepted. I am not skilled enough at reverse engineering to validate the technical steps described here. The re-runs of the solve scripts mentioned below were also done by Claude Code, and they show only that each script reproduces its flag.
I used the 13th FLARE-On Challenge to test how well Claude Code handles reverse engineering. Claude Code, running Opus 5.5 on a Kali virtual machine, solved all nine challenges under a set of rules I wrote into a CLAUDE.md file. The main rule was that no challenge binary is ever run. This post explains that setup, walks through each challenge in the order Claude solved it, and links a complete Python solve script for every challenge. Claude Code later re-ran every script from a clean copy to confirm that it recovers the flag.
The full challenge set and the official solutions are available at flare-on.com/files/Flare-On13_Challenges.zip. This post does not include any challenge files.
The event and the result#
FLARE-On is an annual reverse engineering challenge run by the FLARE team. Each challenge is a password-protected 7z archive (the password is flare), and every flag ends in @flare-on.com. The challenges unlock one at a time, so a challenge has to be solved before the next one appears.
| # | Challenge | What it is | Flag accepted |
|---|---|---|---|
| 1 | FLAreCAPTCHA | HTML page with a fake CAPTCHA | Yes |
| 2 | GhostStream | WIM image, Windows executable that drops a second executable | Yes |
| 3 | FlareOn13.doc | Six-format polyglot file | Yes |
| 4 | ToxicMiner | Backdoored cryptocurrency miner (Windows) | Yes |
| 5 | catthief | Rust data thief plus a packet capture | Yes |
| 6 | Threat Invaders | .NET game with a modified runtime plus a packet capture | Yes |
| 7 | FlareCalc | Windows calculator with a hidden stage in its signature | Yes |
| 8 | crux | Chrome extension backed by obfuscated Go WebAssembly | Yes |
| 9 | NeonOutRun | Rust Tauri racing game with an embedded V8 engine | Yes |
Claude worked through all nine on 16 October 2026. Solving them took about six hours on Opus 5.5. The first five hours used up a full Claude session limit, which ran out partway through challenge 8. Once the limit reset, I told Claude to resume, and it finished the rest in about another hour. Apart from that, my prompts were short: start, keep going, and one request to retry challenge 6 (explained in its section).
How the CLAUDE.md was built#
CLAUDE.md is a file that Claude Code reads at the start of every session in a folder. It works as a standing brief, so the instructions in it survive context resets and session limits.
I started in an empty folder that held only a Python virtual environment and ran /init. With nothing to describe, Claude wrote a short placeholder. I then gave it one prompt describing the job: act as a cybersecurity professional solving the FLARE-On 13 CTF, use my logged-in Firefox session for the CTF platform, give each challenge its own folder, save generated artefacts to a loot subfolder, and finish each challenge with a flag.txt and a writeup.md detailed enough for someone else to follow. I also set the rules: never run a challenge binary, use Ghidra for reverse engineering, provide a full working script whenever Python is used, and install Python libraries only into the virtual environment.
Claude turned that prompt into a structured file and added a few conventions of its own, which it listed for me to accept or change: the script is always named solve.py, headless Ghidra is allowed with its output saved to loot/, and downloaded files are treated as untrusted, so scripts are kept apart from them and Python runs in isolated mode (-I).
The file then grew as the work went on. Claude recorded each new decision or lesson as it happened, so later challenges, and later sessions, followed it automatically:
- Downloading. No browser-control tool was available, so Claude asked how to reach the CTF platform. I approved reusing only the CTF session cookie from Firefox. Saved passwords stayed off limits.
- Flag submission. From challenge 2, I allowed Claude to submit verified flags itself and move straight on to the next challenge.
- A Ghidra time limit. Large Rust binaries can keep Ghidra busy for a long time, so I capped any Ghidra run at 10 minutes, after which Claude carries on with other static tools.
- Scoped exceptions. Challenges 7 and 9 hid constants behind control-flow flattening. Claude evaluated small, pure-arithmetic fragments with the Unicorn CPU emulator: only the code bytes were loaded, and there were no system calls and no process. For challenge 8, I approved running the challenge’s WebAssembly inside a sandboxed interpreter with no browser, network or file access. Each exception is written down in
CLAUDE.mdand disclosed in the relevant writeup. - Tool lessons. For example, Ghidra projects cannot live under a path that contains a dot-folder, and Ghidra rebases x86-64 position-independent executables by
0x100000.
The core of the final file looks like this:
## Layout for each challenge
<challenge-folder>/
├── <original artefacts> # files the CTF provides, exactly as downloaded
├── loot/ # everything generated while solving
├── solve.py # full working solve script, if Python was used
├── flag.txt # the flag only
└── writeup.md # short write-up of the full solution
## Rules
- Never execute challenge binaries (no running, sideloading, emulating them
on the host, or double-clicking them). All analysis must be static.
- Use Ghidra for reverse engineering (headless analyzeHeadless is fine).
- If a Ghidra run takes more than 10 minutes, stop it and keep going with
other static tools (objdump, capstone, pefile, strings).
- Treat downloaded artefacts as untrusted. Keep your own scripts separate
from the artefacts, pass artefact paths as arguments, and run Python with -I.The file is meant to do three things:
- Keep the work safe. FLARE-On binaries behave like malware. Keeping all analysis static means nothing hostile runs on the analysis machine, and every exception is narrow, approved and documented.
- Make the results reproducible. A fixed folder layout, a writeup per challenge and a complete solve script mean every flag can be checked by someone else, not just taken on trust.
- Carry context forward. Claude Code forgets a conversation once it ends, but the file does not, so a decision made during challenge 2 still applied after the session limit reset during challenge 8.
Verifying the solve scripts#
To confirm that each script recovers its flag by itself, Claude Code copied only the original archives and the published scripts into a fresh folder on the Kali machine. It extracted each archive into loot/extracted/ and ran solve.py with the same Python 3.14 virtual environment. All nine printed the accepted flag.
| # | Download | Run time | Needs |
|---|---|---|---|
| 1 | solve.py | under 1 s | nothing extra |
| 2 | solve.py | under 1 s | pefile, 7z |
| 3 | solve.py | 1 s | pikepdf, 7z, mutool (optional) |
| 4 | solve.py, brute_serial.c, ghidra2c.py, sha256_transform.c | 10 s | pefile, gcc with OpenMP |
| 5 | solve.py, extract_http.py | 69 s | pefile, tshark, pillow (optional) |
| 6 | solve.py, extract_http.py | 1 s | tshark |
| 7 | solve.py, walk_modules.py | 36 s | pefile, unicorn, capstone |
| 8 | solve.py, gojs_sandbox.py, pratt.py, target_x.py | 38 s | wasmtime, pycryptodome, sympy |
| 9 | solve.py, extract_js.py, hook_model.py | 38 s | unicorn, pyelftools, zstandard |
All scripts are also in one archive: flare-on-13-solve-scripts.zip. Run times are from a four-core virtual machine. Challenge 4’s time includes a full search of 2³² candidate keys.
To run one, put the scripts in a folder, extract the challenge archive into loot/extracted/ inside it and run solve.py from a virtual environment:
$ cd 07-flarecalc
$ 7z x -pflare -oloot/extracted 7-FlareCalc.7z
$ python -m venv .venv && .venv/bin/pip install pefile unicorn capstone
$ .venv/bin/python -I solve.py
Three challenges need an extra step:
- Challenge 3 reads the Mach-O checker from the disc image inside the polyglot. Run
solve.pyonce to writeloot/carved/cooked.iso, then run7z x -tudf -oloot/carved/udf loot/carved/cooked.isoand7z x -oloot/carved/macho loot/carved/udf/click_me, and runsolve.pyagain. - Challenge 4 needs
loot/ghidra/sha256_transform.c, which is Ghidra’s decompilation of the modified hash function. The download keeps it at that path. - Challenge 8 needs the extension unpacked as well:
7z x -oloot/ext loot/extracted/extension.zip.
Challenge 5’s flag is handwritten on a picture that the script recovers from the packet capture. The script cannot read handwriting, so it saves a crop of the picture and prints the flag as Claude transcribed it. Claude Code later compared the crop with the transcription, and they match.
1. FLAreCAPTCHA#
Provided:
1-flarecaptcha.7z, containingflarecaptcha.html.
The page imitates an “I’m not a robot” CAPTCHA widget. Claude read the inline JavaScript and never opened the page in a browser. A left click always fails. The working branch checks e.button === Math.pow(5, 0) + Math.pow(1, 1), which is a right click, held for 2,000 ms.
The flag is a 30-byte array read through a JavaScript Proxy that XORs every element with document.characterSet.length. The page declares UTF-8, so that length is 5. The result is XORed again with a 16-bit key, built from the hold time and the source length of the resetWidget function:
key = (round(2000 / 1000) * (resetWidget.toString().length + 1115)) & 0xFFFF // 2 * (222 + 1115) = 0x0A72
Even bytes use 0x0A and odd bytes use 0x72. Reformatting the page changes resetWidget’s source length and breaks the key, which works as an anti-tampering check. Claude cross-checked the key with known plaintext: XORing the last 13 bytes with @flare-on.com gives the same two key bytes.
Flag: [email protected]
2. GhostStream#
Provided:
2-GhostStream.7z, containingGhostStream.wim. The brief warns that the executable “refuses to talk to the naked eye” and that “your debugger forces a confession out of it”.
The challenge needed a debugger, so the interesting part was replacing every debugger step with static reasoning.
The archive holds a Windows Imaging (WIM) file, which preserves NTFS alternate data streams (ADS). The name “GhostStream” points to one, and 7-Zip extracts it only with -sns. The file PolyjuicePotion.txt has a hidden stream :LordVoldemort containing AlbusDumbledore.
Claude decompiled GhostStream.exe with headless Ghidra. Its API names are stack strings decoded with a key taken from the executable’s own file name, so renaming the file breaks every string. The parent program:
- Reads
PolyjuicePotion.txt. It switches to the hidden stream only if a global variable equals 1, and nothing in the program ever sets it. This is the “debugger forces a confession” switch. - Measures its own age in minutes and transforms the buffer only if it is more than 10,080 minutes (seven days) old. The transform XORs every byte with
0x82and the first 15 bytes with a fixed mask. - Drops an embedded resource as
Partner_CTF.exeand sends it the buffer through a named pipe.
Applying both forced conditions to the hidden stream turns AlbusDumbledore into HarryPotter. The child program uses the pipe data as an RC4 key, but first it hot-patches strlen so that the call rewrites the string it measures. The patched function decodes a table to Piertotum Locomotor and returns a length that then drives a modified RC4 keystream.
One detail could not be settled statically. Reading the patch, the returned length should be 19, but only 18 decrypts correctly. Rather than guess, solve.py tries both, and lets the child’s own @flare-on.com check pick the result. Only one combination out of all the candidates passes, and the scoreboard accepted it.
Flag: [email protected]
3. FlareOn13.doc#
Provided:
3-flareon13_doc.7z, containingflareon13.doc(1,855,887 bytes).
Despite its name, the file is not a Word document. The same bytes form a polyglot, a file that is valid in several formats at once:
| Offset | Format | Hidden phrase |
|---|---|---|
0x0 | DOS .COM program in the style of the EICAR test file | FLARE-STANDARD-ANTIVIRUS-TEST-FILE! |
0x84 | PDF 1.7, RC4-40 encrypted with an empty user password | invisible EBCDIC text mainframe_ebcdic_ghost, and a JBIG2 image reading jbig2_fax_geometry |
0x0 | Raw CD image (2,352-byte sectors) holding a UDF file system | udf_tagged_descriptor in a volume descriptor |
| inside UDF | Mach-O universal binary with two Arm64 programs, one in Odin and one in Crystal | Ød1n_373rn4l_fl4r3, computed by the Odin program |
0x1C4E3F | Encrypted ZIP holding flag.txt | reduce_not_deflate |
| last 512 bytes | VHD disk footer, which is also the ZIP comment | none |
The ZIP was the hardest layer. It uses ZipCrypto encryption with method 2, “Reduce”, an old compression method that neither 7-Zip nor Python’s zipfile supports. None of the phrases worked as the password. Instead, Claude spotted that the Reduce format leaks known plaintext: its stream begins with 256 follower-set counts, and for a short ASCII file the counts for bytes 0x80 and above are all zero. With 40 known zero bytes, bkcrack recovered the internal ZipCrypto keys in about six minutes. solve.py includes those keys and a Reduce decompressor that Claude wrote, and the decompressed file’s CRC32 matched.
The Crystal program is the checker. It reads six lines, requires each line’s MD5 hash to match one of six stored digests, XORs the SHA-256 hashes of all six into an RC4 key and decrypts the flag.
Flag: [email protected]
4. ToxicMiner#
Provided:
4-ToxicMiner.7z, containingToxicMiner.exe(4.8 MB).
Strings identified the program as cpuminer-multi 1.3.7, an open-source cryptocurrency miner, statically linked. The non-standard log format Accepted: %s led to a single function, the sha256d scan routine, which had been backdoored:
serial = GetVolumeInformationA(...) // the C: drive's volume serial number
if (hash(hash(serial || nonce)) == target) { // target comes from --user
key = hash(hash(serial || 0x0b501e7e))[:16]
print("Accepted: %s", RC4(key, ciphertext))
}The --user target is only a gate. The key depends on nothing but the 32-bit volume serial of the author’s machine, so there are only 2³² possibilities.
Claude’s first brute force used standard SHA-256 and found nothing readable, which sent it back to the assembly. The “toxic” hash runs only 32 rounds, swaps the first two round constants, replaces XOR with addition inside its Ch and Maj functions and adds an extra mixing step at the end. Rather than retype 32 rounds by hand, Claude wrote ghidra2c.py to turn Ghidra’s decompilation into compilable C mechanically. A multithreaded C brute force then found serial 01cec01d in about eight seconds.
Flag: [email protected]
5. catthief#
Provided:
5-catthief.7z, containingcatthief.exe(a Rust program) andcapture.pcapng.
The capture holds six HTTP POST /exfil requests to a small Python server. Every message, in both directions, starts with the same bytes, so the traffic was encrypted with a reused keystream, a “many-time pad”. XORing two messages together already revealed a Windows path ending in important.jpg.
Ghidra hit the 10-minute limit on the large Rust binary, so Claude stopped it and finished with its partial output and objdump. The program:
- builds a 25-byte RC4 key,
i11_b3_p1und3r1n_y3r_d474, by XORing stored bytes with0x33; - creates a fresh RC4 state for every message, which explains the reused keystream;
- compresses each stolen file with canonical Huffman coding before encrypting it;
- receives the path of the next file to steal in each server reply.
solve.py decrypts and decompresses all six uploads, recovering five photos of cats. None of them hides any data. The flag is simply handwritten on a parchment scroll in catcatcat.jpg.
Flag: [email protected]
6. Threat Invaders#
Provided:
6-ThreatInvaders.7z, containingThreatInvaders.exe(a 116 MB self-contained .NET program) andchallenge.pcapng.
This challenge took two attempts. The game’s managed code looked harmless, apart from a decoy flag. The real behaviour was in the .NET runtime bundled into the executable, which had been modified to hook six of the game’s methods. The fake high-score server smuggles commands to the player’s machine as score entries, and the game’s telemetry uploads carry the results back.
During the first attempt, a safety filter stopped one of Claude’s replies while it was describing the hidden code’s behaviour in detail. Claude stopped and reported what it had found so far. When I asked it to try another way, it narrowed the task to recovering the key and decrypting the captured data, and it finished in about a minute and a half.
- A score entry is a command when the first four bytes of the player name, XORed with the score, spell
cmd:. - Commands and their outputs share one XOR keystream, seeded from the victim’s Windows
MachineGuid. - The
MachineGuidnever appears in clear, but the second telemetry upload carries it XORed with the reversed session ID, and the session ID is sent in plain text.
XORing the two recovered the MachineGuid and the keystream. The two commands were cmd.exe /c dir, which listed a 46-byte flag.txt, and cmd.exe /c type flag.txt, whose captured output is the flag.
Flag: [email protected]
7. FlareCalc#
Provided:
7-FlareCalc.7z, containingFlareCalc.exe, a signed Windows calculator.
The calculator has several decoys. Specific calculations such as 525+193 decrypt FLAG{NICE_TRY}, ACCESS DENIED or KEY_EXPIRED, and one sends a Rick Astley lyric. The real trigger is an exception handler for floating-point division by zero. It proceeds only if the last three results were 22/7, 88224646 and 8675309.
The handler then loads a second stage from an unexpected place: the program’s own Authenticode signature. It copies the PKCS#7 signature blob into executable memory and jumps into it. Inside the signature, a custom object identifier 1.3.6.1.4.1.1337.1 holds 1,035 encrypted items. Each is decrypted with an MD5-derived key, built from the string 8675309-88224646-31428571, followed by a de-interleaving step.
Most of the items are small code modules forming a “labyrinth”. Each module takes one input byte and a 32-byte state and returns the ID of the next module. Claude emulated the modules with Unicorn, skipping their anti-analysis busy loops, and searched depth-first for the path from the start module to the exit. That path spells FLAREON_ASN1_Labyrinth_Key_2026!. The exit module is XTEA decryption, keyed from the final state, and it decrypts the flag.
Flag: [email protected]
8. crux#
Provided:
8-crux.7z, containingextension.zip, a Chrome extension.
The extension’s popup passes the user’s input to check(), a function in a 5.8 MB Go WebAssembly module obfuscated with garble. Ghidra cannot decompile WebAssembly, so Claude built its own tools around the WebAssembly Binary Toolkit (WABT):
gowasm_symbols.pyrecovers Go function names from the program’s symbol table, even though garble had changed its magic number;slice_wasm.pycuts out only the functions of interest, so decompiling them does not run out of memory;instrument_wasm.pyadds tracing to chosen functions.
With my approval, Claude also ran the module in gojs_sandbox.py, a Python port of Go’s JavaScript glue code on top of the wasmtime runtime, with no browser, network or file access. This challenge was where the session limit ran out. Claude saved its progress to a notes file, and resumed from it after the reset.
check() base64-decodes the input, decrypts it with AES-256-CBC and parses the result as a recursive binary tree. It then verifies the tree as a Pratt certificate, which is a proof that a number is prime: each node certifies a prime factor of p - 1 for its parent’s p. The certified prime must equal a 256-bit prime that the program rebuilds at run time from an obfuscated constant.
pratt.py uses sympy to factor each level and builds a 3,891-byte certificate for that prime. solve.py encrypts and encodes it and passes it to the sandboxed check(), which returns the flag.
Flag: [email protected]
9. NeonOutRun#
Provided:
9-NeonOutRun.7z, containingneon_outrun, a 21 MB stripped Linux executable.
The game is a Rust Tauri desktop app that also embeds the V8 JavaScript engine. Its web front end only draws frames. The game logic was missing from the front end and from the binary’s readable strings.
Claude located the V8 driver function through panic messages from the Rust V8 bindings, then rebuilt the hidden game script from the binary’s data. The script is XORed with a 2-byte key and compressed three times: a custom LZSS, then zstd, then raw DEFLATE. Flattened control flow hid the constants involved, so Claude evaluated those fragments with Unicorn.
The recovered script was a decoy. Played normally over 28 laps, it builds [email protected]. Note the f1are.
The real check is in three native functions that replace JavaScript built-ins before the script runs:
| Replaced built-in | What the native code does |
|---|---|
Array.prototype.sort | Gives each lap a random weight and sets a target equal to the sum of the weights |
Math.min | Records each lap time in whole seconds as a byte, and adds the lap’s weight multiplied by a sine term that depends on that time |
String.prototype.replaceAll | If the total equals the target, overwrites the message with the recorded bytes |
The weights are random, so the total reliably equals the target only if every sine term is exactly 1. For each lap, exactly one lap time in whole seconds achieves that. hook_model.py evaluates the term for every possible byte on every lap and keeps the byte that gives 1. The 28 required lap times, in seconds, spell the flag in ASCII.
Flag: [email protected]
Takeaways#
- Claude Code can solve hard reverse engineering challenges statically. All nine flags came without running a challenge binary. Where emulation was needed, it was limited to small, pure fragments and written down.
- The
CLAUDE.mdfile did a lot of the work. Rules, folder layout and lessons recorded there carried across the session limit, so I didn’t have to repeat instructions. - Claude reported gaps honestly. It said it couldn’t explain the off-by-one length in challenge 2, and that challenge 5’s flag was transcribed from an image. It also stopped and reported when a safety filter interrupted challenge 6, rather than guessing.
- Ask for reproducible output. Requiring a writeup and a complete solve script per challenge made the results checkable. Every script recovered its flag again from a clean copy of the original archive.




