A Kernel Driver With Its Own Programming Language
Taking apart an unsigned Windows rootkit that ships no targets, only an interpreter
Most malware tells you what it wants. You find the C2 address, the file extensions it encrypts, the process names it kills, and the intent falls out of the strings.
This one has none of that. 654 KB of unsigned x64 kernel driver, and nowhere in it is there a single target. No process to kill, no path to hide, no address to call home. What it ships instead is an interpreter, and it waits for someone in user mode to tell it what to be.
That property is the whole story, so let me put the conclusion up front: the same binary is either an endpoint security product or a rootkit, and which one it is depends entirely on a block of text somebody feeds it after it loads. Nothing in the driver decides. The driver just executes.
Where it came from
It surfaced during a bulk triage run over a batch of samples, flagged as the most interesting thing in the set. Unsigned x64 kernel driver, minifilter registration, WFP and NDIS and TDI network hooks, 35 AES-looking constants, a high-entropy data section. That combination doesn’t occur by accident.
It arrived carrying the family tag helldown, a ransomware intrusion set tracked publicly since around 2024. We threw that tag away. A search across the 814,000-row corpus we keep for exactly this purpose found one imphash match, which was the file itself, and zero neighbours within a TLSH distance of 100. The nine other helldown-tagged native drivers in the corpus share no import table with it at all; the biggest cluster of them imports only ntoskrnl and has no network functionality whatsoever. No public hash hit either, then or since.
So the tag was provenance metadata, not analysis — the same failure I wrote about in the attribution pipeline post, arriving from the other direction. We gave it the working name edrkit because that is functionally what it is, and moved on. No actor attribution is supported by anything we found, and I’m not going to invent one.
The driver’s own idea of its name is more interesting. Its callouts and sublayers identify as “KAD”, and one of them is called Comodo EDR Tcp Listen Callout. Its version resource says “Test Driver”, version 1.0.0.382, with a build tag reading 2026.3.24 18:11. Whoever built this started from commercial endpoint-security code, or from something that shared ancestry with it.
Two languages
The driver accepts text over a single private IOCTL, 0x90000404. Feed it text and it parses. What took a while to work out is that there are two different grammars in there, handled by two different parsers, for two different purposes.
The first, which we’ve been calling Language B, defines persistent rules. It looks like this:
#path(C:\Windows\System32\notepad.exe)
{
phl:path(secret.txt),action(deny);
}
A header block scopes a filter — by image path, command line, PID, file size, PE timestamp, Authenticode signer, file version, or the bare keyword global for system-wide. The body lines inside the braces are the rules that apply within that scope. Wildcards work. || means OR. Comments start with //.
The second grammar, Language A, is a command executor, and it’s the one that matters:
cmd,args&&cmd,args&&cmd,args
That runs inside a rule’s action(...) field. Chained commands, && separated. There are 38 named verbs in a table at 0x140088960, and here is the part where the driver stops looking like a security product:
| Verb | What it does |
|---|---|
file_hide |
hide a file from enumeration |
pro_hide |
hide a process |
drv_fake |
fake a driver |
thd_fake |
fake a thread |
hijack |
scheduled traffic or execution hijack |
crash |
force a bugcheck |
run_script, run_file |
execute, via DLL injection shellcode |
pro_kill, kprocess, sprocess |
kill and suspend processes |
file_delete, file_rename, file_readonly |
file manipulation |
shutdown, restart |
end the session |
Plus the ones you’d expect from an EDR: allow, deny, alert, report.
Both halves of that list live in the same dispatch switch. allow and pro_hide are neighbours in the same table, handled by the same executor, reached by the same parser. There’s no mode flag, no separate malicious build. Hiding a process and permitting one are the same kind of operation to this driver, and that is a design decision somebody made deliberately.
The hijack verb is my favourite piece of engineering in the binary. It takes a time key defaulting to "0:00-23:59", a percent key defaulting to 100, and a required path. It then computes the milliseconds until a random second inside that time window, gates the whole thing behind rand() % 101 <= percent, and enqueues the action. You can tell it to do something to 30% of machines at a random moment between 2 and 4 in the morning. That’s not an incident-response feature. That’s campaign tooling.
The envelope, and a very expensive silence
Rules don’t go in as bare text. The IOCTL’s system buffer has to be exactly a 0x38-byte sealed envelope: 0x28 bytes of DES-encrypted payload, followed by a 16-byte MD5 over those bytes.
Two details there are worth the price of admission. The DES key is the string Fwpkclnt.lib, which is the filename of the Windows Filtering Platform client library the driver links against. It’s sitting in the import directory in plain sight. And the MD5 isn’t MD5 — the a0 initialisation constant is 0x67452311, where the standard says 0x67452301. One digit. Enough that no stock implementation produces a matching digest, cheap enough to implement that it cost the author nothing.
I have a soft spot for that trick, having just spent a week on the other side of an identical one in a Turla sample. The difference is that Kazuar’s wrong digit was our own transcription error. This one is deliberate.
Then the part that actually hurt. If the envelope doesn’t validate, the driver drops the request — and returns success. DeviceIoControl reports that everything worked. Nothing happened. We burned multiple sessions on that, sending rules with an older 0x20-byte plaintext client, watching every call succeed, and wondering why the driver appeared to be ignoring a correctly-formed grammar. It was ignoring us. It just wasn’t willing to say so.
If you take one operational lesson from this writeup: when a kernel driver returns success and does nothing, stop trusting the return code and go read the validator.
Going live
Static analysis got the grammar roughly right and two important things wrong, which is a good argument for not stopping at static analysis.
We set up kernel debugging over KDNET, put breakpoints on the parser entries, and pushed 65 probe rules through the running driver to see which ones took. Two corrections came out of that session immediately.
Rule text is narrow UTF-8 bytes, not UTF-16 as the static reading suggested. The driver widens it internally and stops at the first NUL, so UTF-16 input truncates after one character and a byte-order mark breaks header classification outright. And the sealed envelope turned out to be mandatory rather than optional, which is what finally explained the silent drops.
Live testing also embarrassed a few of our indicators. We had written down CtrlSM7yrqjeGtKB as the control device name, straight out of the binary where it very much appears to be hardcoded. It isn’t. The device follows whatever the service is called, so once it was installed as edrkit_pro the device came up as \\.\edrkit_pro and our lovingly extracted string was worthless. Same story with the minifilter altitude, static 270030 in the binary, 320077 on the running system, configurable per install.
The third one is my favourite. DriverEntry holds a hard list of supported Windows build numbers and aborts the load with a cheerful Kernel Version %d Not Support! for anything else. Build 19045 is not on that list. Build 19045 loads and runs without complaint.
So: three indicators, all extracted carefully from the disassembly, all wrong. Any one of them could have gone into a detection rule if nobody had bothered to boot the thing.
One more, for anyone testing this in a lab: the driver has no stop handler, so sc stop returns 1052, and fltmc unload bugchecks the box with a 0x7E. It calls FltSendMessage on a communication port it has already torn down. Configuration changes need a reboot. We found this the way you’d expect.
What we still don’t know
The run family is unresolved, and it’s the most interesting gap.
Those verbs have no process-creation imports behind them. Instead the handler allocates a 0x140-byte message with the pool tag AAAA, pushes it onto a global queue, and a worker thread moves it from pending to scheduled about every 100 ms, where a collector hands it to one of roughly fourteen per-type processors. Somewhere past that, a user-mode agent is supposed to pick it up over a minifilter communication port and do the actual work.
The port is real and we decoded its wire format. A receiver we wrote picked up image-load telemetry for sshd.exe, conhost.exe, and cmd.exe on the detonation box, so the channel works and something is talking on it.
Then we wrote a rule telling the driver to run cmd.exe /c echo into a marker file. It parsed. It dispatched. We watched it go through the action handler with the right command ID on the stack. The marker file never appeared, and it still hasn’t.
Which is a strange place to end up, because the enforcement half of this thing works fine. Process hiding hides processes. File rules and kills do what the grammar says they do. It’s specifically the execution verbs that go into the queue and, as far as we can prove, never come out the other side.
Either there’s a delivery path we haven’t found yet, or this driver is one half of a pair and we’re holding the kernel half. My money is on the second, and if that’s right then somewhere there’s a user-mode agent that would make all of this make sense at once.
Until it turns up, what we have is a very capable instrument sitting in a lab, fully understood and waiting for instructions from someone who never calls.