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.
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.
Boletim informativo do setor
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.
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.
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.
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.
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:
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.
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:
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".
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:
Redirecionando a saída padrão do console para um named pipe e revertendo para o estado anterior
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:
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:
Instrução If/else para definir a variável de versão .NET
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:
Aplicação de patches de memória do AmsiScanBuffer
Como você pode ver na captura de tela acima, basta fazer o seguinte:
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:
Exemplo de contorno do AMSI com InlineExecute-Assembly
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:
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:
Utilizando o Process Hacker para visualizar as propriedades do PowerShell.exe antes de executar inlineExecute-Assembly com o sinalizador –etw
Executando inlineExecute-Assembly usando o sinalizador –etw
Utilizando o Process Hacker para visualizar as mesmas propriedades do PowerShell.exe após executar inlineExecute-Assembly
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:
Exemplo de InlineExecute-Assembly usando o nome exclusivo do AppDomain e o nome exclusivo do named pipe
Exemplo de nome exclusivo do AppDomain ChangedMe
Exemplo de named pipe exclusivo LookAtMe
Exemplo de AppDomain 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
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:
Abaixo estão algumas considerações de defesa: