rea-reverse

How to Reverse Engineer an Electron App: ASAR, Preload and IPC

Written for REA 6.7.0Published by rea-reverseUpdated 7 min read
On this page

An Electron app’s logic is JavaScript inside resources/app.asar, split into a main process, preload scripts and renderer pages. To reverse engineer one: copy the archive out, extract it, read package.json to find the main entry, then follow BrowserWindow webPreferences to the preload scripts and the IPC channels that connect the page to Node.js. Native .node add-ons and the Electron fuses are the last two pieces.

The worked target here is Joplin 3.7.22, the open-source (AGPL-3.0-or-later) note-taking app from github.com/laurent22/joplin, using the macOS arm64 release DMG. The static analysis uses REA 6.7.0, a third-party open-source tool, on macOS 27.0.1 arm64 with Node.js 22.22.3. Do this only on open-source apps, your own apps, or apps you are authorized to examine.

Reverse Engineer an Electron App with REA: ASAR, Preload, IPCSilent walkthrough with English captions.

Step 1: Copy the archive out of the app

hdiutil attach -nobrowse -readonly -mountpoint ./joplin-mnt Joplin-3.7.22-arm64.dmg
cp joplin-mnt/Joplin.app/Contents/Resources/app.asar work/
cp -R joplin-mnt/Joplin.app/Contents/Resources/app.asar.unpacked work/
ditto joplin-mnt/Joplin.app work/Joplin.app
hdiutil detach ./joplin-mnt

Joplin’s app.asar is 233,595,359 bytes. Next to it, app.asar.unpacked/node_modules/ holds four packages that must exist as real files: keytar, onnxruntime-node, sqlite-vec-darwin-arm64 and sqlite3. Keep both together. Paths on Windows and Linux, and the browser and @electron/asar routes, are in Unpack an ASAR file.

Step 2: Inventory it, and expect an integrity failure

rea inspect-artifact "$PWD/work/app.asar" --json > inspection.json

On Joplin this failed straight away:

{"code":"artifact_integrity_mismatch","category":"integrity_mismatch","message":"Declared ASAR integrity for unpacked entry node_modules/keytar/build/Release/keytar.node contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive. …","details":{"logical_path":"node_modules/keytar/build/Release/keytar.node","declared_sha256":"6c32c41c…","calculated_sha256":"f5112161…","unpacked":true}}

That is normal for a signed macOS release: the native binaries were code-signed after the ASAR header recorded their hashes. Rerun with the contradiction recorded instead of fatal:

rea inspect-artifact "$PWD/work/app.asar" --integrity-policy record-and-continue --json > inspection.json

It took 4.3 s and listed 7,032 inventory nodes and five contradictions, all native binaries in app.asar.unpacked: keytar.node, onnxruntime_binding.node, libonnxruntime.1.24.3.dylib, vec0.dylib and node_sqlite3.node. Each keeps its declared and observed SHA-256, so the mismatch stays visible rather than ignored.

Step 3: Extract and read what shipped

rea extract-artifact "$PWD/work/app.asar" --integrity-policy record-and-continue --json > extract.json
jq -r '.normalized_result.output_root' extract.json

REA wrote 6,324 files to a fresh rea-extracted-<uuid> directory under the system temp folder in 43 s. Start with package.json:

{"name": "@joplin/app-desktop", "version": "3.7.22", "main": "main.bundle.js", "license": "AGPL-3.0-or-later"}

The main entry is a 13,140,998-byte esbuild bundle, and the renderer has a 19,993,163-byte main-html.bundle.js. But look around before you reach for an unbundler: Joplin’s archive also ships its TypeScript sources (391 non-declaration .ts files outside node_modules) and 547 source maps. main.bundle.js.map lists 2,723 sources, including ElectronAppWrapper.ts, but has no sourcesContent, so the map gives names and positions only. Here the .ts files themselves are the readable source.

ElectronAppWrapper.ts answers the first security question directly. The main window is created with:

webPreferences: {
    session: this.joplinSession_,
    nodeIntegration: true,
    contextIsolation: false,
    spellcheck: true,
    enableRemoteModule: true,
}

and windows opened from about:blank get nodeIntegration: false with preload: path.resolve(__dirname, './utils/window/secondaryWindowPreload.js'). That preload is four lines:

const { ipcRenderer } = require('electron');

window.electronWindow = {
	onSetWindowId: windowId => ipcRenderer.send('secondary-window-added', windowId),
};

