Não seja rude, fique: evitando a execução .NET via Fork&Run com InlineExecute-Assembly

Homem olhando para a tela do computador enquanto trabalha até tarde da noite escrevendo código

Alguns de vocês adoram e outros odeiam, mas a esta altura não deve ser surpresa que as técnicas de programação .NET vieram para ficar por mais tempo do que o previsto. O framework .NET é parte integrante do sistema operacional da Microsoft, sendo a versão mais recente o .NET Core. O Core é o sucessor multiplataforma do framework .NET, que leva o .NET também para Linux e macOS. Isso agora torna o .NET mais popular do que nunca para técnicas de pós-invasão entre adversários e red teams. Este blog irá explorar um novo Beacon Object File (BOF) que permite aos operadores executar assemblies .NET em processo via Cobalt Strike, em vez do módulo execute-assembly integrado tradicional, que usa a técnica de fork and run.

Contexto

O Cobalt Strike, um popular software de simulação de adversários, reconheceu a tendência dos red teams de abandonar as ferramentas do PowerShell em favor do C#, devido ao aumento de recursos de detecção do PowerShell, em 2018, com a versão 3.11 do Cobalt Strike, o módulo execute-assembly foi introduzido. Isso permitiu que os operadores aproveitassem o poder dos assemblies .NET pós-invasão, executando-os na memória sem o risco adicional de gravar essas ferramentas em disco. Embora os recursos de carregamento de assemblies .NET na memória via código não gerenciado não fossem novos ou desconhecidos na época do lançamento, eu diria que o Cobalt Strike popularizou esses recursos e ajudou a impulsionar a popularidade do .NET para operações pós-invasão.

O módulo execute-assembly do Cobalt Strike usa a técnica fork and run, que consiste em gerar um novo processo sacrificial, injetar o código malicioso pós-invasão nesse novo processo, executar o código malicioso e, quando terminar, eliminar o novo processo. Isso tem vantagens e desvantagens. A vantagem do método fork and run é que a execução ocorre fora do nosso processo de implantação do Beacon. Isso significa que, se algo em nossa ação pós-invasão der errado ou for detectado, há uma chance muito maior de nosso implante sobreviver. Para simplificar, isso realmente ajuda na estabilidade geral do implante. No entanto, à medida que os fornecedores de segurança passaram a identificar esse comportamento de fork and run, isso acabou introduzindo, como o próprio Cobalt Strike admite, um padrão caro em termos de OPSEC.

A partir da versão 4.1 lançada em junho de 2020, o Cobalt Strike introduziu uma nova funcionalidade para tentar ajudar a lidar com esse problema com a introdução dos Beacon Object Files (BOFs). Os BOFs permitem que os operadores evitem os padrões de execução bem conhecidos como descritos acima ou outras falhas de OPSEC, como o uso de cmd.exe/powershell.exe executando arquivos objeto na memória dentro do mesmo processo do nosso implante beacon. Embora eu não vá me aprofundar no funcionamento interno dos BOFs, aqui estão algumas postagens do blog que achei interessantes:

Se você leu os blogs acima, já deve ter percebido que os BOFs não foram exatamente a salvação que esperávamos e, se você sonhava em reescrever todas aquelas ferramentas incríveis do .NET e transformá-las em BOFs, esses sonhos foram por água abaixo. Desculpe. A esperança, porém, não está perdida, pois, na minha opinião, os BOFs têm muito a oferecer, e recentemente me diverti bastante (e também me frustrei um pouco) explorando os limites do que é possível fazer com eles. A primeira foi a criação do CredBandit, que realiza um despejo completo na memória de um processo como o LSASS e o envia de volta por meio do canal de comunicação do Beacon existente. Hoje estou lançando o InlineExecute-Assembly, que pode ser usado para executar assemblies do .NET dentro do processo do beacon sem nenhuma modificação em suas ferramentas favoritas do .NET. Vamos ver por que escrevi o BOF, algumas de suas características principais, ressalvas e como ele pode ser útil ao conduzir simulações de adversários/red teams.

As mais recentes notícias de tecnologia, corroboradas por insights de especialistas.

