Native Code (Native Addons and Prebuilt Binaries)
Native code is compiled machine code in a package, such as a Node.js addon built by node-gyp or a prebuilt binary, that source review cannot read.
Also known as Node.js addon, C++ addon, native module, prebuilt binary, prebuilds, node-gyp, binding.gyp, .node file, Node-API, N-API, Python extension module, compiled extension
Native code is compiled machine code shipped inside an open source package: a Node.js native addon that node-gyp builds from a binding.gyp file, a prebuilt binary, or a Python extension module. It runs with the full privileges of the process that loads it, often during install, and nobody can review it the way they review JavaScript or Python source.
Native code gives a malicious package two execution points that source review does not cover. The first is the build. npm treats any package with a binding.gyp as having an install script, and node-gyp evaluates that file as a Python expression, so a package whose package.json declares no scripts can still run a shell command during npm install. The second is the load. A .node file is a shared library, and the operating system runs its constructors the moment require() loads it, before any exported function is called.
Our position: a binding.gyp is an install script, and a binary is unreviewed code. pnpm 10 in January 2025 and npm v12 on July 8, 2026 closed the first gap by default. Nothing in npm, pnpm, or pip gates the second. The useful signal is not that a package contains native code, because thousands legitimately do. It is that a package which shipped none in its previous version ships some now.
What is node-gyp?
#node-gyp is the build tool npm bundles to compile Node.js native addons from C and C++ source. Its README calls it "a cross-platform command-line tool written in Node.js for compiling native addon modules for Node.js." It reads binding.gyp from the package root, runs gyp-next, the Node.js project's Python fork of Google's GYP, to generate a Makefile or a Visual Studio project, and compiles against the Node.js headers into build/Release/<target>.node. The installing machine needs Python, make, and a C/C++ toolchain, which is why native builds fail so often in slim CI images.
A native addon is what node-gyp produces. The Node.js addon documentation defines addons as "dynamically-linked shared objects that can be loaded via the require() function." On Linux a .node file is an ELF shared object, on macOS a Mach-O bundle, and on Windows a DLL. Addons built on Node-API, a stable ABI, let one binary run across Node.js versions.
npm runs node-gyp for you. The npm scripts documentation says that when a package root contains binding.gyp and defines no install or preinstall script, npm defaults install to node-gyp rebuild. The rule lives in npm's manifest normalizer, trimmed here from the copy npm 10.9.9 bundles:
// add "install" attribute if any "*.gyp" files exist
if (!scripts.install && !scripts.preinstall && data.gypfile !== false) {
const files = await lazyLoadGlob()('*.gyp', { cwd: pkg.path })
if (files.length) {
scripts.install = 'node-gyp rebuild'
data.scripts = scripts
data.gypfile = true
}
}The script exists only in npm's normalized view of the package, not in the package.json a reviewer opens. That gap between the two copies of a package's metadata is the same one behind manifest confusion.
A binding.gyp looks like JSON but is not. Here is the top of the one bcrypt 6.0.0 ships:
{
"variables": {
"NODE_VERSION%":"<!(node -p \"process.versions.node.split(\\\".\\\")[0]\")"
},
'targets': [
{
'target_name': 'bcrypt_lib',
'sources': [
'src/blowfish.cc',
'src/bcrypt.cc',
'src/bcrypt_node.cc'
],
'dependencies': [
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
],Mixed quote styles, a trailing comma, and two <!(...) command expansions. The GYP input format reference says command expansions make GYP "execute a command at generation time," and that "the entire string contained within the parentheses is passed to the system's shell." bcrypt uses them for ordinary build plumbing. That is the core detection problem: the syntax a malicious binding.gyp needs is the syntax popular legitimate packages use every day.
Why is native code in a package a security risk?
#Native code in a package is a security risk because it executes at two points that source review, JavaScript analysis, and most install controls do not see: when gyp evaluates binding.gyp during the build, and when the operating system loads the compiled binary. Neither point requires package.json to declare anything.
The figure traces a native addon from npm install to its first call in six steps. Step 1, extracting the tarball, runs no package code. Step 2, node-gyp's configure phase, evaluates binding.gyp: command expansions go to a shell and conditions entries run as Python expressions. Step 3 compiles the .node file, or an install script downloads a prebuilt one. Steps 2 and 3 run package-controlled code, and npm 12's allowScripts default and --ignore-scripts both stop them. Step 4 is require(), which calls process.dlopen; the operating system's loader maps the library and runs its constructors. Step 5 is Node-API calling the addon's Init function. Steps 4 and 5 also run package-controlled code, and the only built-in control that stops them is the Node.js permission model with native addons left disabled. Step 6, your code calling an exported function, is where most reviews start looking.
binding.gyp is a program, not a config file
#gyp does not parse binding.gyp; it evaluates it. In the node-gyp 11.5.0 that npm 10.9.9 bundles, gyp-next's input.py loads the file with eval(build_file_contents, {"__builtins__": {}}, None) and runs every conditions entry through Python's compile() and eval(). Removing __builtins__ is not a sandbox. The openapi-react-query-codegen compromise on August 28, 2026 shipped a condition that walks Python's class hierarchy to recover the builtins, imports os, and calls os.system on node 3FWCvzduYZg.js, a 5,616,000-byte payload. Our install script entry walks through that escape. Command expansion is simpler and needs no escape at all.
We packed a package whose package.json has no scripts field and whose binding.gyp is this:
{
"variables": {
"probe": "<!(node -e \"require('fs').writeFileSync('/tmp/gyp-ran', 'ran in ' + process.cwd())\")"
},
"targets": [{ "target_name": "noop", "type": "none" }]
}Installing the tarball with npm 10.9.9 printed the usual two lines, and the probe had run:
$ cat gyp-demo/package.json
{ "name": "gyp-demo", "version": "1.0.0", "main": "index.js" }
$ npm install ../gyp-demo-1.0.0.tgz
added 1 package, and audited 2 packages in 596ms
found 0 vulnerabilities
$ cat /tmp/gyp-ran
ran in /home/claude/demo/app/node_modules/gyp-demo
$ rm -rf node_modules /tmp/gyp-ran
$ npm install ../gyp-demo-1.0.0.tgz --ignore-scripts
added 1 package, and audited 2 packages in 379ms
found 0 vulnerabilities
$ cat /tmp/gyp-ran
cat: /tmp/gyp-ran: No such file or directoryTwo details from the same experiment matter more than the marker. Our first version had an empty targets list, which crashed gyp's Makefile generator with an IndexError, and npm reported the install as failed. The marker file had already been written. Command expansion runs while gyp loads the file, before it generates or compiles anything, so a payload does not need a working build. And npm recorded "hasInstallScript": true for the package in package-lock.json, although its package.json contains no scripts at all.
Attackers adopted this trigger in 2026. The Miasma campaign's "Phantom Gyp" waves added a binding.gyp to packages that had no native code, as in the LeoPlatform wave on June 24, 2026, where command expansion launched a replaced index.js during npm install with no preinstall or postinstall entry for a scanner to match. In the ImmobiliareLabs compromise two days later, the binding.gyp was byte-identical across 22 malicious versions of four @immobiliarelabs Backstage plugins. All ten malicious openapi-react-query-codegen versions carried valid npm provenance, because the attacker triggered the project's own trusted publishing workflow from a pull request comment. Provenance proved which pipeline built the tarball. It said nothing about what the build file inside it would do.
Loading a .node file runs code before you call it
#A native addon executes when it is loaded, not when it is called. require() hands a .node file to process.dlopen, which asks the operating system's dynamic loader to map it. On Linux the loader runs every function in the library's .init_array before dlopen returns, and only then does Node-API call the addon's Init. We compiled this addon with node-gyp:
#include <node_api.h>
#include <stdio.h>
#include <stdlib.h>
/* Runs when the dynamic loader maps the .node file,
before Node.js calls the addon's init function. */
__attribute__((constructor))
static void on_load(void) {
FILE *f = fopen("/tmp/addon-ctor-ran", "w");
if (f) { fprintf(f, "HOME=%s\n", getenv("HOME")); fclose(f); }
}
static napi_value Init(napi_env env, napi_value exports) { return exports; }
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)$ node -e "require('./build/Release/addon.node')"
$ cat /tmp/addon-ctor-ran
HOME=/root
$ nm -D build/Release/addon.node | grep ' U '
U __fprintf_chk@GLIBC_2.3.4
U fclose@GLIBC_2.2.5
U fopen64@GLIBC_2.2.5
U getenv@GLIBC_2.2.5The JavaScript side of such a package is one require() call; everything it does lives in machine code. The dynamic symbol table is the closest thing a binary has to an import list. Even a stripped library must name the libc functions it links against, so calls like getenv, connect, or execve appear there unless the author resolves them at runtime, which is itself a reason to look closer.
The same trick works in Python. In June 2026 the bioinformatics wave of Mini Shai-Hulud, Miasma, and Hades trojanized compiled extension modules such as ensmallen_haswell.abi3.so in ensmallen 0.8.101, which ran a JavaScript stealer when Python imported the package. Its .py files looked normal. Install-time controls never saw it, because installing a wheel runs no package code; the payload waited for the first import.
A binary breaks the link between source and artifact
#A prebuilt binary is an unverified claim that some source, compiled somewhere, produced these bytes. Reviewers read the repository, and users run the artifact. The backdoored xz package lived in that gap: according to the GitHub advisory for CVE-2024-3094, build instructions present only in the 5.6.0 and 5.6.1 release tarballs would "extract a prebuilt object file from one of the test archives" and use it to modify liblzma.
The binaries in a typical dependency tree are not suspect, but most are unverifiable. bcrypt 6.0.0 ships ten prebuilt .node files for Linux, macOS, and Windows, 991,908 bytes of machine code, and all six Linux builds are stripped of symbols. As of November 2, 2026, its registry entry carries no provenance attestation tying those bytes to a build. Checking them means rebuilding from source and comparing, which only works if the build is reproducible.
How do native binaries get into node_modules?
#Native binaries get into node_modules in one of four ways, and the choice decides whether anything runs at install and whether the lockfile pins the binary. We read each example's registry metadata on November 2, 2026, and installed bcrypt and sharp with npm 12.2.1's defaults.
| Model | Examples | Runs package code at install | Binary pinned by lockfile integrity | Works under npm 12 defaults |
|---|---|---|---|---|
| Build from source | binding.gyp with node-gyp rebuild; the fallback in canvas 3.2.3, sqlite3 6.0.1, node-pty 1.1.0 | Yes: gyp evaluation, then the compiler | No: built on the installing machine | No: needs approval, then fails at runtime if skipped |
| Download at install | prebuild-install, used first by canvas 3.2.3, sqlite3 6.0.1, kerberos 7.0.0 | Yes: an install script fetches the binary | No: fetched from outside the tarball | No: needs approval |
| Bundled prebuilds | prebuildify plus node-gyp-build: bcrypt 6.0.0 ships 10 binaries | Only to compile when no bundled binary matches | Yes: the binaries are inside the tarball | Yes: loads its prebuild without approval |
| Platform packages | optionalDependencies filtered by os, cpu, and libc: sharp 0.35.5 lists 25 | No for sharp; esbuild 0.28.2 still runs node install.js as a postinstall | Yes: each platform package has its own integrity entry | Yes |
Verdict: ship platform packages, or bundled prebuilds when the binary set is small. Both put the binary inside a registry tarball, where lockfile integrity pins it and npm provenance can attest it, and neither needs an install script. Download-at-install is the weakest model. prebuild-install 7.1.3 builds its URL from the package's repository field, defaults to the GitHub release for that version, streams the response to disk, and checks no hash, so nothing npm records pins the binary it fetches. Its own README recommends prebuildify with node-gyp-build instead. Building from source belongs only on platforms nobody prebuilt for.
Under npm 12, the models behave the way that table predicts:
$ npm install bcrypt@6.0.0 sharp@0.35.5
...
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts bcrypt@6.0.0 (install: node-gyp-build)
$ node -e "require('bcrypt'); require('sharp'); console.log('both loaded')"
both loadednpm blocked bcrypt's install script, and bcrypt loaded its bundled Linux binary anyway. sharp produced no warning. That is the right outcome for users, and it shows exactly what allowScripts governs: builds, not binaries. Native code that arrives prebuilt loads with no prompt at all.
Platform packages are safer because they move the binary into the registry, not because optionalDependencies deserves trust. In the TanStack compromise in May 2026, malicious versions added an optionalDependencies entry pointing at a git commit whose package.json ran a prepare hook.
Which controls stop native code?
#The controls that stop native code split cleanly by execution point: install-time controls stop binding.gyp evaluation and downloads, and only a runtime control stops a binary from loading.
| Control | Stops binding.gyp evaluation | Stops a malicious binary from loading | Cost and gap |
|---|---|---|---|
| npm 12 allowScripts default, pnpm 10 and later | Yes, until someone approves the package | No | A skipped build fails at runtime, which pushes teams to approve broadly; approvals are keyed to name and version |
| --ignore-scripts | Yes | No | Breaks every package that must build from source |
| pip install --only-binary :all: | Prevents source builds, so setup.py never runs | No: wheels carry compiled extensions | Fails for any package without a wheel for your platform |
| Lockfile integrity | No | Only for binaries inside a tarball | No coverage for install-time downloads, or for npx and global installs, which never touch a project lockfile |
| npm provenance | No | No | Attests the pipeline, not the behavior; the openapi-react-query-codegen versions had it |
| node --permission without --allow-addons | No | Yes: dlopen is refused | All or nothing: sharp and bcrypt cannot load either |
npm 12's script review shows the command, not the code. After installing our gyp-demo tarball with npm 12.2.1, the review listed only the synthesized command:
$ npm install-scripts ls
1 package has install scripts blocked because they are not covered by allowScripts:
gyp-demo@1.0.0 (install: node-gyp rebuild)Nothing in that line reveals the <!(...) expansion. We approved it, reinstalled, and the probe ran. Every legitimate native package prints the same node-gyp rebuild, so the line a reviewer approves is identical for bcrypt and for a Phantom Gyp payload. Review the binding.gyp, not the command npm prints.
The Node.js permission model is the only built-in control on the load path. It has been stable since Node.js 22.13.0 and 23.5.0, according to the permissions documentation, and with --permission set it refuses native addons unless --allow-addons is passed:
$ node --permission --allow-fs-read=* -e "require('./build/Release/addon.node')"
Error: Cannot load native addon because loading addons is disabled.The cost is that the flag is all or nothing. An application that needs one image library has to allow every addon in its tree.
Native code outside npm
#Every major registry carries native code, and each has its own build hook.
- PyPI: wheels ship compiled extension modules (
.soon Linux and macOS,.pydon Windows) and run no code at install. Source distributions run setup.py or a PEP 517 build backend, which pip's--only-binary :all:refuses to do. The ensmallen case shows that wheels are not safe either; they move execution to import time. - crates.io: Cargo compiles and executes a package's
build.rsjust before building the package, per the Cargo book. Build scripts often compile bundled C code, and nothing gates them. - Go modules: cgo compiles C sources in the module, but
go buildruns no package-defined hooks, and the cgo documentation states that "for security reasons, only a limited set of flags are allowed" in#cgodirectives.
Detecting native code that should not be there
#Most native code is legitimate, so its presence is a weak signal and its arrival is a strong one. Malware does not get a CVE, so none of the packages above would have tripped a vulnerability scanner when they ran. Miasma's indicator list in the LeoPlatform report leads with "binding.gyp added to packages that previously did not require native build behavior," followed by a large one-line obfuscated index.js. A pure-JavaScript package that gains a binding.gyp, a .node file, or a new .so in a patch release deserves the same scrutiny as one that gains a postinstall.
Socket's Native code alert flags packages that contain compiled binaries, shared libraries, or native build files. It began as a binding.gyp check and, since an October 2024 update, also detects prebuilt binaries and ecosystem-specific native files; our alert list shows it for npm, PyPI, Go, Maven Central, RubyGems, NuGet, and crates.io, among others. It is Medium severity, which fits the evidence: a package with a legitimate addon trips it too. The alert keys on files inside the tarball rather than on the scripts field, so a binding.gyp with no install script of its own still registers.
npm 12 and pnpm 10 turned the build path off by default, and attackers have already shown the next path. A blocked build no longer runs, but a bundled prebuild still loads without a prompt, and the trojanized ensmallen wheels proved that a binary can wait for import as easily as for install. Until registries can tie each binary to the source and the build that produced it, every native package in a tree is machine code that its users trust and cannot read.