and ElectronAppWrapper.ts line 502 handles it with ipcMain.on('secondary-window-added', …). Most apps do not ship their sources, which is why the next step works from the bundles.

Step 4: Build the application graph, scoped

Parsing all 5,379 relevant files of a 233 MB archive is slow and produces a huge record. When you know which files matter, select them with --source-path (exact archive paths, no globs):

rea analyze-javascript-application "$PWD/work/app.asar" --integrity-policy record-and-continue \
  --source-path package.json --source-path main.bundle.js \
  --source-path utils/window/secondaryWindowPreload.js --source-path gui/note-viewer/preload.js \
  --json > app-evidence.json

REA still inventories and verifies the whole archive, and reports the other 5,375 files as outside the analysis scope. The first run finished in 38 s but with skipped_javascript_files: 1 and this limitation:

main.bundle.js: Resource constraint during analyze_javascript_application: Owned JavaScript worker exhausted its heap while analyzing main.bundle.js during static …

The default per-file budget is 1024 MiB of V8 heap and 300000 ms. Raising it fixed the skip:

rea analyze-javascript-application "$PWD/work/app.asar" --integrity-policy record-and-continue \
  --max-heap-mb 6144 --analysis-timeout-ms 900000 \
  --source-path package.json --source-path main.bundle.js \
  --source-path utils/window/secondaryWindowPreload.js --source-path gui/note-viewer/preload.js \
  --json > app-evidence.json

71 s, 1,915,101 AST nodes visited, and a 132,610,252-byte Evidence file. Never read that whole. Project the summary:

jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
rea inspect-analysis-view app-view.json

The Electron part, trimmed:

browser_windows: 0
explicit_web_preferences: 0
preload_entrypoints: 0
ipc:
  operations: 8
  literal_channels: 5
  dynamic_channel_operations: 3
  renderer_transmissions: 4
  renderer_listeners: 4
  main_handlers: 0
  paired_renderer_transmissions: 0
  unpaired_literal_renderer_transmissions: 1

Step 5: Read the result against what you know

Compare that with Step 3, where we read the source.

What REA found. An electron-main node for main.bundle.js and an electron-preload node for utils/window/secondaryWindowPreload.js, linked by a loads edge with "mechanism":"property:preload". It resolved the path.resolve(__dirname, …) preload path out of the minified main bundle. The four renderer listeners are the ipcRenderer.on handlers in the note viewer’s webview preload (gui/note-viewer/preload.js) for setHtml, scrollToHash, setPercentScroll and setMarkers. The three dynamic operations are sendToHost(callName, …) calls whose channel is a variable, so they are never paired. The one literal send is secondary-window-added. The graph also holds three native-addon nodes (keytar, sqlite3, the onnxruntime binding) and sqlite and localStorage storage nodes.

What it missed. browser_windows: 0, explicit_web_preferences: 0 and main_handlers: 0, although the main window and the secondary-window-added handler are in the source. In the bundle, Electron is reached through esbuild’s module interop, as c3.ipcMain.on("secondary-window-added",…) and cy.BrowserWindow. REA only treats a call as Electron’s API when it can prove the module alias, and that wrapper is the likely reason it did not here. The send stays unpaired and the main window’s nodeIntegration:!0,contextIsolation:!1 is not reported. REA says so itself: “missing findings do not establish absence.”

The practical rule: a zero in the Electron counts is a reason to grep the bundle, not a conclusion. grep -o '.\{60\}contextIsolation.\{30\}' main.bundle.js found the minified webPreferences in one line.

Not every bundle is like this. On a three-file app we packaged ourselves (main.js, preload.js, renderer.js, one notes:export channel; @electron/packager 20.3.0, Electron 44.9.1, an 8,434-byte app.asar), the same command paired the channel:

{"operations":2,"literal_channels":1,"dynamic_channel_operations":0,
 "renderer_transmissions":1,"renderer_listeners":0,"main_handlers":1,
 "paired_renderer_transmissions":1,"ambiguous_renderer_transmissions":0,
 "unpaired_literal_renderer_transmissions":0}

The difference is plain require("electron") in unbundled files versus an esbuild interop wrapper.

Endpoints are noisy. The summary counted 25 endpoint nodes in Joplin’s main bundle. Most were not URLs: \df@tag, \current@color, Debugger.enable, .sync/readme.txt, content-type. Filter them by hand.

Step 6: Trace a channel