Mantenha-se atualizado sobre as tendências mais importantes (e intrigantes) do setor em IA, automação, dados e muito mais com o boletim informativo Think. Consulte a Declaração de privacidade da IBM.

Agradecemos sua inscrição!

Sua assinatura será entregue em inglês. Você pode encontrar um link para cancelar a assinatura em todos os boletins informativos. Você pode gerenciar suas inscrições ou cancelar a inscrição aqui. Consulte nossa Declaração de privacidade da IBM para obter mais informações.

Por que InlineExecute-Assembly?

A razão por trás da criação do InlineExecute-Assembility é bastante simples. Eu queria uma maneira de nossa equipe de simulação de adversários executar assemblies .NET em processo para evitar algumas das armadilhas OPSEC mencionadas acima ao usar o Cobalt Strike para operar em ambientes maduros. Eu também precisava da ferramenta para não sobrecarregar nossa equipe com tempo de desenvolvimento extra, tendo que fazer modificações na maioria das nossas ferramentas .NET atuais. Também precisava ser estável. Afinal, por mais estável que um BOF complexo seja, a última coisa que queremos é perder um dos poucos Beacons no ambiente. Basicamente, deve funcionar da forma mais fluida possível para o operador, assim como o módulo execute-assembly do Cobalt Strike.

Mixture of Experts | 12 de dezembro, episódio 85

Decodificando a IA: resumo semanal das notícias

Participe do nosso renomado painel de engenheiros, pesquisadores, líderes de produtos e outros enquanto filtram as informações sobre IA para trazerem a você as mais recentes notícias e insights sobre IA.

Características principais

Carregando o Common Language Runtime (CLR)

Eu sei, é meio óbvio. Não chegaríamos muito longe sem isso, não é mesmo? Piadas à parte, as complexidades de como o CLR funciona e o que está acontecendo em profundidade poderiam ser um post de blog por si só, então vamos rever o que o BOF usa em um nível muito alto quando carrega o CLR via código não gerenciado.

Captura de tela de carregamento de CLR

Carregando o CLR

Conforme mostrado na captura de tela simplificada acima, as principais etapas que o BOF seguirá para carregar o CLR são as seguintes:

  1. Faça uma chamada para CLRCreateInstance que será usada para recuperar nossa interface ICLRMetaHost.
  2. Então, ICLRMetaHost ->GetRuntime é usado para obter as informações de tempo de execução para a versão do .NET que solicitamos. Se o seu assembly foi criado com o .NET versão 3.5 ou inferior, solicitaremos a v2.0.50727, se o seu assembly foi criado com o .NET 4.0 ou superior, solicitaremos a v4.0.30319. Na verdade, há uma função no BOF que nos ajudará a descobrir qual versão nosso assembly .NET usa automaticamente, mas falaremos sobre isso mais tarde.
  3. Assim que tivermos nossas informações de tempo de execução, usamos ICLRRuntimeInfo->IsLoadable para verificar se nosso tempo de execução pode ser carregado no processo. Isso também levará em consideração se outros tempos de execução já podem ter sido carregados e definirá nosso valor de BOOL fLoadable como 1 (verdadeiro), caso o tempo de execução possa ser carregado no processo.
  4. Se tudo isso for confirmado, executaremos ICLRRuntimeInfo->GetInterface para carregar o CLR em nosso processo e recuperar uma interface para ICorRunTimeHost.
  5. Por último, chamaremos ICorRuntimeHost->Start, que inicia o CLR.

Agora o CLR está inicializado, mas ainda há mais algumas coisas a serem feitas antes de podermos executar nossos assemblies .NET favoritos. Precisamos criar nossa instância AppDomain, que é o que a Microsoft explica como "um ambiente isolado em que as aplicações são executadas". Em outras palavras, isso será usado para carregar e executar nossos assemblies .NET pós-invasão.

Captura de tela: AppDomain sendo criado e assembly sendo carregado/executado

AppDomain sendo criado e assembly sendo carregado/executado

