How to reverse engineer anything
- Published
- Oct 14, 2026
- Tags
- Agent · MCP

Recently a tool called REA (“Reverse Engineer Anything”) has been all over my GitHub feed. The repository, morluto/rea, was created on 14 April 2026 and when I checked the GitHub API on 14 October it had 210,777 stars. That’s a lot of stars for six months. The pitch is simple: you see a feature in some app, you ask your coding agent “how does this work?”, and the agent goes and takes the app apart for you, down to the binary if it has to.
I’ve always been curious about how the apps I use every day actually work under the hood, so I installed it, read through its code and docs, and then pointed it at an app on my own machine. This post is what I found: first how REA works, then a proper walkthrough where we take apart how Obsidian updates itself without ever running its installer again.
Bit of back story
REA is an MCP server and a CLI. MCP is the protocol coding agents like Claude Code, Codex and Cursor use to call outside tools, so once REA is registered your agent gets a whole reverse engineering toolkit:
- Hopper, Ghidra or IDA for native code (executables and libraries)
- a built-in static analyzer for JavaScript and Electron apps
- JADX and adb for Android
- a Chrome browser driven over the DevTools protocol for websites
- readers for .NET assemblies, SQLite databases, packet captures, WebAssembly, firmware and more
The interesting part though isn’t the list of tools. It’s what comes back. Every result is an Evidence record: what was observed, what was inferred, and what is still unknown. We’ll see why that matters in a bit.
Here’s the quick summary:
| What | Details |
|---|---|
| Package | rea-agents on npm (rea command), version 6.4.0 |
| License | MIT (commercial engines like IDA and Hopper keep their own licenses) |
| Interfaces | MCP server over stdio, and a CLI with 127 commands |
| Agents | Claude Code, Codex, Cursor, Gemini CLI, Grok Build, and any MCP client |
| Runs | Locally, nothing is uploaded (your agent’s model provider still sees the results) |
| Needs | Node.js 22.19+, 24.11+ or 26+, and engines only for the targets that need them |
Setting it up is one command:
npx rea-agents setup
It asks which agents you use, registers the MCP server with them, installs a matching agent skill and backs up whatever config it touches. Static JavaScript and .NET analysis don’t need any engine at all, which is why everything I did below needed nothing but Node.
How REA works
Here’s the big picture:
Let’s go through it layer by layer.
Two ways in, one set of workflows
The MCP server (src/main.ts) and the CLI (src/cli.ts) are both thin wrappers over the same
application layer (src/application/). So when your agent calls the analyze_javascript_application tool
and when you type rea analyze-javascript-application in a terminal, the exact same workflow runs
and you get the exact same Evidence back. That’s really handy, anything your agent did, you can run again by hand and compare.
Honestly the CLI is the better way to learn REA. Every command is one process, the output is JSON you can save to a file, and later commands take that saved file as input. Your whole investigation ends up being just a folder of JSON files.
The target decides the tool
REA’s agent skill starts every investigation the same way: look at what the target is, then pick the first tool. A few of the routes:
| Target | First tool | Engine |
|---|---|---|
Electron .asar or a JS app folder | analyze_javascript_application | built in, static |
| Native executable or library | open_binary, then analyze_function | Hopper, Ghidra or IDA |
Android .apk | inspect_android_package | JADX and a JDK |
| .NET assembly | inspect_managed_artifact | built in, static |
| A page open in your browser | list_browser_targets | Chrome over CDP |
| HAR, SAZ or mitmproxy capture | inspect_web_network_capture | built in |
| Go or Rust binary | inspect_go_binary, inspect_rust_binary | built in, reads embedded build metadata |
For native code, open_binary binds exactly one engine to the target for the whole session.
The architecture notes literally say “no fallback”: if Ghidra can’t do something, REA tells you,
instead of quietly asking a different engine and mixing the answers together. I like that.
Engines run as separate processes
The native engines don’t run inside REA. REA launches them and talks to a small bridge that runs inside each one:
a Python script inside Hopper (bridge/hopper_bridge.py), a headless Java script inside Ghidra
(bridge/ghidra/ReaGhidraBridge.java), and the community ida-pro-mcp server for IDA.
All of them sit on one process layer (src/process/) that owns the process, enforces deadlines,
keeps file reads bounded and cleans up temp folders afterwards (on Windows it uses Job Objects).
So if a decompiler hangs, it gets killed instead of hanging your agent with it.
Evidence is the real product
Every REA operation returns an Evidence record. Here’s the top of the one from our walkthrough, trimmed:
{
"evidence_id": "ev_4686a28bd38fd63e73087ff4bdf8549ac1beefadf1967522c3f4c9324034a120",
"subject": { "format": "asar", "digest": { "sha256": "d128a8a06e91…5475739" } },
"provider": { "id": "rea-javascript-application", "version": "1" },
"operation": "analyze_javascript_application",
"confidence": "derived",
"authority": "shipped-artifact",
"limitations": [
"JavaScript and HTML were parsed as inert text; bundle bootstrap code was never executed.",
"Dynamic IPC channel expressions remain unresolved and are never paired by textual similarity.",
"…"
]
}
Three things in there do most of the work:
- Identity: the record is tied to the SHA-256 of the exact bytes that were analyzed, and the ID itself is a hash of the record. Follow-up tools take “authenticated” Evidence and only combine records whose target hashes match exactly.
- Authority: every fact says where it came from:
artifact-bytes(observed),static-relationship-inference(inferred), or a runtime observation. Static analysis never claims something actually ran. - Unknowns: gaps are kept, not hidden. If REA sees a
require()but can’t work out what it loads, you get an edge pointing to?, not a missing edge. Their design notes put it nicely: “Missing coverage is unknown, not absence.”
This is why it works so well with agents. Give a model raw decompiler output and it will happily fill the gaps with guesses. Give it facts already labelled observed, inferred and unknown, and it’s much easier for it to say “I don’t know yet, let me look”.
These records get big though (REA’s own docs mention a 334 MB Evidence file for an Obsidian analysis),
so the follow-up commands read a saved record instead of redoing the analysis:
inspect-analysis-view gives you a summary, a page of modules or one module,
trace-application-feature walks the graph from a seed (a module, string, API, IPC channel or native export),
and compare-application-versions diffs two versions of an app.
Static first, runtime when you ask
Most of REA just reads files without running them. There’s a separate set of capture-* and observe-* tools that do run things:
capture-browser-scenario clicks and types through a page, observe-javascript-runtime attaches to a Node or Electron V8 Inspector,
capture-process records a terminal program, and on macOS observe-native-calls launches a binary under LLDB.
Each one only touches the target and actions you declare, and saves its results as runtime Evidence so it never gets mixed up with the static kind.
The skill and the prompts
The last layer is just advice for the agent. Setup installs a skill called reverse-engineer-anything that teaches it
the routing above, the “summary first, then drill down” habit, and to keep observed, inferred and unknown separate in its final answer.
The MCP server also exposes six guided prompts like investigate_feature, compare_application_versions and trace_crash,
which suggest where to start without forcing a sequence of calls.
Let’s take apart Obsidian
Enough theory, let’s actually use it. Obsidian is an Electron app, and every now and then its settings show a “Relaunch” button to finish an update, even though I never downloaded a new installer. So where does the new version come from? How does Obsidian decide to trust it? And how does it swap it in?
Electron apps ship their code as .asar archives, which is exactly what REA’s static analyzer reads.
Everything below ran on Windows 11 with Node 24.11.1 and rea-agents@6.4.0, against Obsidian’s 1.10.6 installer.
REA never ran Obsidian.
Step 0: Make a copy first
Obsidian’s code lives in %LOCALAPPDATA%\Programs\Obsidian\resources: a 479 KB app.asar, a 26 MB obsidian.asar
and an app.asar.unpacked folder. Rule number one of reverse engineering, never touch the original.
So we copy them out and make sure the copies are identical:
O="$LOCALAPPDATA/Programs/Obsidian/resources"
cp "$O/app.asar" "$O/obsidian.asar" ./target/
cp -r "$O/app.asar.unpacked" ./target/
sha256sum "$O/app.asar" ./target/app.asar
The output:
d128a8a06e9192e5b442c7958c2293264f7d3c9885b8c63cae86c92c85475739 …/resources/app.asar
d128a8a06e9192e5b442c7958c2293264f7d3c9885b8c63cae86c92c85475739 ./target/app.asar
Same hash, so we’re good.
Step 1: The first run fails
Electron loads the small app.asar first, so that’s where we start:
npx -y rea-agents@6.4.0 analyze-javascript-application ./target/app.asar --format json > app.json
and about six seconds later, here we have the error:
{
"code": "artifact_integrity_mismatch",
"message": "Declared ASAR integrity for unpacked entry node_modules/btime/binding.node contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive. …",
"details": {
"declared_sha256": "4cbfb569d7ca58bd3af49e06e5688412aad848af816743a079edd4a60f77005a",
"calculated_sha256": "5ae52323bc9c911d7e125c5758239bdfe82ff4d50198925d7aceb4882b89ce5f"
}
}
Annoying, right? But this one is actually a finding. The ASAR header stores a hash for every file, including the native addons
kept outside the archive in app.asar.unpacked. Two of those addons, btime and get-fonts, don’t match their stored hashes.
REA’s guess was that they got signed after packing, so I checked with PowerShell’s Get-AuthenticodeSignature,
and sure enough all four .node files are validly signed by “Dynalist Inc” (the company behind Obsidian).
The build signed the binaries after writing the archive, which changed their bytes.
Nothing shady, so we run it again with the option REA suggests, which keeps those bytes but marks them as untrusted:
npx -y rea-agents@6.4.0 analyze-javascript-application ./target/app.asar \
--integrity-policy record-and-continue --format json > app.json
Thirteen seconds later we have a 12 MB Evidence file. Here’s what happened in between:
Step 2: Read the summary first
Before reading any code, the summary already tells us a lot:
- One entry point: a single
electron-mainnode,main.js. Thepackage.jsonsays"name": "obsidian","version": "1.10.6","main": "main.js". - Almost no dependencies: five packages, Obsidian itself,
@electron/remote,node-addon-api, and the two signed native addonsbtimeandget-fonts. - No windows:
browser_windows: 0andpreload_entrypoints: 0. The 15 IPC operations it found all belong to@electron/remote, not to Obsidian.
Wait, an Electron app whose main archive doesn’t create a single window? That’s not the app. That’s a loader.
Step 3: Trace out of main.js
Let’s see what main.js reaches. We build a trace input with main.js as the seed, outgoing only:
node -e '
const fs = require("fs");
const application = JSON.parse(fs.readFileSync("app.json", "utf8"));
fs.writeFileSync("trace.json", JSON.stringify({
application, seed: { kind: "module", value: "main.js", match: "exact" }, direction: "outgoing"
}));'
npx -y rea-agents@6.4.0 trace-application-feature ./trace.json --format json > trace.out
The trace reached 56 nodes over 69 edges (47 observed facts, 97 inferred). The imports are path, util, os, crypto,
fs, zlib, events, electron and @electron/remote, plus one import that points to ?.
A loader that needs crypto and zlib? It’s verifying and unpacking something. And that ? is the interesting one:
REA can see a require() there but not what it loads, because the path is only known at runtime.
A static tool can’t follow that, so this is where we go read the code ourselves.
Step 4: Read the bootstrap
Luckily main.js is only 465 lines and isn’t minified. The startup code at the bottom does this:
let asarPath = path.join(APP_PATH, 'obsidian.asar');
// …scan app.getPath('userData') for obsidian-<version>.asar, keep the newest…
if (updatedAsarPath) {
log('Loading updated app package', updatedAsarPath);
success = loadApp(updatedAsarPath);
}
if (!success) {
log('Loading main app package', asarPath);
success = loadApp(asarPath);
}
And loadApp is our ? from the trace:
function loadApp(asarPath) {
let main = path.join(asarPath, 'main.js');
let fn;
try { fn = require(main); } catch (e) { return false; }
if (fn) { fn(asarPath, updateEvents); return true; }
return false;
}
So the program you install is just a shell. The real app is a second archive, loaded from one of two places:
an updated copy in your user data folder if it’s newer than the shell, otherwise the obsidian.asar that came with the installer.
If loading the update throws, it falls back to the shipped one. The app’s main.js exports a single function,
which gets its own path and an EventEmitter called updateEvents.
Step 5: Read the updater
The rest of main.js is the updater. It runs once when Electron is ready and then every hour
(setInterval(queueUpdate, 60 * 60 * 1000)). Each run:
- Fetches
desktop-releases.jsonfrom theobsidianmd/obsidian-releasesrepository on GitHub, and falls back toreleases.obsidian.mdif that fails. On the side, it also sends a fire-and-forget request toreleases.obsidian.md/desktopwith the version, the platform and a random 16-byte install ID that it keeps in a file calledidin the user data folder. - Reads
minimumVersion,latestVersion,downloadUrl,hashandsignature. If the installed shell is older thanminimumVersion, it emitsupdate-manual-requiredand stops, because the new app needs a newer Electron than this shell has, and only a full reinstall fixes that. - Downloads the gzipped archive into memory, not to disk.
- Verifies it before writing a single byte:
let newHash = crypto.createHash('SHA256').update(compressedBuffer).digest('base64');
let verifiedHash = hash === newHash;
let verifiedSignature = crypto.createVerify('RSA-SHA256')
.update(compressedBuffer)
.verify(SIGNATURE_CERT, signature, 'base64');
- Only if both pass, it gunzips it, writes
obsidian.asar.tmp, renames that toobsidian-<version>.asar, emitsupdate-downloadedand deletes older versions (but never the one that’s currently running).
SIGNATURE_CERT is a certificate pasted right into the source. I decoded it with Node’s crypto.X509Certificate
and it’s a self-signed 2048-bit RSA certificate for CN=dynalist.io, O=Dynalist, valid from May 2016 to May 2040.
This is the clever bit. The hash on its own proves nothing, because it comes in the same JSON as the download URL, so anyone who could swap the download could swap the hash too. The signature though can only come from whoever holds the private key for a certificate the app already has baked in.
Step 6: Follow it into the app
The bootstrap only emits events, so something inside obsidian.asar has to listen to them and talk to the UI.
Same command on the 26 MB archive, and this time it took 8 minutes 55 seconds and wrote 48 MB of Evidence:
76 files, 3.8 million AST nodes, two BrowserWindows, three renderer pages and 39 IPC handlers in the main process,
every single one with a literal channel name. Six of those channels are the update plumbing:
check-update, update, relaunch, disable-update, insider-build and update-menu-items.
My first trace from the check-update channel used "direction": "both" and came back with 388 nodes. That’s the entire graph.
REA’s skill actually warns about this: containment edges let a two-way trace wander into every sibling module.
Tracing incoming only gave a clean 10-node answer:
package obsidian-dev --loads--> electron-main index.js
index.js --imports--> main.js
main.js --contains--> ipc-handler check-update (observed, main.js:38:5747)
ipc-handler check-update --handles--> ipc-channel check-update
What the trace doesn’t show is who sends on that channel. The summary says renderer_transmissions: 0, and the limitations say why:
REA’s worker ran out of its default 1 GB heap on the 3.5 MB app.js and only kept the static facts for that file.
REA marks this as unknown, and reading it as “the renderer never sends anything” would be the mistake here.
So I just searched app.js for the channel names. The Settings button calls ipcRenderer.send("check-update", true),
then polls sendSync("check-update") every second while the check is running, reads sendSync("update") to decide
between showing “Relaunch” or the “needs a major installer update” message, and the Relaunch button calls sendSync("relaunch").
In the main process, check-update emits check on updateEvents, which is the bootstrap’s cue to run queueUpdate(true).
And that closes the loop in the diagram above.
I did try giving it more memory with --max-heap-mb 4096, but no luck. The worker still ran out of memory on app.js,
and 13 minutes in the run stopped with provider_timeout on a PDF.js fallback file that hit the 300-second per-file limit.
It did hand back its partial Evidence inside the error though, still with renderer_transmissions at 0.
So for a bundle this size, use the static graph for the main process and search the code for the renderer side.
Step 7: Check it against my machine
Static analysis tells you what the code can do. The machine tells you what it actually did. Two quick read-only checks close the loop.
First, %APPDATA%\obsidian has a 27 MB obsidian-1.14.4.asar in it. So even though my installer is 1.10.6,
the app I actually run every day is 1.14.4.
Second, obsidian.log in the same folder logs every step of the code we just read:
2026-10-10 23:13:54 Downloading update from https://github.com/obsidianmd/obsidian-releases/releases/download/v1.14.4/obsidian-1.14.4.asar.gz
2026-10-10 23:14:36 Verifying hash & signature. Size= 9200670
2026-10-10 23:14:36 Saving file
2026-10-10 23:14:36 Update complete.
2026-10-11 02:13:49 Latest version is 1.14.4
2026-10-11 02:13:49 An update is already downloaded.
2026-10-11 02:15:47 Loading updated app package C:\Users\…\AppData\Roaming\obsidian\obsidian-1.14.4.asar
2026-10-11 02:15:47 App is up to date.
The download, the verification, the hourly checks, and the next start loading the new package. All of it matches the code.
What I’d steal from it
Usually the reason you reverse engineer a feature is to build your own. If you’re shipping a desktop app, Obsidian’s updater comes down to five ideas worth copying:
- Split the shell from the app: the installed program is a 465-line loader that barely changes. Updates only replace the app archive, so no installer, no admin rights, no reinstall.
- Pin the signer, not the server: verify an RSA signature against a certificate built into the shell. Then even a compromised GitHub release or CDN can’t push code.
- Verify in memory, then write atomically: download to a buffer, check it, write a
.tmpfile, then rename it. A half-written file never looks like a valid update. - Keep an escape hatch:
minimumVersioncovers the day the app needs a newer runtime than the shell has, and falling back to the shipped archive covers a broken download. - Log every step: a plain text log with timestamps is what made Step 7 a two-minute check.
Where REA falls short
It’s not perfect, a few things I ran into:
- The output is heavy. A 479 KB archive produced 12 MB of Evidence. That’s the price of hashing and labelling everything, and it’s why the summary, page and trace views exist. Don’t paste raw Evidence into a chat.
- Static analysis has a ceiling. REA was right to mark the
require()handoff as unknown, but that means the most important edge in this whole teardown came from me reading code, not from the graph. Dynamic loading, computed IPC channel names and huge minified bundles all leave gaps that only reading the code, or runtime capture, can fill. - Native code needs engines. Anything compiled needs Hopper, Ghidra or IDA. Setup can install Hopper for you (with your approval), but Ghidra and IDA (and IDA’s license) are on you. Ghidra on Windows is still marked experimental.
- It moves fast. The README asks you to update often, and the skill warns that a skill from the
mainbranch can describe tools your installed version doesn’t have yet. Pin a version like I did with@6.4.0if you want a run you can repeat.
Use it responsibly
REA’s code is MIT licensed, but its disclaimer says it’s meant for lawful research, education, interoperability, software preservation, and analysis of software you own or are authorized to examine. A lot of apps’ terms of service forbid reverse engineering outright, so check before you go poking.
Everything in this post is static analysis of files already installed on my own machine, done on copies, plus reading a log Obsidian writes for its own user. I haven’t published any keys, tokens or private endpoints, and the only certificate I decoded is a public one that exists to verify signatures.
Conclusion
REA doesn’t do the reverse engineering for you, but it does the boring parts really well: picking the right tool, running it safely, and being honest about what it actually knows. The rest is still you (or your agent) reading code and checking it against reality. If you want to try it yourself, the repository has the full tool catalog and installation guide, and rea.tools has more case studies, including a DX-Ball sound routine rebuilt from x86 and Notion’s Electron clipboard bridge.