node --input-type=module -e '
import { readFileSync, writeFileSync } from "node:fs";
const application = JSON.parse(readFileSync("app-evidence.json", "utf8"));
writeFileSync("trace-input.json", JSON.stringify({
  application, seed: { kind: "channel", value: "secondary-window-added" }, direction: "incoming"
}));'
rea trace-application-feature ./trace-input.json --json > trace.json
jq -c '.normalized_result.summary' trace.json

direction takes incoming, outgoing or both. On Joplin:

direction traced nodes traced edges terminal paths
incoming 10 11 0
outgoing 1 0 0
both 15,812 16,214 5,410

incoming gives the useful chain: package → electron-main (main.bundle.js) → loads → electron-preload (secondaryWindowPreload.js) → sends → ipc-channel. outgoing stops at the channel itself, which is the missing main handler from Step 5 again. both walked up to the archive root and back down through every containment edge. It ended at 5,372 unknown nodes (the unselected files), 25 endpoints, 5 storage nodes and 3 native add-ons, so it says nothing useful about this channel. Start with one direction.

Other seed kinds are node-id, route, string, api, module and native-export. Matching is literal: string seeds match substrings, the others exact values. A match proves a static link, not that the code runs in a given session.

Step 7: Native add-ons

REA records .node files that JavaScript loads and the member names the syntax asks for, but it does not parse them. Joplin’s three came back as native handoffs with status requires-provider-analysis, each naming the add-on’s digest. To go further, analyze the file from app.asar.unpacked/ with a native provider:

rea analyze "$PWD/work/app.asar.unpacked/node_modules/keytar/build/Release/keytar.node" --provider ghidra --json

The Ghidra workflow is in REA with Ghidra. We did not run it on Joplin’s add-ons for this article.

Step 8: Check the fuses

The code cannot tell you whether the Electron runtime around it is locked down. That lives in the fuse bytes of the Electron binary:

rea inspect-electron-fuses "$PWD/work/Joplin.app" --json > fuses.json
jq -c '.normalized_result.runtime_implications[] | {subject, outcome}' fuses.json

REA resolved the bundle to its Electron Framework (191,759,520 bytes, Electron 42.11.18, one arm64 slice, sentinel at offset 150027024). Joplin 3.7.22 ships the stock values: RunAsNode, EnableNodeOptionsEnvironmentVariable and EnableNodeCliInspectArguments enabled, and EnableEmbeddedAsarIntegrityValidation and OnlyLoadAppFromAsar disabled:

{"subject":"node_cli_inspect_arguments","outcome":"honored"}
{"subject":"electron_run_as_node","outcome":"honored"}
{"subject":"embedded_asar_integrity_validation","outcome":"not_enforced"}
{"subject":"only_load_app_from_asar","outcome":"not_restricted_to_app_asar"}

Its Info.plist still carries an ElectronAsarIntegrity hash for Resources/app.asar, but with the validation fuse off, nothing checks it. What each fuse does, the wrong-file trap and cross-checking with @electron/fuses are in Inspect Electron fuses.

Step 9 (optional): Compare with the running app

Static analysis says what the code can do. To see what a page actually loaded, REA can attach passively to a CDP endpoint on loopback. Start the app with Electron’s own --remote-debugging-port=9223 switch (some apps ignore it), then:

rea list-electron-targets http://127.0.0.1:9223 --json
rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID --observation-ms 100 --json

On our self-built test app this listed one page target titled Notes and reported Electron/44.9.1 in its user agent. Inspection does not evaluate JavaScript, click or invoke IPC. We did not run this step on Joplin. rea capture-electron-scenario records IPC channel and argument-shape metadata while it launches the app with your normal user privileges; see electron-observation.md.

If the main or renderer code is a minified bundle without useful maps, split it into modules first: How to reverse engineer a webpack bundle.

Troubleshooting

invalid_request with --source-path. We passed src/main.js for a file at main.js and got "Select an exact relevant file path from the artifact inventory; no globs or path aliases." Copy paths from the inventory or the extracted tree.

A file is listed under skipped JavaScript. Look for a Resource constraint limitation naming it, then raise --max-heap-mb and --analysis-timeout-ms as in Step 4.

Zero paired IPC calls. Either the channels are built at runtime (a variable or a wrapper function), or, as with Joplin, the bundle reaches Electron through an interop wrapper REA cannot prove. Grep the bundle for the channel string and read the code around it.

Output too big for an agent over MCP. Use summary and page views, --source-path, or --format json --filter-output evidence_id,normalized_result.statistics.