Strictly for educational and research purposes. This repository documents the reverse engineering of an Electron desktop application's integrity verification system, the techniques used to analyse a native Node.js addon binary, and the strategies used to patch it. No copyrighted source code or proprietary binaries from the target application are included.
- Overview
- Application Architecture
- The Integrity System — How It Works
- Error Taxonomy (E1–E6)
- Reverse Engineering the Native Module
- Patch 1 — Binary Patch of
blitz_core.node - Patch 2 — ASAR Extraction, JS Modification, and Repacking
- auth.js — Fake Premium User
- autoUpdater/index.js — Disable Auto-Updates
- crashReporter.js — Disable Telemetry
- ota.js — Disable OTA Updater
- electronWindowHandlers.js — Network-Level Ad Block
- createWindow.js — Premium Window Size
- blitz-entry.js — Privacy Sandbox, Auto-Start, MachineID
- util/domain.js — Pin Domain, Disable Geo Fingerprinting
- pinApp/index.js — Disable Silent Taskbar Pin
- Patch 3 —
.env.production - Patch 4 —
app-update.yml - ASAR Repacking — Why
--unpackMatters - Tools Used
- Usage
- Restore
- Disclaimer
Blitz is an Electron desktop application (LoL/Valorant companion) built on:
- An Electron shell (
Blitz.exe) loading a remote React web app via aBrowserView - A local Node.js ASAR (
app.asar) containing the main process JavaScript - A native C++ Node.js addon (
blitz_core.node) handling game integration, crash reporting, and integrity verification
This project documents a complete patch pipeline that:
- Neutralises the native module's integrity checking loop via a PE binary patch
- Modifies the Electron main-process JavaScript via ASAR extraction and repacking
- Strips telemetry configuration from environment files
C:\Users\<user>\AppData\Local\Programs\Blitz\
├── Blitz.exe ← Electron shell (Chromium + Node.js)
├── icudtl.dat ← ICU i18n data (verified by blitz_core.node)
└── resources\
├── app.asar ← Main process JS (archived with Electron ASAR)
├── app.asar.unpacked\ ← Native addons that cannot live inside ASAR
│ └── node_modules\
│ ├── classic-level\ ← LevelDB binding (used for local app DB)
│ └── lzma-native\ ← LZMA compression (used for update delta patches)
├── binaries\
│ └── blitz_core.node ← Native C++ addon (812 KB PE/COFF DLL)
├── .env.production ← Environment config (Sentry DSNs, API keys)
└── app-update.yml ← electron-updater feed config
C:\Users\<user>\AppData\Roaming\Blitz\
├── blitz-deps\{version}\ ← Runtime copy of binaries\ (copied on startup)
│ └── blitz_core.node ← The file actually loaded at runtime
├── appdb\ ← LevelDB database (window state, auth token, etc.)
├── app.log ← Main process log
└── crash.log ← Native crash handler log
Key architectural detail: On every startup, blitz-entry.js calls copyDeps(), which copies the entire resources/binaries/ directory to %APPDATA%\Blitz\blitz-deps\{appVersion}\. This means:
- The source of truth for the binary is
resources/binaries/blitz_core.node - The runtime binary actually loaded is
blitz-deps/{version}/blitz_core.node - Both must be patched to ensure the patch survives restarts
The native addon blitz_core.node is a Win32 PE DLL loaded by the Electron main process via Node.js's require(). Once initialised, it spawns an internal thread that runs a continuous verification loop inside the function we identified as FUN_1800161c0.
The loop performs these checks on a timer:
| Check | Mechanism | Purpose |
|---|---|---|
| Debugger detection | IsDebuggerPresent() + PEB BeingDebugged flag + NtQueryInformationProcess(ProcessDebugPort) |
Detect attached debuggers |
| Remote debugger | CheckRemoteDebuggerPresent() |
Detect remote debuggers |
| Timing check | Compare QueryPerformanceCounter deltas |
Detect single-stepping (breakpoints slow execution) |
CRC32 of icudtl.dat |
Read entire file, compute CRC32 | Detect tampering with ICU data |
| Path verification | Check process path matches expected install location | Detect execution from wrong directory |
| ASAR integrity | CRC32 / hash of app.asar |
Detect ASAR modification |
If any check fails, the function writes an error code (E1–E6) to the crash log and triggers process termination.
Why is blitz_core.node the gatekeeper? Because it's a compiled native binary — unlike the JavaScript in app.asar, it cannot be modified by simply unpacking an archive. The JavaScript has no way to override what the native code does once it's loaded.
These error codes appear verbatim in crash.log and were found as literal strings in blitz_core.node using Ghidra's string analysis:
| Code | Trigger condition |
|---|---|
PRE E1 |
Integrity loop not yet initialised (startup guard) |
E1 Error |
IsDebuggerPresent() returned non-zero |
E2 Error |
PEB NtGlobalFlag indicates debugger present |
E3 Error |
NtQueryInformationProcess(ProcessDebugPort) ≠ 0 |
E4 Error |
Timing anomaly detected (execution too slow = breakpoint) |
E5 Error |
Process path check failed |
E6 Error |
CRC32 mismatch on icudtl.dat (or ASAR hash mismatch) |
The E6 Error was the one triggered when app.asar was modified — the native module hashes the asar file and compares it to an expected value embedded in the binary. Any byte-level change to the asar, including adding a single trailing null byte, triggers E6.
- Ghidra 12.1 (NSA's open-source reverse engineering suite)
tools/GhidraDecompExport.java— included in this repo, a polished headless Ghidra script that batch-exports decompiled C, cross-references, strings, imports/exports, data types, and memory segments to plain text for analysis
analyzeHeadless <project_dir> BlitzProject \
-import blitz_core.node \
-postScript GhidraDecompExport.java \
-processor x86:LE:64:default \
-cspec windowsThe GhidraDecompExport.java script exports:
decompiled_all.c— All decompiled functions (concatenated — ideal for grep/AI)functions/— One.cfile per function for targeted inspectionstrings.txt— All string literals (where E1–E6 were found)exports.txt— Exported N-API symbolsimports.txt— DLL import tablexrefs.txt— Cross-reference call graphdatatypes.txt— Recovered structs and enumssegments.txt— PE section layout (RVA ranges, permissions)
String search for "E6 Error" in strings.txt produced the virtual address 0x180095430. Cross-referencing this address (via Ghidra's xrefs) led to the function FUN_1800161c0, which contained all six error strings and the verification logic.
| Symbol | Virtual Address | Purpose |
|---|---|---|
FUN_1800161c0 |
0x1800161C0 |
Main integrity verification loop |
DAT_1800bf8ac |
0x1800BF8AC |
"Verified" flag byte (1 = passed) |
IMAGE_BASE |
0x180000000 |
PE preferred load address |
The decompiled pseudocode (simplified) for FUN_1800161c0:
void FUN_1800161c0(void) {
// Anti-debug checks
if (IsDebuggerPresent()) { write_crash("E1 Error."); terminate(); }
if (peb->NtGlobalFlag & 0x70) { write_crash("E2 Error."); terminate(); }
if (NtQueryInformationProcess(..., ProcessDebugPort, ...) != 0) {
write_crash("E3 Error."); terminate();
}
// Timing check
QueryPerformanceCounter(&t1);
// ... some work ...
QueryPerformanceCounter(&t2);
if ((t2 - t1) > THRESHOLD) { write_crash("E4 Error."); terminate(); }
// Path check
if (!check_process_path()) { write_crash("E5 Error."); terminate(); }
// CRC32 check on icudtl.dat (and app.asar)
uint32_t crc = compute_crc32(icudtl_path);
if (crc != EXPECTED_CRC) { write_crash("E6 Error."); terminate(); }
DAT_1800bf8ac = 1; // Set "verified" flag
// Loop with sleep interval...
}Bypass the integrity checking mechanism within FUN_1800161c0 while ensuring the game detection and IPC logic at the start of the function continues to run.
Initially, a blunt patch was used that placed a RET at the very beginning of the function. This bypassed the checks but inadvertently broke the game overlay by skipping the EnumWindows and game detection logic.
The new patch targets the specific conditional jump guarding the integrity checks (CMP + JNZ), forcing the "verified" flag to true and unconditionally jumping over the checks.
Original Instructions at 0x180016C08:
; Check if the "verified" flag is set
CMP byte ptr [DAT_1800bf8ac], 0
JNZ SKIP_CHECKSNew Patched Instructions:
; Instruction 1: MOV byte ptr [DAT_1800bf8ac], 1
; Opcode encoding: C6 /0 /disp32 /imm8
C6 05 9D 8C 0A 00 01
; Instruction 2: JMP rel32 (+0x1361)
E9 61 13 00 00
; Instruction 3: NOP (Padding to match original 13 bytes)
90RIP-Relative Calculations:
In x86-64, accessing globals often requires RIP-relative addressing (RIP + displacement). RIP is the address of the next instruction.
Instruction 1 VA: 0x180016C08
MOV length: 7 bytes
RIP after MOV: 0x180016C0F
Target (DAT_1800bf8ac): 0x1800BF8AC
Displacement: 0x1800BF8AC - 0x180016C0F = 0x000A8C9D (stored LE: 9D 8C 0A 00)
Instruction 2 VA: 0x180016C0F
JMP length: 5 bytes
RIP after JMP: 0x180016C14
Target (end of check loop): 0x180017F75
Displacement: 0x180017F75 - 0x180016C14 = 0x00001361 (stored LE: 61 13 00 00)
The virtual address 0x180016C08 cannot be directly used as a file offset — PE files are mapped differently in memory than on disk. We must walk the section headers to convert the RVA (Relative Virtual Address) to a raw file offset:
RVA = VA - IMAGE_BASE = 0x180016C08 - 0x180000000 = 0x16C08
For each section header:
if (VirtualAddress <= RVA < VirtualAddress + VirtualSize):
FileOffset = RawDataPointer + (RVA - VirtualAddress)
Result: File offset 0x16008 (in the .text section)
Offset 0x16008:
Before: 80 3D 9D 8C 0A 00 00 0F 85 60 13 00 00
After: C6 05 9D 8C 0A 00 01 E9 61 13 00 00 90
Every time the Electron process calls FUN_1800161c0 (via an internal timer), the game detection logic runs perfectly. But when it reaches the integrity checks, it immediately:
- Writes
1toDAT_1800bf8ac— the "verified" global flag - Jumps to the end of the loop — skipping all anti-debug, timing, path, and CRC checks
This allows us to freely modify app.asar without triggering E6, while keeping the application's core functionality (the overlay) fully operational.
Electron ASAR (Atom Shell Archive) is a tar-like archive format used to package JavaScript source files. The format consists of:
- A 4-byte magic number
- A Chromium Pickle-encoded header containing a JSON filesystem tree (with each file's offset, size, and optional SHA-256 integrity hash)
- Raw concatenated file content following the header
The format is designed for fast random-access reads — Electron patches Node.js's require() to transparently read files from inside the ASAR without extracting.
Before patching blitz_core.node, any modification to app.asar triggered E6. This was confirmed by the following experiment table:
| Modification | Result |
|---|---|
| Zero-change re-serialisation (identical bytes) | ✅ Works |
| Add 1 trailing null byte | ❌ E6 Error |
| Update integrity hash only | ❌ E6 Error |
| Change content + update hash | ❌ E6 Error |
| Change content, keep old hash | ❌ E6 Error |
The native module was performing a whole-file or block-level hash check on app.asar. Now that the checker is bypassed, repacking works freely.
Original app.asar (backup)
│
▼ npx @electron/asar extract
extracted/
└── src/
├── auth.js ← PATCH: fake premium user
├── autoUpdater/
│ └── index.js ← PATCH: no-op all update functions
├── crashReporter.js ← PATCH: no-op crash/telemetry reporting
├── ota.js ← PATCH: no-op OTA version check
├── electronWindowHandlers.js ← PATCH: cancel ad network requests
├── createWindow.js ← PATCH: premium window dimensions
└── blitz-entry.js ← PATCH: disable ads API + auto-start
│
▼ npx @electron/asar pack --unpack "{*.node,*.dll}"
patched app.asar + app.asar.unpacked/
│
▼ copy to resources/
Installed!
Original behaviour: fetchUser() reads a JWT from the local LevelDB store, POST's it to https://auth.blitz.gg/graphql, and returns the user's profile including their roles array. hasPremiumRole() then checks if any role is PRO_SUBSCRIBER, FREE_PRO_SUBSCRIBER, or CRYPTO_PRO_SUBSCRIBER.
Why this matters: The result of hasPremiumRole() is used in createWindow.js to determine the minimum window size. Free users get a 1420×850 minimum (sized to fit ad units). Premium users get 940×500.
Patch: Replace fetchUser() with a function that returns a hardcoded object with PRO_SUBSCRIBER role, and make hasPremiumRole() always return true.
// BEFORE
async function fetchUser() {
const token = await getToken();
if (!token?.authToken) return;
return axios("https://auth.blitz.gg/graphql", { ... })
.then(r => r?.data?.data?.me);
}
// AFTER
async function fetchUser() {
return { name: "PremiumUser", roles: [{ code: "PRO_SUBSCRIBER" }] };
}Original behaviour: Uses electron-updater to poll the GitHub releases feed at theblitzapp/blitz-core, download delta patches (.exe files), verify their SHA-256, and apply them by spawning the installer. Updates are applied automatically for "security" releases.
Patch: Replace the entire module with stub functions that return resolved Promises. The app-update.yml feed URL is also replaced with localhost:0 as a belt-and-suspenders measure.
Original behaviour:
- Calls
Electron.crashReporter.start()with the Sentry minidump DSN from.env.production— this registers a system-level crash handler that uploads minidumps to Sentry on process crash - On Windows, calls
blitz_core.node'sInitCrashHandler()to register a native crash dump writer to%TEMP%\blitz\
Patch: Replace with a no-op setupCrashHandler(). The .env.production SENTRY_DSN values are also cleared.
Original behaviour: A separate update mechanism (distinct from electron-updater) that polls utils.iesdev.com to determine which version of the remote web app to load. Stores the resolved version in LevelDB. Runs on startup and polls every hour.
This is separate from the binary updater — it controls which URL the BrowserView loads (https://probuilds.net/v{version}).
Patch: Return the version from the bundled package.json directly, making zero network calls.
Why this matters: The original ota.js was discovered when the app threw ENOTFOUND utils.iesdev.com errors because the app's startup OTA check was failing (network not yet up or domain blocked), causing an unhandled rejection that crashed the window.
Original behaviour: interceptRequests() is called for each BrowserWindow/BrowserView. It sets up session.webRequest.onBeforeSendHeaders() to modify headers for the LCU WebSocket and Blitz CDN. It does not block ad networks.
Patch: We inject an additional session.webRequest.onBeforeRequest() handler that cancels any request whose URL matches a list of known ad networks and telemetry endpoints before the TCP connection is even opened.
window.webContents.session.webRequest.onBeforeRequest(
{ urls: AD_BLOCK_PATTERNS },
(_details, callback) => callback({ cancel: true })
);Electron URL pattern syntax note: Patterns follow Chrome extension match pattern syntax. A common mistake is using wildcard TLDs like *://host.*/* — these are invalid in Electron and throw:
TypeError: Invalid url pattern *://adservice.google.*/*: Invalid host wildcard.
Only subdomain wildcards (*://*.host.com/*) are supported.
Original behaviour:
const DEFAULT_WIDTH = 1420;
const DEFAULT_HEIGHT = 850;
fetchUser().then((user) => {
if (user && hasPremiumRole(user.roles)) {
MIN_WIDTH = 940; MIN_HEIGHT = 500;
windows.client.setMinimumSize(940, 500);
} else {
MIN_WIDTH = DEFAULT_WIDTH; MIN_HEIGHT = DEFAULT_HEIGHT;
// Force resize if window is smaller than ad-friendly minimum
windows.client.setMinimumSize(1420, 850);
}
});Patch: Change the constants and replace the fetchUser() callback with a static assignment:
const DEFAULT_WIDTH = 940; // PATCHED
const DEFAULT_HEIGHT = 500; // PATCHED
// PATCHED: hardcode premium window dimensions
MIN_WIDTH = 940; MIN_HEIGHT = 500;
write("MIN_WIDTH", MIN_WIDTH); write("MIN_HEIGHT", MIN_HEIGHT);
windows.client.setMinimumSize(940, 500);Original behaviour: The app entry point does three things patched here:
- Appends
--enable-privacy-sandbox-ads-apisto Chromium's command-line arguments, enabling the Topics API (a FLoC successor for browser-based ad targeting) - Calls
addAutoStartOnFirstRun()which registers Blitz as a Windows startup app via the registry on first launch - Calls
writeMachineID()on every launch — reads a hardware fingerprint (derived from CPU/disk serials viablitz_core.node) and writes it to%APPDATA%/.machineId, which is used as a persistent cross-session tracking identifier
Patches: Topics API switch commented out; auto-start registration no-op'd; writeMachineID() replaced with an empty function.
Original behaviour: getDomain() performs two network calls on every launch:
- Fetches
https://cloudflare.com/cdn-cgi/traceto determine the user's country code (exposing IP to Cloudflare) - Computes
CRC32(machineID + "desktop-traffic") % 100and uses the result as an A/B bucket to route traffic to one of five branded mirror domains:
| Domain | Countries |
|---|---|
agentselect.net |
US, FR, HK, AR, CZ, AT… |
championselect.net |
US, AU, CA, NL, NZ, SG… |
lolstats.com |
US, BR, CA, KR… |
probuilds.net |
US, AU, CA, GB, DE, KR… |
tftcomps.gg |
US, BR, CA, KR… |
This means Blitz uses your hardware machine ID as a persistent tracking seed to deterministically assign you to an A/B group — surviving reinstalls and account changes.
Patch: Replace the entire module with a two-line stub that returns "probuilds.net" directly — no Cloudflare request, no CRC32, no machine ID computation:
async function getDomain(_version) {
return MAIN_DOMAIN.hostname; // always "probuilds.net"
}Original behaviour: On first launch, init() checks if Blitz is pinned to the Windows taskbar. If not, it spawns PinManager.exe (a bundled helper binary in blitz-deps/) with a Blitz.lnk shortcut path as its argument, silently pinning the app to the taskbar without any confirmation dialog. The result is written to LevelDB so pinning only runs once.
Patch: Replace the init export with a no-op function so PinManager.exe is never spawned.
The app loads resources/.env.production via dotenv at startup. It contains:
SENTRY_DSN=https://d0d473722f3c496a9d6097abb79c953f@sentry.blitz.gg/2
SENTRY_MINIDUMP_DSN=https://sentry.blitz.gg/api/2/minidump/?sentry_key=...
REACT_APP_LOCIZE_API_KEY=d983c536-1533-4230-be6b-ee764813355fPatch: Clear the values (keep the keys so dotenv doesn't fail) to ensure no crash events, error reports, or i18n telemetry are sent even if the crashReporter.js patch is somehow bypassed:
SENTRY_DSN=
SENTRY_MINIDUMP_DSN=
REACT_APP_LOCIZE_API_KEY=electron-updater reads this file to find the update feed:
# Original
owner: theblitzapp
repo: blitz-core
provider: githubPatch: Change to an unreachable generic URL:
# Patched
provider: generic
url: http://localhost:0The autoUpdater/index.js JS patch already makes the updater a no-op. This config change is belt-and-suspenders — even if the JS is somehow reverted, the updater will fail to resolve localhost:0 at the network level.
When @electron/asar pack is run without --unpack, all files including native .node addons are packed inside the ASAR. However, native addons loaded via require() are handled differently from regular JS files:
- JS files: Node.js reads them directly from the ASAR virtual filesystem via a patched
require() - Native
.nodefiles: Node.js loads them withdlopen()(Linux) /LoadLibrary()(Windows), which requires a real filesystem path
If a .node file is inside the ASAR, dlopen fails with ERR_DLOPEN_FAILED.
lzma-native's electron.napi.node links against liblzma.dll at runtime. Windows resolves DLL dependencies by searching directories in this order:
- The directory of the loading DLL itself
- System directories (
System32, etc.) - PATH
If liblzma.dll is packed inside the ASAR but electron.napi.node is unpacked to app.asar.unpacked/, Windows can't find liblzma.dll in directory 1 (it's not on disk next to the .node file). The crash:
Error: The specified module could not be found.
\\?\C:\...\app.asar.unpacked\node_modules\lzma-native\prebuilds\win32-x64\electron.napi.node
Fix: Use --unpack "{*.node,*.dll}" to unpack both .node files AND their companion DLLs to disk.
After repacking, the directory structure is:
app.asar ← ASAR archive (JS source only)
app.asar.unpacked/ ← Real files on disk (native modules + DLLs)
└── node_modules/
├── classic-level/prebuilds/win32-x64/
│ └── node.napi.node ← LevelDB native addon
└── lzma-native/prebuilds/win32-x64/
├── electron.napi.node ← LZMA native addon
├── node.napi.node
└── liblzma.dll ← Required companion DLL
Important: Both app.asar and app.asar.unpacked/ must be installed together. Replacing only app.asar without updating the .unpacked/ directory will cause stale native modules to be loaded.
| Tool | Version | Purpose |
|---|---|---|
| Ghidra | 12.1 | Disassembly and decompilation of blitz_core.node |
tools/GhidraDecompExport.java |
— | Headless Ghidra script: exports decompiled C, strings, xrefs, segments |
| Python | 3.8+ | PE binary patching, ASAR orchestration |
| Node.js + npx | 18+ | Running @electron/asar for extraction/repacking |
| @electron/asar | latest | Official ASAR packing/unpacking tool |
| PowerShell | 5.1+ | File search, process management, verification |
struct (Python stdlib) |
— | PE header parsing for RVA→file offset conversion |
- Windows 10/11
- Python 3.8+
- Node.js 18+ (for
npx) - Blitz desktop app installed at its default path
# Clone the repo
git clone https://github.com/ameen/blitz-crank
cd blitz-crank
# Apply all patches
python tools\patch.pyThe script will:
- Kill any running Blitz processes (to release locked native file handles)
- Backup all original files to
_backup/ - Patch
blitz_core.nodein-place (8 bytes — integrity bypass) - Extract
app.asarfrom the backup, apply all 10 JS patches, repack with--unpack "{*.node,*.dll}" - Install patched
app.asar+app.asar.unpacked/ - Clear Sentry DSNs and Locize API key from
.env.production - Disable the update feed in
app-update.yml
JS patches applied (in order):
| File | Effect |
|---|---|
auth.js |
fetchUser() returns hardcoded PRO_SUBSCRIBER; hasPremiumRole() always true |
autoUpdater/index.js |
All update functions no-op'd |
crashReporter.js |
Sentry crash reporter disabled |
ota.js |
OTA version check returns bundled version |
electronWindowHandlers.js |
30+ ad/telemetry domains cancelled at session layer |
createWindow.js |
All 3 MIN_WIDTH/MIN_HEIGHT gates set to 940×500 |
blitz-entry.js |
Topics API, auto-start, and machineID write no-op'd |
util/domain.js |
Geo lookup + A/B routing replaced with static probuilds.net |
pinApp/index.js |
Silent taskbar pin (PinManager.exe) not spawned |
python tools\patch.py --restoreRestores all files from the _backup/ directory created during the first run.
This project is provided strictly for educational and research purposes. It documents:
- x86-64 binary analysis and patching techniques
- PE file format internals (section headers, RVA-to-offset conversion)
- RIP-relative addressing in x86-64
- Electron application internals (ASAR format, native addon loading, webRequest API)
- Node.js native addon (
node-api) architecture
No Blitz source code, binaries, or proprietary assets are included in this repository. The patch script modifies locally installed files and does not distribute any Blitz IP.
Use of these techniques against software you do not own, or in violation of its Terms of Service, may be unlawful. The authors accept no responsibility for misuse.