O Windows Defender Application Control (WDAC) é uma funcionalidade de segurança do Windows que ajuda a prevenir que códigos não autorizados (como malwares ou executáveis e scripts não confiáveis) seja executados em um sistema. É um mecanismo de lista de permissões de aplicações que impõe políticas que permitem que apenas executáveis, scripts e drivers explicitamente confiáveis sejam executados em um sistema. É frequentemente usado em ambientes de alta segurança ou rigidamente controlados, nos quais a segurança e a integridade do sistema são críticos, como aqueles que a equipe do X-Force Red Adversary Simulation está empenhada em testar.
Algumas semanas atrás, meu colega Bobby Cooke publicou um post de blog detalhando um método para burlar até mesmo as políticas mais rígidas do WDAC ao criar backdoors em aplicações confiáveis da Electron. Recomendo muito a leitura desse post de blog para entender como os aplicações Electron usam Node.js e como podem ser vulneráveis a ataques.
Como parte dessa pesquisa, ele também disponibilizou o código aberto Loki C2, um framework de comando e controle baseado em Node.js. Graças ao excelente trabalho de Bobby e Dylan no desenvolvimento do Loki C2, a equipe do X-Force Adversary Simulation conseguiu executar códigos em ambientes protegidos que utilizam WDAC.
Então, onde entra essa pesquisa? A técnica mencionada acima tem uma falha: você está limitado a executar apenas código JavaScript e não pode executar código nativo, como carregar DLLs ou executar EXEs. Você também não pode executar shellcode para iniciar uma carga útil C2 de estágio 2. Este post de blog aborda uma técnica que utilizamos para contornar essas restrições.
De início, Bobby e eu começamos a fazer engenharia reversa de módulos Node.js carregados por aplicações Electron, procurando vulnerabilidades que pudessem permitir a execução de código de baixo nível e em nível de instrução. Após alguma exploração inicial e por sugestão do jeffssh, minha atenção se voltou para o mecanismo V8 usado pelo Node.js e pelo Chrome.
Em vez de encontrar uma vulnerabilidade em um módulo Node.js, que tal fazer uma exploração do mecanismo V8 com um N-day?
O cenário de ataque é familiar: trazer um binário vulnerável, mas confiável, e aproveitar o fato de ele ser confiável para obter acesso ao sistema. Neste caso, usamos uma aplicação Electron confiável com uma versão vulnerável do V8, substituindo o main.js por uma exploração V8 que executa o estágio 2 como a carga útil, e pronto, temos a execução nativa de shellcode. Se a aplicação explorada estiver na lista de permissões/assinada por uma entidade confiável (como a Microsoft) e normalmente tem permissão para ser executada de acordo com a política da WDAC empregada, ela pode ser usada como um recipiente para a carga útil maliciosa.
Além de poder executar livremente o shellcode, essa abordagem também tem o benefício de executar o shellcode no contexto de um processo semelhante ao de um navegador, o que tem vantagens. Comportamentos que poderiam ser sinalizados como suspeitos pelo EDR parecem normais para um navegador, como ter a memória RWX mapeada para código Just-In-Time (JIT).
Essa abordagem parece bastante simples, mas eu ainda tinha algumas dúvidas. Uma exploração pública do Chrome V8 N-day realmente funcionaria dentro de uma aplicativo Electron? Como o mecanismo V8 usado no Chrome difere daquele usado no Node.js? Que modificações serão necessárias para a exploração? Como posso depurar isso?
Descobri que já existe um trabalho público sobre a exploração do V8 em aplicativos Electron, que, infelizmente para mim, só encontrei depois de ter terminado. O Turb0 faz um excelente trabalho ao cobrir o processo (um tanto angustiante) de adaptar uma exploração pública v8 e suas primitivas de leitura/gravação correspondentes para funcionar dentro de uma aplicação Electron. A postagem do Turb0 no blog já aborda muitos dos detalhes técnicos aprofundados com os quais tive que lidar, recomendo muito que você dê uma olhada. O restante desta postagem do blog se concentrará nos estágios do ciclo de desenvolvimento da exploração no que diz respeito ao direcionamento do Windows com o objetivo específico de criar um desvio WDAC e nos problemas que encontrei ao operacionalizar a exploração para uso no mundo real.
A primeira coisa que eu precisava fazer era descobrir os alvos exatos. Eu precisava escolher uma aplicação Electron confiável e uma vulnerabilidade para explorá-la. Eu tinha pouca experiência com invasão de navegadores antes disso, então a vulnerabilidade escolhida deveria ter uma exploração pública para servir como ponto de partida.
Eu não tinha certeza de como as versões do V8 correspondiam à versão do V8 usada pelo Electron, ou como saber se ela era realmente vulnerável. A versão do V8 do Electron geralmente fica atrás da versão mais recente do V8 do Chrome. Os responsáveis pela manutenção do Electron incorporam correções de segurança importantes de versões mais recentes na versão que foi congelada para um determinado lançamento do Electron. Isso significa que, mesmo que o Electron use uma versão mais antiga do V8, não significa necessariamente que ele seja vulnerável a um bug, já que correções podem ter sido implementadas em versões anteriores. Os patches selecionados que eles aplicam estão armazenados aqui.
Decidi que a abordagem mais fácil seria usar uma vulnerabilidade que fosse corrigida após o lançamento da versão da aplicação. Dessa forma, não haveria absolutamente nenhuma chance de que a versão do aplicativo ainda tivesse sido corrigida. Após alguma pesquisa, encontrei downloads das versões do VSCode dos últimos 2 anos, aproximadamente. Eu tinha uma variedade razoável de aplicações vulneráveis assinadas pela Microsoft para escolher 😊.
Para começar, simplesmente peguei um PoC público recente de exploração do V8 e inseri um backdoor no aplicativo Electron vulnerável, substituindo o main.js pelo código da exploração, e cruzei os dedos. Talvez fosse assim tão fácil, certo? Eu esperava pelo menos que ocorresse uma falha. Como já era esperado, nada aconteceu quando abri o aplicativo. A contragosto, eu sabia que precisaria desenvolver o V8 para entender o que estava acontecendo em um nível mais profundo. Ao desenvolver o V8, eu seria capaz de criar a versão de depuração (d8), me aprofundar na exploração e, em seguida, ajustá-la para a versão específica almejada.
Meu primeiro objetivo foi estabelecer uma “verdade fundamental” – replicar o ambiente exato em que se sabe que a exploração funciona. Então, eu poderia examinar as diferenças entre essa versão e a versão que eu almejava para entender o que estava acontecendo de errado.
A maioria das explorações públicas do V8 que encontrei tinham como alvo o Linux. Então, comecei compilando o V8 no Linux, verificando o commit exato que a exploração pública que escolhi tinha como alvo. Depois, executei a exploração para ter certeza de que funcionou. Felizmente, isso aconteceu. Agora eu tinha minha verdade fundamental.
A partir daí, compilei a versão do V8 que eu estava almejando (a mesma usada pelo aplicativo Electron), mas no Linux. A exploração não funcionou logo de cara. A vantagem de construir um projeto por conta própria é que você pode analisar o código com a profundidade que precisar. Em particular, o V8 tem o d8, o shell autônomo para o mecanismo JavaScript V8, usado principalmente para testar, depurar e executar código JavaScript e WebAssembility fora de um navegador ou ambiente Node.js. O d8 tem funcionalidades de depuração interna habilitadas com a
Com isso, eu poderia exibir os endereços dos objetos de interesse e ajustar os deslocamentos fixos da exploração pública. Agora eu estava chegando a algum lugar. Eu só precisava migrar minha exploração para o Windows.
Compilar uma versão mais antiga do V8 no Windows me deu muitas dores de cabeça. Precisei corrigir vários problemas de dependências, então fiz algumas modificações internas duvidosas no código. Os detalhes me escapam agora – meu cérebro os bloqueou para minha própria proteção. Após horas de luta, finalmente consegui compilar a versão que eu precisava! Para minha surpresa, a exploração modificada do Linux funcionou no Windows sem ajustes.
Agora, tudo o que faltava era testar a exploração no aplicativo Electron e prender a respiração... Ops, não funcionou! Mas por quê?
No início, eu estava esperançoso, porque o alvo de fato caiu. Afinal, eu não havia adaptado a carga útil do Linux para o Windows, então não podia esperar que nada de interessante acontecesse. Para confirmar o comportamento, alterei a carga útil de exploração para ser executada no endereço 0x4141414141. Essa é uma técnica comum que os criadores de exploração de vulnerabilidades usam para poder ver/provar que obtiveram o controle do programa ao controlar o endereço do ponteiro de instruções. No entanto, depois de analisar o erro no WinDbg, não encontrei o que procurava. Eu estava recebendo uma falha de segmentação ao substituir o ponteiro de função de destino.
Lembra do fato de o Electron ter escolhido os commits do V8 que eu estava comentando antes? Acontece que, embora o aplicativo estivesse vulnerável ao bug que eu estava usando para exploração, o método de fuga da área de testes usado pela exploração pública já estava corrigido por meio de um cherry-pick. Se você não estiver familiarizado com a área de testes/gaiola de memória do V8, pode ler sobre ela aqui. Basicamente, é uma maneira de tornar a invasão V8 mais difícil no caso de uma vulnerabilidade.
Para entender o que estava acontecendo, precisei desenvolver novamente a versão desejada do V8, desta vez aplicando os patches selecionados. Além dos patches de segurança, o Node.js também aplica patches específicos do Node.js à versão do V8 que o Electron usa. Levei muito tempo para perceber que precisava fazer isso, pois a forma como o Electron e o Node.js lidam com suas várias dependências não ficou clara de início.
Depois de um ou dois dias tentando ter certeza de que a versão do V8 que eu estava compilando era *idêntica* ao meu alvo e também lendo sobre técnicas recentes de fuga de área de testes, tive progressos. Consegui encontrar uma técnica de fuga que funcionaria para o meu alvo. Depois de ajustar a exploração, finalmente consegui travar o aplicativo com o controle do ponteiro de instruções. Uma doce vitória, o final estava próximo...
Nesse ponto, tudo o que faltava fazer era modificar a carga útil de exploração pública para executar nossa carga útil C2. Essa mudança aparentemente simples provou ser mais chata do que eu pensava. A carga útil do Linux da exploração pública era simples, capaz de abrir um shell, e tinha apenas alguns bytes de tamanho. A carga útil do C2 era... muito maior do que isso.
Se você entende de codificação em shellcode, saberá que escrever shellcode do Windows é mais irritante do que shellcode no Linux, principalmente porque não há uma maneira simples de fazer chamadas de sistema diretas de forma independente de posição, como você pode fazer no Linux. A carga útil também precisava ser “contrabandeada por JOP” dentro de um array de ponto flutuante:
Obviamente, toda a carga útil do estágio C2 (que tinha vários milhares de bytes de tamanho) não pôde ser executada assim. Então, eu precisava escrever uma carga útil de bootstrap que mapeasse uma página executável, copiasse a carga útil final para ela e, em seguida, saltasse para ela.
O problema com a carga útil de bootstrap é que, embora eu tivesse o controle do programa, não tinha como passar argumentos para a carga que foi executada. Então, meu shellcode contrabandeado não saberia o endereço da carga útil final a partir da qual deveria copiar. Eu resolvi isso com algo que chamei de “contrabando de argumentos”.
Eu sabia que o endereço do objeto JSFunction sobrescrito seria armazenado no registrador rcx. Então, usando a primitiva de escrita arbitrária, armazenei a página mapeada em um dos campos do objeto que não seriam necessários. Isso exigiu um pouco de tentativa e erro, pois a substituição de alguns desvios causava falhas. Eu fiz a mesma coisa para o valor a ser copiado e o deslocamento para onde copiá-lo. O deslocamento do campo pode ser codificado no shellcode, para que ele saiba de onde copiar a carga útil. Chamei a carga útil n vezes, onde n é o número de bytes a serem copiados.
O TurboFan, compilador de otimização do V8, atrapalhou um pouco meus planos. Devido às otimizações do TurboFan, o contrabando de sequências de instruções traduzidas em vários pontos flutuantes do mesmo valor resultaria em apenas uma instância desse valor na memória. Isso impôs limitações sobre a frequência com que as instruções poderiam ser repetidas. Resolvi isso tornando meu shellcode o mais compacto possível e também variando a posição das instruções contrabandeadas caso fosse absolutamente necessário repetir alguma instrução, de forma que o valor de ponto flutuante fosse diferente e não houvesse entradas repetidas.
Eu também tive problemas para copiar o shellcode quando a carga útil do estágio 2 era muito grande, provavelmente devido ao número de vezes que eu precisava chamar o mesmo JSFunction e TurboFan compactado, tentando otimizar isso. Finalmente, consegui contornar isso copiando e colando vários loops em "WriteShellcode" em vez de um único loop grande. Terrivelmente chato, mas funcionou! Mais tarde, Bobby e Dylan otimizaram a carga útil do C2 para um estágio que recuperou a carga útil maior do armazenamento de blob, de modo que a carga útil final não precisava ser armazenada em disco. Isso também ajudou a manter o tamanho do arquivo main.js em um nível razoável.
A preparação para o uso operacional real de explorações deve sempre incluir testes em ambientes diferentes. Para o contexto da operação, não sabíamos em qual ambiente a carga útil seria executada, apenas que era um sistema Windows que provavelmente tinha o WDAC habilitado. Portanto, a exploração precisava funcionar independentemente do sistema operacional. Eu estava confiante de que, como a versão V8 da aplicação e todas as dependências estavam contidas no aplicativo, não haveria muita variabilidade. Eu estava errado nessa suposição.
Por razões que desconheço, o deslocamento do ponteiro de função vulnerável a ser sobrescrito mudou entre as versões do Windows. Não fazia sentido porque, pelo que entendi, a distância de deslocamento é determinada pelo mecanismo V8 JIT, cujas bibliotecas são carregadas diretamente do pacote da aplicação. Isso significa que exatamente as mesmas bibliotecas V8 são carregadas, independentemente do sistema operacional. Para tornar as coisas ainda mais confusas, a variação não parecia seguir nenhum tipo de padrão. Às vezes, o deslocamento estava reduzido em 4 bytes em algumas versões do Windows (tanto as mais antigas quanto as mais recentes). Isso era muito irritante porque não havia como (pelo que eu pude perceber) obter o deslocamento correto de dentro da exploração em JavaScript. A única maneira de calcular isso era utilizar o shell de depuração para ler o endereço de memória e fazer o cálculo, o que obviamente não era uma opção dentro do aplicação Electron. TLDR: a variação de compensações não pode ser calculada no tempo de execução da exploração.
Para contornar o problema de inconsistência de deslocamento, Bobby e Dylan reestruturaram a exploração para que o main.js a executasse várias vezes, tentando diferentes deslocamentos possíveis até obter sucesso. Isso era feito fazendo com que o processo de código inicial executasse um loop. Esse loop gerou processos secundários que tentariam a exploração com um deslocamento único. Se a exploração falhasse, o processo secundário seria encerrado. Se a exploração fosse bem-sucedida, o shellcode seria executado e gravaria um arquivo Mutex antes de implementar o estágio 2 C2. Assim que a exploração fosse bem-sucedida, o processo inicial sairia do loop e entraria em estado de espera para sempre.
Embora isso significasse que uma tentativa de deslocamento incorreta causaria uma falha, nossos testes revelaram que não havia erros visíveis para o usuário e que a funcionalidade da aplicação ainda parecia funcionar sem dificuldades. Embora não fosse a solução mais limpa e nem muito discreta devido às falhas, o tempo era essencial. Isso é o que chamamos de "JIT xdev" e funcionou perfeitamente para nossas necessidades.
Obviamente, não queríamos que a exploração fosse óbvia, se fôssemos pegos alguém poderia analisar o ponto de entrada main.js da aplicação. Para evitar isso, aplicamos um ofuscador JavaScript no código de exploração, o que o tornou praticamente incompreensível ao olho humano. Graças ao talento e à dedicação de Chris Spehn, que mantém o pipeline de CI/CD de carga útil da equipe, conseguimos simplificar a entrega dessa carga útil e ofuscar o código sempre que ela era gerada, para que pudéssemos reutilizar a aplicação indefinidamente com um código de exploração diferente a cada vez. Isso impediu que a carga útil fosse assinada e se mostrou especialmente útil, pois, infelizmente, na primeira vez que tentamos usar o recurso, fomos pegos porque o usuário sinalizou o e-mail de phishing 🙁. Curiosamente, embora a equipe de segurança do cliente tenha analisado o aplicação presente no e-mail de phishing, não conseguiu descobrir a finalidade, nem identificar a exploração V8 embutida.
Ainda não entendo muito bem por que os deslocamentos de função JITted dependiam do sistema operacional, já que todas as bibliotecas V8 relevantes deveriam estar incluídas no aplicação Electron. Se alguém tiver alguma ideia do porquê disso, por favor, me avise!
O Electron lançou uma funcionalidade experimental de integridade que verifica a integridade de todos os arquivos da aplicação em tempo de execução. Está disponível para macOS desde a versão 16 e para Windows desde a versão 30. Os desenvolvedores de aplicações podem habilitar esse recurso do Electron para garantir que nenhum dos arquivos da aplicação seja adulterado. Se estiverem, o processo será encerrado automaticamente e nada será executado.
Essa funcionalidade impede a modificação de qualquer um dos arquivos empacotados do aplicativo Electron, incluindo main.js, e impede as técnicas discutidas. No entanto, ainda não foi implementado nas aplicações mais populares. Se e quando essa funcionalidade tiver um uso mais generalizado, ainda será importante observar que as versões mais antigas da aplicação, anteriores ao mecanismo de integridade, permanecerão vulneráveis e utilizáveis para esse ataque.
Bobby Cooke e Dylan Tran – ajudando a operacionalizar a exploração
Dylan Tran – Criação de diagramas
Chris Spehn -integração da carga útil em nosso pipeline de CI/CD (e todos os outros trabalhos ingratos de DevOps feitos para a equipe)
jeffssh – Inspiração
j j – um hacker mestre em V8, cujos diversos projetos de PoC em V8 ajudaram imensamente