Conforme mostrado na captura de tela simplificada acima, as principais etapas que o BOF seguirá para carregar e invocar nosso assembly .NET são as seguintes:

  1. Use ICorRuntimeHost->CreateDomain para criar o AppDomain exclusivo
  2. Use IUnknown->QueryInterface (pAppDomainThunk) para obter um ponteiro para a interface AppDomain
  3. Crie o SafeArray e copie os bytes do assembly .NET para ele.
  4. Carregue o assembly via AppDomain->Load_3
  5. Obtenha o ponto de entrada no assembly via Assembly->EntryPoint
  6. Por fim, invoque o assembly via MethodInfo->Invoke_3

Esperamos que agora você tenha uma compreensão geral da execução do .NET por meio de código não gerenciado, mas isso ainda não nos leva nem perto de ter uma ferramenta operacionalmente sólida. Portanto, vamos analisar algumas funcionalidades implementadas no BOF para elevá-lo de "mais ou menos" para "totalmente legítimo".

Redirecionando o Console STDOUT para Named Pipe ou Mail Slot: evitando a modificação da ferramenta

Você provavelmente está se perguntando por que isso é importante. Bem, se você é como eu e valoriza seu tempo, não vai querer gastá-lo modificando praticamente todos os assemblies .NET para que seu ponto de entrada retorne uma string com todos os seus dados que normalmente seriam simplesmente enviados para a saída padrão do console, não é? Imagino que sim. Para evitar isso, o que precisamos fazer é redirecionar a saída padrão para um named pipe ou um mail slot, ler a saída após ela ter sido gravada e, em seguida, restaurá-la ao seu estado original. Dessa forma, podemos executar nossos assemblies não modificados da mesma forma que faríamos com o cmd.exe ou o powerShell.exe. Antes de analisarmos qualquer código, preciso agradecer a @N4k3dTurtl3 e seu post de blog sobre execute-assembly em processo e mail slots. Originalmente, foi isso que me levou a implementar essa técnica em meu próprio implante C quando ele foi lançado e, muitos meses depois, transferi essa mesma funcionalidade para um BOF. Ok, agora que os créditos foram dados, vamos ver um exemplo simplificado de como isso seria alcançado redirecionando stdout para um named pipe abaixo:

Captura de tela: redirecionando a saída padrão do console para o named pipe e revertendo

Redirecionando a saída padrão do console para um named pipe e revertendo para o estado anterior

Determinando a versão .NET do assembly

Você lembra que, ao carregar o CLR via ICLRMetaHost ->GetRuntime, tivemos que especificar qual versão do framework .NET era necessária? Lembra que isso depende da versão com a qual nosso assembly .NET foi compilado? Não seria muito divertido ter que especificar manualmente qual versão é necessária todas as vezes, não é? Para nossa sorte, @b4rtik implementou uma função interessante para lidar com isso em seu módulo execute-assembly para o framework Metasploit que podemos implementar facilmente em nossas próprias ferramentas mostradas abaixo:

Captura de tela: função que lê o assembly .NET e ajuda a determinar qual versão do .NET precisamos ao carregar o CLR

Função que lê o assembly .NET e ajuda a determinar qual versão do .NET precisamos ao carregar o CLR

Essencialmente, o que esta função faz é, ao receber nossos bytes de assembly, ler esses bytes e procurar os valores hexadecimais 76 34 2E 30 2E 33 30 33 31 39, que, convertidos para ASCII, correspondem a v4.0.30319. Espero que isso lhe pareça familiar. Se esse valor for encontrado durante a leitura do assembly, a função retornará 1 ou verdadeiro, e se não for encontrado, retornará 0 ou falso. Podemos usar isso para determinar facilmente qual versão carregar, independentemente de o resultado ser 1/verdadeiro ou 0/falso, como mostrado no exemplo de código abaixo:

Captura de tela: instrução If/else para definir variável de versão do .NET

Instrução If/else para definir a variável de versão .NET

Aplicando patch na Antimalware Scan Interface (AMSI)

