Autonomous Reverse Engineering: Evaluating AI with Interactive Disassembly
How an AI coding agent handled a live disassembler, working three binaries from a symbol-rich build down to packed, obfuscated XLoader, and where its confident narrative stopped matching the bytes.
Continuing my exploration of AI in deep technical domains, I designed an experiment to evaluate its reverse engineering capabilities. I wired Claude Code to Ghidra through an MCP server, giving it direct access to an interactive disassembler, and challenged it with three malware samples of increasing complexity. The goal was to determine how well an AI agent can navigate, analyze, and reverse engineer real-world binaries, not just explain decompiled code. The MCP gave Claude the same tools a reverse engineer use in static analysis like list segments, list strings, decompile, disassemble, follow cross-references, rename functions, write comments back into the project database.
- BrowserSnatch, my own tool, compiled with a full PDB. Best case by construction.
- Ryuk, real ransomware, stripped, no symbols, but lightly obfuscated.
- XLoader 4.3 (FormBook), stripped, packed, encrypted functions, hashed APIs, anti-VM. Worst case.
Every one of these was static only. The samples were never executed. No debugger, no sandbox, no network. That constraint matters, and I will come back to it.
📦 Analysis artifacts, the annotated Ghidra project, the unpacking scripts, the recovered keys and decrypted string tables, are in the GitHub repository. Samples use password
infected.
The verdict up front
| Sample | Conditions | My accuracy call |
|---|---|---|
| BrowserSnatch | PDB present, full private symbols | 90% |
| Ryuk | Stripped, minimal obfuscation | 85% |
| XLoader 4.3 | Stripped, packed, encrypted bodies, hashed APIs | 50% |
That is the whole article in one chart. The fall between Ryuk and XLoader is the gap between “usable unsupervised” and “needs someone in the loop”, and it is not a gentle slope. The rest explains why those numbers land where they do.
The setup
Claude Code talks to Ghidra through LaurieWired’s GhidraMCP bridge. That is the part people underestimate, it is not pasting decompiled code into a chat window. The model calls Ghidra’s own API:
list_segments · list_strings · list_imports · list_functions
decompile_function_by_address · disassemble_function · get_xrefs_to
rename_function_by_address · rename_data · set_decompiler_comment
Two things follow from that. First, it can survey a binary in four calls before deciding
what to read, breadth before depth, which is exactly how I work. Second, findings persist
in the project database, not in a chat log. Function names, data names and comments are
committed to the .gpr and travel with it. When the session ends, the analysis is still there.
For XLoader I also drove it to headless Ghidra (analyzeHeadless) with custom Java
GhidraScripts for bulk decompilation, and used Ghidra’s own EmulatorHelper to
execute individual functions out of a program that was never running. No third-party
emulator, no pip install, everything came out of the box.
Case 1 - BrowserSnatch: symbols make it easy
Conditions: x64 PE, full PDB, private symbols including author class names.
BrowserSnatch is my own tool for extracting browser data including app-bound-encrypted data. I used it as the control case: if AI can’t reverse a binary that ships its own symbols, the experiment ends there.
I asked it to give me full methodology of breaking app-bound-encryption. It reconstructed the entire chain correctly. The important insight, and it got this without prompting, is that the tool never breaks Chrome’s app-bound encryption. It elevates to SYSTEM and asks the browser’s own elevation service to unwrap the key for it.
| Step | What it found | Address |
|---|---|---|
| 1 | Self-elevates via ShellExecuteExW verb runas on its own path, argument -app-bound-decryption |
1400153bc–1400154ef |
| 2 | Registers a scheduled task, principal SYSTEM, author shaddy43; sleep 500 ms, run, sleep 3s, delete |
140015824–14001586c |
| 3 | Regex-extracts app_bound_encrypted_key from Local State, base64-decodes, strips the 4-byte APPB prefix |
140003ac0 |
| 4 | CoCreateInstance on the per-browser elevator CLSID with CLSCTX_LOCAL_SERVER, then CoSetProxyBlanket at packet privacy with impersonation and dynamic cloaking |
140002ab0 |
| 5 | Drops the recovered 32-byte AES key, hex-encoded as JSON, to C:\Users\Public\NTUSER.dat |
- |
| 6 | Parent reads it back and rebuilds COM_master_key_blob |
140001ed0 |
| 7 | Decrypts v20 records: skip the 3-byte v20 tag, nonce from bytes 3–15, tag from the last 16, BCrypt AES-256-GCM |
140004510 |
It also picked up that IElevator::DecryptData sits at vtable slot 5 for Chrome and Brave
but slot 8 for Edge, a detail that matters and is easy to skim past.
The whole chain was verified against decompilation and full disassembly of
app_bound_browsers_cookie_collector (140014110–1400172a6, 2,389 instructions).
Where it stopped: one byte. The delimiter constant at 14012734c is undefined data, and
it decides whether the browser token comes from the file name or the parent directory name.
It reasoned that only the backslash reading makes the code work, and then said so plainly:
that is elimination, not a byte I read. I want to flag that, because a tool that tells you
which of its conclusions are inferred is far more useful than one that presents everything
at the same confidence.
With symbols, this is close to a solved problem.
Case 2 - Ryuk: stripped, but honest code
Conditions: x86-64 PE, image base 0x140000000, no symbols, ~90 APIs resolved at runtime
from an XOR-encrypted name table. No packing.
This is the realistic middle case, and where I found the tooling most valuable.
The first useful signal came from one call. list_segments showed a 3.5 MB .data
section against a 90 KB .text, something is stored, not computed. Then list_functions
showed 380 functions, but Ghidra’s signature matching had already named the CRT. What was
left unnamed below 0x140007410 was the actual malware: about thirty functions. That
step is what turns an intimidating binary into a tractable one.
The encryption routine
Ryuk uses a textbook hybrid scheme. One RSA-2048 public key is baked into the binary; every file gets a fresh AES-256 key which is wrapped under it and stapled to the file. Constants were decoded in place as they appeared:
CryptAcquireContextW(..., 0x18, ...) 0x18 = 24 = PROV_RSA_AES
CryptImportKey(..., 0x114, ...) 0x114 = 276 = BLOBHEADER(8) + RSAPUBKEY(12) + 256-byte modulus -> RSA-2048
CryptGenKey(..., 0x6610, 1, ...) 0x6610 = CALG_AES_256, 1 = CRYPT_EXPORTABLE
The genuinely interesting engineering is in what Ryuk refuses to encrypt, and both guards are recovery-relevant:
- Guard A: size cap. If the computed block count exceeds 4000, the routine returns
2without writing a byte. Large databases, VM disks and archives may be completely intact. Before declaring total loss, check for the absence of the trailer rather than assuming everything in the directory was hit. - Guard B: re-encryption check. It seeks to
EOF-0x122, reads 25 bytes and scans forHERMES, a plain unrolled byte comparison, not a hash or flag file. Marker present, return5.
Files are not renamed in this variant. There is no .RYK extension, so detection and
triage both hang on the HERMES string near EOF. HERMES is inherited from the Hermes
family Ryuk was built from, a lineage marker that survived into production and is now the
single most reliable artifact for identifying an encrypted file.
The return-code table it built out is directly operational: codes 8–0x11 mean the file
was partially encrypted but the key blob was never written, those files are not
decryptable even with the attacker’s private key. Interrupting Ryuk mid-run does not save
the files it is currently working on.
Other findings worth keeping: encryption is parallel across a 250-slot worker pool
(0x36d0 bytes each, spinning on Sleep(150) when saturated), and self-injection is done
at the process’s own image base, GetModuleHandleA(NULL) → VirtualAllocEx(0x40) →
WriteProcessMemory → CreateRemoteThread, no reflective loader needed.
What the session actually produced
Recovering the hidden import table is the part I would have found tedious by hand. Ryuk
resolves its APIs at runtime, so the PE imports hide WNetOpenEnumW, GetIpNetTable, the
whole Crypt* family and CreateThread. Each opaque DAT_* pointer was named from
call-site evidence: argument count, magic constants, return handling etc.
before (*DAT_14002bd80)(&DAT_14002ce18, &DAT_140024990, &DAT_140029a30, 0x18, 0x10);
after (*p_CryptAcquireContextW)(&g_hCryptProv, &DAT_140024990, &DAT_140029a30, 0x18, 0x10);
Where it fell short
Three things, and they are instructive:
- Names are inferred, not decoded. The API name table is XOR-encrypted and only
materialises at runtime. Every
p_*label is a judgement from call-site behaviour. Some are effectively certain (CryptGenKeyfrom0x6610). One module handle could not be pinned at all and was namedhmod_UNIDENTIFIED_1rather than guessed, the right call. - Tool feedback can be ambiguous.
rename_datareturned “Rename data attempted”, which reads like success. Re-decompiling exposed two silent no-ops, the tool only renames an existing data item, and those addresses held undefined bytes. Write operations against an analysis database need verification, not trust. - Static analysis has a floor. The ransom note’s Bitcoin address and campaign ID sit behind two XOR passes over 6.6 KB of key material. Getting them requires emulating or reimplementing the routine. The decompiler will not do that for you.
And three findings came from asking “why is that there?” rather than from any tool output:
the misspelled lsaas.exe in the injection exclusion list means lsass.exe is not
excluded; the 4000-block cap is a recovery lead; the runas retry loop means declining the
UAC prompt preserves shadow copies. The toolchain read the binary. Deciding which facts
mattered was the analysis.
Fast, thorough, and honest about its uncertainty.
Case 3 - XLoader 4.3: where it needs an analyst
Conditions: stripped, packed, single .text section, no imports, no strings, five
function bodies encrypted at rest, every API referenced by hash, anti-VM checks whose results
are consumed as key material.
This is the case that produces the interesting answer, so I will spend the most space here.
What it got right, and it is a lot
Starting from a 14-function stub in 185 KB, working purely statically, it unwrapped six layers and recovered 393 functions.
Layer 1: import-less API resolution. Three inline blobs RC4-decrypted to ntdll.dll,
NtQueryVirtualMemory, NtProtectVirtualMemory. Module lookup by PEB walk, exports parsed
by hand.
Layer 2: the payload. A sentinel 0x5977BDF1 (stored XORed with 0x16D565C0) locates
a 0x2C600-byte blob at file offset 0x1DE3, RC4’d with a key derived by XORing the first
key with that same constant.
The verification here is the part I liked. The first relative call in the decrypted code lands exactly on the
memsetfunction that had been identified independently. A wrong key cannot produce a call that resolves to a correct function boundary. That is proof, not plausibility.
Layer 3: anti-emulation theatre. The payload is XORed with 0x15, split into
256 blocks of 0x2C6 (exactly covering 0x2C600), and each block XORed with 0x15
again, the two cancel. What the routine really does is visit those blocks in an order
shuffled by a PRNG seeded from KUSER_SHARED_DATA, temporarily overwrite each block’s first
11 bytes with a decoded stub, call it, then restore. The stub is
55 8B EC 90 90 90 90 8B E5 5D C3, a pure no-op. 256 meaningless calls in a
nondeterministic order, purely to burn an emulator’s budget. Proving that an entire
obfuscation layer cancels out is a negative result that is hard to establish by debugging,
and it is the kind of thing static analysis is uniquely good at.
Layer 4: self-decrypting function bodies. Five functions keep their bodies RC4-encrypted
at rest, decrypt on entry, run, then re-encrypt and restore their delimiters. Ghidra’s
pcode error at 0000c07d was the finding, the decompiler was being handed ciphertext.
All five recovered, with seeds and exact ranges. This also explains something my own earlier
write-up noted but did not explain: 4.3’s markers look randomised because the 6-byte
markers are themselves RC4-encrypted per seed.
Layer 5: steganographic config. Four config blobs are stored as fake x86 instructions
preceded by 55 8B EC, with the real bytes carried in immediate and displacement fields,
extracted by a 1,980-byte opcode dispatcher. Rather than reimplement that dispatcher, it
ran it inside Ghidra’s own emulator. All four blobs decoded first try.
Layer 6: the key schedule. Three chained SHA-1 → RC4 rounds. And a genuine catch:
SHA1_Final writes its digest back without byte-swapping, so the key material is the
five state words in native little-endian, not the standard big-endian digest. Textbook SHA-1
gives garbage at every round. Testing both orderings isolated it, seven combinations scored
~0.37 printable, the correct one scored 0.99.
Result: the master string key 5f1d73306e4a22c31669b76c20764fa4b4467d86, the full
137-entry string table, the API hashing algorithm (CRC-32/BZIP2 over the lowercased
name), and the injection technique, NtCreateSection → map locally → map into
explorer.exe as RWX → overwrite a hot-patch prologue with 8B FF 55 8B EC E8 <rel32> →
self-terminate.
Where it broke
I compared its output against my own full lab analysis of the same sample, done with a debugger, in an instrumented VM, with network capture. The gap is real:
| It missed | Verdict | Why |
|---|---|---|
| Anti-VM process blocklist: procmon, wireshark, vmware, vbox, sandboxie | In reach | The hashes were already in the array it had recovered |
| Anti-VM flags double as the decryption seed | In reach | It had decompiled the function and left it uninterpreted |
Clean-ntdll manual mapping (Lagos Island) to bypass EDR hooks |
In reach | It listed the three APIs under a generic heading and never read the sequence as a technique |
| Real C2 domains | In reach | Key is runtime-derived, but its seed was static-visible |
| First injection into a spawned SysWOW64 process | More tracing | Needs a deeper cross-reference pass than the session made |
| Inline browser hooks, the actual form grabber | More tracing | Needs a deeper cross-reference pass than the session made |
COM / dllhost.exe elevation |
More tracing | Needs a deeper cross-reference pass than the session made |
XLNG botnet registration, version string |
Needed the lab | Genuinely not derivable without running the sample |
Four of the eight were reachable from output it had already produced. Three more were within reach of a more patient static pass. Only one genuinely required the lab.
It also stated things too confidently. It published that the 112 unresolved API hashes were “largely the browser and mail stealer surface”, a guess presented at the same confidence as its measured findings. A single wordlist of analysis-tool names would have falsified it, and when we later ran exactly that, the anti-VM blocklist fell out immediately.
The most instructive miss: it decompiled the anti-VM gate function, printed it, left it unnamed, and moved on, then later declared the C2 list unrecoverable for want of exactly that seed. No debugger was required to see it.
The correction that proves the point
Late in the session I asked it to check for stack strings, strings built inline as immediate operands rather than stored in the encrypted table. It had never looked.
Seventy-seven turned up across 33 functions, including the entire browser target list in what turned out to be the largest encrypted body:
BraveSoftware\Brave-Browser AVAST Software\Browser Yandex\YandexBrowser
CatalinaGroup\Citrio Fenrir Inc\Sleipnir5 Epic Privacy Browser
Elements Browser 360Chrome\Chrome CCleaner Browser
Sputnik\Sputnik Opera Software uCozMedia\Uran
Microsoft\Edge Comodo\Dragon QIP Surf
Chromium Iridium Kometa Chedot Torch
Plus the Chrome v80+ encrypted_key extraction, the cookies and autofill SQL, IE
IntelliForms, the Firefox logins.json fields, and \3r9Pk-75_, the %TEMP% staging path
it had previously classified as “generated at runtime, not derivable from the binary.”
That was wrong. It is a hardcoded stack string.
One question from me, “check the stack strings”, moved the target count from 7 browsers to 20+ and corrected a published claim. That is the shape of the whole thing: it does not know what it has not looked at, and it will write a clean, confident narrative around the gap.
Asking about stack strings helped it find most of stealer capabilities.
With an analyst asking the right questions, comfortably higher.
Where the accuracy actually goes
The failure modes were consistent enough across all three sessions to name:
| Failure mode | What it looks like |
|---|---|
| Categorised instead of comprehended | Filed NtCreateFile → NtQueryInformationFile → NtReadFile under “Registry & file”. That exact sequence against \System32\ntdll.dll is EDR unhooking. It matched APIs to a taxonomy and never read them as intent. |
| Asserted a cause without testing it | Published a guess about 112 hashes at the same confidence as measured findings. |
| Generalised from one traced path | Found the explorer.exe injection, described it as the injection technique. |
| Declared a blocker while holding the key | Gave up on the C2 list; the seed was in a function it had already decompiled. |
| Stopped at the edge of the question | Traced the local phase to completion, treated the injected phase, where most behaviour lives, as future work. |
| Mistook a clean narrative for completeness | The report reads finished. Coherence hid the gaps; nothing in the output flagged that the largest behaviours were unexamined. |
That last one is the dangerous one. A junior analyst who does not know what is missing produces obviously incomplete work. This produces work that looks finished.
What it is genuinely good at
I want to be fair, because the wins are real:
- Breadth before depth. Segments, strings, imports and the full function list in four calls, so the choice of what to decompile is informed rather than arbitrary.
- Bulk mechanical work. 137 strings decrypted, 196 API hashes cracked against 112,094 system exports, 110 functions renamed, 77 stack strings extracted. It does not get bored.
- Constants decoded in place.
0x6610,0x114,0x18,0x2C6resolved to meaning as they appeared, without breaking flow to search headers. - Proving negatives. Demonstrating that an obfuscation layer cancels out, or that a function is CRT boilerplate rather than attacker code, is genuinely hard by debugging and easy by reasoning over the whole binary.
- Findings persist. Names, comments and bookmarks land in the
.gpr. That is durable output, not a transcript. - It records its own uncertainty, when asked to.
hmod_UNIDENTIFIED_1, the one unresolved byte in BrowserSnatch, the failed static decryption of XLoader’s stealer body.
My verdict
With a PDB: 90%. Symbols carry the semantics; the model just has to read carefully. Faster than me, and it does not skim.
Stripped but light obfuscation: 85%. This is the sweet spot. It handled Ryuk’s hidden import table, its guard logic and its threading model correctly, and it flagged what it had inferred versus what it had read. I would use it here without hesitation.
Packed and heavily obfuscated: 50%. It got further than I expected, six layers, every key, the full string table, but it missed anti-VM logic that was sitting in its own output, and it wrote a confident report around the gap. With me in the loop asking targeted questions, it climbed well past that. Unaided, I would not trust the result.
The pattern is simple: the harder the sample, the more the value shifts from the tool to the questions. It is an extremely capable instrument and a mediocre investigator. It will decrypt anything you point it at and then tell you the analysis is finished.
Use it the way you would use a very fast, very thorough, slightly overconfident junior: give it the mechanical work, read its output sceptically, and keep the judgement calls.
Artifacts
| File | Contents |
|---|---|
tools_and_scripts/ |
Ghidra GhidraScripts (bulk decompile, blob emulation, stack-string extraction, annotation) and the Python crypto/hash-cracking scripts |
unpacked/ |
XLoader stage-2 unpacked payload and the fully annotated Ghidra project, 393 functions, 110 named, bookmarked and commented |
data/ |
Decrypted 137-entry string table, resolved API hash map, stack strings |
Samples are not duplicated here, they already exist in the repository:
- XLoader 4.3,
/Xloader4.3/sample/ - Ryuk,
/Ryuk/ - BrowserSnatch, open source at github.com/shaddy43/BrowserSnatch
⚠️ Archives containing malware code use password
infected. The unpacked XLoader payload is live malicious code, handle it in an isolated VM.
Tools and references
| Tool | What it is | Link |
|---|---|---|
| Claude Code | Anthropic’s agentic CLI, the analyst in this experiment | claude.ai/code |
| Ghidra | The open-source reverse engineering suite doing the actual disassembly and decompilation | github.com/NationalSecurityAgency/ghidra |
| GhidraMCP | The MCP server bridging Ghidra to the model, this is the piece that makes the whole thing work | github.com/LaurieWired/GhidraMCP |
| BrowserSnatch | The browser stealer used as the symbol-rich control case | github.com/shaddy43/BrowserSnatch |
Scope
All three analyses were static only. No sample was executed, no debugger was attached,
and no network activity was generated or observed. The XLoader comparison baseline is my own
separate dynamic analysis, published at
/Xloader4.3/.
Accuracy figures are my own judgement against work I had already done by hand, not a benchmark, and not reproducible in the formal sense. Take them as an experienced opinion, which is exactly what they are.