{
	"id": "b5fdd32e-2256-4db8-a904-6f08dfe8fce0",
	"created_at": "2026-05-06T02:03:27.586785Z",
	"updated_at": "2026-05-06T02:03:52.970149Z",
	"deleted_at": null,
	"sha1_hash": "5c7a933562a50cc1e03506463d3143e4dc12ff9c",
	"title": "Defensive Rootkits: Engineering Kernel-Level Malware Analysis from Ring 0",
	"llm_title": "",
	"authors": "",
	"file_creation_date": "0001-01-01T00:00:00Z",
	"file_modification_date": "0001-01-01T00:00:00Z",
	"file_size": 518675,
	"plain_text": "Defensive Rootkits: Engineering Kernel-Level Malware Analysis\r\nfrom Ring 0\r\nBy Alberto Marín\r\nPublished: 2026-03-24 · Archived: 2026-05-06 02:01:23 UTC\r\nIntroduction: The Sandbox Evasion Crisis\r\nWe are in the middle of the next transition. This time, the failures are silent. Modern malware is industrialized:\r\nsold as-a-service, rapidly iterated, and designed to scale. At the same time, defenders increasingly depend on\r\nautomated pipelines. When those pipelines are evaded, failures are silent; which creates a dangerous gap between\r\nwhat is detected and what is actually happening.\r\nMany of the malware families we see most often (e.g. infostealers, loaders, RATs and ransomware) reflect this\r\nshift. These categories dominate both prevalence and impact: infostealers account for a large share of infections,\r\nwhile loaders and RATs support intrusion chains, and ransomware remains a primary monetization stage. All have\r\nlargely mastered sandbox evasion. Before delivering a payload, these samples interrogate their environment: they\r\ncheck hardware characteristics, measure execution timing and scan for telltale artifacts in the system before\r\nproceeding. If anything smells like an analysis environment, they shut down silently. The analyst sees nothing.\r\nThe report says “no malicious behavior observed.” The threat passes through undetected.\r\nThis is not an edge-case problem. MITRE ATT\u0026CK’s Virtualization/Sandbox Evasion technique (T1497) is\r\nconsistently among the top-ten most observed techniques in real-world incidents. Commodity crimeware families\r\nlike LummaC2, GuLoader and xLoader routinely apply evasion logic that was, just a few years ago, associated\r\nonly with sophisticated threat actors. What was once a sign of sophisticated threat actors is now commodity\r\ntechnique.\r\nWhy Traditional Analysis Platforms Fall Short\r\nThe three dominant monitoring approaches to malware analysis each have structural blind spots.\r\nUser-mode monitoring instruments the analyzed process directly (typically by injecting a DLL that intercepts\r\nAPI calls). It is the simplest approach and works well against unsophisticated threats. Against anything that checks\r\nfor foreign DLLs in its own memory, unusual executable regions, or simply enumerates the process list and looks\r\nfor monitoring tools, it fails quickly. The hooks are visible to any code that knows where to look. And today’s\r\ncommodity malware knows where to look.\r\nFor instance, malware can detect injected DLLs used for monitoring, read their own memory to look for inline\r\nhooks or even manually map needed .dlls in their memory to bypass already hooked functions. The most\r\naggressive approach is to bypass user-mode hooks entirely by issuing SYSCALL instructions directly rather than\r\ncalling through ntdll.dll , jumping over any user-mode intercept layer completely. All of these bypass\r\ntechniques are well-documented and implemented in many off-the-shelf malware kits.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 1 of 55\n\nHypervisor-based monitoring operates from outside the guest operating system, which eliminates many in-guest\r\nartifacts. This is a genuine architectural improvement. However, hypervisor-based analysis faces a deeper\r\nstructural challenge: there is no easy access to the full context of every syscall being executed by monitored\r\nprocesses. Translating raw hardware-level observations into meaningful OS-level semantics (which process wrote\r\nto which registry key, what arguments a specific syscall received), requires complex memory-introspection and\r\nmapping that introduces both latency and analytical complexity. This semantic gap also makes it harder to bypass\r\nanti-analysis techniques and to assist malware detonation in the targeted way that a kernel-level component can.\r\nThe performance overhead of managing VM exits for every relevant event compounds these difficulties.\r\nBare-metal analysis eliminates virtualization artifacts entirely by executing samples directly on physical\r\nhardware, rather than in a virtualized environment. However, this architectural choice alone does not capture all\r\nmalicious activity. Without active process and behavior monitoring, malware that relies on persistence or staging\r\ncan evade detection: for example, a loader that sets a Run key and exits cleanly will never reveal its payload\r\nunless the analysis system observes actions across reboots or extended execution. Rebuilding and re-imaging\r\nmachines after each analysis is slow, expensive, and impractical at the scale required to process hundreds of\r\nthousands of samples daily for a Malware Intelligence solution. Bare-metal analysis is therefore most effective for\r\ntargeted, in-depth investigations, but even then, it must be paired with monitoring mechanisms that track process\r\nbehavior and system changes over time.\r\nKernel-level monitoring addresses the shortcomings of all three approaches from a different angle. By operating\r\ninside the guest OS at Ring 0, it has direct access to every system call, every kernel data structure, and every piece\r\nof runtime state the OS itself processes. There is no semantic gap: the kernel already knows which process made\r\nwhich call, what the arguments were, and what the result should be. Interception and modification are surgical and\r\nprecise. Detection surface is minimal when implemented correctly.\r\nEach approach fails to answer the same fundamental question: If malware calls an operating system function to\r\ninterrogate its environment, can we intercept that call and return something believable, without the interception\r\nitself being detectable? User-mode cannot; it operates above the call path. Hypervisors can intercept certain\r\ninstructions through VM-exit handling, but face inherent limits in manipulating OS-level semantics coherently\r\nfrom below the OS. Bare-metal cannot, without kernel modification. Only a kernel-level component can do this\r\ncleanly and at scale – but even it must contend with certain low-level hardware checks.\r\nThe Core Insight\r\nThe answer lies in operating at the same privilege level as both the operating system and the most sophisticated\r\nmalware: Ring 0. A kernel-mode component running inside the analysis VM has access to every system call, every\r\nkernel data structure, and every piece of information that the OS itself processes. There is no semantic gap: the\r\nkernel already knows which process made which call, what the arguments were, and what the result should be. We\r\nsimply intercept the answer before it reaches the caller and, where necessary, change it.\r\nBut our goal is not just evasion resistance. We designed this system to do something more: to follow malware\r\nthrough its complete infection chain. Observing not just the initial dropper’s environment checks, but the\r\npersistence mechanisms it establishes, the next-stage payloads those mechanisms trigger, and the behavioral\r\nevidence that malware routinely destroys before analysts can examine it. We call the result a defensive rootkit.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 2 of 55\n\nThe architectural answer to this crisis has three interlocking elements. First, a kernel-mode driver running at Ring\r\n0 (below every user-mode monitoring technique), intercepts all system calls with full semantic context: which\r\nprocess made the call, what the arguments were, and what the correct answer would be. This is the foundation that\r\nmakes anti-evasion surgical rather than heuristic. Second, a hypervisor layer operating outside the guest OS is the\r\nmost robust place to handle hardware-level probes such as CPUID fingerprinting, RDTSC -based timing\r\nanomalies, CPU feature flag queries, and VM artifact masking (device identifiers, firmware strings, and other\r\nplatform-identifying values that are cleanly interceptable from outside the guest). Third, a real-time persistence\r\ndetection and execution engine follows malware through multi-stage infection chains (e.g. detecting registry Run\r\nkey writes, scheduled task registrations, and service entries as they occur), then immediately triggering registered\r\nnext-stage payloads without requiring a system reboot. Together, these three layers address the full attack surface\r\nof sandbox evasion: the OS-level checks that a kernel driver intercepts, the hardware-level checks that a\r\nhypervisor handles, and the staging gap that persistence-based payloads depend on. The following sections explain\r\neach layer in technical depth.\r\nDefensive Rootkits: The Concept\r\nDefensive Rootkits: What the Term Means, and Why We Use It Deliberately\r\nA rootkit, in the traditional sense, is a kernel-mode component that uses system call interception, stealth\r\ntechniques, and direct kernel structure manipulation to modify the system’s behavior; typically to conceal malware\r\nfrom the operating system and from security tools. The term carries connotations of malice, concealment, and\r\nactive deception at the kernel level.\r\nWe use the term deliberately. What we built is a defensive rootkit, a kernel-mode component that applies exactly\r\nthe same techniques, with the opposite purpose: not to conceal malware from defenders, but to conceal the\r\nanalysis infrastructure from malware, and to intercept the environment checks that evasive samples use to decide\r\nwhether to detonate.\r\nThe symmetry is not coincidental. It is the point.\r\nMalware rootkits hook NtQuerySystemInformation to hide their processes from process enumeration. Our\r\ndefensive rootkit hooks the same function to hide analysis tools from malware. Malware rootkits intercept registry\r\nqueries to conceal their configuration. Ours intercepts registry queries to spoof hardware identifiers. Malware\r\nrootkits use filesystem minifilters to hide their files. Ours uses the same minifilter mechanism to intercept file\r\ndeletions and preserve forensic evidence. Same mechanism, opposite direction.\r\nIt is also worth noting why traditional offensive rootkits are nowadays relatively rare in commodity malware:\r\nWindows kernel protection mechanisms, such as PatchGuard (Kernel Patch Protection), actively detect and\r\nrespond to unauthorized modifications of critical kernel structures, making it significantly more difficult and risky\r\nfor malware to operate stably at the kernel level. Also operating at the kernel level requires a significant\r\nengineering investment. This same engineering investment is precisely what enables our defensive rootkit to\r\noperate below the detection horizon of any user-mode analysis technique.\r\nBeyond Concealment: What Defensive Rootkits Actually Do\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 3 of 55\n\nIt would be easy to reduce defensive rootkits to their anti-evasion capabilities and to describe them as\r\nsophisticated sandbox-hardening tools. That framing undersells what they enable.\r\nAnti-evasion is the first capability: ensuring that malware detonates and executes its first stage. But our kernel\r\ndriver does not stop there. Once malware is running and actively executing, our driver:\r\nMonitors every persistence mechanism the malware establishes (e.g. registry Run keys, startup folder\r\nfiles, service registrations, scheduled tasks) in real time, from kernel space, before malware has any chance\r\nto obscure them.\r\nForces execution of next-stage payloads without waiting for an actual system reboot or user login event,\r\nallowing analysts to observe the complete infection chain (dropper to loader to final payload) in a single\r\nanalysis session.\r\nPreserves forensic evidence that malware routinely destroys: files that are dropped and deleted before\r\nanalysts can examine them, memory contents of processes at the moment of termination, and the complete\r\nchain of spawned processes and injection targets.\r\nConceals the analysis infrastructure from malware: monitoring agents, analysis tools, and other\r\ninfrastructure processes running alongside the sample are hidden at the kernel level. Malware attempting to\r\nenumerate running processes, open handles to known tools, or detect analysis artifacts sees a clean\r\nenvironment, ensuring it does not abort before executing its payload.\r\nAutomatically tracks injections across process boundaries, ensuring that even code injected into\r\nunrelated processes remains under observation without requiring manual configuration.\r\nDetects and resists kernel-level tampering: if a sample achieves Ring 0 control inside the analysis VM\r\nand attempts to remove or bypass the driver’s hooks, a dedicated protection thread detects the modification\r\nand restores it within one second. The tampering attempt itself is logged as a high-confidence signal of a\r\nkernel-aware adversary. This is not a capability analysis platforms commonly provide.\r\nThe goal is not just to see stage one. It is to see everything, including the behavior of samples sophisticated\r\nenough to fight back.\r\nZynap’s Mixed Approach: Better Together\r\nBefore describing the kernel driver in detail, it is worth explaining why Zynap uses both kernel-level and\r\nhypervisor-level monitoring and why this combination delivers meaningfully better results than either approach\r\ndeployed alone.\r\nWhat Each Layer Does Best, and Why Neither Is Sufficient Alone\r\nHypervisor monitoring excels at capabilities that in-guest software fundamentally cannot replicate. Consider the\r\nCPUID instruction: it is not a system call. Malware executes it directly in user space and receives the result\r\ndirectly from the CPU. A hypervisor handles the resulting VM exit and can synthesize any response it chooses\r\n(e.g. erasing VMware or VirtualBox vendor strings from CPUID results, returning physical hardware identifiers\r\nand masking CPU feature flags that are anomalous in virtual environments).\r\nSimilarly, hardware-level timing anomalies (measurable differences in instruction latency that reveal virtualized\r\nexecution) are most cleanly handled at the hypervisor level. Careful VM-exit management and virtual\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 4 of 55\n\nenvironment tuning can bring timing characteristics within the range of physical hardware.\r\nAnd critically: hypervisor monitoring is isolated from the guest OS. A malware sample that achieves full kernel-level control inside the guest cannot tamper with or detect hypervisor-level monitoring, because the hypervisor is\r\ninvisible from below. For the most capable samples, this provides an additional safety boundary.\r\nKernel-level monitoring inside the guest does things that a hypervisor cannot do without enormous complexity.\r\nSystem calls carry rich semantic context: the calling process, the exact parameters, the OS-level return value.\r\nIntercepting NtQuerySystemInformation at the kernel level gives us the caller’s process ID, the specific\r\ninformation class being queried, and direct write access to the returned buffer (all without any semantic gap\r\ntranslation). We know which monitored malware process made the call, what it was asking for, and we can modify\r\nthe answer with surgical precision.\r\nKernel-level monitoring also enables capabilities that are inherently about OS-level state: watching for registry\r\nwrites to persistence-related keys, intercepting file deletion to preserve forensic evidence, detecting code injection\r\ninto other processes, and tracking the full chain of child processes spawned by malware. A hypervisor monitoring\r\nbelow the OS must infer OS-level intent indirectly (working through raw memory introspection and complex OS\r\nstructure reconstruction rather than accessing this information natively as the kernel already does). This involves\r\nmanaging exceptions, forcing VM exits to occur (for example, by changing page protections to cause access\r\nfaults), and dealing with the resulting engineering complexity. The kernel, by contrast, has all this information\r\nnatively and immediately available at the point of every syscall.\r\nA hypervisor-only approach covers CPUID , low-level timing, and VM artifact masking, but struggles with the\r\nsemantic richness needed for persistence detection, injection tracking, and surgical system call manipulation. A\r\nkernel-only approach covers all OS-level semantics beautifully, but cannot address hardware-level probes that\r\nexecute below the OS entirely.\r\nEvasive malware does not constrain itself to one probe type. A sophisticated sample checks CPUID and queries\r\ndisk size via system call and reads registry keys and measures sleep precision and enumerates the process list.\r\nAny analysis approach that only covers some of these is allowing the rest to be used as reliable detection signals.\r\nHow They Complement Each Other in Practice\r\nIn Zynap’s architecture, the two layers divide responsibilities according to where each has a genuine advantage:\r\nHypervisor layer: handles CPUID spoofing, RDTSC -based timing anomalies, low-level CPU feature\r\nmasking, and VM artifact masking at the hardware configuration level (device identifiers, firmware strings,\r\nand other platform-identifying values that are straightforward to intercept and replace from outside the\r\nguest). It provides the “hardware looks real” layer that is visible to any code executing on the CPU,\r\nregardless of privilege level.\r\nKernel driver layer: handles everything that requires OS-level context. For example: syscall interception,\r\nregistry and filesystem manipulation, process tracking, injection detection, persistence monitoring, and\r\nforensic evidence capture. It provides the “the OS looks real” layer, controlling what the operating system\r\nreports to processes and what the kernel does on their behalf.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 5 of 55\n\nTogether, these layers address the full spectrum of environment checks that evasive malware performs. A sample\r\nusing hardware-level CPUID fingerprinting encounters our hypervisor response. The same sample querying\r\nregistry keys for hardware identifiers encounters our kernel-level registry filter. The same sample measuring disk\r\nsize through a system call encounters our SSDT hook. For any individual evasion technique, at least one layer of\r\ndefense covers it. For most techniques, both do.\r\nThis is not a configuration option, it is an architectural choice. And it delivers a consistent, coherent picture of a\r\nphysical workstation at every level a malware sample probes.\r\nOne important clarification about the role of the kernel driver within the broader platform: the defensive rootkit\r\nis not Zynap’s primary monitoring solution. Capturing a complete picture of what malware actually does at the\r\nAPI level (which functions it calls, with what arguments, in what order) is the responsibility of a dedicated kernel\r\nmonitoring driver that operates alongside the defensive rootkit. That driver intercepts high-level API calls (without\r\nany user-mode hooks) and provides the behavioral record analysts rely on. SSDT hooks on syscalls alone are a\r\npoor fit for this role: syscalls operate at a lower abstraction level than the Win32 or NT APIs malware actually\r\nuses, so they produce a noisier, harder-to-interpret call record compared to monitoring at the API layer directly.\r\nThe defensive rootkit’s direction is different: improving analysis coverage by ensuring malware detonates and\r\nexecutes fully (bypassing anti-analysis checks), detecting and triggering persistence mechanisms, and preserving\r\nforensic artifacts that malware destroys. The two drivers are complementary and communicate with each other.\r\nFor example, when the monitoring driver detects a process injection, it can notify the defensive rootkit to add the\r\ntarget process to its tracked list, and vice versa, ensuring both drivers maintain consistent scope across the full\r\ninfection chain.\r\nThis post focuses on the kernel driver layer: its architecture, implementation, and the engineering discipline\r\nrequired to make it reliable in production. The hypervisor layer ( CPUID spoofing, timing normalization, VM\r\nartifact masking, and low-level hardware fingerprint masking) is a subject we’ll cover in a dedicated follow-up\r\npost.\r\nFull Platform Component Map\r\nThe platform is organized across two privilege layers: Kernel Driver (Ring 0) and Hypervisor (Ring -1 / VMM).\r\nEach with its own subsystems and clean interface boundaries. The kernel driver alone is organized into mulitple\r\nsubsystems. At the highest level, the two layers and their major components interact as follows:\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 6 of 55\n\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 7 of 55\n\nFigure 1. Full Platform Compenent Map\r\nAn important design principle: the driver acts only on specific processes. It maintains three distinct process lists,\r\neach with a well-defined behavioral role:\r\nMonitored processes are the malware samples under analysis. All SSDT hook logic, minifilter\r\ninterception, persistence detection, and injection tracking apply exclusively to processes on this list. If the\r\ncalling process is not monitored, hooks pass the call through to the original syscall without modification.\r\nResulting in zero overhead and zero behavioral change for the rest of the system.\r\nBlacklisted processes are explicitly excluded from all driver logic. A blacklisted process is neither\r\nmonitored nor subjected to SSDT hook behavior (its syscalls pass through unmodified and it is not tracked\r\nfor persistence or injection events). This is used to whitelist known-good system components or support\r\nprocesses that must operate without interference from the analysis driver.\r\nHidden processes are concealed from monitored processes. When a monitored malware process\r\nenumerates running processes (e.g. via NtQuerySystemInformation ), entries for hidden processes are\r\nstripped from the returned list. This allows analysis tooling and any other infrastructure processes to\r\nremain invisible to the sample under observation.\r\nThe key invariant is that monitored is mutually exclusive with both hidden and blacklisted: a process under active\r\nanalysis is neither concealed nor exempted. Hidden and blacklisted can coexist, which gives flexibility when a\r\nprocess needs to be both invisible to malware and excluded from hook logic. System-wide hooks exist, but their\r\nlogic executes only when the calling process is on the relevant list, keeping overhead minimal for everything\r\noutside the monitored set.\r\nThe Engine: SSDT Hooking on x64\r\nThe System Service Descriptor Table (SSDT) is the kernel’s dispatch table for system calls. When user-mode code\r\nexecutes a SYSCALL instruction, the processor transitions to Ring 0 and the kernel uses a numeric index to look\r\nup the corresponding handler in the SSDT. Intercepting that lookup is how we intercept system calls.\r\nSSDT hooking is a technique that has been covered extensively in the community (there’s a wealth of public\r\nliterature on the mechanics, and the references at the end of this section point to some great resources). What we’ll\r\ngo through here is how we implement it reliably in a production analysis platform.\r\nOn 32-bit Windows, KeServiceDescriptorTable was an exported symbol and modifying it was straightforward.\r\nOn 64-bit Windows, several things changed: the symbol is no longer exported, the table lives in write-protected\r\nmemory, and multi-processor systems require careful synchronization to modify it safely. The hooking process\r\nrequires a chain of non-trivial steps.\r\nA note on diagnostic utilities in code excerpts: DBG_INFO , DBG_WARNING , DBG_ERROR (and W -\r\nsuffixed Unicode variants) wrap DbgPrintEx and compile to nothing in release builds (zero overhead\r\nin production). Base variants are callable at any IRQL; W variants add an IRQL check that suppresses\r\noutput above PASSIVE_LEVEL , where %wZ format specifiers can page-fault. LgPrintfW writes\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 8 of 55\n\nstructured entries to a persistent log file and survives release builds, callable up to DISPATCH_LEVEL :\r\nsynchronous inline write at PASSIVE_LEVEL ; blocking work item at APC_LEVEL ; fire-and-forget async\r\nat DISPATCH_LEVEL (returns STATUS_PENDING , completes later at PASSIVE_LEVEL ).\r\nLocating the SSDT Dynamically\r\nSince KeServiceDescriptorTable is not exported, we must locate it by navigating the kernel’s own code. Our\r\nstarting point is the LSTAR MSR ( 0xC0000082 ), which holds the address of the system call entry point on x64\r\nWindows. From there, we apply a two-stage byte-pattern search through the kernel code. Stage 1 scans from\r\nKiSystemCall64Shadow for an LFENCE instruction followed by a characteristic pattern from the Meltdown\r\nmitigation code, then follows the relative jump to locate KiSystemServiceUser . The specific pattern:\r\nfffff803`154b43cc 0faee8 lfence\r\nfffff803`154b43cf 65c60425...00 mov byte ptr gs:[853h], 0\r\nfffff803`154b43d8 e93d0a97ff jmp nt!KiSystemServiceUser\r\nStage 2 scans from KiSystemServiceUser for the LEA R10, [RIP + offset] instruction (opcode 4C 8D 15 )\r\nthat directly references KeServiceDescriptorTable :\r\nfffff807`05211c84 4c8d1535fc9e00 lea r10, [nt!KeServiceDescriptorTable]\r\nThe signed 32-bit relative offset in bytes 3-6 of this instruction, added to the instruction’s address plus its 7-byte\r\nlength, gives us the table address directly. Each stage searches within a 0x400-byte window, which has proven\r\nsufficient and stable across Windows 10 and 11 builds.\r\nThis would be the complete function for SSDT location:\r\n_Must_inspect_result_\r\nstatic PSERVICE_DESCRIPTOR_TABLE_ENTRY SdtGetSSDTBaseAddr()\r\n{\r\n PUCHAR KiSystemCall64Shadow = NULL;\r\n PUCHAR KiSystemServiceUser = NULL;\r\n PUCHAR StartAddr = NULL;\r\n PUCHAR i = NULL;\r\n UCHAR b1 = 0, b2 = 0, b3 = 0;\r\n LONG KiSystemServiceUser_offset = 0;\r\n // Latest Win10 with Meltdown mitigation or Windows 11 returns KiSystemCall64Shadow in MSR instead of KiSyst\r\n // Get KiSystemCall64Shadow address from LSTAR MSR\r\n KiSystemCall64Shadow = (PVOID)__readmsr(0xC0000082);\r\n StartAddr = KiSystemCall64Shadow;\r\n // Validate the MSR-provided address as it comes from hardware (could be invalid if MSR is corrupted)\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 9 of 55\n\nif (!StartAddr || !MmIsAddressValid(StartAddr))\r\n {\r\n DBG_ERROR(\"[!] Invalid StartAddr from MSR: 0x%p\", StartAddr);\r\n return NULL;\r\n }\r\n // Stage 1: Find KiSystemServiceUser\r\n for (i = StartAddr; i \u003c= StartAddr + 0x400; i++)\r\n {\r\n // Post-Meltdown Windows 10 and Windows 11, msr[0xC0000082] = nt!KiSystemCall64Shadow\r\n // We need search back\r\n // fffff803`154b43c8 4883c408 add rsp,8\r\n // fffff803`154b43cc 0faee8 lfence \u003c-- all of these bytes\r\n // fffff803`154b43cf 65c604255308000000 mov byte ptr gs:[853h],0 \u003c-- last byte\r\n // fffff803`154b43d8 e93d0a97ff jmp nt!KiSystemServiceUser (fffff803`14e24e1a) Branch \u003c-- fir\r\n // Check if we can safely read 3 bytes for LFENCE pattern\r\n if (!MmIsAddressValid(i) || !MmIsAddressValid(i + 2))\r\n {\r\n continue;\r\n }\r\n b1 = *(i);\r\n b2 = *(i + 1);\r\n b3 = *(i + 2);\r\n // Search for lfence instruction (opcodes 0F AE E8 -\u003e LFENCE)\r\n if (b1 == 0x0f \u0026\u0026 b2 == 0xae \u0026\u0026 b3 == 0xe8)\r\n {\r\n // Once found lfence instruction, search for jmp nt!KiSystemServiceUser, it should be really close\r\n // To ensure we are in the correct jmp, we check previous byte (0x00) (from \"mov byte ptr gs:[853h],\r\n // and check for byte 0xe9 (relative jmp from \"jmp nt!KiSystemServiceUser\")\r\n PUCHAR j = NULL;\r\n for (j = i; j \u003c i + 0x64; j++)\r\n {\r\n if (!MmIsAddressValid(j - 1) || !MmIsAddressValid(j + 4))\r\n {\r\n continue;\r\n }\r\n b1 = *(j - 1);\r\n b2 = *(j);\r\n if (b1 == 0x00 \u0026\u0026 b2 == 0xe9)\r\n {\r\n // We found \"jmp nt!KiSystemServiceUser\"\r\n // We use j+1 as origin to skip first byte (opcode for relative jmp 0xe9)\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 10 of 55\n\n// 0xe9 xx xx xx xx (4 bytes for relative offset to nt!KiSystemServiceUser)\r\n RtlCopyMemory(\u0026KiSystemServiceUser_offset, j + 1, 4);\r\n // fffff803`154b43d8 e93d0a97ff jmp nt!KiSystemServiceUser (fffff803`14e24e1a)\r\n // jmp nt!KiSystemServiceUser instruction is 5 bytes long\r\n // Calculate the target address properly maintaining pointer type\r\n // j was declared as PUCHAR, as we are using it to read individual bytes when checking for\r\n // the jump pattern and PUCHAR makes byte-by-byte reading clear and explicit.\r\n // The cast to ULONG_PTR for address arithmetic is proper for pointer calculations\r\n KiSystemServiceUser = (PUCHAR)(KiSystemServiceUser_offset + (ULONG_PTR)j + 5);\r\n break;\r\n }\r\n }\r\n }\r\n if (KiSystemServiceUser)\r\n {\r\n // Avoid doing unnecessary iterations if we already found nt!KiSystemServiceUser\r\n break;\r\n }\r\n }\r\n if (!KiSystemServiceUser)\r\n {\r\n DBG_ERROR(\"[!] Unable to find nt!KiSystemServiceUser\");\r\n return 0;\r\n }\r\n // At this point, we should have found nt!KiSystemServiceUser address, which is close to nt!KiSystemServiceR\r\n // nt!KiSystemServiceRepeat is the actual function that references SSDT as it can be seen:\r\n /*\r\n lkd\u003e u nt!KiSystemServiceRepeat\r\n nt!KiSystemServiceRepeat:\r\n fffff807`05211c84 4c8d1535fc9e00 lea r10,[nt!KeServiceDescriptorTable (fffff807`05c018c0)]\r\n fffff807`05211c8b 4c8d1daead8e00 lea r11,[nt!KeServiceDescriptorTableShadow (fffff807`05afca40)]\r\n fffff807`05211c92 f7437880000000 test dword ptr [rbx+78h],80h\r\n fffff807`05211c99 7413 je nt!KiSystemServiceRepeat+0x2a (fffff807`05211cae)\r\n fffff807`05211c9b f7437800002000 test dword ptr [rbx+78h],200000h\r\n fffff807`05211ca2 7407 je nt!KiSystemServiceRepeat+0x27 (fffff807`05211cab)\r\n fffff807`05211ca4 4c8d1d55af8e00 lea r11,[nt!KeServiceDescriptorTableFilter (fffff807`05afcc00)]\r\n fffff807`05211cab 4d8bd3 mov r10,r11\r\n */\r\n // Proceed to search for fffff807`05211c84 4c8d1535fc9e00 lea r10,[nt!KeServiceDescriptorTable\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 11 of 55\n\n// like we usually do in earlier Windows10 versions\r\n // Stage 2: Find SSDT reference\r\n PSERVICE_DESCRIPTOR_TABLE_ENTRY KeServiceDescriptorTable = NULL;\r\n LONG KeServiceDescriptorTable_offset = 0;\r\n StartAddr = KiSystemServiceUser;\r\n for (i = StartAddr; i \u003c= StartAddr + 0x400; i++)\r\n {\r\n // Check if we can safely read 7 bytes (full instruction length)\r\n if (!MmIsAddressValid(i) || !MmIsAddressValid(i + 6))\r\n {\r\n continue;\r\n }\r\n b1 = *(i);\r\n b2 = *(i + 1);\r\n b3 = *(i + 2);\r\n if (b1 == 0x4c \u0026\u0026 b2 == 0x8d \u0026\u0026 b3 == 0x15)\r\n {\r\n // We found \"lea r10,[nt!KeServiceDescriptorTable]\"\r\n // We use i+3 as origin to skip first three bytes (opcodes of lea instruction)\r\n // 0x4c 0x8d 0x15 xx xx xx xx (4 bytes for relative offset to nt!KeServiceDescriptorTable)\r\n RtlCopyMemory(\u0026KeServiceDescriptorTable_offset, i + 3, 4);\r\n //fffff807`05211c84 4c8d1535fc9e00 lea r10, [nt!KeServiceDescriptorTable(fffff807`05c018c0)]\r\n // lea r10, [nt!KeServiceDescriptorTable] instruction is 7 bytes long\r\n KeServiceDescriptorTable =\r\n (PSERVICE_DESCRIPTOR_TABLE_ENTRY)(KeServiceDescriptorTable_offset + (ULONG_PTR)i + 7);\r\n break;\r\n }\r\n }\r\n DBG_INFO(\"[+] SdtGetSSDTBaseAddr offset: 0x%lx\", KeServiceDescriptorTable_offset);\r\n DBG_INFO(\"[+] SdtGetSSDTBaseAddr addr: 0x%p\", KeServiceDescriptorTable);\r\n return KeServiceDescriptorTable;\r\n}\r\nFor deeper reading on SSDT internals, location patterns, and evolution across Windows versions, the following\r\nare excellent references:\r\nWindows 11 SSDT and ShadowSSDT fetch problem\r\nSSDT Hook – MoukaNotes\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 12 of 55\n\nA Syscall Journey in the Windows Kernel – Alice Climent-Pommeret (archived version)\r\nCompatibility: Fallback Path for Earlier Windows 10 Builds\r\nThe two-stage function above targets post-Meltdown Windows 10 and Windows 11, where the LSTAR MSR\r\npoints to KiSystemCall64Shadow . Earlier Windows 10 builds (pre-Meltdown mitigation) do not have\r\nKiSystemCall64Shadow , the MSR points directly to KiSystemCall64 , and KiSystemServiceRepeat (which\r\ncontains the LEA R10, [KeServiceDescriptorTable] reference) is close enough to the entry point that a single-stage scan suffices. The driver includes a dedicated fallback function for this case:\r\n_Must_inspect_result_\r\nstatic PSERVICE_DESCRIPTOR_TABLE_ENTRY SdtGetSSDTBaseAddrOld()\r\n{\r\n // Get KiSystemCall64 address from LSTAR MSR\r\n PUCHAR StartSearchAddress = (PUCHAR)__readmsr(0xC0000082);\r\n // Validate the MSR-provided address as it comes from hardware (could be invalid if MSR is corrupted)\r\n if (!StartSearchAddress || !MmIsAddressValid(StartSearchAddress))\r\n {\r\n DBG_ERROR(\"[!] Invalid StartSearchAddress from MSR: 0x%p\", StartSearchAddress);\r\n return NULL;\r\n }\r\n PUCHAR EndSearchAddress = StartSearchAddress + 0x500;\r\n PUCHAR i = NULL;\r\n UCHAR b1 = 0, b2 = 0, b3 = 0;\r\n LONG offset = 0;\r\n PSERVICE_DESCRIPTOR_TABLE_ENTRY addr = 0;\r\n for (i = StartSearchAddress; i \u003c EndSearchAddress; i++)\r\n {\r\n // Check if we can safely read 7 bytes (full instruction length)\r\n if (!MmIsAddressValid(i) || !MmIsAddressValid(i + 6))\r\n {\r\n continue;\r\n }\r\n b1 = *(i);\r\n b2 = *(i + 1);\r\n b3 = *(i + 2);\r\n if (b1 == 0x4c \u0026\u0026 b2 == 0x8d \u0026\u0026 b3 == 0x15)\r\n {\r\n RtlCopyMemory(\u0026offset, i + 3, 4);\r\n //fffff800`03e8b772 4c 8d 15 c7 20 23 00 4c-8d 1d 00 21 23 00 f7 83 L... #.L...!#...\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 13 of 55\n\n//templong = 002320c7, i = 03e8b772, 7 is the instruction length\r\n addr = (PSERVICE_DESCRIPTOR_TABLE_ENTRY)(offset + (ULONG_PTR)i + 7);\r\n break;\r\n }\r\n }\r\n DBG_INFO(\"[+] SdtGetSSDTBaseAddr offset: 0x%lx\", offset);\r\n DBG_INFO(\"[+] SdtGetSSDTBaseAddr addr: 0x%p\", addr);\r\n return addr;\r\n}\r\nThe difference is straightforward: on older builds there is no intermediate KiSystemCall64Shadow indirection, so\r\nStage 1 (the LFENCE/ MOV GS pattern scan) is skipped entirely. The driver tries SdtGetSSDTBaseAddr first; if\r\nthat returns NULL , it falls back to SdtGetSSDTBaseAddrOld . The search window is slightly wider (0x500 bytes\r\nvs. 0x400) to accommodate the larger code distance on older kernels. Neither function uses hardcoded offsets or\r\nbuild-specific tables: both rely exclusively on byte-pattern scanning from the LSTAR MSR, so the driver works\r\ncorrectly across the entire Windows 10/11 version matrix.\r\nSyscall indices are resolved dynamically at driver load time. We read ntdll.dll from disk, parse its PE export\r\ntable, and extract the MOV EAX, imm32 value from each syscall stub (the immediate operand is the syscall\r\nnumber). This makes the driver compatible across Windows versions without hardcoded index tables that would\r\nrequire maintenance on every new build. The resolution logic walks ntdll.dll ‘s export directory, resolves each\r\ntarget function by name, converts its address to an RVA, then scans the first 32 bytes of the stub for the 0xB8\r\n( MOV EAX, imm32 ) opcode.\r\nntdll!NtCreateFile:\r\n 4C 8B D1 mov r10, rcx\r\n B8 52 00 00 00 mov eax, 52h ; \u003c- syscall index\r\n 0F 05 syscall\r\n C3 ret\r\n/*\r\n * Searches for syscall index in the assembly code of a syscall stub by analyzing\r\n * the function's prologue for the standard Windows x64 syscall pattern. This function\r\n * serves as a critical component in SSDT hook implementation by extracting the\r\n * system call index from the 'mov eax, XX' instruction present in ntdll.dll stubs.\r\n*/\r\n_Must_inspect_result_\r\nstatic ULONG SdtSearchSyscallIndexFromBinaryPattern(\r\n CONST IN ULONGLONG Address,\r\n CONST IN ULONG AddressRVA,\r\n CONST IN ULONG Size)\r\n{\r\n // Parameter validation\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 14 of 55\n\nif (!Address ||\r\n !Size ||\r\n AddressRVA \u003e= Size)\r\n {\r\n return 0;\r\n }\r\n ULONG SyscallIndex = 0;\r\n // Search in the first 32 bytes, the binary pattern mov eax, XX - (0xB8 ?? ?? ?? ??)\r\n // to get the SSDT index for this syscall.\r\n for (ULONG i = 0; i \u003c 32 \u0026\u0026 AddressRVA + i \u003c Size; i++)\r\n {\r\n UCHAR CurrentByte = *(PUCHAR)((PUCHAR)Address + i);\r\n if (CurrentByte == 0xC2 || CurrentByte == 0xC3) // ret\r\n {\r\n DBG_INFO(\"[+] Found RET\");\r\n break;\r\n }\r\n // 0xB8 is followed by a 4-byte immediate; guard against reading past the search window or the\r\n // mapped image. In practice unreachable (0xB8 always appears early in the stub) but retained for correc\r\n if (CurrentByte == 0xB8 \u0026\u0026 i + 4 \u003c 32 \u0026\u0026 AddressRVA + i + 4 \u003c Size) // mov eax, XX - (0xB8 ?? ?? ?? ??)\r\n {\r\n DBG_INFO(\"[+] Found 0xB8 opcode. At i: %lu. Retrieving syscall index...\", i);\r\n SyscallIndex = *(PULONG)((PUCHAR)Address + i + 1);\r\n break;\r\n }\r\n }\r\n return SyscallIndex;\r\n}\r\nOnce each syscall index is resolved, it is stored alongside the original and new SSDT offsets in the\r\ng_SSDT_HooksInfo array. This structure is the foundation for both self-protection (detecting tampered entries)\r\nand clean unhooking:\r\n/*\r\n * SSDT hook state tracking structure\r\n * Maintains information about active SSDT hooks for protection and restoration.\r\n * Used to verify hook integrity and restore original state when needed.\r\n*/\r\ntypedef struct _SSDT_HOOKS_INFO {\r\n PCHAR SyscallName; // Name of the hooked syscall\r\n ULONG SyscallId; // SSDT index of the syscall\r\n ULONG OldOffsetToFunction; // Original SSDT entry offset\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 15 of 55\n\nULONG NewOffsetToFunction; // Hooked syscall offset in SSDT\r\n} SSDT_HOOKS_INFO, *PSSDT_HOOKS_INFO;\r\n// Successful Hooks Information (For SSDT UnHook and Protection of our Hooks)\r\nstatic SSDT_HOOKS_INFO g_SSDT_HooksInfo[ARRAYSIZE(g_Hooks)];\r\nAt hook installation time, after writing each new trampoline offset to the SSDT, the driver records the syscall\r\nname, index, original offset, and new offset in the corresponding g_SSDT_HooksInfo entry. The self-protection\r\nthread uses this stored state to detect and restore any tampered entries (described in detail in Section 8).\r\nCode Caves and Trampoline Architecture\r\nWriting our hook functions’ addresses directly into SSDT entries is not viable: the SSDT format stores a signed\r\n32-bit relative offset from KiServiceTable , not an absolute address. Our hook functions live in non-paged pool,\r\nwhich is typically far too distant from KiServiceTable for the offset to fit in 32 bits.\r\nThe solution is to place 12-byte trampolines in code caves (sequences of NOP 0x90 and INT3 0xCC padding\r\nbytes that the compiler leaves between functions in the kernel’s .text section). Since both KiServiceTable\r\nand the .text section belong to ntoskrnl.exe ‘s address space, the relative offset from any cave to the table\r\nalways fits in 32 bits. A typical Windows kernel build contains roughly 3,000 such caves which is more than\r\nenough for the hooks we need.\r\nEach trampoline is a 12-byte absolute indirect jump: MOV RAX, hook_address; JMP RAX , patched at install time\r\nwith the address of the actual hook function:\r\n// 12-byte trampoline shellcode for hook redirection\r\nstatic UCHAR g_TrampolineOpcodes[] = {\r\n 0x48, 0xB8, // mov rax, imm64\r\n 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, // \u003c- address placeholder - 8 bytes overwritten with actual\r\n 0xFF, 0xE0 }; // jmp rax\r\nFinding those caves requires scanning ntoskrnl.exe ‘s .text section at runtime. The driver locates the module\r\nbase via ZwQuerySystemInformation with SystemModuleInformation , parses the PE section headers to find the\r\n.text section’s virtual address and size, then scans for contiguous sequences of 0x90 (NOP) or 0xCC (INT3)\r\nbytes of the required length:\r\n/*\r\n * Searches for a code cave within a specified memory range by identifying continuous\r\n * sequences of NOP (0x90) or INT3 (0xCC) instructions. This function serves as a\r\n * critical component in the SSDT hooking system by locating suitable spaces in\r\n * executable memory for injecting trampoline code.\r\n *\r\n * Code cave identification:\r\n * * Searches for continuous sequences of:\r\n * - 0x90 (NOP instruction)\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 16 of 55\n\n* - 0xCC (INT3/breakpoint instruction)\r\n * * Must match exact size requirement\r\n * * Average availability of ~3000 valid caves in typical kernel builds\r\n*/\r\n_IRQL_requires_(PASSIVE_LEVEL)\r\n_Must_inspect_result_\r\nstatic NTSTATUS SdtSearchCodeCave(\r\n CONST IN PUCHAR StartAddress,\r\n CONST IN PUCHAR EndAddress,\r\n CONST IN ULONG CodeCaveSize,\r\n OUT PVOID* CodeCave)\r\n{\r\n // IRQL validation\r\n if (KeGetCurrentIrql() \u003e PASSIVE_LEVEL)\r\n {\r\n return STATUS_INVALID_LEVEL;\r\n }\r\n // Parameter validation\r\n if (!StartAddress ||\r\n !EndAddress ||\r\n !CodeCave ||\r\n !CodeCaveSize ||\r\n EndAddress \u003c= StartAddress ||\r\n !MmIsAddressValid(StartAddress) ||\r\n !MmIsAddressValid(EndAddress))\r\n {\r\n return STATUS_INVALID_PARAMETER;\r\n }\r\n for (ULONG i = 0, j = 0; StartAddress + CodeCaveSize + i \u003c= EndAddress; i++)\r\n {\r\n UCHAR CurrentByte = *(PUCHAR)((PUCHAR)StartAddress + i);\r\n if (CurrentByte == 0x90 || CurrentByte == 0xCC)\r\n {\r\n // Check if we have found a CodeCave of the needed size CodeCaveSize\r\n if (++j == CodeCaveSize)\r\n {\r\n *CodeCave = (PVOID)((PUCHAR)StartAddress + i - CodeCaveSize + 1);\r\n return STATUS_SUCCESS;\r\n }\r\n }\r\n else\r\n {\r\n j = 0;\r\n }\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 17 of 55\n\n}\r\n return STATUS_UNSUCCESSFUL;\r\n}\r\nFor each hook, a fresh cave is located by searching from the previous cave’s address forward, ensuring no two\r\ntrampolines share the same padding sequence. The sequential search approach works well given the density of\r\nNOP/INT3 padding in a typical kernel build. Roughly 3,000 caves are available in the .text section, far more\r\nthan the number of hooks we install.\r\nThe SSDT entry encodes the offset to the trampoline in its upper 28 bits; the lower 4 bits encode the syscall’s\r\nparameter count and must be preserved when modifying entries. When installing a hook we read the original\r\nentry, extract its lower nibble, and OR it into the new offset value pointing to our trampoline.\r\nA key strength of the trampoline design is that each hook retains a pointer to the original syscall function\r\n( OldNtXxx ). This means every hook can call through to the real implementation, passing original or modified\r\nparameters as needed, and intercept or modify the return value. Hooks are not dead-ends, they are interception\r\npoints that can observe, modify inputs, call the real function, and then observe or modify outputs. This is what\r\nenables malware detonation: instead of blocking or failing a suspicious call, we allow it to succeed while curating\r\nthe result.\r\nA short representative subset of the hooks we install:\r\nstatic HOOK g_Hooks[] = {\r\n \"NtCreateFile\", (PVOID)NewNtCreateFile, (PVOID)\u0026OldNtCreateFile,\r\n \"NtDelayExecution\", (PVOID)NewNtDelayExecution, (PVOID)\u0026OldNtDelayExecution,\r\n \"NtDeviceIoControlFile\", (PVOID)NewNtDeviceIoControlFile, (PVOID)\u0026OldNtDeviceIoControlFile,\r\n \"NtOpenProcess\", (PVOID)NewNtOpenProcess, (PVOID)\u0026OldNtOpenProcess,\r\n \"NtProtectVirtualMemory\", (PVOID)NewNtProtectVirtualMemory, (PVOID)\u0026OldNtProtectVirtualMemory,\r\n \"NtQuerySystemInformation\", (PVOID)NewNtQuerySystemInformation, (PVOID)\u0026OldNtQuerySystemInformation,\r\n \"NtQueryVolumeInformationFile\", (PVOID)NewNtQueryVolumeInformationFile, (PVOID)\u0026OldNtQueryVolumeInformationF\r\n \"NtRaiseException\", (PVOID)NewNtRaiseException, (PVOID)\u0026OldNtRaiseException,\r\n \"NtTerminateProcess\", (PVOID)NewNtTerminateProcess, (PVOID)\u0026OldNtTerminateProcess,\r\n \"NtTerminateThread\", (PVOID)NewNtTerminateThread, (PVOID)\u0026OldNtTerminateThread,\r\n // ... additional hooks for injection detection, APC monitoring, forensic capture, etc.\r\n};\r\nEach hook serves a specific purpose (e.g. anti-evasion, injection detection, forensic capture, or some\r\ncombination). Having OldNtXxx function pointers means hooks can always call the original syscall and can pass\r\nthrough any parameters unchanged when the calling process is not monitored, adding zero overhead to\r\nunmonitored system activity. When a monitored process is involved, the hook intercepts and modifies inputs or\r\noutputs to serve the analysis goals.\r\nSdtWriteHooks is the orchestration function that ties together all of the primitives described above into a\r\ncomplete SSDT hook installation pipeline. It accepts a pointer to KiServiceTable (the SSDT base), the mapped\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 18 of 55\n\nntdll.dll image and its size, and a start/end address range for code cave allocation within the kernel .text\r\nsection. After IRQL and parameter validation, it iterates over the global g_Hooks array (the table of syscalls to\r\nintercept), and for each entry executes the following sequence:\r\n1. Resolve the syscall index: SdtGetSyscallIndexByName parses the ntdll.dll stub to extract the MOV\r\nEAX,imm32 syscall number for the target syscall name.\r\n2. Save the original address: SdtGetAddressFromSSDTById decodes the current SSDT entry and stores the\r\nreal syscall address in the hook descriptor’s OldAddress pointer, making it available to hook handlers for\r\noptional call-through to the real implementation.\r\n3. Find a code cave: SdtSearchCodeCave scans the kernel .text section for a region of contiguous NOP\r\n( 0x90 ) or INT3 ( 0xCC ) bytes of sufficient length to hold the trampoline. Each iteration advances the\r\nsearch base past the previously allocated cave to prevent collisions between hooks.\r\n4. Write the trampoline: SdtWriteTrampoline patches the code cave with the absolute MOV RAX, imm64 /\r\nJMP RAX sequence redirecting execution to the hook handler.\r\n5. Calculate the new SSDT offset: The SSDT stores relative offsets from KiServiceTable , not absolute\r\naddresses, and the lower 4 bits of each entry encode the syscall’s parameter count. The new offset is\r\ncomputed as (CodeCave − KiServiceTable) \u003c\u003c 4 , with the original lower 4 bits OR’d back in to preserve\r\nthe parameter count metadata intact.\r\n6. Patch the SSDT entry: Temporarily clear the WP bit in CR0 to write the new offset into\r\nKiServiceTable[SyscallId] , redirecting that syscall through the trampoline and into the hook handler.\r\n7. Record hook state: On success, the original and new SSDT offsets, syscall name, and index are committed\r\nto g_SSDT_HooksInfo . This state is used later by the integrity-monitoring component to detect and restore\r\nany tampering with the installed hooks.\r\nAt this point, the full SSDT hooking pipeline is complete: locate the table dynamically via pattern scanning from\r\nthe LSTAR MSR, resolve each syscall’s index by parsing the MOV EAX, imm32 stub from ntdll.dll , find a\r\ncode cave in the kernel’s .text section, write a 12-byte absolute trampoline there, calculate the SSDT-relative\r\noffset to the cave (preserving the lower 4 bits that encode the parameter count), and finally patch that offset into\r\nthe correct SSDT entry (temporarily disabling write protection in CR0 to do so).\r\nOne aspect that technically minded readers will naturally ask about: since we are directly patching\r\nKiServiceTable (a structure that PatchGuard actively monitors, responding to unauthorized modifications with a\r\nCRITICAL_STRUCTURE_CORRUPTION bugcheck), how does the system not crash? The answer is that our platform\r\noperates in a purpose-built analysis environment where kernel integrity constraints are appropriately managed for\r\nthis use case. This is an explicit architectural prerequisite of the deployment model.\r\nActive Detonation: Answering Every Probe.\r\nThe Paradigm Shift: Both Hiding and Cooperating\r\nMost sandbox-hardening approaches we’ve seen take a purely defensive posture: patch VMware registry keys,\r\nrename analysis tool processes, remove telltale files. This is fundamentally reactive: a growing blocklist that\r\nmalware authors can enumerate, verify, and adapt to. Every static patch becomes a new detection signal once it is\r\nknown.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 19 of 55\n\nOur driver takes both approaches simultaneously. On one side, it hides the analysis infrastructure: monitoring\r\nprocesses are invisible to malware’s enumeration attempts, analysis-related files and registry keys are concealed or\r\nspoofed through the filesystem minifilter and registry callback, and the operating environment presents as a real\r\nphysical workstation. On the other side, it cooperates with malware’s probes rather than merely blocking them.\r\nFor every environment check that malware performs routes through a syscall, we intercept it and return whatever\r\nanswer encourages detonation. Malware can probe as aggressively as it wants. Every probe goes through our\r\nhooks. We control the response.\r\nThe combination is more powerful than either technique alone: not only do we prevent malware from detecting\r\nthe analysis environment, but we actively assist malware in reaching its payload by capturing syscalls, spoofing\r\nreturn values to look real, and allowing malware to proceed through its execution chain. The filesystem minifilter\r\nand registry callback hide and spoof values at the filesystem and registry level. SSDT hooks do the same at the\r\nsyscall level. The practical consequence: malware detonates. It sees a hardware environment that looks like a real\r\nphysical workstation, concludes it is not being analyzed, and executes its payload. We capture everything.\r\nHardware Spoofing, Time Manipulation, and Process Hiding\r\nMalware applies many categories of environment checks simultaneously. Effective detonation assistance requires\r\naddressing all of them coherently as a mismatch between two sources is itself a detection signal for sophisticated\r\nsamples.\r\nTime manipulation remains one of the most reliable evasion techniques we encounter in the wild. A call to\r\nNtDelayExecution (backing Sleep() ) with an interval of minutes or hours, betting that the analysis\r\nenvironment has a fixed time window, will cause unsophisticated sandboxes to time out before the payload runs.\r\nOur hook on NtDelayExecution caps sleep intervals at a configurable maximum, but advances system time and\r\nthe tick count by the amount skipped. This way, malware waking up perceives the correct elapsed time.\r\nDisk size is queried via NtDeviceIoControlFile with multiple control codes (e.g.\r\nIOCTL_DISK_GET_LENGTH_INFO , IOCTL_DISK_GET_DRIVE_GEOMETRY , and IOCTL_DISK_GET_DRIVE_GEOMETRY_EX ).\r\nOur hook on NtDeviceIoControlFile calls the real function first, then modifies the output buffer for monitored\r\nprocesses (e.g. spoofing Length.QuadPart , Cylinders.QuadPart , and DiskSize.QuadPart ) to reflect a realistic\r\nphysical machine. Similarly, NtQueryVolumeInformationFile is hooked to spoof FileFsFullSizeInformation\r\nand FileFsSizeInformation , adjusting TotalAllocationUnits and free-space figures consistently.\r\nProcessor count requires several independent sources to be spoofed consistently, because sophisticated samples\r\ncross-check them against each other:\r\n1. KUSER_SHARED_DATA : The kernel structure mapped at fixed address 0xFFFFF78000000000 in kernel space\r\n(and 0x7FFE0000 in user space as a read-only mapping), is modified directly from kernel mode.\r\n2. Registry ( HKLM\\HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\ ): intercepted by the registry callback,\r\nwhich replaces the value data inside each processor subkey (vendor identifier, CPU name, and related\r\nfields) to reflect a physical machine.\r\n3. NtQuerySystemInformation with SystemBasicInformation , SystemEmulationBasicInformation , and\r\nSystemNativeBasicInformation (intercepted by the SSDT hook), which also spoofs physical memory\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 20 of 55\n\nfigures across SystemMemoryUsageInformation , SystemPerformanceInformation , and\r\nSystemBasicPerformanceInformation .\r\nThese are some of the checks that should be taken into consideration when building a comprehensive detonation\r\nenvironment and the list evolves as malware authors discover new fingerprinting vectors.\r\nKUSER_SHARED_DATA modifications deserve a closer look because they are more nuanced than a simple\r\nwrite. KUSER_SHARED_DATA uses a seqlock-style update protocol for fields like TickCount to allow non-blocking\r\nreads: Writers first update a “high” version field High1Time , then write the main value LowPart , and finally\r\ncopy the high field again ( High2Time = High1Time ). Readers check that High1Time == High2Time before\r\nand after reading LowPart ; if the values differ, a write was in progress and the read is retried. This pattern\r\nensures readers either see a fully consistent value or retry, without blocking writers, and relies on atomic 32-bit\r\nreads/writes to detect in-progress updates.\r\nNTSTATUS DsIncreaseTickCount(CONST IN LARGE_INTEGER TimeSkipped)\r\n{\r\n NTSTATUS ret = STATUS_SUCCESS;\r\n LARGE_INTEGER Ticks = DsMilliseconds2Ticks(TimeSkipped);\r\n ULONG SpoofedTickCount_Low = g_UserSharedData-\u003eTickCount.LowPart + Ticks.LowPart;\r\n LONG SpoofedTickCount_High = g_UserSharedData-\u003eTickCount.High1Time + Ticks.HighPart;\r\n // Write in correct seqlock order: High1Time, LowPart, High2Time\r\n g_UserSharedData-\u003eTickCount.High1Time = SpoofedTickCount_High;\r\n g_UserSharedData-\u003eTickCount.LowPart = SpoofedTickCount_Low;\r\n g_UserSharedData-\u003eTickCount.High2Time = SpoofedTickCount_High;\r\n return ret;\r\n}\r\nThe DsMilliseconds2Ticks helper converts a millisecond interval to tick units. The Windows system timer runs\r\nat 15.625 ms, but this cannot be represented directly with floating-point math in kernel mode. While CPUs can\r\nexecute floating-point instructions in ring 0, the Windows kernel does not automatically save or restore the\r\nFPU/SSE registers on context switches. Any floating-point instruction in kernel code that runs without explicitly\r\nsaving and restoring the state (via KeSaveExtendedProcessorState / KeRestoreExtendedProcessorState ) can\r\ncorrupt the FPU state of an interrupted user-mode thread (a subtle, hard-to-reproduce form of memory corruption).\r\nTo avoid this entirely, we use an integer multiplication/division trick to safely approximate tick conversion. Since\r\nthe system timer runs at 15.625 ms, we represent this period as the integer ratio 125/8 and perform all tick-count\r\narithmetic using integer multiplication and division. No floating-point instructions are needed, and truncation of\r\nfractions is acceptable given the timer’s granularity.\r\n// 15.625ms = 125/8 - integer representation avoids floating-point in kernel mode\r\n#define TIMER_TICKS_PER_PERIOD 125 // Numerator of system timer period (15.625ms)\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 21 of 55\n\n#define PERIODS_PER_INTERVAL 8 // Denominator of system timer period\r\n// Clear calculation without floating point:\r\n// Instead of: value / 15.625\r\n// We use: (value * PERIODS_PER_INTERVAL) / TIMER_TICKS_PER_PERIOD\r\nTicks.QuadPart = (TimeSkipped.QuadPart * PERIODS_PER_INTERVAL) / TIMER_TICKS_PER_PERIOD;\r\nKUSER_SHARED_DATA is mapped at two fixed virtual addresses: read-only at 0x7FFE0000 in user mode, and\r\nwritable at 0xFFFFF78000000000 in kernel mode, both established at boot. Unlike write-protected structures such\r\nas the SSDT, the kernel-mode mapping of KUSER_SHARED_DATA is already writable (no need for CR0.WP\r\nmanipulation here). Direct writes through SharedUserData are the correct approach.\r\n// Spoof USER_SHARED_DATA NumberOfPhysicalPages - direct write\r\ng_UserSharedData-\u003eNumberOfPhysicalPages += SPOOFED_RAM_ADDITIONAL_PHYSICAL_PAGES;\r\nSystem uptime spoofing is a wrapper around tick count advancement: DsSpoofSystemUptime calls\r\nDsSpoofTickCount , which invokes DsIncreaseTickCount with a preconfigured startup time offset. Together\r\nthese modifications ensure that GetTickCount and direct KUSER_SHARED_DATA reads reflect a machine that has\r\nbeen running for a plausible amount of time.\r\nNote that writing large jumps to TickCount will cause a brief black screen flash, as the Desktop Window Manager\r\nuses it for frame scheduling and loses sync when the value changes abruptly. In practice, a brief mouse movement\r\nis sufficient to restore normal rendering.\r\nProcess Hiding is also an critical capability as it allows to hide security or specific analysis tools that we want to\r\nrun in the same machine that detonates malware while we want to keep them safe from detection.\r\nProcess enumeration via NtQuerySystemInformation with SystemProcessInformation ,\r\nSystemSessionProcessInformation , or SystemExtendedProcessInformation is filtered by our hook, which\r\nwalks the returned process list and removes entries for hidden processes before the result reaches the caller. Here\r\nis a representative excerpt showing the SystemBasicInformation spoofing path (the first check in a hook that\r\nhandles multiple information classes):\r\nNTSTATUS NTAPI NewNtQuerySystemInformation(\r\n IN SYSTEM_INFORMATION_CLASS SystemInformationClass,\r\n OUT PVOID SystemInformation,\r\n IN ULONG SystemInformationLength,\r\n OUT OPTIONAL PULONG ReturnLength)\r\n{\r\n GrdIncThreadsIntoHooks();\r\n ULONG ProcessId = PslGetCurrentThreadProcessId();\r\n // Call the original syscall first. Let it populate the buffer\r\n NTSTATUS NtStatus = OldNtQuerySystemInformation(\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 22 of 55\n\nSystemInformationClass,\r\n SystemInformation,\r\n SystemInformationLength,\r\n ReturnLength);\r\n if (NT_SUCCESS(NtStatus) \u0026\u0026 PslIsProcessMonitored(ProcessId) \u0026\u0026 ExGetPreviousMode() != KernelMode)\r\n {\r\n if ((SystemInformationClass == SystemBasicInformation ||\r\n SystemInformationClass == SystemEmulationBasicInformation ||\r\n SystemInformationClass == SystemNativeBasicInformation) \u0026\u0026\r\n SystemInformation \u0026\u0026\r\n SystemInformationLength \u003e= sizeof(SYSTEM_BASIC_INFORMATION))\r\n {\r\n SYSTEM_BASIC_INFORMATION* sbi = (SYSTEM_BASIC_INFORMATION*)SystemInformation;\r\n __try\r\n {\r\n ProbeForWrite(sbi, sizeof(SYSTEM_BASIC_INFORMATION), 1);\r\n sbi-\u003eNumberOfProcessors = (CCHAR)SPOOFED_NUMBER_OF_PROCESSORS;\r\n sbi-\u003eNumberOfPhysicalPages += SPOOFED_RAM_ADDITIONAL_PHYSICAL_PAGES;\r\n }\r\n __except (EXCEPTION_EXECUTE_HANDLER)\r\n {\r\n DBG_ERROR(\"[!] Unable to spoof NumberOfProcessors at NewNtQuerySystemInformation\");\r\n }\r\n }\r\n // ... additional information class handlers follow (SystemProcessInformation,\r\n // SystemMemoryUsageInformation, SystemPerformanceInformation, etc.)\r\n }\r\n GrdDecThreadsIntoHooks();\r\n return NtStatus;\r\n}\r\nAs we can see, the pattern is consistent across all handlers: call the real function first, then selectively modify the\r\noutput buffer for monitored processes only. The __try/__except block around each ProbeForWrite +\r\nmodification sequence is essential (user-mode callers can provide buffers that become invalid between the original\r\ncall and our modification), and an unhandled access violation in kernel mode is a system crash.\r\nProcess hiding can be implemented at two distinct levels, and both have trade-offs. The first approach is based on\r\nunlinking a process from the EPROCESS.ActiveProcessLinks doubly-linked list (this is a well-known rootkit\r\ntechnique that makes the process invisible to any code walking that list). The second approach consists on filtering\r\nthe output of NtQuerySystemInformation via SSDT hook (this is more surgical as it provides per-caller control).\r\nThe hook can hide processes from monitored (malware) processes while returning accurate information to\r\neverything else on the system.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 23 of 55\n\nBut filtering NtQuerySystemInformation alone is not sufficient. Malware that suspects its process-enumeration\r\nresults are being filtered may fall back to PID bruteforcing: iterating over integer values and calling\r\nNtOpenProcess for each one, checking whether a handle is returned for later inspection. Our NtOpenProcess\r\nhook closes this gap: when a monitored process attempts to open a handle to a hidden PID, the hook returns\r\nSTATUS_INVALID_CID without calling the real function, as if that PID does not exist:\r\nNTSTATUS NTAPI NewNtOpenProcess(\r\n OUT PHANDLE ProcessHandle,\r\n IN ACCESS_MASK DesiredAccess,\r\n IN POBJECT_ATTRIBUTES ObjectAttributes,\r\n IN OPTIONAL PCLIENT_ID ClientId)\r\n{\r\n GrdIncThreadsIntoHooks();\r\n NTSTATUS NtStatus;\r\n ULONG ProcessId = PslGetCurrentThreadProcessId();\r\n if (PslIsProcessMonitored(ProcessId) \u0026\u0026 ExGetPreviousMode() != KernelMode)\r\n {\r\n if (ClientId)\r\n {\r\n __try\r\n {\r\n ProbeForRead(ClientId, sizeof(CLIENT_ID), sizeof(ULONG_PTR));\r\n if (ClientId-\u003eUniqueProcess)\r\n {\r\n ULONG TargetPid = (ULONG)(ULONG_PTR)ClientId-\u003eUniqueProcess;\r\n if (PslIsProcessHidden(TargetPid))\r\n {\r\n DBG_INFO(\"[+] Hiding process %lu from process %lu.\", TargetPid, ProcessId);\r\n // Pretend the process does not exist\r\n NtStatus = STATUS_INVALID_CID;\r\n goto ignore_original_and_exit;\r\n }\r\n }\r\n }\r\n __except (EXCEPTION_EXECUTE_HANDLER)\r\n {\r\n NtStatus = STATUS_ACCESS_VIOLATION;\r\n goto ignore_original_and_exit;\r\n }\r\n }\r\n }\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 24 of 55\n\n// Call the original syscall\r\n NtStatus = OldNtOpenProcess(ProcessHandle, DesiredAccess, ObjectAttributes, ClientId);\r\nignore_original_and_exit:\r\n GrdDecThreadsIntoHooks();\r\n return NtStatus;\r\n}\r\nThe combination of NtQuerySystemInformation filtering and NtOpenProcess interception means that\r\nmonitoring processes are hidden from both enumeration-based discovery and PID-bruteforce discovery (the two\r\nprimary methods malware uses to locate and interact with analysis tools). Covering all these vectors eliminates the\r\ninconsistencies that sophisticated samples exploit. If even one source returns the real value, a capable sample will\r\nnotice and abort detonation.\r\nSidebar: The ExGetPreviousMode() != KernelMode Guard\r\nEvery SSDT hook applies its interception logic only when ExGetPreviousMode()!= KernelMode . This is a critical\r\nsafety invariant, not an optimization. ExGetPreviousMode() returns UserMode when the thread entered via a\r\nSYSCALL from user space, and KernelMode when the call originated from another kernel component.\r\nWithout this guard, hooks would fire on kernel-mode callers too. Many kernel subsystems call\r\nNtQuerySystemInformation , NtCreateFile, and similar functions internally and spoofing those return values\r\ncorrupts kernel state and causes hard-to-diagnose deferred crashes. With the guard, interception applies only to\r\nuser-mode syscalls. As a side effect, since the vast majority of calls through hooked functions originate from the\r\nkernel itself, the guard also eliminates overhead for most invocations.\r\nFollowing the Full Infection Chain.\r\nWhy Persistence Detection Changes the Game\r\nThis is one of the capabilities we consider most important in the driver, and requires dedicated kernel-level\r\npersistence awareness to address effectively.\r\nModern multi-stage malware follows a consistent pattern: stage one (a loader or dropper) arrives via phishing or\r\nexploitation, performs environment checks, and if it decides the environment is safe, establishes persistence. This\r\npersistence mechanism is designed to trigger stage two when the system “reboots” or when a user logs in. Stage\r\ntwo is the actual payload: the information stealer, the ransomware core, the banking trojan, the RAT.\r\nSome sandboxes analyze stage one. They watch it drop a file and write a Run key, and then conclude the analysis.\r\nStage two is never executed because there is no simulated reboot, no triggering of the persistence mechanism. The\r\nanalyst knows persistence was established but never sees what it deploys.\r\nIf you cannot see the final payload, you cannot build detection for it. If you cannot build detection for it, it lands in\r\nyour environment undetected.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 25 of 55\n\nOur driver closes this gap.\r\nFilesystem Minifilter and Registry Callback: Monitoring for Persistence\r\nOur filesystem minifilter and registry callback both maintain real-time awareness of persistence-related activity\r\nfrom monitored processes.\r\nThe registry callback (registered via CmRegisterCallbackEx ) intercepts all registry operations system-wide.\r\nWhen a monitored process writes to persistence-related registry locations (e.g.\r\nHKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run , RunOnce or service registration paths), the filter\r\ncaptures the written value and immediately records and dispatches it for tracking. The write operation completes\r\nnormally; the malware’s persistence mechanism is established as intended. We simply observe it, in real time,\r\nbefore any cleanup can occur.\r\nThe CmRegisterCallbackEx API delivers every registry operation to a single callback function with an operation\r\ntype parameter ( REG_NOTIFY_CLASS ). Rather than a large switch statement, the implementation uses a dispatch\r\ntable (an array of function pointers indexed by operation type). This keeps the main callback function minimal and\r\nmakes adding or removing handlers for specific operation types straightforward:\r\n// Dispatch table: one entry per REG_NOTIFY_CLASS value\r\nstatic PEX_CALLBACK_FUNCTION g_RegistryCallbackTable[MaxRegNtNotifyClass] = { 0 };\r\n// Main callback - routes to the appropriate handler\r\nstatic NTSTATUS RfRegistryCallback(\r\n IN PVOID CallbackContext,\r\n IN PVOID Argument1,\r\n IN PVOID Argument2)\r\n{\r\n REG_NOTIFY_CLASS Operation = (REG_NOTIFY_CLASS)(ULONG_PTR)Argument1;\r\n // Defensive bounds check: future Windows versions may introduce REG_NOTIFY_CLASS values\r\n // beyond the SDK-defined MaxRegNtNotifyClass. Always guard before indexing kernel tables.\r\n if (Operation \u003e= MaxRegNtNotifyClass || !g_RegistryCallbackTable[Operation])\r\n return STATUS_SUCCESS;\r\n return g_RegistryCallbackTable[Operation](CallbackContext, Argument1, Argument2);\r\n}\r\nThe table is populated at initialization with handlers for each operation we care about:\r\nstatic VOID RfInitRegistryCallbackTable()\r\n{\r\n // Access Control: Key Creation and Opening (hide VM-related keys)\r\n g_RegistryCallbackTable[RegNtPreOpenKeyEx] = (PEX_CALLBACK_FUNCTION)RfPreOpenCreateKeyEx;\r\n g_RegistryCallbackTable[RegNtPreCreateKeyEx] = (PEX_CALLBACK_FUNCTION)RfPreOpenCreateKeyEx;\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 26 of 55\n\n// Access Control: Key Enumeration\r\n g_RegistryCallbackTable[RegNtQueryKey] = (PEX_CALLBACK_FUNCTION)RfQueryKey;\r\n g_RegistryCallbackTable[RegNtPreEnumerateKey] = (PEX_CALLBACK_FUNCTION)RfPreEnumerateKey;\r\n g_RegistryCallbackTable[RegNtEnumerateKey] = (PEX_CALLBACK_FUNCTION)RfEnumerateKey;\r\n // Access Control: Deletion Protection\r\n g_RegistryCallbackTable[RegNtPreDeleteKey] = (PEX_CALLBACK_FUNCTION)RfPreDeleteKey;\r\n g_RegistryCallbackTable[RegNtPreDeleteValueKey] = (PEX_CALLBACK_FUNCTION)RfPreDeleteValueKey;\r\n // Monitoring: Persistence Detection (captures Run key writes, service registrations)\r\n g_RegistryCallbackTable[RegNtPreSetValueKey] = (PEX_CALLBACK_FUNCTION)RfPreSetValueKey;\r\n // Monitoring: Value Spoofing (hardware identifiers, CPU info, etc.)\r\n g_RegistryCallbackTable[RegNtPostQueryValueKey] = (PEX_CALLBACK_FUNCTION)RfPostQueryValueKey;\r\n}\r\nAny REG_NOTIFY_CLASS value with no registered handler returns STATUS_SUCCESS immediately, resulting in zero\r\noverhead for operations we do not need to intercept.\r\nProtecting Persistence Entries from Deletion\r\nThe RegNtPreDeleteKey and RegNtPreDeleteValueKey entries serve a different purpose than the hiding\r\nhandlers: they protect persistence-related registry keys and values from being deleted by monitored processes.\r\nThis matters because sophisticated malware sometimes probes the registry not only by opening keys but by\r\nattempting to delete them. If a deletion of a VM-artifact key succeeds, that is itself confirmation that the\r\nenvironment is real and that no defensive driver is intercepting registry operations. By blocking deletion attempts\r\non the same set of keys that RfPreOpenCreateKeyEx hides, the driver presents a consistent picture regardless of\r\nhow the malware interrogates the registry and eliminates this side-channel.\r\nHiding VM-Revealing Registry Keys\r\nThe RegNtPreOpenKeyEx and RegNtPreCreateKeyEx entries both route to RfPreOpenCreateKeyEx . This handler\r\nis responsible for making virtualization-related registry keys appear nonexistent to monitored processes.\r\nHypervisors and virtualization platforms leave characteristic footprints in the registry (VMware stores identifiers\r\nunder HKLM\\SOFTWARE\\VMware, Inc.\\VMware Tools , VirtualBox leaves traces under\r\nHKLM\\HARDWARE\\ACPI\\DSDT\\VBOX__ , and the HARDWARE enumeration tree contains device strings that reveal\r\nthe underlying platform). Any monitored process attempting to open or create these keys should receive\r\nSTATUS_OBJECT_NAME_NOT_FOUND as if the key simply does not exist.\r\n/*\r\n * Pre-operation callback for registry key open/create operations.\r\n * Blocks access to hidden registry keys.\r\n *\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 27 of 55\n\n* Parameters:\r\n * CallbackContext - Context pointer supplied during registration (unused)\r\n * Argument1 - Registry operation type (unused)\r\n * CallbackData - Information about the key being opened/created\r\n *\r\n * Returns:\r\n * NTSTATUS:\r\n * - STATUS_SUCCESS - Allow the operation\r\n * - STATUS_OBJECT_NAME_NOT_FOUND - Block access to hidden key\r\n * - Other error codes from RfBuildCompleteRegistryKeyString\r\n *\r\n * Notes:\r\n * - Must be called at PASSIVE_LEVEL due to memory operations\r\n * * This callback is always invoked at IRQL = PASSIVE_LEVEL (guaranteed by the registry filter manager).\r\n * - Only processes requests from monitored processes\r\n * - Attempts to build complete path, falls back to relative path if needed\r\n*/\r\n_IRQL_requires_(PASSIVE_LEVEL)\r\n_IRQL_requires_same_\r\n_Function_class_(EX_CALLBACK_FUNCTION)\r\nstatic NTSTATUS RfPreOpenCreateKeyEx(\r\n IN OPTIONAL PVOID CallbackContext,\r\n IN OPTIONAL PVOID Argument1,\r\n IN PREG_OPEN_KEY_INFORMATION CallbackData)\r\n{\r\n UNREFERENCED_PARAMETER(CallbackContext);\r\n UNREFERENCED_PARAMETER(Argument1);\r\n NTSTATUS NtStatus = STATUS_SUCCESS;\r\n PUNICODE_STRING KeyNameBeingOpened = NULL;\r\n PUNICODE_STRING LocalCompleteName = NULL;\r\n ULONG ProcessId = PslGetCurrentThreadProcessId();\r\n // Parameter validation\r\n if (!CallbackData || !CallbackData-\u003eCompleteName)\r\n {\r\n DBG_ERROR(\"[!] Invalid Callback data at RfPreOpenCreateKeyEx\");\r\n return STATUS_INVALID_PARAMETER;\r\n }\r\n // We do not care if the request comes from a non-monitored process or from Kernel\r\n if (!PslIsProcessMonitored(ProcessId) || ExGetPreviousMode() == KernelMode)\r\n {\r\n return STATUS_SUCCESS;\r\n }\r\n // Try to build complete path\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 28 of 55\n\nif (!NT_SUCCESS(RfBuildCompleteRegistryKeyString(CallbackData, \u0026LocalCompleteName)))\r\n {\r\n KeyNameBeingOpened = CallbackData-\u003eCompleteName;\r\n }\r\n else\r\n {\r\n KeyNameBeingOpened = LocalCompleteName;\r\n }\r\n // HIDE\r\n if (RfIsHide(KeyNameBeingOpened))\r\n {\r\n DBG_INFOW(\"[+] Detected HIDDEN registry at RfPreOpenCreateKeyEx: %wZ\", KeyNameBeingOpened);\r\n NtStatus = STATUS_OBJECT_NAME_NOT_FOUND;\r\n }\r\n if (LocalCompleteName)\r\n {\r\n ExFreePool(LocalCompleteName);\r\n LocalCompleteName = NULL;\r\n }\r\n return NtStatus;\r\n}\r\nThe handler extracts the requested key path from the REG_OPEN_CREATE_KEY_EX_INFORMATION structure provided\r\nin Argument2 , then checks it against a curated list of known VM-revealing path prefixes. When a monitored\r\nprocess attempts to open a matching key, the handler replaces the result with STATUS_OBJECT_NAME_NOT_FOUND\r\nbefore the operation reaches the registry stack.\r\nThe filesystem minifilter (operating through the Windows Filter Manager fltmgr.sys ) intercepts\r\nIRP_MJ_CREATE and IRP_MJ_SET_INFORMATION operations. When a monitored process creates a file in a\r\npersistence-relevant location (e.g. startup folders) the minifilter captures the file path and records it. When a file is\r\nrenamed or moved, the minifilter captures both the old and new paths via FileRenameInformation and\r\nFileRenameInformationEx callbacks.\r\nBeyond persistence detection, the same minifilter implements file hiding for analysis infrastructure. A\r\nconfiguration table lists filenames and path fragments that should be invisible to monitored processes. For each\r\nIRP_MJ_CREATE operation from a monitored process, the pre-operation callback checks the requested path against\r\nthis table. On a match, it sets Data-\u003eIoStatus.Status to STATUS_OBJECT_NAME_NOT_FOUND and returns\r\nFLT_PREOP_COMPLETE , short-circuiting the rest of the filter stack and returning the synthesized failure directly to\r\nthe caller (as if the file simply does not exist). This same path also suppresses DeleteOnClose attempts against\r\nhidden files. Note that hiding files from direct open attempts ( IRP_MJ_CREATE ) is distinct from hiding them from\r\ndirectory listings, which would require scrubbing entries in an IRP_MJ_DIRECTORY_CONTROL post-operation\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 29 of 55\n\ncallback, which is a complementary layer that keeps hidden items absent from both direct opens and directory\r\nenumeration.\r\nBoth filters operate entirely transparently from the malware’s perspective: there is no blocking, no modification of\r\nthe malware’s behavior. The malware succeeds in establishing persistence. We know about it the moment it\r\nhappens.\r\nTriggering Forced Execution of Next-Stage Payloads\r\nWhen a persistence event is captured, a trusted user-space component spawns the registered payload in the\r\nappropriate execution context.\r\nThis transforms the analysis outcome from “we observed persistence establishment” to “we observed the complete\r\ninfection chain, including the final payload’s behavior.” For multi-stage loaders that only deploy the final payload\r\nafter confirming successful persistence, this capability is what makes full behavioral capture possible: within a\r\nsingle analysis session, without manual analyst intervention and without waiting for a real reboot cycle.\r\nAutomatic Injection Chain Propagation\r\nModern malware rarely operates in a single process. Loaders inject into legitimate system processes. Injected code\r\nspawns further processes. Without automatic tracking across process boundaries, monitoring the initial sample\r\ncaptures only a fraction of the malicious activity.\r\nOur driver handles this automatically through two mechanisms: Kernel process creation callbacks and SSDT\r\nhooks on injection-related syscalls like NtQueueApcThread (and its Windows 10 variants NtQueueApcThreadEx\r\nand NtQueueApcThreadEx2 ) or NtSetContextThread . For instance, when a monitored process successfully\r\nqueues an APC to a thread in another process, the hook calls PslFollowThreadInjection to automatically add\r\nthe target process to the monitored list. NtSetContextThread and NtProtectVirtualMemory (when the latter\r\nadds executable permissions to a region in a different process) similarly trigger injection tracking. The result:\r\nanalysts see the complete behavioral graph of the infection, from the initial sample through every spawned child\r\nand injection target, without any manual configuration of additional PIDs to monitor.\r\nPslFollowThreadInjection is the function all injection-detecting hooks call once they identify a cross-process\r\noperation. It resolves the owning process of the target thread, confirms the target differs from the injector\r\n(rejecting same-process cases) and calls PslThreadInjectionFollowProcess to add the target to the monitored\r\nlist and notify the monitoring driver. It is only called after a successful cross-process syscall, which is sufficient\r\nevidence of injection intent. NtCreateThreadEx needs no special handling here since remote thread creation\r\nalready triggers the kernel thread-creation callback; it is only indirect techniques (e.g. APC queuing, thread\r\nhijacking, context redirection) that require this path.\r\nThe injection detection side, happens in the individual syscall hooks before they invoke\r\nPslFollowThreadInjection . APC injection can reach a target thread through three syscall variants:\r\nNtQueueApcThread , and the extended NtQueueApcThreadEx and NtQueueApcThreadEx2 (introduced in Windows\r\n10 with additional parameters that enable more sophisticated APC queuing control). All three variants receive\r\ntheir own SSDT hooks with identical detection logic. The base implementation for NtQueueApcThread :\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 30 of 55\n\n/*\r\n * SSDT hook for NtQueueApcThread that monitors APC-based thread injection techniques\r\n * for tracked processes. Detects when monitored processes queue APCs to other threads,\r\n * which is commonly used for code injection and process hollowing attacks.\r\n *\r\n * Since the original function is called before hook logic, parameter validation, buffer\r\n * alignment, and structure integrity are assumed to be handled by the original NtQueueApcThread\r\n * implementation.\r\n*/\r\nNTSTATUS NTAPI NewNtQueueApcThread(\r\n IN HANDLE ThreadHandle,\r\n IN PPS_APC_ROUTINE ApcRoutine,\r\n IN OPTIONAL PVOID ApcArgument1,\r\n IN OPTIONAL PVOID ApcArgument2,\r\n IN OPTIONAL PVOID ApcArgument3)\r\n{\r\n GrdIncThreadsIntoHooks();\r\n // Call the original syscall\r\n NTSTATUS NtStatus = OldNtQueueApcThread(\r\n ThreadHandle,\r\n ApcRoutine,\r\n ApcArgument1,\r\n ApcArgument2,\r\n ApcArgument3);\r\n if (NT_SUCCESS(NtStatus))\r\n {\r\n ULONG ProcessId = PslGetCurrentThreadProcessId();\r\n if (PslIsProcessMonitored(ProcessId) \u0026\u0026 ExGetPreviousMode() != KernelMode)\r\n {\r\n // We can bypass PslAdd validity check as we know the process exists (we are doing this\r\n // after a successful call to the original syscall).\r\n NTSTATUS ret = PslFollowThreadInjection(ThreadHandle, ProcessId, 0, TRUE);\r\n if (!NT_SUCCESS(ret))\r\n {\r\n // Ignore error if TargetPid and InjectorPid is the same (not an actual thread injection)\r\n if (ret != STATUS_NOT_SUPPORTED)\r\n {\r\n DBG_ERROR(\"[!] Unable to PslFollowThreadInjection for PID: %lu at NewNtQueueApcThread. NTSTA\r\n ProcessId,\r\n ret);\r\n LgPrintfW(ERROR, L\"[!] Unable to PslFollowThreadInjection for PID: %lu at NewNtQueueApcThrea\r\n ProcessId,\r\n ret);\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 31 of 55\n\n}\r\n }\r\n else\r\n {\r\n DBG_INFO(\"[+] Successfully followed thread injection at NewNtQueueApcThread. Injector PID: %lu\",\r\n LgPrintfW(INFO, L\"[+] Successfully followed thread injection at NewNtQueueApcThread. Injector PI\r\n }\r\n }\r\n }\r\n GrdDecThreadsIntoHooks();\r\n return NtStatus;\r\n}\r\nNtSetContextThread injection (commonly used to redirect execution in a thread belonging to another process by\r\noverwriting its register context) follows the same detection pattern. Our hook intercepts the context-modification\r\nstep at the syscall boundary: if a monitored process calls NtSetContextThread on a thread belonging to a\r\ndifferent process, PslFollowThreadInjection is called to add the target to the monitored list.\r\n/*\r\n * SSDT hook for NtSetContextThread that detects thread context manipulation in monitored\r\n * processes, a common technique used in code injection attacks (thread hijacking).\r\n *\r\n * The hook calls the original syscall first to ensure parameter validation and successful\r\n * execution. Only after success does it track the potential thread injection via\r\n * PslFollowThreadInjection, which monitors cross-process thread manipulation patterns.\r\n*/\r\nNTSTATUS NTAPI NewNtSetContextThread(\r\n IN HANDLE ThreadHandle,\r\n IN PCONTEXT ThreadContext)\r\n{\r\n GrdIncThreadsIntoHooks();\r\n // Call the original syscall\r\n NTSTATUS NtStatus = OldNtSetContextThread(ThreadHandle, ThreadContext);\r\n if (NT_SUCCESS(NtStatus))\r\n {\r\n ULONG ProcessId = PslGetCurrentThreadProcessId();\r\n if (PslIsProcessMonitored(ProcessId) \u0026\u0026 ExGetPreviousMode() != KernelMode)\r\n {\r\n /*\r\n * No need to validate ThreadHandle or target process here:\r\n * - NtSetContextThread already validated parameters and thread existence.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 32 of 55\n\n* - NtStatus success guarantees ThreadHandle refers to a valid thread.\r\n *\r\n * We can also bypass PslAdd validity check as we know the process exists\r\n * (we are doing this after a successfull call to the original syscall).\r\n */\r\n NTSTATUS ret = PslFollowThreadInjection(ThreadHandle, ProcessId, 0, TRUE);\r\n if (!NT_SUCCESS(ret))\r\n {\r\n // Ignore error if TargetPid and InjectorPid is the same (not an actual thread injection)\r\n if (ret != STATUS_NOT_SUPPORTED)\r\n {\r\n DBG_ERROR(\"[!] Unable to PslFollowThreadInjection for PID: %lu at NewNtSetContextThread. NTS\r\n ProcessId,\r\n ret);\r\n LgPrintfW(ERROR, L\"[!] Unable to PslFollowThreadInjection for PID: %lu at NewNtSetContextThr\r\n ProcessId,\r\n ret);\r\n }\r\n }\r\n else\r\n {\r\n DBG_INFO(\"[+] Successfully followed thread injection at NewNtSetContextThread. Injector PID: %lu\r\n LgPrintfW(INFO, L\"[+] Successfully followed thread injection at NewNtSetContextThread. Injector\r\n }\r\n }\r\n }\r\n GrdDecThreadsIntoHooks();\r\n return NtStatus;\r\n}\r\nThe structure is identical to the APC injection hooks: call the original first, check success, confirm the caller is a\r\nmonitored user-mode process, then call PslFollowThreadInjection . The STATUS_NOT_SUPPORTED check\r\nsuppresses the expected non-error case where the thread being modified belongs to the calling process itself,\r\nwhich would mean this is a self-context modification, not an injection.\r\nThe NtProtectVirtualMemory case covers the write-then-make-executable pattern. When a monitored process\r\ncalls NtProtectVirtualMemory to add executable permissions to a region in a different process, that is a strong\r\nsignal that code has been written there and is about to be invoked. The hook resolves the process handle in the call\r\nto determine the target, then calls PslFollowThreadInjection via the owning thread.\r\nTogether, the detection paths described in this section demonstrate how kernel-level monitoring enables injection\r\ndetection and dynamic scope expansion of the monitored process set. When a monitored process performs\r\noperations associated with code injection (e.g. queuing APCs to foreign threads, redirecting thread contexts, or\r\nmarking remote memory regions as executable), the target process is automatically added to the monitored list.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 33 of 55\n\nThis covers the injection patterns described above, and in each case the injection target begins generating\r\nbehavioral records from its first instruction in the new context, without any manual analyst configuration.\r\nForensic Preservation: Capturing What Malware Destroys\r\nBeyond detonation assistance and infection chain tracking, the driver provides forensic capture capabilities that\r\npreserve evidence malware routinely destroys: evidence that is gone by the time analysts examine the system post-analysis.\r\nPre-Deletion File Dumping\r\nMalware frequently drops payloads to disk as temporary files like decrypted shellcode, second-stage executables,\r\nconfiguration dat, etc. Uses them briefly, and then deletes them. Without intervention, these files are gone from the\r\nsandbox as surely as from a physical machine.\r\nFiles can be deleted through two main mechanisms in Windows: opening with FILE_DELETE_ON_CLOSE or\r\nDELETE access then closing the handle (signaled through IRP_MJ_CLEANUP ), or calling NtSetInformationFile\r\nwith FileDispositionInformation or FileDispositionInformationEx (signaled through\r\nIRP_MJ_SET_INFORMATION ). Our filesystem minifilter handles both paths.\r\nFor the FILE_DELETE_ON_CLOSE path, the minifilter uses file contexts to track state across the two-phase\r\ncreate/cleanup lifecycle. The workflow is split between the IRP_MJ_CREATE pre-operation and post-operation\r\ncallbacks. In the pre-operation callback, when FILE_DELETE_ON_CLOSE is detected in the create options, a\r\nDELETE_ON_CLOSE_CONTEXT structure is allocated and populated with the file path, then passed as a completion\r\ncontext to the post-operation callback. In FsFltCreatePostOperation (once the kernel has completed the\r\nIRP_MJ_CREATE request and the file is successfully open), the context is attached to the file object via\r\nFltSetFileContext . This binding persists until the cleanup callback releases it: when the file handle is closed\r\nand IRP_MJ_CLEANUP fires, the cleanup callback retrieves the attached context and dumps the file before deletion\r\ncompletes. The two-phase design is necessary because FltSetFileContext requires a successfully opened file\r\nobject, which only exists after the create operation completes:\r\n/*\r\n * Post-operation callback for IRP_MJ_CREATE in the minifilter, used to finalize context setup for\r\n * files opened with the FILE_DELETE_ON_CLOSE option.\r\n *\r\n * Parameters:\r\n * Data - Pointer to the FLT_CALLBACK_DATA structure for the completed create/open operation.\r\n * FltObjects - Pointer to the FLT_RELATED_OBJECTS structure for the current operation.\r\n * CompletionContext - Pointer to a DELETE_ON_CLOSE_CONTEXT structure, if allocated in the pre-operation\r\n * callback.\r\n * Flags - Post-operation flags (unused).\r\n *\r\n * Returns:\r\n * FLT_POSTOP_CALLBACK_STATUS:\r\n * - FLT_POSTOP_FINISHED_PROCESSING: Indicates that post-operation processing is complete.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 34 of 55\n\n*\r\n * Notes:\r\n * - Must be called at IRQL ≤ APC_LEVEL.\r\n * * This is guaranteed by the Filter Manager for minifilter post-operation callbacks.\r\n * * No explicit IRQL validation is required in this function.\r\n * - The function only processes requests from monitored user-mode processes; requests from non-monitored\r\n * processes or from kernel mode are ignored.\r\n * - If a DELETE_ON_CLOSE_CONTEXT was provided (i.e., FILE_DELETE_ON_CLOSE was requested and context was\r\n * allocated in the pre-operation callback), the function attempts to set this context on the file object\r\n * using FltSetFileContext.\r\n * - If FltSetFileContext fails, the function frees the context and any associated memory.\r\n * - If FltSetFileContext succeeds, ownership of the context is transferred to the filter manager, which\r\n * will free it when the file context is deleted (e.g. in the cleanup pre-operation callback).\r\n * - If the create/open operation failed, the function frees the context and any associated memory.\r\n * - The function assumes that the minifilter infrastructure is correctly managing IRQL and context lifetimes.\r\n */\r\n_IRQL_requires_max_(APC_LEVEL)\r\n_Function_class_(PFLT_POST_OPERATION_CALLBACK)\r\nstatic FLT_POSTOP_CALLBACK_STATUS FsFltCreatePostOperation(\r\n IN OUT PFLT_CALLBACK_DATA Data,\r\n IN PCFLT_RELATED_OBJECTS FltObjects,\r\n IN OPTIONAL PVOID CompletionContext,\r\n IN FLT_POST_OPERATION_FLAGS Flags\r\n)\r\n{\r\n UNREFERENCED_PARAMETER(Flags);\r\n ULONG ProcessId = PslGetCurrentThreadProcessId();\r\n // We do not care if the request comes from a non-monitored process or from Kernel\r\n if (!PslIsProcessMonitored(ProcessId) || ExGetPreviousMode() == KernelMode)\r\n {\r\n return FLT_POSTOP_FINISHED_PROCESSING;\r\n }\r\n PDELETE_ON_CLOSE_CONTEXT DeleteOnCloseContext = (PDELETE_ON_CLOSE_CONTEXT)CompletionContext;\r\n // Only proceed if we have a context (i.e. DELETE_ON_CLOSE was requested)\r\n if (DeleteOnCloseContext)\r\n {\r\n if (NT_SUCCESS(Data-\u003eIoStatus.Status))\r\n {\r\n // File was successfully opened, set the file context\r\n NTSTATUS NtStatus = FltSetFileContext(\r\n FltObjects-\u003eInstance,\r\n FltObjects-\u003eFileObject,\r\n FLT_SET_CONTEXT_KEEP_IF_EXISTS, // Don't overwrite if another context is already set\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 35 of 55\n\nDeleteOnCloseContext,\r\n NULL // Don't need the old context back\r\n );\r\n if (!NT_SUCCESS(NtStatus))\r\n {\r\n // Failed to set context (maybe another filter set one first), free our context\r\n DBG_ERROR(\"[!] Unable to FltSetFileContext at FsFltCreatePostOperation. NTSTATUS: 0x%X\", NtStatu\r\n if (DeleteOnCloseContext-\u003eFileName.Buffer)\r\n {\r\n ExFreePool(DeleteOnCloseContext-\u003eFileName.Buffer);\r\n }\r\n FltReleaseContext(DeleteOnCloseContext);\r\n }\r\n /* If successful, ownership of the context is now with the filter manager. In other words, if\r\n * FltSetFileContext succeeds, the filter manager owns the context and will free it when we\r\n * call FltDeleteFileContext in cleanup (at FsFltCleanupPreOperation)\r\n */\r\n else\r\n {\r\n DBG_INFO(\"[+] Successfully executed FltSetFileContext at FsFltCreatePostOperation\");\r\n }\r\n }\r\n else\r\n {\r\n // Create/open failed, free our context\r\n if (DeleteOnCloseContext-\u003eFileName.Buffer)\r\n {\r\n ExFreePool(DeleteOnCloseContext-\u003eFileName.Buffer);\r\n }\r\n FltReleaseContext(DeleteOnCloseContext);\r\n }\r\n }\r\n return FLT_POSTOP_FINISHED_PROCESSING;\r\n}\r\nFor the NtSetInformationFile ( FileDispositionInformation ) path, the minifilter intercepts the\r\nIRP_MJ_SET_INFORMATION pre-callback. Because this callback may fire at APC_LEVEL in some scenarios\r\n(asynchronous I/O completions, thread exit cleanup), the code path is IRQL-aware: if we are already at\r\nPASSIVE_LEVEL , it opens a handle to the file directly in the callback to prevent deletion while dumping. If not at\r\nPASSIVE_LEVEL , a PFLT_GENERIC_WORKITEM is queued via FltQueueGenericWorkItem to perform the dump at\r\nPASSIVE_LEVEL in a deferred context (accepting the small risk that in rare cases the file may already be deleted\r\nby the time the work item runs).\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 36 of 55\n\nThis IRQL discipline is not optional. File I/O operations such as opening a handle or reading file contents require\r\nPASSIVE_LEVEL . Attempting them at APC_LEVEL does not generate a clean error; it causes a bugcheck\r\n( IRQL_NOT_LESS_OR_EQUAL ) or silent memory corruption depending on which code path is entered. Without the\r\nFltQueueGenericWorkItem deferral path, a driver would crash the system on any deletion attempt that arrives\r\nabove PASSIVE_LEVEL (exactly the scenario that occurs when malware opens a file for deletion in an APC\r\ncontext).\r\nThe dumped files are available for post-analysis: static analysis, signature matching, further behavioral analysis in\r\na controlled environment. Files that the malware tried to erase become permanent evidence.\r\nMemory Capture: Intercepting Payloads at Critical Stages\r\nWhen a monitored process terminates, its address space (containing heap allocations, decrypted payloads, runtime\r\ndata structures, and whatever the malware had loaded and unpacked in memory), is freed by the kernel. Without\r\nintervention, that memory state is permanently gone.\r\nBeyond termination, the driver also captures memory at a critically important earlier moment: when a monitored\r\nprocess marks a memory region as executable. This is the write-then-execute pattern that is common across many\r\nprocess injection techniques (e.g. shellcode or a PE image is written into a target process’s memory, and then\r\nNtProtectVirtualMemory is called to add executable permissions before triggering execution). Intercepting this\r\npermission change captures the target process at precisely the moment the injected payload is finalized and about\r\nto run in its fully decrypted, pre-execution state.\r\n/*\r\n * SSDT hook for NtProtectVirtualMemory that monitors memory protection changes with\r\n * execute permissions for tracked processes. Triggers process memory dumps when\r\n * monitored processes modify memory protections to include any execute flag\r\n * (PAGE_EXECUTE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY).\r\n *\r\n * Since the original function is called before hook logic, parameter validation, buffer\r\n * alignment, and structure integrity are assumed to be handled by the original\r\n * NtProtectVirtualMemory implementation.\r\n*/\r\nNTSTATUS NTAPI NewNtProtectVirtualMemory(\r\n IN HANDLE ProcessHandle,\r\n IN OUT PVOID* BaseAddress,\r\n IN OUT PSIZE_T RegionSize,\r\n IN ULONG NewProtection,\r\n OUT PULONG OldProtection)\r\n{\r\n GrdIncThreadsIntoHooks();\r\n // Call the original syscall\r\n NTSTATUS NtStatus = OldNtProtectVirtualMemory(\r\n ProcessHandle,\r\n BaseAddress,\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 37 of 55\n\nRegionSize,\r\n NewProtection,\r\n OldProtection);\r\n if (NT_SUCCESS(NtStatus) \u0026\u0026 (NewProtection \u0026 (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PA\r\n {\r\n ULONG CurrentProcessId = PslGetCurrentThreadProcessId();\r\n if (PslIsProcessMonitored(CurrentProcessId) \u0026\u0026 ExGetPreviousMode() != KernelMode)\r\n {\r\n ULONG TargetProcessId;\r\n PEPROCESS TargetProcess;\r\n // Handle current-process pseudo-handle (NtCurrentProcess() == -1)\r\n if (ProcessHandle == NtCurrentProcess())\r\n {\r\n TargetProcess = PsGetCurrentProcess();\r\n if (TargetProcess == NULL)\r\n {\r\n goto return_from_original_syscall;\r\n }\r\n /*\r\n * CRITICAL: PsGetCurrentProcess does NOT increment reference count.\r\n * We must manually reference it to safely use TargetProcess\r\n * during memory dumping. Don't forget to ObDereferenceObject afterwards.\r\n */\r\n ObReferenceObject(TargetProcess);\r\n TargetProcessId = (ULONG)(ULONG_PTR)PsGetProcessId(TargetProcess);\r\n }\r\n else\r\n {\r\n NTSTATUS InfoFromHandleStatus;\r\n /*\r\n * GetProcessInfoFromProcessHandle returns a referenced PEPROCESS.\r\n * Caller is responsible for calling ObDereferenceObject(TargetProcess)\r\n * once we're done with it.\r\n *\r\n * NOTE: This is consistent with the NtCurrentProcess() case, where we\r\n * manually reference PsGetCurrentProcess().\r\n */\r\n // Uses UserMode AccesMode as these handles come from user-mode.\r\n InfoFromHandleStatus = GetProcessInfoFromProcessHandle(ProcessHandle, UserMode, \u0026TargetProcessId\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 38 of 55\n\nif (!NT_SUCCESS(InfoFromHandleStatus))\r\n {\r\n DBG_ERROR(\"[!] Unable to GetProcessInfoFromHandle. NTSTATUS: 0x%X\", InfoFromHandleStatus);\r\n goto return_from_original_syscall;\r\n }\r\n }\r\n if (!TargetProcessId)\r\n {\r\n // This should not happen if we got to this point.\r\n // Checking this for consistency.\r\n // Release reference we took before finishing\r\n ObDereferenceObject(TargetProcess);\r\n goto return_from_original_syscall;\r\n }\r\n DBG_INFO(\"[+] NtProtectVirtualMemory Hook: Detected memory protection change (+EXECUTE) for monitore\r\n TargetProcessId,\r\n CurrentProcessId);\r\n LgPrintfW(INFO, L\"[+] NtProtectVirtualMemory Hook: Detected memory protection change (+EXECUTE) for\r\n TargetProcessId,\r\n CurrentProcessId);\r\n // Prepare the context for the dump function.\r\n PROCESS_DUMP_CONTEXT DumpContext;\r\n DumpContext.TargetProcess = TargetProcess;\r\n DumpContext.TargetProcessId = TargetProcessId;\r\n NTSTATUS DumpStatus = DmpTryDumpProcessMemoryToFile(\u0026DumpContext, FALSE);\r\n if (!NT_SUCCESS(DumpStatus))\r\n {\r\n DBG_ERROR(\"[!] Unable to Dump process memory for PID: %lu at NtProtectVirtualMemory. NTSTATUS: 0\r\n TargetProcessId,\r\n DumpStatus);\r\n LgPrintfW(ERROR, L\"[!] Unable to Dump process memory for PID: %lu at NtProtectVirtualMemory. NTS\r\n TargetProcessId,\r\n DumpStatus);\r\n }\r\n else\r\n {\r\n DBG_INFO(\"[+] Successfully dumped process memory for PID: %lu at NtProtectVirtualMemory\", Target\r\n LgPrintfW(INFO, L\"[+] Successfully dumped process memory for PID: %lu at NtProtectVirtualMemory\\\r\n }\r\n // CRITICAL: Before we proceed, we must release the reference we took.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 39 of 55\n\nObDereferenceObject(TargetProcess);\r\n }\r\n }\r\nreturn_from_original_syscall:\r\n GrdDecThreadsIntoHooks();\r\n return NtStatus;\r\n}\r\nThis is a post-success hook: the original OldNtProtectVirtualMemory is called first, and the dump logic only\r\nexecutes if the call succeeded and the new protection includes any executable flag ( PAGE_EXECUTE ,\r\nPAGE_EXECUTE_READ , PAGE_EXECUTE_READWRITE , or PAGE_EXECUTE_WRITECOPY ). The target can be the calling\r\nprocess itself (common during unpacking, when a loader marks its own shellcode executable), or a different\r\nprocess, which is the classic cross-process injection scenario. In the cross-process case, this hook serves dual duty:\r\nthe same protection change that signals injection also triggers the memory snapshot, capturing the injected\r\npayload at the instant it becomes executable before a single instruction of it has run.\r\nTo perform the actual dump, we read the target process’s address space page by page using\r\nMmCopyVirtualMemory . This contrasts with the user-mode ReadProcessMemory , which requires a valid handle\r\nwith PROCESS_VM_READ access and routes through ntdll.dll where user-mode hooks could interfere.\r\nOur hook on NtTerminateProcess provides complementary coverage at the other end of the process lifecycle:\r\nintercepting the explicit termination path ( ExitProcess() , TerminateProcess() ) to dump the process before\r\nthe kernel frees its address space. Because NtTerminateProcess is called from user-mode thread context and\r\nalways executes at PASSIVE_LEVEL , the dump can be performed synchronously inside the hook, with the address\r\nspace fully intact and accessible.\r\nThe dump subsystem also handles WoW64 processes correctly. 32-bit malware running under the WoW64\r\ncompatibility layer on a 64-bit OS is common in commodity crimeware, and the subsystem automatically detects\r\nthis case and uses the appropriate memory information structures and address space for the dump (a detail that\r\nmatters in practice given how prevalent 32-bit crimeware remains).\r\nThe resulting memory dumps contain decrypted payloads that were never written to disk, unpacked shellcode, C2\r\nconfiguration data extracted from memory, and the runtime state of whatever the malware was doing at the\r\nmoment of capture.\r\nThe Last-Thread Exit Blind Spot: Why NtTerminateThread Is Also Hooked\r\nNtTerminateProcess covers the explicit termination path ( ExitProcess() , TerminateProcess() ) but there is a\r\nsecond path that bypasses it entirely. When a process terminates by calling ExitThread() or\r\nTerminateThread() on its last remaining thread, the call routes through NtTerminateThread , not\r\nNtTerminateProcess . Inside the kernel, NtTerminateThread ends up driving the thread exit through\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 40 of 55\n\nPspExitThread , which (upon detecting that no other threads remain alive) calls PspTerminateProcess directly.\r\nThis is an internal kernel-to-kernel call; it does not go through the SSDT, and the NtTerminateProcess SSDT\r\nhook is never invoked. This is not an edge case in the Windows implementation, it is a direct consequence of how\r\nthe SSDT boundary works: it intercepts user-mode-to-kernel transitions for public system calls, and\r\nPspTerminateProcess is an internal function with no SSDT entry.\r\nOur hook on NtTerminateThread closes this gap: when the hook fires for a thread in a monitored process, it\r\nchecks the remaining thread count via GetProcessThreadCount . If ThreadCount == 1 (the thread being\r\nterminated is the last), the hook performs the memory dump before calling the original function, capturing the\r\nprocess state before PspExitThread hands control to the internal cleanup path.\r\nA Note on NtRaiseException as a Complementary Capture Point\r\nNtTerminateProcess covers the explicit, clean-exit termination path. A complementary hook point worth\r\nconsidering alongside it is NtRaiseException . When a process encounters an unhandled exception. Whether\r\nfrom a genuine crash, a deliberate self-destruct triggered by anti-analysis logic, or a corrupted state induced by a\r\npacker; the exception dispatch path passes through NtRaiseException before the process address space is freed.\r\nThis makes it analytically valuable in several specific scenarios:\r\nPacker exception cleanup routines: Certain packers register an unhandled exception filter via\r\nSetUnhandledExceptionFilter that erases or unmaps the unpacked payload before re-raising the exception as a\r\ndeliberate forensic countermeasure. An NtRaiseException hook fires before this filter executes, ensuring the\r\npayload is captured in its unpacked state, not after the cleanup has run.\r\nAnti-analysis exception flooding: Some malware families deliberately generate large volumes of exceptions in\r\nrapid succession as an anti-analysis technique, intending to overwhelm monitoring tools or trigger anomalous\r\nbehavior in systems that process every exception individually. To address this, the dump subsystem applies two\r\nlayers of rate limiting for exception-triggered dumps. The first is a time-based deduplication window per process.\r\nThe second is a per-process hard cap on total exception dumps. Together, these limits prevent unbounded artifact\r\ngeneration from exception-heavy samples while still capturing the first meaningful occurrences.\r\nTogether, these hooks provide layered termination coverage: NtRaiseException fires before any exception filter\r\nor cleanup routine runs, preserving the process state at the moment the exception was raised;\r\nNtTerminateProcess catches the eventual explicit termination.\r\nDropped File Tracking and the Complete Artifact Chain\r\nThe filesystem minifilter monitors IRP_MJ_CREATE operations across the system. When a monitored process\r\ncreates a new file, the minifilter captures the path. The driver maintains a complete artifact chain: dropper created\r\npayload1.exe , payload1.exe created payload2.dll , payload2.dll wrote configuration to config.dat ,\r\nand so on. This way, analysts can understand the complete sequence of files involved in an infection, even when\r\nsome of those files are temporary or were deleted. Combined with the pre-deletion dumping, every file that\r\ntouched the system during the analysis (regardless of whether the malware attempted to clean it up) is preserved\r\nfor examination.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 41 of 55\n\nTo avoid duplicates (the same file creation event can trigger multiple callbacks), the minifilter maintains two\r\ncircular buffers: one for file creation events and one for file move/rename events; using dual-hash deduplication\r\n(djb2 and sdbm). Atomic index allocation ensures lock-free operation from multiple concurrent callbacks. The\r\nresult is a clean, non-redundant event stream even during aggressive file creation activity.\r\nThe deduplication mechanism is concise enough to show in full. Each entry stores two independent hash values;\r\nrequiring both to match before treating an event as a duplicate keeps the false-positive rate negligible:\r\ntypedef struct _RECENT_FILE_NEW_NOTIFICATION_ENTRY {\r\n ULONG FilePathHash1; // Primary hash (djb2)\r\n ULONG FilePathHash2; // Secondary hash (sdbm)\r\n} RECENT_FILE_NEW_NOTIFICATION_ENTRY, *PRECENT_FILE_NEW_NOTIFICATION_ENTRY;\r\n#define MAX_FILE_NEW_RECENT_NOTIFICATIONS 128\r\nstatic RECENT_FILE_NEW_NOTIFICATION_ENTRY g_RecentFileNewNotifications[MAX_FILE_NEW_RECENT_NOTIFICATIONS] = { 0\r\nstatic volatile LONG g_RecentFileNewNotificationIndex = 0;\r\nWhen recording a new notification, a slot is allocated atomically and the two hash values are written:\r\nstatic VOID FsFltMarkFileNewAsNotified(CONST IN PCUNICODE_STRING FilePath)\r\n{\r\n ULONG hash1 = StrHashUnicodeString_djb2(FilePath);\r\n ULONG hash2 = StrHashUnicodeString_sdbm(FilePath);\r\n if (hash1 == 0 || hash2 == 0)\r\n return; // Can't mark this file_new\r\n // Get unique index atomically - circular buffer semantics\r\n ULONG Index = InterlockedIncrement(\u0026g_RecentFileNewNotificationIndex) - 1;\r\n Index = Index % MAX_FILE_NEW_RECENT_NOTIFICATIONS;\r\n PRECENT_FILE_NEW_NOTIFICATION_ENTRY Entry = \u0026g_RecentFileNewNotifications[Index];\r\n Entry-\u003eFilePathHash1 = hash1;\r\n Entry-\u003eFilePathHash2 = hash2;\r\n}\r\nThe InterlockedIncrement ensures each concurrent callback gets a unique buffer slot without any lock. When\r\nchecking for duplicates, the code scans all entries comparing both hash values; a match on both means the event\r\nhas already been dispatched and should be suppressed. The same pattern is used for the move/rename buffer,\r\nextended with four hash fields (old path and new path, each hashed twice).\r\nSelf-Protection: The Arms Race Malware Cannot Win\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 42 of 55\n\nTraditional kernel rootkits are uncommon in contemporary commodity malware. This is partly due to the\r\nengineering cost, but also because Windows has made it significantly harder to operate stably at the kernel level.\r\nKernel Patch Protection (PatchGuard) actively monitors critical kernel structures and triggers a system crash\r\n( BSOD ) if unauthorized modifications are detected, making it a substantial barrier for malware that wants to\r\ntamper with the SSDT or other protected structures. But kernel-capable malware still exists, particularly in\r\ntargeted attacks and sophisticated crimeware families.\r\nThe Meta-Rootkit Problem\r\nA kernel-mode malware sample can walk the SSDT, detect entries that have been modified from their expected\r\nvalues, and restore the originals; eliminating our monitoring silently. Without a self-protection mechanism, a\r\nsufficiently sophisticated sample could blind our analysis driver entirely before executing its payload.\r\nThis is the meta-rootkit problem: two kernel-mode components competing for control of the same system\r\nstructures. The attacker’s rootkit unhooks our defensive rootkit. Our defensive rootkit never sees the payload.\r\nContinuous Hook Integrity Monitoring\r\nWe address this with a dedicated protection thread that runs continuously, validating SSDT integrity and restoring\r\nany hooks that have been tampered with:\r\n/*\r\n * System thread routine that periodically checks and repairs SSDT hooks.\r\n *\r\n * Parameters:\r\n * StartContext - Context passed from CreateProtectionThread (always NULL)\r\n *\r\n * Notes:\r\n * - Must run at PASSIVE_LEVEL because:\r\n * * SdtCheckAndPatchSSDTHooks requires PASSIVE_LEVEL (uses SuperCopyMemory)\r\n * * KeDelayExecutionThread requires \u003c= APC_LEVEL\r\n * - Thread runs continuously until g_StopProtectionThread is set\r\n * - Performs SSDT hook validation every 1 second\r\n * - Signals g_ProtectionThreadExitEvent before terminating\r\n * - Thread termination:\r\n * * Normal: When g_StopProtectionThread is set\r\n * * Error: On invalid parameters or IRQL\r\n * - Never returns directly - always terminates via PsTerminateSystemThread\r\n *\r\n * Global Dependencies:\r\n * g_StopProtectionThread - Controls thread execution loop\r\n * g_ProtectionThreadExitEvent - Signaled before thread exit\r\n*/\r\n_IRQL_requires_(PASSIVE_LEVEL)\r\nstatic VOID GrdProtectionThread(IN PVOID StartContext)\r\n{\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 43 of 55\n\nUNREFERENCED_PARAMETER(StartContext); // Always NULL\r\n DBG_INFO(\"[+] Protection thread started\");\r\n // IRQL validation\r\n if (KeGetCurrentIrql() \u003e PASSIVE_LEVEL)\r\n {\r\n DBG_ERROR(\"[!] Protection thread: Invalid IRQL, terminating\");\r\n // Signal exit event even on error\r\n KeSetEvent(\u0026g_ProtectionThreadExitEvent, IO_NO_INCREMENT, FALSE);\r\n PsTerminateSystemThread(STATUS_INVALID_LEVEL);\r\n return;\r\n }\r\n // Initialize stop flag\r\n g_StopProtectionThread = FALSE;\r\n // Set delay interval for periodic checks\r\n LARGE_INTEGER Delay;\r\n Delay.QuadPart = -1 * 1000 * 1000 * 10; /* 1 second */\r\n DBG_INFO(\"[+] Protection thread entering main loop\");\r\n // Main protection loop\r\n while (!g_StopProtectionThread)\r\n {\r\n NTSTATUS NtStatus = SdtCheckAndPatchSSDTHooks();\r\n if (!NT_SUCCESS(NtStatus))\r\n {\r\n DBG_ERROR(\"[!] Unable to SdtCheckAndPatchSSDTHooks! NTSTATUS: 0x%X\", NtStatus);\r\n }\r\n // When KeDelayExecutionThread returns, we are guaranteed to be in PASSIVE_LEVEL\r\n KeDelayExecutionThread(KernelMode, FALSE, \u0026Delay);\r\n }\r\n DBG_INFO(\"[+] Protection thread exiting main loop\");\r\n // Signal that thread is about to exit BEFORE calling PsTerminateSystemThread\r\n KeSetEvent(\u0026g_ProtectionThreadExitEvent, IO_NO_INCREMENT, FALSE);\r\n DBG_INFO(\"[+] Protection thread terminating\");\r\n // This is the normal exit path when g_StopProtectionThread is signaled\r\n PsTerminateSystemThread(STATUS_SUCCESS);\r\n}\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 44 of 55\n\nSdtCheckAndPatchSSDTHooks iterates over the g_SSDT_HooksInfo array populated at install time and compares\r\neach current SSDT entry against the stored NewOffsetToFunction . Any entry that differs is immediately restored:\r\n_IRQL_requires_(PASSIVE_LEVEL)\r\n_Must_inspect_result_\r\nNTSTATUS SdtCheckAndPatchSSDTHooks()\r\n{\r\n if (KeGetCurrentIrql() \u003e PASSIVE_LEVEL)\r\n return STATUS_INVALID_LEVEL;\r\n if (!g_KiServiceTable)\r\n return STATUS_INVALID_PARAMETER;\r\n NTSTATUS ret = STATUS_SUCCESS;\r\n for (ULONG i = 0; i \u003c ARRAYSIZE(g_SSDT_HooksInfo); i++)\r\n {\r\n // Only check entries where hook installation succeeded\r\n if (g_SSDT_HooksInfo[i].OldOffsetToFunction \u0026\u0026 g_SSDT_HooksInfo[i].NewOffsetToFunction)\r\n {\r\n ULONG CurrentValue = SdtGetEntryFromSSDTById(g_KiServiceTable, g_SSDT_HooksInfo[i].SyscallId);\r\n ULONG ExpectedValue = g_SSDT_HooksInfo[i].NewOffsetToFunction;\r\n if (CurrentValue != ExpectedValue)\r\n {\r\n // Tampering detected - log and restore\r\n LgPrintfW(INFO, L\"[ROOTKIT] Detected rootkit patch for SyscallId: %lu and SyscallName: %s\",\r\n g_SSDT_HooksInfo[i].SyscallId,\r\n g_SSDT_HooksInfo[i].SyscallName);\r\n NTSTATUS NtStatus = SysSuperCopyMemory(\r\n \u0026g_KiServiceTable[g_SSDT_HooksInfo[i].SyscallId],\r\n \u0026ExpectedValue,\r\n sizeof(ExpectedValue));\r\n if (!NT_SUCCESS(NtStatus))\r\n {\r\n DBG_ERROR(\"[!] Unable to patch hook for SyscallId: %lu, SyscallName: %s. NTSTATUS: 0x%X\",\r\n g_SSDT_HooksInfo[i].SyscallId,\r\n g_SSDT_HooksInfo[i].SyscallName,\r\n NtStatus);\r\n ret = NtStatus;\r\n // Continue - try to restore as many hooks as possible\r\n }\r\n }\r\n }\r\n }\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 45 of 55\n\nreturn ret;\r\n}\r\nSysSuperCopyMemory is a utility function for writing to write-protected kernel memory. Beyond the CR0\r\nmanipulation, it performs upfront parameter validation (IRQL check, null pointer checks, size validation) before\r\ntouching any protected memory. The core mechanism is Bit 16 of CR0, the hardware enforcement for page-level\r\nwrite protection: when set, the CPU honors read-only page attributes even in Ring 0; when cleared, kernel code\r\ncan write to any mapped page regardless of its protection flags. The WP bit is cleared, RtlCopyMemory writes the\r\nnew value, and the WP bit is restored. This same function is used both in hook installation and restoration.\r\nThe function attempts to restore every tampered entry before returning, maximizing the number of active hooks\r\neven if one restoration fails. Within one second of any tampering, the hook is restored.\r\nThis continuous monitoring also enables detection of malicious kernel activity: if an SSDT entry is found to have\r\nchanged when no legitimate modification was expected, that itself is a signal that a kernel-mode actor has\r\ninterfered with the analysis environment. Such events can be logged for attribution and flagged as indicators of\r\nsophisticated, kernel-aware malware. This is actionable intelligence about the sophistication of the sample being\r\nanalyzed.\r\nIf malware continuously removes our hooks, we continuously restore them. This creates a feedback loop that\r\nmalware cannot escape without either burning measurable CPU (which itself becomes an anomaly) or crashing the\r\nsystem, which defeats its own objectives.\r\nIt is worth being explicit about what this means in practice: a sample that achieves kernel-level control inside the\r\nanalysis VM (a capability that in itself represents a high level of sophistication) will still be detected and logged,\r\nbecause the defensive rootkit detects the tampering regardless of how it was performed. This is not a capability\r\nthat analysis platforms commonly provide. The self-protection mechanism turns kernel-level interference from a\r\nblind spot into a detection signal.\r\nAtomic Thread Counting for Safe Unhooking\r\nThere is a subtle race condition when restoring SSDT entries (e.g. during tampering response). A thread may be\r\nexecuting inside our hook function at the exact moment we overwrite the SSDT entry. We track in-flight hook\r\nexecutions with an atomic counter:\r\n// Atomic counter of threads currently executing hooked functions\r\nstatic volatile LONG g_NumThreadsInsideHook = 0;\r\n/*\r\n * Atomically increments the count of threads executing inside SSDT hooks.\r\n *\r\n * Notes:\r\n * - No IRQL restrictions as InterlockedIncrement is safe at any IRQL\r\n * - Thread-safe through use of InterlockedIncrement\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 46 of 55\n\n* - Must be paired with a subsequent decrement\r\n * - Used when a thread enters a hooked function\r\n * - Updates g_NumThreadsInsideHook with new incremented value\r\n *\r\n * Global Dependencies:\r\n * g_NumThreadsInsideHook - Atomic counter tracking threads in hooks\r\n *\r\n * Warning:\r\n * - Must only be called when actually entering a hook\r\n*/\r\nVOID GrdIncThreadsIntoHooks()\r\n{\r\n InterlockedIncrement(\u0026g_NumThreadsInsideHook);\r\n}\r\n/*\r\n * Atomically decrements the count of threads executing inside SSDT hooks.\r\n *\r\n * Notes:\r\n * - No IRQL restrictions as InterlockedDecrement is safe at any IRQL\r\n * - Thread-safe through use of InterlockedDecrement\r\n * - Must be paired with a previous increment\r\n * - Used when a thread exits a hooked function\r\n * - Updates g_NumThreadsInsideHook with new decremented value\r\n *\r\n * Global Dependencies:\r\n * g_NumThreadsInsideHook - Atomic counter tracking threads in hooks\r\n *\r\n * Warning:\r\n * - Must only be called when actually exiting a hook\r\n*/\r\nVOID GrdDecThreadsIntoHooks()\r\n{\r\n InterlockedDecrement(\u0026g_NumThreadsInsideHook);\r\n}\r\n```\r\nEvery hook function increments this counter on entry (before any other logic) and decrements it on exit (after all\r\nlogic, in every return path). This can be seen in the hook code snippets shared. When unhooking, we wait for the\r\ncounter to reach zero before freeing hook memory:\r\n/*\r\n * Safely unhooks the SSDT and waits for any pending hook operations to complete.\r\n *\r\n * Returns:\r\n * NTSTATUS:\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 47 of 55\n\n* - STATUS_SUCCESS - SSDT successfully unhooked and all operations completed\r\n * - STATUS_INVALID_LEVEL - Called at wrong IRQL\r\n * - Other NTSTATUS values from SdtRestoreSSDT\r\n *\r\n * Notes:\r\n * - Must run at PASSIVE_LEVEL because:\r\n * * SdtRestoreSSDT requires PASSIVE_LEVEL (SuperCopyMemory)\r\n * * Uses KeDelayExecutionThread which requires \u003c= APC_LEVEL\r\n * - Function will block until all threads exit hooked functions\r\n * - Allows one thread (current IOCTL handler) to remain in hooks\r\n * - Implements graceful shutdown by:\r\n * 1. Restoring original SSDT entries\r\n * 2. Waiting for pending operations to complete\r\n * - Called through IOCTL interface\r\n*/\r\n_IRQL_requires_(PASSIVE_LEVEL)\r\n_Must_inspect_result_\r\nNTSTATUS GrdSafeManageUnhookSsdtIoctl()\r\n{\r\n // IRQL validation\r\n if (KeGetCurrentIrql() \u003e PASSIVE_LEVEL)\r\n {\r\n return STATUS_INVALID_LEVEL;\r\n }\r\n // Restore original SSDT entries (Unhook SSDT)\r\n NTSTATUS NtStatus = SdtRestoreSSDT();\r\n if (!NT_SUCCESS(NtStatus))\r\n {\r\n DBG_ERROR(\"[!] Unable to Restore SSDT. NTSTATUS: 0x%X\", NtStatus);\r\n return NtStatus;\r\n }\r\n else\r\n {\r\n DBG_INFO(\"[+] Successfully Restored SSDT\");\r\n }\r\n // Wait for threads to exit hooks\r\n // We allow one thread (current) to remain as it's the IOCTL handler\r\n // (We accept one thread because this thread has came from NtDeviceIoControlFile SSDT hook)\r\n while (GrdNumThreadsIntoHooks() \u003e 1)\r\n {\r\n LARGE_INTEGER Delay;\r\n Delay.QuadPart = -1 * 1000 * 1000 * 10; /* 1 second */\r\n KeDelayExecutionThread(KernelMode, FALSE, \u0026Delay);\r\n }\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 48 of 55\n\nreturn STATUS_SUCCESS;\r\n}\r\nInterlockedIncrement and InterlockedDecrement are safe at any IRQL, making this counter correct regardless of\r\nthe IRQL at which a hook fires. The “tolerate 1” comment is important: the IOCTL handler that initiates\r\nunhooking may itself be executing inside a hook function, so the counter will be at least 1 during the wait. The\r\nentire safe-unhooking mechanism (including the thread counter, the SSDT restoration, and the drain wait), is\r\nexposed through the IOCTL interface, making SSDT hooks protection a dynamically controllable capability.\r\nReal-World Validation: Families That Confirm the Architectural Choice\r\nThe techniques described in this post did not emerge from theoretical analysis. They emerged from encountering\r\nreal samples that defeated conventional analysis platforms and understanding exactly how they did it. This section\r\ndescribes representative malware families well-documented in public research, whose evasion techniques map\r\ndirectly to the architectural decisions we made, and explains how each driver capability addresses each family’s\r\nanti-analysis approach.\r\nGuLoader: The RDTSC Problem\r\nGuLoader is a highly obfuscated malware family first seen in December 2019. Its anti-analysis approach is\r\nrepresentative of the state of the art: a multi-layered gauntlet that checks several independent sources before\r\nproceeding. We will now go through each of these checks and how our architecture addresses them.\r\nThe RDTSC timing check is GuLoader’s most architecturally significant evasion. The loader executes a tight\r\nloop of instructions bracketed by two RDTSC reads, computes the delta, and compares it against a threshold. On\r\nphysical hardware, this delta is small and consistent. In a virtualized environment the delta is measurably larger,\r\nbecause instructions inside that loop that cause VM exits (such as CPUID , which unconditionally triggers a VM\r\nexit in VMX non-root mode regardless of any hypervisor configuration) introduce round-trip latency through the\r\nhypervisor on every execution. GuLoader uses this discrepancy as a reliable VM indicator.\r\nA traditional kernel-level approach to bypass RDTSC involves setting the CR4.TSD bit so that any user-mode\r\nexecution of the instruction triggers a general protection fault (#GP). By hooking the corresponding IDT entry\r\n(interrupt 0x0D), the kernel can intercept the fault, verify that the instruction is indeed RDTSC, and emulate it by\r\nreturning crafted timestamp values while skipping the original instruction. However, this method introduces\r\nnoticeable side effects (like abnormal exception patterns) that can be detected by security mechanisms. In contrast,\r\nhandling this at the hypervisor level avoids tampering with the guest OS entirely and provides stronger isolation,\r\nsince the control logic operates outside the operating system’s visibility, making it both more robust against OS-level defenses and harder for software running inside the system to detect or interfere with.\r\nThis is precisely the capability that motivates the hypervisor layer in our architecture. One subtlety worth noting:\r\nunlike CPUID , RDTSC does not cause a VM exit by default. A hypervisor must explicitly enable the RDTSC\r\nexiting control bit (bit 12 of the Primary Processor-Based VM-Execution Controls) to intercept it; otherwise\r\nRDTSC executes at native hardware speed without any hypervisor involvement. An alternative is the TSC offset\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 49 of 55\n\nfeature, which lets the hypervisor add a constant to the TSC value returned to the guest without any VM exit at all,\r\ntransparent to the guest and with zero per-instruction overhead. The check passes. GuLoader proceeds.\r\nCPUID vendor fingerprinting is GuLoader’s second major check. It executes CPUID with leaf 0 and inspects\r\nthe returned vendor string against a list of known virtualization platform identifiers. The hypervisor present bit in\r\nCPUID leaf 1 ECX (bit 31) is also checked. Our hypervisor synthesizes a physical CPU vendor string and clears\r\nthe hypervisor bit, making the environment appear as physical hardware to any code that interrogates CPUID .\r\nProcess and registry artifact checks complete GuLoader’s gauntlet. It queries process names and registry keys\r\nfor known sandbox indicators. Our NtQuerySystemInformation hook filters the process list; our registry callback\r\nhides VM-revealing keys. The complete GuLoader evasion sequence encounters a different kind of obstacle at\r\nevery check: hardware-level responses for hardware-level checks, kernel-level responses for OS-level checks;\r\nwith consistent results across all sources. In conventional sandbox testing, GuLoader historically achieves near-zero detonation rates against platforms lacking hypervisor-level capabilities. In our mixed-layer environment,\r\nGuLoader’s full payload chain detonates reliably.\r\nLummaC2: Direct Syscalls, Heaven’s Gate, and Related Techniques\r\nLummaC2 (also known as Lumma Stealer) is a modern and sophisticated information stealing malware. Its anti-monitoring approach targets the most common sandbox instrumentation technique: user-mode API hooking.\r\nLummaC2 resolves critical APIs dynamically and invokes them via direct SYSCALL instructions rather than\r\nthrough ntdll.dll stubs, explicitly bypassing the function prologues where user-mode hooks (inline detours\r\nplaced by AVs, EDRs, and sandbox agents on Nt* / Zw* exports) are inserted. Some variants also employ\r\nHeaven’s Gate: a CPU mode-switching mechanism that lets a 32-bit process issue a far jump to segment 0x33 ,\r\ntransitioning to 64-bit execution. Once in 64-bit mode, the malware can either call 64-bit ntdll.dll functions\r\ndirectly (bypassing the 32-bit WoW64 instrumentation layer entirely) or skip ntdll.dll altogether and issue a\r\nraw SYSCALL , bypassing both layers at once.\r\nThe same goal of avoiding hooked ntdll.dll stubs has produced a family of widely used techniques: Hell’s\r\nGate and its extensions (Halo’s Gate, Tartarus Gate), SysWhispers/SysWhispers2/SysWhispers3, etc. The\r\nunderlying logic is consistent across all variants: avoid ntdll.dll function prologues entirely, because that is\r\nwhere every layer of user-mode instrumentation is commonly placed.\r\nWhy neither technique affects our architecture: SSDT hooks operate at Ring 0, intercepting the syscall at the\r\nkernel transition point: below the entire user-mode layer. LummaC2’s direct SYSCALL instructions still transition\r\nto Ring 0 and still hit our SSDT hooks, because the intercept is not in ntdll.dll and cannot be bypassed by\r\navoiding ntdll.dll . Heaven’s Gate transitions are no different: regardless of which execution mode issues the\r\nSYSCALL , all user-mode syscalls enter the same kernel dispatch path that our hooks intercept. LummaC2’s anti-monitoring logic passes harmlessly. Our hooks remain active and invisible.\r\nThis is a category-level advantage over user-mode monitoring. Not a better implementation of the same approach,\r\nbut a fundamentally different intercept location that user-mode hook scanning cannot reach by design.\r\nxLoader and SmokeLoader: Private ntdll Copies as Hook Bypass\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 50 of 55\n\nxLoader (versions 6 and 7) loads a private copy of ntdll.dll into its own memory space and resolves API\r\nfunctions through that copy instead of the system-loaded instance. If a monitoring tool has placed breakpoints or\r\ninline hooks on the exported functions of the original ntdll.dll , xLoader’s private copy remains unmodified.\r\nIts API calls route through the clean copy, bypassing user-mode instrumentation entirely.\r\nSmokeLoader takes the same approach with variant-specific mechanics. Int latest variants it creates a copy of\r\nntdll.dll in memory using CreateFileMappingW and MapViewOfFile . In its early stages, the malware\r\ndropped a copy of the library to disk in the %TEMP% folder and loaded it from there via LdrLoadDll . In all cases,\r\nthe private copy is unhooked, allowing SmokeLoader to execute its API calls without triggering any\r\ninstrumentation placed on the system ntdll.dll .\r\nWhy loading a private ntdll copy fails against kernel-level monitoring: ntdll.dll is a user-mode wrapper.\r\nIts exported functions ultimately issue SYSCALL instructions that transition to Ring 0. Regardless of which copy\r\nof ntdll.dll a process loads (the system copy, a private memory-mapped copy, or one extracted from disk),\r\nevery call requiring OS services must eventually execute a SYSCALL instruction. That transition is the point\r\nwhere our SSDT hooks intercept. There is no user-space path that bypasses this transition; it is architecturally\r\nmandated. The private ntdll technique defeats user-mode instrumentation precisely because user-mode\r\ninstrumentation sits above the syscall boundary. Our monitoring sits below it.\r\nMulti-Stage Loaders: The Persistence-Based Staging Gap\r\nAn important blind spot in some sandbox analysis platforms is not any individual evasion technique, it is the\r\nstructural failure to observe final-stage payloads that only deploy after persistence is confirmed.\r\nConsider the following execution chain, representative of how contemporary loaders operate:\r\n1. Stage 1 (dropper) arrives via phishing. It performs environment checks and, finding the environment\r\nacceptable, drops a stage 2 binary and writes a registry Run key.\r\n2. Stage 1 exits. The sandbox records: file written, registry key written, process exited. Analysis complete.\r\n3. Stage 2 (the actual payload) never executes, because nothing triggers the Run key.\r\nAgainst a conventional sandbox with a fixed analysis window and no mechanism to trigger persistence, this loader\r\nproduces no observable malicious payload behavior. Detection rate for the final payload in conventional\r\nautomated sandboxes: near zero.\r\nAgainst our platform: when Stage 1 writes the Run key, our registry callback’s RfPreSetValueKey handler\r\ncaptures the registered binary path and triggers execution of Stage 2 (simulating the reboot or login event that\r\nwould normally trigger it). The full behavioral record of Stage 2, including any further stages it deploys, is\r\ncaptured within the same analysis session.\r\nBy detecting persistence establishment in real time and immediately triggering next-stage execution, the platform\r\ncollapses what would otherwise require a multi-stage, multi-session manual analysis workflow into a single\r\nautomated session. Importantly, the platform also dynamically extends the analysis time budget when persistence\r\nevents are detected (instead of running for a fixed window and stopping before next-stage payloads complete their\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 51 of 55\n\nbehavior), the session continues until all triggered stages have run to completion. This ensures that even heavily\r\nstaged loaders produce a complete behavioral record, from the initial dropper through the final payload.\r\nWhy Architecture, Not Signatures\r\nAs we’ve seen throughout this section, the common thread is that these evasion techniques are not advanced\r\nnation-state tradecraft (they are documented, commoditized capabilities in widely used crimeware kits).\r\nGuLoader’s RDTSC trick has been described in public research since at least 2020. SmokeLoader and xLoader\r\nanti-hook mechanisms are widely documented (xLoader technical analysis, SmokeLoader history).\r\nThe sandbox that defeats these techniques is not one with better signatures for each specific check. It is one whose\r\narchitecture operates at the correct layer to make the checks irrelevant. RDTSC timing is irrelevant when the\r\nhypervisor controls the result. User-mode hook scanning is irrelevant when there are no user-mode hooks.\r\nRegistry VM artifact checks are irrelevant when a Ring 0 minifilter intercepts them first. Persistence-based staging\r\nis irrelevant when the platform triggers the persistence mechanism itself.\r\nThis is the architectural argument: not that it catches specific malware families, but that it eliminates entire\r\ncategories of evasion technique by operating at the layer where those techniques have no effect.\r\nConclusion: The Future of Malware Analysis. What We Built and Why It Matters\r\nThe driver described here addresses a problem that has become increasingly urgent: sophisticated malware\r\nroutinely defeats conventional analysis environments, delivering nothing observable in sandboxes while operating\r\nfully against real targets. The result is a systematic blind spot at the center of many organizations’ threat\r\nintelligence pipelines.\r\nOur kernel-level analysis driver closes that blind spot. It provides:\r\nSyscall interception at Ring 0, below any user-mode detection mechanism, with full access to caller\r\ncontext and result data.\r\nActive detonation assistance: Returning curated responses to every environment probe, ensuring malware\r\nconcludes the environment is real and executes its payload.\r\nMulti-layer anti-evasion addressing time manipulation, hardware fingerprinting, registry and filesystem\r\nartifact discovery, and process enumeration in cooperation with hypervisor-level protections that cover\r\nwhat the kernel cannot reach.\r\nPersistence detection with forced execution: Capturing the complete infection chain, including final-stage payloads that only deploy after persistence is confirmed.\r\nForensic preservation of evidence that malware routinely destroys: files deleted before analysts can\r\nexamine them, memory state at process termination, dropped payload chains.\r\nAutomatic injection chain tracking following the infection through every spawned child process and\r\ninjection target without manual configuration.\r\nSelf-protection against tampering by kernel-level malware samples, with detection capability for kernel-aware adversaries.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 52 of 55\n\nNone of the individual techniques here are novel in isolation and we want to be precise about that. SSDT hooking\r\nis documented in depth in public literature. Code cave discovery is a well-known technique in rootkit\r\ndevelopment. Minifilters are a standard Windows driver pattern covered in the WDK documentation and countless\r\ndriver development resources. What is novel is not the techniques, it is what the integration of these techniques\r\nachieves that no subset of them can achieve independently.\r\nComplete infection chain capture in a single session: The combination of real-time persistence detection with\r\nforced execution of registered payloads inside a continuously monitored analysis session is what enables full\r\ninfection chain visibility (from the initial dropper through every subsequent stage), within a single analysis run.\r\nThis is not simply a matter of better evasion resistance at any single layer; it comes from the kernel-level\r\npersistence monitor and the payload triggering mechanism being co-designed with full awareness of each other.\r\nCross-layer consistency under adversarial cross-checking: Sophisticated samples do not rely on a single\r\nenvironment check, they cross-check multiple independent sources. A representative example: a sample that\r\nqueries total physical memory via NtQuerySystemInformation with SystemBasicInformation , then reads\r\nKUSER_SHARED_DATA.NumberOfPhysicalPages directly (a technique requiring no syscall, and therefore invisible to\r\nSSDT-only solutions), and finds any inconsistency between them, aborts. Our architecture manages both sources\r\nsimultaneously: the NtQuerySystemInformation SSDT hook adjusts NumberOfPhysicalPages in the returned\r\nstructure, while KUSER_SHARED_DATA.NumberOfPhysicalPages is patched directly from kernel mode at\r\ninitialization (using the same constant, ensuring the values are identical). The coherence across these sources is\r\nnot incidental, it’s explicitly enforced. That kind of cross-layer consistency management is not present in any\r\nsingle-layer solution.\r\nZero semantic gap with complete interception: A hypervisor below the OS must infer which process made\r\nwhich syscall, what the arguments meant in OS terms, and whether a particular memory write corresponds to a\r\npersistence event. Our kernel driver has all of this natively, without translation, at the point of every syscall. That\r\nsemantic immediacy is what makes behavioral monitoring precise enough to be useful at production scale, and\r\nundetectable enough to not be a detection signal itself.\r\nIn short, the platform described here is distinguished not by any single capability but by the combination: Ring 0\r\nsyscall interception that cannot be bypassed by user-mode techniques, persistence detection and forced execution\r\nthat closes the staging gap, forensic preservation that survives malware cleanup routines, injection tracking that\r\nfollows the infection across process boundaries, and cross-layer consistency enforcement that defeats multi-source\r\nenvironment fingerprinting. Each of these properties depends on operating at the kernel level; none of them is\r\nachievable from user-mode or hypervisor-only approaches alone.\r\nThe Mixed-Layer Advantage\r\nAs described in Section 3, the combination of hypervisor-level and kernel-level monitoring is what closes the full\r\ncoverage gap. The mixed-layer architecture delivers capabilities that neither layer could provide on its own.\r\nHardware-level probes are answered at the hypervisor before they reach the OS. OS-level syscalls are intercepted\r\nat Ring 0 with full semantic context. Persistence mechanisms are detected and triggered in real time. Injected code\r\nis tracked across process boundaries. Forensic evidence is preserved before malware can destroy it. Taken\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 53 of 55\n\ntogether, these cover the full spectrum of what evasive malware does, from the first environment check to the final\r\npayload, within a single analysis session.\r\nA sample performing both hardware-level and OS-level environment checks encounters a consistent picture of a\r\nphysical workstation at every layer it probes. A sample attempting to evade monitoring through persistence-based\r\nstaging encounters forced execution of its next stage. A sample attempting to destroy evidence encounters a driver\r\nthat has already preserved it.\r\nThis is not two systems working in parallel. It is a single, integrated analysis platform with each layer doing what\r\nit does best.\r\nWhere the Industry Is Heading\r\nThe trajectory is clear, and the data supports it. MITRE ATT\u0026CK’s Virtualization/Sandbox Evasion category\r\n(T1497) has been among the top-ten most observed techniques in real-world incident reporting for multiple\r\nconsecutive years. What used to be advanced is now easily accessible in a short time: techniques that required\r\nresources in 2018 ship in commercial crimeware kits today, available to any threat actor willing to pay a\r\nsubscription fee.\r\nFor CISOs and security program owners, the practical implication is straightforward. If your threat intelligence\r\npipeline depends on automated sandbox analysis (as most do, given the volume of samples requiring triage) and if\r\nthat sandbox cannot handle evasive samples, then your intelligence is systematically incomplete for exactly the\r\nthreats that are most likely to be used against you. A loader that bypasses your sandbox does not appear in your\r\nbehavioral detection baseline, does not contribute to your IOC feeds, and does not generate the telemetry that\r\nwould train your ML-based detection models. The evasion is not just a single missed detection, it is a persistent\r\nblind spot in your defensive posture.\r\nEvasion techniques that were sophisticated APT tradecraft five years ago are commodity crimeware today. Multi-stage loaders, behavioral checks, hardware fingerprinting, persistence-based staging; these ship in off-the-shelf\r\ncrimeware kits sold on underground markets. Analysis platforms that cannot counter them are operating with\r\nprogressively declining visibility into real-world threats.\r\nKernel-level analysis has already become a baseline expectation for enterprise malware analysis platforms,\r\nfollowing the same pattern by which behavioral analysis replaced signature detection a decade ago. Enterprise-grade sandboxes and analysis platforms increasingly rely on kernel ETW providers, kernel callbacks, and\r\nminifilter-based monitoring precisely because user-mode instrumentation is insufficient against modern threats.\r\nThe same architectural reasoning that made kernel-level EDR more effective than user-mode EDR has driven the\r\nsame transition in malware analysis platforms. The question today is not whether to operate at the kernel level, but\r\nhow deep and how precisely that kernel-level instrumentation is integrated and whether it is paired with the\r\npersistence detection, forensic capture, and anti-evasion capabilities that turn raw syscall visibility into actionable\r\nbehavioral intelligence.\r\nIf evasive malware does not detonate in your analysis environment, and if you cannot see what happens after it\r\nestablishes persistence, you are making security decisions based on incomplete information. At Zynap Labs, we\r\nbuilt the infrastructure to change that.\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 54 of 55\n\nSource: https://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nhttps://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/\r\nPage 55 of 55",
	"extraction_quality": 1,
	"language": "EN",
	"sources": [
		"Malpedia"
	],
	"origins": [
		"web"
	],
	"references": [
		"https://www.zynap.com/blog/defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0/"
	],
	"report_names": [
		"defensive-rootkits-engineering-kernel-level-malware-analysis-ring-0"
	],
	"threat_actors": [],
	"ts_created_at": 1778033007,
	"ts_updated_at": 1778033032,
	"ts_creation_date": 0,
	"ts_modification_date": 0,
	"files": {
		"pdf": "https://archive.orkl.eu/5c7a933562a50cc1e03506463d3143e4dc12ff9c.pdf",
		"text": "https://archive.orkl.eu/5c7a933562a50cc1e03506463d3143e4dc12ff9c.txt",
		"img": "https://archive.orkl.eu/5c7a933562a50cc1e03506463d3143e4dc12ff9c.jpg"
	}
}