Com certeza não poderíamos falar sobre táticas ofensivas em .NET sem mencionar o AMSI. Embora não vamos detalhar o que é AMSI e todas as maneiras pelas quais ele pode ser ignorado, já que isso foi abordado muitas vezes, falaremos um pouco sobre por que corrigir o AMSI pode ser necessário, dependendo do que você decidir executar por meio do BOF. Por exemplo, se você decidir executar o Seatbelt sem nenhuma ofuscação, perceberá rapidamente que não recebeu nenhuma saída de volta e que seu beacon está morto. Sim, MORTO, morto. Isso ocorre porque o AMSI pegou seu assembly, classificou como malicioso e te desligou, igual a uma festa em casa que está fazendo barulho demais. Não é o ideal, certo? Agora temos duas boas opções em relação ao AMSI: podemos ofuscar nossas ferramentas .NET usando algo como ConfuserX ou Invisibility Cloak, ou podemos desativar o AMSI usando diversas técnicas. No nosso caso, usaremos uma solução do RastaMouse, que consiste em modificar o arquivo amsi.dll na memória para que retorne E_INVALIDARG e faça com que o resultado da verificação seja 0. Como mencionado na postagem do blog deles, isso geralmente é interpretado como AMSI_RESULT_CLEAN. Vejamos uma versão simplificada do código para um processo x64 abaixo:

Captura de tela: aplicação de patches em memória do AmsiScanBuffer

Aplicação de patches de memória do AmsiScanBuffer

Como você pode ver na captura de tela acima, basta fazer o seguinte:

  1. Carregue amsi.dll e obtenha um ponteiro para AMSiscanBuffer
  2. Altere a proteção da memória
  3. Aplique o patch com os bytes do nosso amsiPatch[]
  4. Reverta a proteção de memória ao seu estado original

Ao implementar isso em nossa ferramenta, agora devemos conseguir executar a versão padrão do Seatbelt.exe usando o sinalizador –amsi para ignorar a detecção do AMSI, conforme mostrado abaixo:

Captura de tela: exemplo de contorno do AMSI do InlineExecute-Assembly

Exemplo de contorno do AMSI com InlineExecute-Assembly

Aplicando patch no Event Tracing for Windows (ETW)

Felizmente para os defensores, existe mais do que apenas o AMSI para ajudar na detecção de técnicas maliciosas do .NET usando o ETW. Infelizmente, assim como o AMSI, isso também pode ser facilmente contornado por adversários, e a @xpn fez uma pesquisa realmente incrível sobre como isso poderia ser feito. Vejamos abaixo um exemplo simplificado de como você poderia corrigir o ETW para desativá-lo completamente:

Captura de tela: aplicação de patches em memória do EtwEventWrite

Aplicação de patches em memória de EtwEventWrite

Como você pode ver na captura de tela acima, os passos são praticamente idênticos aos que usamos para corrigir o AMSI, então não vou repeti-los neste caso. Você pode ver uma captura de tela do antes e depois da execução do sinalizador –etw abaixo:

Captura de tela: usando o Process Hacker para visualizar as propriedades do PowerShell.exe antes de executar InlineExecute-Assembly com o sinalizador –etw

Utilizando o Process Hacker para visualizar as propriedades do PowerShell.exe antes de executar inlineExecute-Assembly com o sinalizador –etw

Captura de tela: executando InlineExecute-Assembly usando o sinalizador –etw

Executando inlineExecute-Assembly usando o sinalizador –etw

Captura de tela: usando o Process Hacker para visualizar as mesmas propriedades do PowerShell.exe após executar InlineExecute-Assembly

Utilizando o Process Hacker para visualizar as mesmas propriedades do PowerShell.exe após executar inlineExecute-Assembly

AppDomains exclusivos, Named Pipes, Mail Slots

Por padrão, AppDomain, Named Pipe ou Mail Slot criado utiliza o valor padrão "totesLegit". Esses valores podem ser alterados para melhor se integrarem ao ambiente que você está testando, alterando-os no script agressor fornecido ou por meio de sinalizadores de linha de comando na hora. Um exemplo de como alterá-los por meio da linha de comando pode ser visto abaixo:

Captura de tela do terminal mostrando a execução do comando inlineExecute-Assembly --dotnetassembly /root/Desktop/MessageBoxCS.exe no Beacon. A saída apresenta mensagens de status, como: running inlineExecute-Assembly, host called home (16319 bytes enviados), saída recebida “Hello From .NET!” e mensagem de finalização “inlineExecute-Assembly Finished”.

