Autonomous reverse engineering, evaluating AI with interactive disassembly
Malware Analysis Series  ·   ·  by shaddy43

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.

AI Agent Ghidra MCP Reverse Engineering Ransomware Infostealer

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.

  1. BrowserSnatch, my own tool, compiled with a full PDB. Best case by construction.
  2. Ryuk, real ransomware, stripped, no symbols, but lightly obfuscated.
  3. 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

Accuracy against my own hand analysis Accuracy against my own hand analysis My judgement, not a benchmark. Bar length is the estimate; shade tracks sample difficulty. BrowserSnatch PDB present · full private symbols 90% Ryuk Stripped · no symbols · unobfuscated 85% XLoader 4.3 Stripped · packed · encrypted bodies 50% 0 25 50 75 100%
Figure 1, accuracy against my own hand analysis, across the three samples.
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 1400153bc1400154ef
2 Registers a scheduled task, principal SYSTEM, author shaddy43; sleep 500 ms, run, sleep 3s, delete 14001582414001586c
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 (1400141101400172a6, 2,389 instructions).

Reverse engineering BrowserSnatch via Claude code & Ghidra

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.

90%accuracy

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:

Ryuk on-disk layout after encryption BEFORE — ORIGINAL FILE Original contents offset 0x00 → EOF AFTER — SMALL FILE (< 5 MB) · ENCRYPTED END TO END AES-256 ciphertext whole file, in place HERMES 6 B RSA-wrapped key SIMPLEBLOB AFTER — LARGE FILE (≥ 5 MB) · PARTIAL ENCRYPTION AES-256 ciphertext n × 1,000,000 B blocks Untouched plaintext beyond the block count HERMES|n|HERMES n = block count RSA-wrapped key SIMPLEBLOB 16 B tail Files are not renamed in this variant — no .RYK extension, so triage depends on the HERMES string near EOF. On large files a substantial plaintext region survives between the encrypted blocks and the footer.
Figure 2, Ryuk on-disk layout before and after encryption, for small and large files.

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 80x11 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)WriteProcessMemoryCreateRemoteThread, no reflective loader needed.

What the session actually produced

380functions inventoried~30 attacker-authored
22decompiled end to end
32functions renamedin the project database
50data globals renamedthe hidden import table
17comments written back
0times executedstatic analysis only

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);
Reverse engineering Ryuk ransomware via Claude code & Ghidra

Where it fell short

Three things, and they are instructive:

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.

85%accuracy

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.

14 393functions recoveredfrom a 185 KB stub
6obfuscation layerseach unwrapped in turn
137strings decryptedfull table, from zero
5encrypted function bodiesseeds and ranges recovered
196API hashes97 resolved, CRC-32/BZIP2
0times executedno debugger, no sandbox
XLoader 4.3 — six obfuscation layers Six layers, in order Each had to be defeated before the next was even visible. From a 14-function stub to 393 functions. L1 Import-less API resolution Three inline RC4 blobs → ntdll.dll, NtQueryVirtualMemory, NtProtectVirtualMemory. PEB walk, exports parsed by hand. RC4 L2 Marker-anchored payload Sentinel 0x5977BDF1 locates a 0x2C600-byte blob; key derived by XOR with the same constant. RC4 L3 Anti-emulation theatre 256 blocks × 0x2C6, XOR 0x15 globally then per block — the two cancel. 256 no-op calls in PRNG-shuffled order. net zero L4 Self-decrypting function bodies Five functions decrypt on entry, run, then re-encrypt. Ghidra's pcode error was itself the finding. 5 seeds L5 Steganographic config blobs Four blobs stored as fake x86 instructions; real bytes in immediate fields, behind a 1,980-byte dispatcher. emulated L6 The key schedule Three chained SHA-1 → RC4 rounds. SHA1_Final does not byte-swap — the trap that makes textbook SHA-1 fail. SHA-1
Figure 3, XLoader 4.3’s six obfuscation layers, each with its mechanism.

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 memset function 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.

Reverse engineering Ryuk ransomware via Claude code & Ghidra
50%accuracy

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 NtCreateFileNtQueryInformationFileNtReadFile 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:


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:

⚠️ 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.