Defense Evasion via Single Executable Applications (SEA) in Node.js
What is SEA (Single Executable Applications)?
When a threat actor chooses Node.js SEA to deliver a payload, they are not making a development decision. They are making a detection decision.
Node.js SEA was built to solve a legitimate problem: packaging an entire application into a single self-contained binary, with no requirement for the target to have a runtime installed. What lands on disk is a functional Node.js process with the __NODE_SEA_BLOB section and __NODE_SEA segment generated by the official toolchain itself. No header manipulation, no external packer. The malicious structure is built by the same tools a legitimate developer would use. That is the central advantage of the technique.
What SEA means from an offensive perspective
From an offensive standpoint, SEA is a Living-off-the-Land (LotL) primitive: the payload travels inside a runtime that security tools recognize as legitimate. There is no exposed script on disk, no loose dependency, no revealing import table. The final binary is, for all practical purposes, Node.js.
The barrier to entry is low. Any actor with access to postject and a copy of the runtime can package a payload in minutes. This explains why the technique is being adopted independently by different actors, it is not sophistication, it is operational convenience.
A content search on VirusTotal can still find payload strings when there is no obfuscation — SEA alone does not solve that problem. What the actor gains is the absence of loose artifacts and a binary that passes automated triage without raising suspicion. When the JavaScript is obfuscated before packaging, the problem scales: static analysis sees a runtime, not a payload. And when anti-sandbox enters as a third layer, dynamic analysis fails too, but that is the subject of the next section.
What about dynamic analysis?
Many actors using SEA as an evasion primitive implement environment checks before executing any malicious code. The binary verifies whether it is running inside a virtual machine, whether the CPU count matches a real system, whether the hostname contains words like “sandbox” or “analysis”, whether analysis tools are running. If any of those checks fail, the malware simply exits no behavior, no network traffic, no artifacts.
Two recent cases illustrate this in practice. The Stealit campaign, documented by Fortinet in October 2025, used Node.js SEA with multiple obfuscation layers and implemented environment checks covering minimum system memory, CPU count, hostname blacklists, process blacklists, and analysis of DLLs loaded by the process. If any of those checks failed, the malware exited silently no behavior, no network, no artifacts.
GachiLoader, analyzed by ThreatDown in April 2026, followed the same logic. Before executing the payload, the binary queried Win32_VideoController to detect virtual environments, confirmed more than 2 CPUs and at least 4GB of RAM, and escalated privileges via UAC. Only after passing all those checks was the payload decrypted and executed in memory.
The combination is not a coincidence. It is an architectural decision: SEA handles the static layer, obfuscation handles the blob content, anti-sandbox closes the dynamic layer.
js-logger-pack: SEA as a supply chain vector
The js-logger-pack case introduces a different dimension: SEA not as a binary delivered directly to the victim, but as a second stage in a supply chain attack via npm.
The published package was a bait-and-switch. dist/index.js contained a plausible logger implementation. The actual trigger was the postinstall script in package.json, which executed print.cjs a downloader that immediately detached from the parent process so that npm install would finish normally while the download continued in the background. The victim installed a dependency and saw nothing unusual.
What made this stage technically interesting was the packaging choice. The four downloaded binaries were not four different malware families, they were the same JavaScript payload injected into four Node.js SEA containers: a PE32+ for Windows, two Mach-O binaries for macOS x64 and arm64, and an ELF for Linux. The actor wrote the implant once and used SEA as a universal wrapper. Operational portability at minimal development cost.
This also reveals a hunting artifact. Because the same cross-platform JavaScript bundle was packed into all four containers, the Windows binary contained systemd strings and the Linux binary contained Windows scheduled task strings. The actor optimized for portability — and that trade-off left detectable evidence inside each binary.
The key lesson for threat hunting comes from the Node.js runtime itself. The section name NODE_SEA_BLOB is a universal constant, it cannot be changed without breaking the runtime’s loading mechanism. It is the most reliable artifact for hunting and should anchor any YARA rule or search query.
The segment name __NODE_SEA is different. It is a convention, not a hard requirement of the runtime and should not be treated as a primary IOC. Use it as a secondary or contextual indicator, never as a standalone anchor. To extend historical coverage, complement with NODE_JS_CODE and --macho-segment-name NODE_JS to catch SEA binaries built with older versions of Node.js v18.
What the actor could not hide
As robust as the SEA + obfuscation + anti-sandbox chain is, some artifacts are immutable, determined by the Node.js runtime itself and impossible to change without breaking the binary.
Section name:
NODE_SEA_BLOBis fixed. The runtime locates the blob by this name during loading, renaming it prevents execution. Every functional SEA binary will have this section, without exception.Segment name:
__NODE_SEAon Mach-O is equally fixed for the same reason. Validated empirically, changing the segment name via--macho-segment-nameresults in an immediate segfault.Magic number: The first four bytes of the blob are
20 DA 43 01on Node.js v20, the value the runtime uses to validate the blob before executing it. Different Node.js versions may have different values, but the magic number will always be present in the first bytes of the blob and is a reliable static artifact for the corresponding version.Anomalous size: SEA binaries carry the complete Node.js runtime — typically between 80MB and 100MB depending on the version. A native executable doing the same operations would be a fraction of that size. This delta is detectable and measurable without executing the binary.
The actor optimized against detection of the payload. They could not optimize against the structure that carries it.
A note on naming by format: In Mach-O binaries (macOS), the linker prefixes section names with two underscores by convention, resulting in
__NODE_SEA_BLOB. In PE (Windows) and ELF (Linux), the name remainsNODE_SEA_BLOB. When hunting across platforms, include both variations:__NODE_SEA_BLOBfor Mach-O andNODE_SEA_BLOBfor PE/ELF.
Plugin
During the research for this article, I could not find any existing tool for analyzing Node.js SEA binaries inside Binary Ninja. Blob extraction was a manual process. I decided to fix that.
The SEA Analyzer automatically detects the NODE_SEA_BLOB section when a binary is opened, validates the magic number, extracts the JavaScript, and runs static analysis across seven categories: Network, Process Execution, Filesystem, Persistence, Anti-Sandbox, Crypto & Exfiltration, and Obfuscation Indicators. Everything inside Binary Ninja, without leaving the analysis environment.
The result against the MicrosoftSystem64-darwin-arm64 sample from js-logger-pack: 95 indicators across 7 categories — GPU enumeration, CPU and RAM checks, Hugging Face API, keylogger, WebSocket C2, Telegram session theft, LaunchAgent persistence.