Exemplo de InlineExecute-Assembly usando o nome exclusivo do AppDomain e o nome exclusivo do named pipe

Captura de tela: exemplo de nome exclusivo do AppDomain ChangedMe

Exemplo de nome exclusivo do AppDomain ChangedMe

Captura de tela: exemplo de named pipe exclusivo LookAtMe

Exemplo de named pipe exclusivo LookAtMe

Captura de tela: exemplo de AppDomain sendo removido após a conclusão com sucesso da execução

Exemplo de AppDomain sendo removido após a conclusão bem-sucedida da execução

Captura de tela: exemplo de named pipe sendo removido após a conclusão bem-sucedida da execução

Exemplo de named pipe sendo removido após a conclusão bem-sucedida da execução

Ressalvas

Esta seção será praticamente uma repetição do que mencionei no repositório do GitHub, mas senti que era importante reiterar algumas coisas que você precisa ter em mente ao usar esta ferramenta:

  1. Embora eu tenha tentado tornar o sistema o mais estável possível, não há garantias de que as coisas nunca irão falhar e que os beacons não irão morrer. Não temos o luxo adicional de "fork and run", em que, se algo der errado, nosso beacon permanecerá ativo. Essa é a contrapartida dos BOFs. Dito isso, não posso deixar de enfatizar a importância de você testar os assemblies com antecedência para garantir que eles funcionem corretamente.
  2. Como o BOF é executado em processo e assume seu controle durante a execução, isso deve ser levado em conta antes de ser usado para assemblies de execução longa. Se você optar por executar algo que demore muito para retornar resultados, seu beacon não estará ativo para executar mais comandos até que os resultados sejam obtidos e seu assembly termine de ser executado. Isso também não segue o sleep set. Por exemplo, se o seu sleep estiver definido como 10 minutos e você executar o BOF, receberá os resultados assim que o BOF terminar de executar.
  3. A menos que sejam feitas modificações nas ferramentas que carregam PEs na memória (por exemplo, SafetyKatz), é muito provável que isso desative o beacon. Muitas dessas ferramentas funcionam bem com execute-assembly porque conseguem enviar sua produção de console do processo sacrificial antes de encerrar. Quando saem via BOF em processo, matam nosso processo, o que mata nosso beacon. Isso pode ser modificado para funcionar, mas eu recomendaria executar esse tipo de assembly via execute-assembly, já que outras coisas não amigáveis do ponto de vista de OPSEC podem ser carregadas no seu processo e não serem removidas.
  4. Se o seu assembly usar o Environment.Exit, ele precisará ser removido, pois encerrará o processo e o beacon.
  5. Named pipes e mail slots precisam ser exclusivos. Se você não receber dados de volta e seu beacon ainda estiver ativo, o problema provavelmente é que você precisa selecionar um named pipe ou mail slot diferente.

Considerações de defesa

Abaixo estão algumas considerações de defesa:

  1. Utiliza PAGE_EXECUTE_READWRITE ao realizar a aplicação de patches de memória AMSI e ETW. Isso foi feito de propósito e deve ser um sinal de alerta, pois pouquíssimos programas têm intervalos de memória com a proteção de memória PAGE_EXECUTE_READWRITE.
  2. O nome padrão do named pipe criado é "totesLegit". Isso foi feito de propósito e a detecção de assinaturas poderia ser usada para sinalizar isso.
  3. O nome padrão do mail slot criado é "totesLegit". Isso foi feito de propósito e a detecção de assinaturas poderia ser usada para sinalizar isso.
  4. O nome padrão do AppDomain carregado é "totesLegit". Isso foi feito de propósito e a detecção de assinaturas poderia ser usada para sinalizar isso.
  5. Boas dicas sobre como detectar o uso malicioso do .NET (por @bohops) aqui, (por F-Secure) aqui e aqui
  6. Procurando por carregamento do .NET CLR em processos suspeitos, como processos não gerenciados que nunca deveriam ter o CLR carregado.
  7. Mais sobre o Rastreamento de Eventos.
  8. Procurando outros IOCs conhecidos do Cobalt Strike Beacon ou IOCs de saída/comunicação C2.