To access material, start machines and answer questions login.
The case was closed properly last night. The malicious file was deleted, the account was reset, and the checklist came back clean across services, scheduled tasks, Run keys, subscriptions and startup folders. By morning the same workstation is calling the same blacklisted domain, with nothing downloaded, no remote session open and nobody at the keyboard, so something on that host is starting itself and the checklist never had a chance of finding it.
Windows documents dozens of locations where a process is told to load a third-party at boot, at logon or when a service starts, and an adversary who can write to one of them exploits nothing at all: they register, and Windows does the remainder on the next trigger, inside spoolsv.exe (opens in new tab) as Gelsemium did when it swapped a print processor DLL the spooler already expected, inside lsass.exe, or inside explorer.exe (opens in new tab) as UAT-5647 did with a hijacked COM class. That is what a registered extension point buys them: time (opens in new tab), and a process tree in which nothing ever looks out of place.
In this room we reopen that case from the files the responders left behind, with no live host and no EDR console to fall back on. We build a baseline of what the machine looked like before the intrusion, diff it four different ways to pull four mechanisms out of one workstation, prove which of them actually ran and which one Windows quietly refused to load, work out how to remove each without breaking the host, and finish on a second machine that you will need to triage.
| Evidence | What it holds |
|---|---|
C:\Case\baseline\ |
The same build before the incident, collected the same way |
C:\Case\DEV-WS\triage\ |
Machine and user hives from the workstation that reopened the case |
C:\Case\DEV-WS\evtx\ |
Four exported event channels |
C:\Case\DEV-WS\files\ |
The handful of files the responder captured |
C:\Case\DEV-WS\mft\$MFT |
The filesystem record, for dating files independently of any log |
C:\Case\MKT-WS\ |
A second workstation, with its own baseline |
C:\Case\MKT-WS-baseline\ |
The same build of that second workstation before the incident |
Learning Objectives
By the end of this room, you will be able to:
- Name the operating-system process behind each registered DLL extension point, and classify a finding by its trigger and its privilege
- Read Sysmon image-load and registry events, and separate a legitimate load from a planted one
- Sweep DLL autostart locations offline with
autorunsc -z, diff two sweeps against each other, and explain what that sweep cannot reach - Confirm a persistence write with 13, and use the LSA package load events Security 4610/4614/4622 to prove a load did not happen
- Prove a DLL load and date it with Sysmon 7,
$MFTand keyLastWriteTime - Reason about absent evidence, using System 7045 and CodeIntegrity 3033, then eradicate a mechanism and verify it by re-diffing against the baseline after a reboot
Prerequisites
- Windows event logs:
Get-WinEventand XPath filtering (Windows Event Logs room) - Registry and disk artifacts: Hives, MFTECmd and Timeline Explorer (Compromised Windows Analysis room)
- Sysmon: Event IDs 1, 13 and 22 (Sysmon room)
RECmd, sigcheck and autorunsc are introduced as they are needed, so previous exposure to them helps but is not assumed.
Machine Access
Two machines are attached to this room. The IR-Storage machine holds the triage the response team collected, and the DefenseBox is your analysis workstation, with the DFIR toolkit already installed.
Set up your virtual environment
DefenseBox Credentials
Use these credentials if you are connecting using RDP
IR-Storage Credentials
Use these credentials if you are connecting via RDP or when using the net use command
Getting the Evidence
Start the machine above and log in. The collection does not sit on the DefenseBox itself. It sits on the storage network share, which is where a response team keeps a case once it has been acquired, and the files are in the Artifacts directory on that share. Copy them across to the DefenseBox before starting Task 2. Once they are there you should have four directories: baseline, DEV-WS, MKT-WS and MKT-WS-baseline.
Note: Put them wherever you like on the DefenseBox, but every command in this room is written against C:\Case. Anywhere else and you will need to adjust each path yourself, and several commands in Task 3 take two paths at once, a baseline and an incident copy, so both halves have to change together.
The introduction described the room's techniques in one sentence: an adversary writes a registry value, and Windows loads their code inside a process nobody suspects. Every technique here ends the same way, with a mapped into a process Windows started on purpose, so before we can hunt any of them we need a way to watch that happen.
That is harder than it sounds, because a DLL is not a process. It is a module mapped into an address space with threads and a token that already exist, so Task Manager shows us explorer.exe and never what explorer.exe actually loaded. A malicious module never appears in a process tree at all.
Working From Exported Logs
Everything in this room is an exported channel rather than a live host, so every query names a file:

Five records come back as flat name and value pairs, and all five describe powershell.exe loading its own dependencies out of the .NET native image cache. That is what ordinary looks like, and it is worth reading once before we go looking for the unusual.
Reading the Sysmon Logs
Event ID 7 is the record of a module being mapped into a process, and these are the fields we work with:

| Key Field | What it holds |
|---|---|
Image |
Path of the process that did the loading |
ImageLoaded |
Path of the module that was loaded |
Signed |
Whether the loaded module carries a signature |
SignatureStatus |
Whether the signature could be checked, and what came back |
Look at Signed and SignatureStatus in that output before going any further. Every one of those five loads is a genuine Microsoft binary, and every one of them reports Signed: false with SignatureStatus: Unavailable. Nothing is wrong with the files. The exported log was written on a host that could not reach a catalog store, so the signature column describes the telemetry itself rather than the module. Unsigned, on its own, is not a finding in this collection. It becomes one only when it is paired with something else, and Task 5 is where that pairing gets made.
Two further properties of the record decide how far we can trust it:
-
Event ID 7 is high volume, so most production configurations filter it down to a few process names or to unsigned modules only.
-
It records only loads that succeeded. If a configuration names a DLL that was never placed on disk, or one that could not be loaded correctly, the load fails and no Event ID 7 record is written at all, so an absent load event does not mean an absent configuration. Task 6 is built on exactly that distinction.
Warning: UtcTime inside the event body and the record's own TimeCreated are two different clocks, and on this collection they differ by several seconds for the same load. Decide which one a finding is quoted against and stay with it. Every Sysmon time in this room is read from UtcTime. The System, Security and CodeIntegrity channels carry no such field, so times from those are the record's own.
Event ID 7 tells us what got loaded and says nothing about why, and for every technique in this room the why is a registry value, so Sysmon's registry coverage is the other half of the telemetry. Event ID 12 records a key being created or deleted, and Event ID 13 records a value being set. A 13 naming an extension point followed by a 7 naming the file that value pointed at is the pair we go looking for, and we find that complete pair twice in this room. The other two mechanisms each break one half of it, which is what Tasks 5 and 6 are about.

Windows DLL Extension Points
A registered extension point is a registry value that Windows reads at a particular moment and treats as the following instruction: "load the DLL named at this location into this process". Windows does the loading. The registry value only does the naming, and none of it is abnormal on its own. This is legitimate, built-in Windows functionality, which is exactly what makes it useful to an adversary.
Print drivers, credential providers, shell integrations and time sources are all third-party by design, so Windows publishes locations where a vendor says "load my DLL into your process". A print processor names a DLL under Print Processors\<name>\Driver and the spooler loads it. A shared service names its module in Services\<svc>\Parameters\ServiceDll and svchost.exe loads it when the service starts. A COM class points InprocServer32 at a file and any process instantiating that class loads it in-process. Whoever can write to one of those values gets their code run by whichever process reads it.
We organize this map by host process rather than by technique number, because that is the direction an investigation actually runs. We do not open a case knowing we are looking at a print processor abuse. We open it knowing spoolsv.exe reached an address it had no business reaching, and the useful question from there is what that particular process can be told to load, and by whom.
| Host process | Registered at | Trigger | Write privilege |
|---|---|---|---|
explorer.exe |
Software\Classes\CLSID\{GUID}\InprocServer32, and Explorer's shell extension keys |
Every logon | None for the per-user hive |
explorer.exe |
SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\ShellIconOverlayIdentifiers |
Every logon | Admin |
spoolsv.exe |
Control\Print\Environments\Windows x64\Print Processors\<name>\Driver |
Spooler start, so every boot | Admin |
spoolsv.exe |
Control\Print\Monitors\<name>\Driver |
Spooler start | Admin |
svchost.exe |
Services\<svc>\Parameters\ServiceDll |
Service start | Admin |
lsass.exe |
Control\Lsa\Security Packages, Authentication Packages, Notification Packages |
Boot, plus every password change | Admin |
netsh.exe |
SOFTWARE\Microsoft\Netsh |
Whenever anybody runs netsh, so on demand |
Admin |
| any process | SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs |
Every user-mode process load, if enabled | Admin |
winlogon.exe |
SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Notify |
Logon events, on legacy builds only | Admin |
The last two rows on that map deserve a note, because both of them look like loads and neither one is:
-
AppInit_DLLsis inert on any modern build for two separate reasons.LoadAppInit_DLLsdefaults to0andRequireSignedAppInit_DLLsdefaults to1, and Secure Boot (opens in new tab) disables the mechanism outright. A populated value on such a host never executed. It is still worth having, because it dates the adversary's activity and names a file worth hashing, but it is not a load. -
Winlogon\Notifypackages were removed in Vista, so on Windows 10 or 11 that location is dead. A value there means something old touched the host, and nothing more.
The privilege column is worth a mention. Every row on that map needs administrative rights except the per-user COM registration, and that single exception is why the first mechanism we work end to end is the one that needed no privilege at all.
A host beacons twenty seconds after boot with no user logged on. Which registry hive can be ruled out as the source of that persistence? (Answer Format: four letters)
Which UEFI feature, when enabled, makes the AppInit_DLLs mechanism inert?
A configuration names a DLL that was never written to disk. How many Event ID 7 records does that produce?
Task 2 gave us a sample map of extension points and the events that detect them. That map is generic. It lists every location where a could be registered on a Windows build, and it says nothing at all about what is registered on THM-DEV-WS. The job now is to find out which of those locations this host has actually been told to load something from, and whether that configuration is one the build shipped with.
Answering that needs a baseline: a record of what the same locations held on a known-good system. Ours is C:\Case\baseline\, taken off this build before the incident and collected exactly the way the response later collected the host itself. Against it sits C:\Case\DEV-WS\triage\. Because the two collections have the same shape, every question in this task gets asked twice and the answers compared.
| Baseline holds | So we can compare |
|---|---|
SYSTEM and SOFTWARE hives |
machine-wide extension points |
each user's NTUSER.DAT and UsrClass.dat |
per-user class registrations |
| the files the collection captured | what a registered DLL actually is |
A baseline is what turns judgment into facts, because "this DLL looks odd" changes from analyst to analyst and falls apart the moment somebody asks why, while "this key does not match the baseline" is a fact that survives review. Where no baseline exists, a freshly built peer or a fleet-wide count of who else carries the same value does the same job. One captured after the compromise does not.
Warning: Mounting an offline hive writes to it. reg load does, and so does an autorunsc sweep, which leaves fresh transaction logs beside the hive and changes the primary file underneath. Copy the collection once and work from the copy, or the hashes recorded on arrival will not be the hashes anybody can reproduce later. RECmd parses a hive as a file rather than mounting it, so it is safe to point at the collection directly.
Make the working copy and a place for output before anything else:
Working with an Offline Hive
There are two ways to read an offline hive. The native one needs no forensic tooling, which matters on a host we cannot install software on, and it works by mounting each hive file under a temporary key name so the ordinary registry cmdlets can read it:

Read each pair side by side rather than one listing at a time, because the value of a baseline lives entirely in the comparison and not in either half of it. The two print processor listings match exactly: one subkey named winprint, whose Driver property is winprint.dll, in both hives. The two Control\Lsa listings do not. Notification Packages holds {scecli} on both sides, and Security Packages is {""} on the baseline and {msvctrl} on the incident host.
Two extension points, one clean and one dirty, out of a single pair of commands. The dirty one is a lead we will not touch until Task 6.
Note: An offline SYSTEM hive has no CurrentControlSet link, because that link is built at boot, so use ControlSet001. It is a common way to get "key not found" from a hive that holds the key.
RECmd reaches the same answer without mounting anything and without an elevated session, which matters when we would rather not have a tool writing to an evidence file at all. It parses the hive as a file and prints the key we ask for:

Both hives return the same list of Netsh helper DLLs, and the value counts match. That is a third extension point cleared, and it is worth sitting with how unremarkable the result looks, because a populated Netsh key on a host we already know is compromised is exactly the kind of thing an analyst without a baseline would flag.
The Baseline
| Location | How to read it | Analyst rule |
|---|---|---|
Control\Print\Environments\Windows x64\Print Processors |
Compare the subkey list and each Driver value in both hives |
A subkey the baseline does not carry, investigate |
Control\Lsa\Notification Packages |
Compare the value data in both hives | A name the baseline does not carry, investigate |
Control\Lsa\Security Packages |
Compare the value data in both hives | A name the baseline does not carry, investigate |
SOFTWARE\Microsoft\Netsh |
Compare the value list in both hives | A populated key is normal, a different key is not |
Services\<svc>\Parameters\ServiceDll |
Compare the path in both hives | A path outside System32, investigate |
Explorer\ShellIconOverlayIdentifiers |
Compare the handler list in both hives | A handler with no matching install, investigate |
The baseline is what tells us which of these configuration points is normal on this build, and it is the only reason any of these rows can be judged at all. The Netsh row is worth highlighting, because a populated key is not automatically a malicious key. Netsh ships with a long list of Microsoft helpers, Control\Print\Monitors ships with five, and a stock Windows install registers several icon overlay handlers before anybody touches it. We diff, rather than judge by emptiness.
Scope the diff to autostart keys. Two SYSTEM hives taken a day apart will differ in DHCP leases, mounted devices and service state whatever anybody did to the host.
The Base Sweep
Diffing by hand works for the six locations above and stops there. A sweep asks that same question, "what is configured to load automatically", against every autostart location the tool knows about, in one pass. autorunsc.exe enumerates them, one row per registered entry.
Offline, the tool takes two paths, a Windows directory and one user profile, and two details decide whether it runs at all. The directory has to look like a Windows installation, which in practice means System32\ntdll.dll must sit alongside System32\config\, or the tool exits with Error loading offline system: 87. A mounted offline hive is also never released afterwards, so a second -z sweep in the same session fails with Error loading offline system: 5.
Note: Reboot between sweeps. One offline sweep per boot is a hard limit of the tool, not a sign that anything is wrong with the evidence.
One sweep of a host we already know is compromised is a listing, not a finding by itself, so we take two. The baseline first:
Warning: Run both sweeps from cmd.exe, not PowerShell. PowerShell wraps redirected native output at the console width, and with -h and -s these rows carry four hash columns and full paths, so the CSV breaks apart mid-row and every diff built on it returns nonsense.
Reboot, then sweep the host under investigation:

Around fifteen hundred rows come back from each. Reading either one top to bottom is the mistake the baseline exists to prevent, so we diff them, and the columns we key on decide what the diff can see:

Two rows survive out of three thousand.
| Category | Entry | Image Path |
|---|---|---|
| Services | SysMain |
File not found: C:\ProgramData\Microsoft\DeviceSync\syncsvc.dll |
| LSA Providers | msvctrl |
File not found: msvctrl.dll |
Both of those are leads, and the second one is the same Security Packages value the manual comparison already found, arrived at a completely different way. That agreement is the point of running both.
The filter on _[0-9a-f]{5,6}$ is what makes the result that clean. It drops per-session user service instances such as CDPUserSvc_1edf6e, whose suffixes are regenerated at every logon and differ between any two captures of any two hosts, however fresh the baseline is. Without it, five hundred and fifty rows of churn sit on top of these two.
Keying on Image Path matters as much. The service is named SysMain in both sweeps, so a diff keyed on the entry name alone finds nothing at all. Only the DLL behind the name changed:
| Sweep | SysMain image path |
|---|---|
| baseline | C:\Windows\system32\sysmain.dll |
| incident | C:\ProgramData\Microsoft\DeviceSync\syncsvc.dll |
Note: File not found in an offline sweep is a statement about the collection, not about the file. A triage collection is hives and a handful of files, so almost nothing the sweep names is actually present to be examined. Neither of those two DLLs was captured.
The sweep writes two kinds of column and they do not deserve the same trust.
| Column | Comes from | Trust it? |
|---|---|---|
Entry Location, Entry, Image Path |
the collected hives, read directly | Yes. This is what is configured, and it is the same data the manual comparison read |
Signer |
an attempt to verify a file, from outside the machine that file belongs to | No |
Verification fails offline for two unrelated reasons, and the CSV shows both the same way.
The first is catalog signing, as we have seen with the PowerShell loaded modules. Most Windows binaries carry no signature inside the file at all. They are signed through catalog files (opens in new tab), .cat files holding hashes of many binaries at once, signed once and kept in the target's own CatRoot. Verifying such a file means hashing the bytes you are holding and looking that hash up in a catalog store, and a tool standing outside the machine has no catalog store to search. The bytes are fine. The lookup is impossible.
The second is simpler and this collection is full of it. Both rows that survived our diff read File not found, because a triage collection is hives and a handful of files, and the file the row names was never captured. There is nothing to verify at all.
That gives us the rule the rest of the room runs on, and it is one-directional:
- A verified result offline is not a fact about this host. The lookup that produced it may have hit the analysis workstation's catalog store rather than the target's, and even a genuine Microsoft binary can be one that has no business being on this machine.
- An unverified result is ambiguous, because it collapses catalog-unreachable, file-not-collected and genuinely-unsigned into one word.
- An unsigned result on a file we are actually holding is still a fact about those bytes.
Only the third one survives contact with an offline collection, and Task 5 is where we make it count, by abandoning the signer column entirely and reading what the file itself carries.
Where the Sweep Is Blind
A clean sweep is not an all-clear, and the fastest way to see why is a mechanism the tool was never built to find. autorunsc does not enumerate COR_PROFILER at all, and that one is not an autostart entry. It lives in the environment variables block, where no Auto Start Extension Point sweep looks. Setting COR_ENABLE_PROFILING to 1 and pointing COR_PROFILER at a profiler's CLSID machine-wide makes every process that loads the .NET runtime attach that profiler, because the runtime is doing exactly what it was built to do. Since .NET Framework 4 the profiler does not even have to be COM-registered, because COR_PROFILER_PATH can name the file directly. Blue Mockingbird (opens in new tab) used it for persistence, and no sweep of autostart keys will ever show it.
Offline mode narrows the tool further. With -z it parses registry-based autostart locations only, so scheduled tasks and WMI event subscriptions do not appear at all, and it reads the one user profile handed to it on the command line rather than every profile on the host. It also does not enumerate print processors, because -a p covers printer monitor DLLs, which is a different extension point that happens to live nearby.
That is three whole classes of mechanism the base sweep cannot report, and two of them matter on this host. We close both gaps with a second tool.
The Auxiliary Sweeps
RECmd runs a batch of key definitions against a single hive file, which is the shape the evidence already has. Each sweep below is still a comparison, and what changes is the reference we compare against.
Note: RECmd refuses to parse a dirty hive that has no .LOG1 and .LOG2 beside it, printing Aborting!! and writing no CSV at all. Passing --nl tells it to continue anyway. Every hive in this collection ships with its logs so the switch is not needed here, and the challenge host in Task 8 ships without them, so it is needed there.
The UserClassesASEPs batch walks the class registrations inside a user's UsrClass.dat, and the baseline collection captured the user hives too, so this sweep gets the same treatment as the machine hives:

Nineteen rows differ out of roughly four hundred and seventy, and grouping them sorts the question out immediately. Eighteen are shell Open Command entries written when Store apps updated overnight. One is CLSID InprocServer32, and that is the only category in the diff that can pull a DLL into a running process:

One row. A class registration in this user's hive that the baseline did not have, pointing at a DLL under the user's own AppData. That is the third lead, and Task 4 opens with it.
Two things are worth noticing about how little work that took. The hive holds fifteen CLSID InprocServer32 entries in total, and the diff reduced them to one without anybody judging a single filename. Filtering by location instead would have returned thirty-eight rows, because OneDrive registers its shell extensions from a versioned directory inside the same AppData tree, and a DLL's address says nothing about its intent.
The same batch idea run against the machine hives cross-checks the base sweep rather than extending it:

Five hundred and fifty-one rows differ: five hundred and fifty of them the per-session service churn the base sweep filter already taught us to expect, and one LSA Security Packages. A different tool, a different parsing method, and the same single finding the manual comparison and the base sweep both produced.
The third batch covers the machine-wide class registrations and shell extension points, which live in SOFTWARE rather than SYSTEM:
Roughly three thousand one hundred rows come back from each side, and two of them differ, both Schedule TaskCache entries written when Windows rescheduled its own maintenance work overnight. Nothing under ShellIconOverlayIdentifiers, which is the row the leads table closes on this host.
That is a clean result and it is worth having, because it is the one sweep in this task that finds nothing at all.
Note: Know what a batch covers before you trust a clean result from it. SoftwareASEPs.reb walks the shell extension points, the Run keys and the scheduled-task cache, and it does not walk Classes\CLSID. So this sweep clears the overlay handlers and says nothing whatsoever about machine-wide COM classes, which is why Task 4 goes back to the same hive with a direct key read. A sweep answers the question its author wrote down, not the question you have.
Diffing What Is Not in the Registry
Three leads so far, and every one of them came out of a hive. The print processor row closed clean, one subkey named winprint with the same Driver value in both hives, and by the rule we wrote that row is finished.
The rule is sound but the conclusion is wrong, because of what the registry stores here. Print Processors\winprint\Driver holds a file name, not a path, and the spooler resolves that name inside C:\Windows\System32\spool\prtprocs\x64\. Two hosts can agree perfectly on the name and disagree completely on the file.
A triage collection is hives and a few files, and the few files are the other half of the comparison:

One mismatch comes back. Same file name, same registered location, same registry value in both hives, and a different file underneath.
That is the fourth lead, and it is the one the registry could never have given us.
What This Pass Produced
Four comparisons, four references, four leads. Nothing below was handed to us, and every row names the comparison that produced it.
| Extension point | Reference compared against | Result | Goes to |
|---|---|---|---|
user CLSID\{...}\InprocServer32 |
baseline UsrClass.dat |
a class the baseline did not carry | Task 4 |
Print Processors\winprint\Driver |
baseline file, not hive | same name, different file | Task 5 |
Services\<svc>\Parameters\ServiceDll |
baseline SYSTEM hive |
same service, different DLL path | Task 6 |
Lsa\Security Packages |
baseline SYSTEM hive |
a name the baseline never carried | Task 6 |
Control\Lsa\Notification Packages |
baseline SYSTEM hive |
scecli on both sides, cleared |
closed |
SOFTWARE\Microsoft\Netsh |
baseline SOFTWARE hive |
identical value list, cleared | closed |
Explorer\ShellIconOverlayIdentifiers |
baseline SOFTWARE hive, via SoftwareASEPs |
not in the diff at all, cleared | closed on this host |
Every row came from the same operation. Sweep both sides, compare, read what only one side has. What changed each time is the reference, and picking the right one is the whole skill: a machine hive for machine-wide keys, the user's own hive for per-user classes, and the collected files for a mechanism the registry describes by name only.
The print processor row is the one to sit with. Its registry comparison passed, correctly, and the mechanism was still there. A diff is only ever as good as the reference it was given, and a reference made of hives can only answer questions about hives.
The next three tasks work these four leads in the order the table lists them, which is the order of increasing difficulty rather than the order the adversary used. Each one gets the same three questions asked of it, and each one breaks a different part of the method.
Which value under Control\Lsa carries a name the baseline does not have?
What is the full path that ServiceDll points to?
Hashing the collected files against the baseline returns one mismatch. Which file?
The first lead from the leads table is the class registration the user hive carried and the baseline did not. We take it first because of what the privilege column in Task 2 said about it: this is the only mechanism on the whole map that needs no administrative rights whatsoever, which makes it the one most likely to be sitting on a workstation right now. Working it end to end also establishes the three layers every remaining task leans on.
COM, the Component Object Model (opens in new tab), is how one program uses a component from another without linking against it. A component is registered once under a class identifier, a CLSID, and anything that wants it asks Windows for that CLSID rather than for a file. Explorer does this constantly, for thumbnail handlers, icon overlays and context menus, so a shell drawing a folder window is loading third-party code by design. The registry is what maps a CLSID into a file, and that is the part an adversary needs.
InprocServer32 says which file answers to a CLSID, so any process calling CoCreateInstance for that CLSID loads whatever the key points at, in-process, with no new process created.

Per-user class registrations under HKCU\Software\Classes are consulted before the machine-wide ones under HKLM, so a standard user can shadow a system COM class without touching anything privileged. What decides which hive a process reads is the token it runs under, not how privileged it is: a process running as that user reads that user's classes, elevated or not. This is why the technique persists a standard user rather than escalating one, and it is also why the same primitive shows up in UAC bypass techniques, where an auto-elevated process running as the same user reads the same per-user key.
That cuts both ways as a finding. A COM hijack in a user's own hive tells us the adversary held that user's session, and it does not on its own tell us they ever held more.
RomCom, tracked by Cisco Talos as UAT-5647 in October 2024 (opens in new tab), hijacked a CLSID that Explorer uses for icon caching, pointed it at a DLL under %LocalAppData%, and restarted explorer.exe to detonate it. No admin rights anywhere in the chain.
Picking the Right User Hive
HKCU\Software\Classes is not stored in NTUSER.DAT. It is backed by a separate hive file, %LocalAppData%\Microsoft\Windows\UsrClass.dat, and both files are sitting in the collection in adjacent directories:

Pointing a tool at the wrong one of those two returns nothing and looks exactly like a clean result. The Task 3 diff was run against UsrClass.dat for that reason.
Step 1: Read the Hijacked Key
The diff gave us a key path and a value. Read the key itself for its write time and its full contents:

The key carries two values, the default one naming the DLL under AppData\Local\KeyStore and ThreadingModel set to Apartment, with a LastWriteTime of 2026-08-17 06:41:15.
ThreadingModel is worth a second look, because it is free signal. It tells COM how the object may be called across threads, and the machine-wide registration we are about to read declares Both. A hijack written by hand often declares Apartment because it is the safest value to copy, and a per-user key whose threading model disagrees with the machine-wide class it shadows is a small, cheap thing to notice on a host where nothing else looks wrong.
Step 2: Compare Against the Machine Hive
The CLSID being shadowed is not unique; it has a system-wide counterpart. Read the same CLSID out of the machine hive:

HKLM answers for that class with C:\Windows\System32\thumbcache.dll, ThreadingModel of Both, and a LastWriteTime of 2025-08-04 22:18:39, which is when this image was built. The user hive answers for the same class with a file under AppData, written at 06:41:15 on the morning of the incident, a little over a year later.
Nothing was replaced and nothing was deleted. A second answer was added in front of the first one, and Explorer takes the first answer it finds. thumbcache.dll is Explorer's icon cache handler, and the shell resolves that class at every logon without anybody doing anything. This is the same CLSID Talos published for UAT-5647, down to the KeyStore directory the DLL sits in, so what we are looking at is a documented technique reproduced move for move rather than something improvised.
Step 3: Confirm the Write
The registry says what is configured. Sysmon says who configured it:

The write lands at 06:41:14, one second before the key's own LastWriteTime, from powershell.exe, under liam.patel. The target renders as HKU\<SID>_Classes\CLSID\..., which is the per-user class store seen from Sysmon's side and the reason the Task 2 warning about HKCU matters.
Step 4: Prove the Load

explorer.exe loaded the at 06:41:25, ten seconds after the key was written. Nothing about that gap is accidental. Explorer resolves this class at startup, so the adversary restarted the shell rather than waiting for a logon, which is the same detonation step the Talos report describes.
Step 5: Date the File Independently
Every timestamp so far came from a log, and logs can be cleared. $MFT carries the filesystem's own record:
Warning: --at is not optional here. Without it MFTECmd writes the $FN timestamp columns only when they differ from the $SI ones, so Created0x30 comes back blank in exactly the case we care about, which is the two agreeing. The column looks empty and the comparison looks impossible.
Load mft.csv in Timeline Explorer and filter File Name for keyprov. The columns that matter are Created0x10, which is the $SI timestamp any process can rewrite, and Created0x30, the $FN copy that only the kernel writes:
Two records come back. keyprov.dll is 42,496 bytes, created 2026-08-17 06:40:39, thirty-five seconds before the registry write, and its Has Ads column is ticked. The second record is what that column is pointing at: keyprov.dll:Zone.Identifier, 26 bytes, an alternate data stream that carries a zone marker. Windows attaches one when a file arrives through an application that sets it, a browser or a mail client being the usual cases, so its presence says the file was marked as coming from outside this machine. It does not name the route, and an application that never sets the marker leaves no stream at all, so absence proves nothing either.
Warning: Wrap any path containing $ in single quotes in PowerShell. Without them $MFT is read as a variable, expands to nothing, and the command silently parses a path that does not exist.
Step 6: Watch the Effect

Four seconds after the load, explorer.exe resolved cdn-telemetry-sync.net. A shell does not look up a domain nobody typed, and this is the record that closes the loop back to the beaconing that reopened the case in Task 1.
What We Just Did
Look at what each layer contributed, because the shape repeats twice more. The diff gave us a single row out of four hundred and seventy-six, and nothing in that row said malicious on its own. Event ID 13 confirmed the write and named the account behind it. Event ID 7 proved the load and put a time on it that turned out to describe the adversary's tradecraft. $MFT dated the file without relying on any log and showed it came from outside, and Event ID 22 showed the effect.
Sweep and diff, confirm the write, prove the load. The next two implants get the same three questions, and each one breaks a different layer, which is how we find out what any of them is worth on its own.
Which CLSID was hijacked on THM-DEV-WS?
Which DLL answers for the same CLSID in the machine hive?
Task 4's implant lived in a hive and answered to a hive diff. The second lead does not, and that is the whole reason it is worth its own task.
The Task 3 hive comparison showed one print processor on this host, named winprint, with a Driver value of winprint.dll, and the baseline says a clean build carries exactly that. Same subkey, same value, both hives, and that row closed clean. The file behind the name did not, and the hash mismatch we pulled out of the collected files is the only reason we are still looking at the spooler.
That changes what the rest of this task has to work with. There is no registry write to confirm, so Sysmon 13 has nothing to say about this mechanism at all, and the middle layer of the method is simply missing. We hold a file and a name, and everything else gets built from those two things.
How the Mechanism Works
A print processor is a the spooler loads to turn a spooled job into the data stream a particular printer expects. Windows ships one, winprint, and lets vendors register their own, because a printer manufacturer knows its own formats and Microsoft does not. That is the legitimate purpose, and it is the reason the spooler will pull a third-party DLL into a SYSTEM process without anything looking wrong.
Under Control\Print\Environments\Windows x64\Print Processors\<name>, the Driver value holds a DLL name only, with no path, which the spooler resolves inside C:\Windows\System32\spool\prtprocs\x64\. That detail is what makes the whole technique possible. The registry names a file and never says where that file lives, so control of the directory is worth as much as control of the key. spoolsv.exe runs as LocalSystem and starts at boot, so anything registered here is SYSTEM persistence with a boot trigger.
This corner of the spooler has been worked repeatedly. Winnti's PipeMon registered a typosquatted PrintFiiterPipelineSvc, documented by ESET in May 2020 (opens in new tab). Gelsemium, documented by ESET in June 2021 (opens in new tab), is the one that matters here, because it did not register anything at all. It overwrote the DLL the spooler already expected, which is exactly what we are holding.
Step 1: Compare the File Against the Baseline
Task 3 established that offline signature verification cannot discriminate, because catalog-signed Windows binaries come back unverified whether they are genuine or not. This is where that matters. Run sigcheck with -a against both copies and read everything except the signature line:

Both files report Verified: Unsigned, exactly as predicted, and that column tells us nothing. Everything underneath it does.
| Field | Baseline | Collected |
|---|---|---|
| Company | Microsoft Corporation |
n/a |
| Description | Windows Print Processor DLL |
n/a |
| File version | 10.0.22621.457 |
n/a |
| Original Name | winprint.dll |
n/a |
| Link date | 5/3/1904 |
8/16/2026 10:04 PM |
| Entropy | 4.073 |
5.927 |
A file claiming to be a Windows component with no version resources at all is a finding regardless of what the signer column says. The entropy gap points the same way, though more gently than it looks: 5.927 against 4.073 says the collected file's bytes are less repetitive than the genuine one's, which is what stripping version resources and metadata does. It is not high enough to claim packing, which sits nearer 7.0. Read it as corroboration, not as a verdict. The link date is the most interesting field and the one to hold most loosely. The genuine winprint.dll carries the reproducible-build epoch Microsoft stamps into shipped binaries, and the collected file carries 8/16/2026 10:04 PM, which looks like the moment this implant was compiled. It is a value inside the PE header, so whoever built the file chose it, and a build timestamp is trivially set to anything at all. Treat it as a lead worth pivoting on across other hosts rather than as a fact about when this file was made.
Step 2: Prove the Load
The registry cannot tell us this ran. Sysmon can, and the filter has to be specififed correctly:
One row comes back: spoolsv.exe loading C:\Windows\System32\spool\prtprocs\x64\winprint.dll, unsigned, at 2026-08-16 23:01:47. Carry the date as well as the clock from here on, because this task and the next one work an evening and the following morning, and two of the mechanisms are separated by seven hours rather than seven minutes.
Unsigned on its own would not have found this, and Task 2 is where we learned why: over two thousand of this host's image-load records report Signed: false because offline signature status is unavailable for nearly everything. It is the combination of an unsigned module and a SYSTEM host process reading a name we already know is suspect that produces exactly one row.
Step 3: Establish the Trigger
Task 2's map says this mechanism fires at spooler start, and a load time on its own does not prove that. What proves it is the company the record keeps. Pull every module spoolsv.exe loaded and sort by time:

The implant sits inside a dense burst of spooler module loads, the ordinary set of DLLs spoolsv.exe pulls in as it initializes. A module that loads in the middle of a service's startup sequence loaded because the service started. That is the trigger confirmed against evidence rather than read off a map, and it means this one re-arms itself at every boot with nobody logged on.
Note: The Service Control Manager's "entered the running state" record, Event ID 7036, is the obvious place to look for this. Query it with Get-WinEvent -Path 'C:\Case\DEV-WS\evtx\System.evtx' -Oldest -FilterXPath "*[System[EventID=7036]]" and nothing comes back, because this build does not emit that record at all. Note the -Oldest: without it the same query returns nothing whether the records exist or not, and we would be proving the wrong thing. Absence here is a property of the host's logging rather than of the spooler, and Task 6 turns that distinction into a method.
What We Just Did
The file was the first layer, the middle layer did not exist, and the load carried the trigger with it. The rule worth keeping: when a registry check matches the baseline, the next question is whether the file behind it is the file the baseline named.
There is a second thing this task settled that is easy to walk past. This implant required administrator rights, was built at 22:04 and loaded at 23:01. Task 4's implant required none and was planted at 06:41 the following morning. The mechanism that needed the most privilege came first, which means whoever did this already had it.
At what time (UtcTime) did spoolsv.exe load the print processor DLL?
Two leads are left on the table from Task 3, and we have not touched either: a service whose ServiceDll points outside System32, and a value under Control\Lsa that does not match the baseline. Both are here rather than in tasks of their own, because both are found the same way, and it is not the way Tasks 4 and 5 worked.
Task 4 ran all three layers cleanly: sweep and diff, confirm the write, prove the load. Task 5 lost the middle one, because a file swap writes no registry value. These two are different again, and they are different from each other.
The service DLL runs all three layers and still leaves a hole, because the event a service-persistence check actually looks for, System 7045, is never written. The LSA package gets its configuration and never gets its load at all, because Windows refused. One absence is a property of the technique and the other is a property of the defense, and neither can be read until we have proved the channel was recording.
Working out which event is missing, and proving it is genuinely absent rather than merely unfound, is the method this task is about. It has two steps and they run in this order:
- Prove the telemetry was recording. Show the channel covers the window, and show it was not cleared.
- Show the specific record is absent from a channel that was demonstrably working.
A count of zero from a channel nobody checked is worth nothing. A count of zero from a channel proven to be recording is a finding.
How a Service Runs From a DLL
Windows runs dozens of services, and giving each one its own process was once considered too expensive, so related services were grouped and hosted inside shared svchost.exe processes.
A service in one of those groups ships as a rather than an executable, and the registry is what tells svchost which DLL to load. There is no binary of its own to inspect: ImagePath points at svchost.exe -k <group>, Parameters\ServiceDll names the module that does the work, and Start decides whether it fires at boot. Change that one value and the service keeps its name, its description and its entire history while running completely different code.
That distinction, a new service versus an old service pointed somewhere new, decides which events get written:
| Evidence | New service installed | Existing service repointed |
|---|---|---|
| System 7045 | Yes | No |
| Security 4697 | Yes, with auditing enabled | No |
| Sysmon 13 | Yes | Yes |
| Sysmon 7 | At next start | At next start |
The two rows that say no are the whole point. Every service-persistence check built around 7045 sees nothing here, and the checklist that closed the original case in Task 1 was almost certainly one of them.
Confirming the Write

ServiceDll points at C:\ProgramData\Microsoft\DeviceSync\syncsvc.dll and the Parameters key was last written at 23:02:41. The second command reads the parent SysMain key, because Start and ImagePath live there rather than under Parameters: Start is 2, which is automatic, and ImagePath is the stock svchost.exe -k LocalSystemNetworkRestricted -p. The service itself was never touched. Only the module it points at. Sysmon agrees:

The load follows one second later:
svchost.exe loaded it at 23:02:42, running as NT AUTHORITY\SYSTEM. One second after the write is not a boot. That is the adversary restarting the service to detonate it immediately, the same impatience Task 4 showed with the shell. The configured trigger is still boot, and Start = 2 is what will bring it back tomorrow morning whether anybody restarts anything or not.
Proving an Absence
Now the record that should not be there. First, prove the channel works:

One record comes back, a service installation unrelated to this intrusion, written at 23:00:27. That single row is doing real work: it proves the channel is present in the export, that 7045 records are being written on this host, and that the export window covers the period we care about, because it lands two minutes before the ServiceDll write we just timestamped at 23:02:41.
Check the window has not been trimmed either:

The log was cleared once, before the legitimate LabHealthProbe service was installed. Now the absence means something: there is no 7045 for SysMain, on a channel that demonstrably records 7045s, across a window that demonstrably covers the write. The event is absent because repointing an existing service does not generate one.
The LSA Packages
Authentication is extensible by design. A security support provider implements an authentication protocol, an authentication package validates credentials, and a notification package is told about every password change so a product can enforce a policy on it. All three are DLLs, all three are named in the registry, and all three are loaded by LSA itself.
Microsoft documents the interfaces in SSP packages provided by Microsoft (opens in new tab) and the password filter contract in Password Filters (opens in new tab), worth reading once because the abuse here is the documented behavior used exactly as written.
lsass.exe runs as SYSTEM from boot and it is where authentication happens, so a security support provider registered under Control\Lsa is loaded into that process, and a notification package, better known as a password filter, is invoked on every password change with the plaintext in hand. That is persistence and credential access from one registry value.
Native Windows auditing is sufficient here. The Audit Security System Extension (opens in new tab) subcategory logs 4610 for an authentication package loaded by LSA, 4614 for a notification package loaded by SAM, and 4622 for a security package.
Start with the write:

Security Packages carries msvctrl against an empty value in the baseline, and Notification Packages holds the stock scecli. Further down the same listing, one more value decides how this story ends: RunAsPPL is set to 2.
Warning: The key's LastWriteTime is shared by every value underneath it, including ones Windows itself writes at boot such as LsaPid. LastWriteTime dates the key, not the value, so it cannot tell us when msvctrl specifically was added.
Proving the Second Absence
If that package loaded, LSA wrote a 4622 for it. Prove the channel first:

Eleven records come back, and they all carry the same timestamp, 01:57:28. That is one LSA initialization pass, writing out every security package it loaded, one row each: lsasrv, negoexts, kerberos, msv1_0, tspkg, pku2u, cloudAP, wdigest, schannel twice and sfapm. The instrument was not merely working, it was working at the exact instant our package should have appeared in that list.
Check this channel was not cleared either, the same way we checked the System log:

Again, once before anything malicious started, so the record we are about to call absent was not deleted. Now count it:
Zero. A package registered in the hive, absent from a list written at the moment that list was built.
Before we call that a finding, it has to survive the other explanations, because a zero has three of them and only one is interesting.
The first is that the subcategory was never recording. Audit Security System Extension is off by default, and on a host where nobody enabled it there would be no 4610, no 4614 and no 4622 either, so a zero would mean nothing at all. We already know that is not the case here, because the eleven records we just counted came out of that same subcategory. The instrument was on.
The second is that LSA never tried. A package added after the last boot sits in the hive unread until the next one, and its absence from a list written at 01:57:28 would say nothing about whether Windows would accept it. Ruling that one out needs a third channel.
The third is that Windows refused it, and that is what the evidence actually shows:
Warning: Query all three, not just 3033. 3033 means the load was refused. 3065 and 3066 are the audit-mode pair, and they mean the same load was observed and allowed. Audit mode is the default on Windows 11 22H2 and later, with enforcement enabled automatically only under narrower conditions.

At 01:57:28, the same second, Code refused it: lsass.exe attempted to load msvctrl.dll and it did not meet the signing level requirements. RunAsPPL = 2 in the key we read earlier is what set that requirement. LSA Protection runs lsass.exe as a protected process, and a protected process will not map an unsigned module.
That record settles the second explanation as well as the third. LSA reached for msvctrl.dll during the same 01:57:28 initialization that produced the 4622 list, so the configuration was already in place when that list was written. The package was not added late. It was present, it was read, and it was refused.
That is what makes the absence admissible. A package registered after the 01:57:28 boot would also be missing from a list written at that boot, and its absence would prove nothing at all.
The 3033 message also carries the only copy of the implant's on-disk path anywhere in this collection, \Device\HarddiskVolume4\Windows\System32\msvctrl.dll, which is the path Task 7 removes.
The adversary got the write. Windows declined the load. Both halves are evidence, and the second half only exists because somebody had turned that setting on.
That is four mechanisms accounted for. Three of them ran, one of them was stopped, and the next task removes all four.
At what time (UtcTime) did svchost.exe load the malicious service DLL?
Which registry value under Control\Lsa is the protection that blocked it?
Note: Everything in this task operates on a live host. The collection in C:\Case\ is a set of exported files, so these commands are the plan we hand to whoever still has the machine, not something to run against the evidence. Read them as the remediation half of the report.
Four mechanisms, four different removals, and one rule that applies to all of them: remove the configuration before the file. Deleting a DLL while the registry still names it leaves a host that is broken rather than clean, because the next trigger fires, the load fails, and we have traded a working implant for an error nobody will investigate. Worse, the configuration is what an adversary re-uses. Put the file back and the mechanism works again.
Blue Mockingbird, documented by Red Canary in May 2020 (opens in new tab), set machine-scope COR_PROFILER environment variables under Session Manager\Environment, so every process that loaded the .NET runtime then loaded their profiler DLL with it. Delete the downstream payload and the next .NET process start puts it back, because the payload was never the persistence. The configuration was.
Order of Operations
- Isolate the host. Everything below changes state, and anything worth preserving has to be preserved first.
- Collect before you touch anything. On a live host that means memory first and disk second, because collecting writes to disk and the volatile half does not survive the wait.
- Remove the configuration, one mechanism at a time.
- Remove the file.
- Reboot.
- Verify.
Steps five and six are not optional and they are the two most often skipped.
Removing Each Mechanism
The COM hijack lives in a user hive, so it has to be removed from that user's context or from the loaded hive directly. Removing the CLSID key restores the machine-wide class, which was never touched:
Warning: HKCU in an elevated session is the administrator's hive, not the user's, which is why this command names HKU\<SID> explicitly. Point it at HKCU while elevated and reg delete will report ERROR: The system was unable to find the specified registry key or value and exit with code 1, because the key genuinely is not there. /f suppresses the confirmation prompt, not the error. That failure is easy to read as "already gone" by somebody working quickly, and the implant is still sitting in the user's own hive.
The print processor is a file replacement, so the fix is a file restoration. The registry needs no change at all:
Verify the restored file against the baseline before trusting it. The known-good SHA256 is 4E95D66DB477AE92B57F8FBA7F9FAD50E090DE6E9F9D1F4E4FF690971E9DC520 at 69,632 bytes, and sigcheck -a on it should bring back the Microsoft version resources Task 5 showed the implant was missing.
Note: A mapped image cannot be deleted or overwritten while a process holds it, which is why the spooler stops first. If a file refuses to go, something still has it open, and that is information rather than an obstacle.
The service DLL is a value restoration followed by a file removal:
The service is not deleted. It is a legitimate Windows service that was pointed somewhere else, and deleting it would be its own outage.
Warning: Write the value back in the form the baseline holds, not the form a tool displayed. Stock Windows stores %SystemRoot%\system32\sysmain.dll as a REG_EXPAND_SZ, and autorunsc shows it already expanded to C:\Windows\system32\.... Restoring the expanded literal works today and fails the verification diff, because it no longer matches what the baseline hive holds. Read the value out of the baseline and write exactly that.
The LSA package needs the most care, because the value is a REG_MULTI_SZ that legitimately holds other names. Read it, remove the one entry, and write the remainder back:
Warning: Do not build the replacement by filtering the live value. On this host Security Packages holds one entry, so removing it leaves $null, and Set-ItemProperty rejects that outright. Restoring from the baseline sidesteps the whole problem and is the only version that survives the verification step, because the verification compares against the baseline anyway.
This host is the easy case, because the baseline value was empty and the cleaned value is empty too. On a host where it was populated, the baseline is the only thing that says which names belong.
Verifying the Host Is Clean
Reboot first. A sweep run straight after removal comes back clean and is worth nothing, for three reasons:
- The implant's code may still be resident in the process that loaded it, so it can put back what we just removed.
- A boot-triggered mechanism only proves it is dead by not firing at the next boot.
- A file that was mapped at deletion time may not be gone.
Warning: A reboot is not enough for all four. Three of these mechanisms fire at boot and the COM hijack fires at logon, so verifying it needs the affected user to sign in again. Reboot, sweep, see nothing and call the host clean, and the one mechanism that needed no privilege to plant is still waiting for liam.patel to log on. Verify machine-wide mechanisms after the reboot and the per-user one after a logon as that user.
After the reboot, take a fresh collection and sweep it exactly the way Task 3 swept the first one:
The same two constraints from Task 3 apply, because this is the second sweep of the day. One offline sweep per boot, so the post-eradication sweep needs its own reboot if anything was swept earlier in the session, and the Windows directory handed to -z must contain System32\ntdll.dll or the tool refuses to start. Sweep a copy, not the collection.
Compare offline to offline. A live sweep resolves catalogs that an offline one cannot, so a live-versus-offline diff returns a page of signer differences that have nothing to do with the intrusion.
Then run the same four checks that found the mechanisms in the first place:
| Mechanism | Check | Clean result |
|---|---|---|
| COM hijack | Diff the user hive against the baseline | No CLSID InprocServer32 row in the diff |
| Print processor | Hash winprint.dll against the baseline |
Matches 4E95D66D... |
| Service DLL | Diff the machine hive against the baseline | SysMain present, ServiceDll matching the baseline |
| LSA package | Read Security Packages |
Matches the baseline value |
The service row is the one to read carefully. A correct restoration leaves SysMain exactly where it was, in the sweep and in the hive. What disappears is the diff row, not the service.
Finally, confirm the effect is gone as well as the cause. The C2 domain from Task 4 should not reappear in DNS after a reboot, and that is the check that speaks to whoever asked whether the host is safe to return.
Hardening
LSA Protection is the highest-value setting here, because it already blocked one of the four implants on this host without anybody configuring it for that purpose. Secure Boot makes AppInit_DLLs inert. The Print Spooler can be disabled outright on hosts that do not print, which removes an entire row from the Task 2 map.
A boot-triggered print processor implant is removed while its DLL is still mapped into a running spoolsv.exe. What single action must be taken before a re-sweep can confirm eradication?
Scenario
Different department, different user, different privileges. The account behind it is not liam.patel, and the mechanism is not the one you spent yesterday proving, which means whoever this is did not simply repeat themselves. The host was pulled off the network within the hour and a collection was taken off it, and the responder who took it went home. Nobody has looked at it since, so there is no handover note, no summary, and the entry that tripped the sweep was never identified.
Two users signed into that machine after the entry was written, which is one more than the THM-DEV-WS case ever gave you. Work out what is registered on that host, what it loads, when it was planted, who planted it, and how many times it ran.
You have less to work with this time. The responder collected the SYSTEM and SOFTWARE hives, three event channels and the $MFT, and nothing else.
The hives arrived without their .LOG1 and .LOG2 files, so RECmd refuses them outright with Aborting!! until you pass --nl. The entry that tripped the sweep names a CLSID rather than a file, so finding the DLL is a second lookup rather than the same one.
Work the Task 3 diff against the two SOFTWARE hives, then confirm it the way Tasks 4 and 6 confirmed theirs. Everything you need is in the collection.
Diffing the two SOFTWARE hives leaves one added entry under ShellIconOverlayIdentifiers. What is the name of that entry?
Which CLSID does that entry point to?
What is the full path of the DLL registered in that CLSID's InprocServer32 value?
Which user account wrote the overlay handler's registry values?
When was the malicious DLL written to disk, by its $MFT Created0x10 timestamp?
How many times did explorer.exe load that DLL?
Across this room we investigated five registered mechanisms on two hosts, four that loaded and one that was blocked, and not one of them was ever going to turn up on the persistence checklist that closed the original case. The checklist was not wrong about what it checked. It was wrong about what counts as a place code can be told to start from, and that is a much easier mistake to make. That case is closed properly now.
Takeaways
-
Windows loads third-party DLLs at documented registry locations on purpose, and a registered extension point is what one of those locations is called. An adversary who can write to one of those keys needs no exploit, and the process that runs their code is one we would never look twice at.
-
Trigger analysis is the question that organizes a persistence investigation. Boot, logon, service start, on demand, or dead tells us when the thing runs next, whether a reboot re-detonates it, and how much time we actually have.
-
Diff against a baseline instead of eyeballing names. "This looks odd" changes from analyst to analyst, while "this does not match the baseline" survives review, and it is the only triage axis that still works when signature verification cannot be trusted.
-
Choosing the reference is the skill, not running the diff. A machine hive answers questions about machine-wide keys, a user hive answers questions about per-user classes, and neither one can say anything about a file the registry names but does not describe. The print processor was found only when the reference changed from hives to files.
-
An absence is only evidence once we have proven the instrument was recording. A missing 7045 and a missing 4622 both became findings by the same two-step method, and a count of zero on its own is worth nothing.
Every finding here came out of a folder of files by asking the same three questions in the same order. What is configured that should not be, who configured it, and did it run. The evidence class changed underneath every time, from an offline sweep to a hive diff to a file hash to an exported channel, and twice one of those three questions had no answer at all, which was itself the finding. That habit is what carries into the next collection somebody hands you.
There is one more thing worth knowing about the method, and it is the part this room could not teach you. Every mechanism here announced itself in the registry. Even the print processor did, in its way: the key matched the baseline perfectly and still named the file we went on to hash. That is what made four references enough.
Now picture a host where the adversary wrote no key at all, and simply left a DLL somewhere a privileged process was already going to look for it by name. Windows loads it, because the loader is doing exactly what it was built to do. Run every comparison from Task 3 against that host and all four come back clean, because there is nothing in any hive to differ. Same outcome, same SYSTEM process, no registry write to find.
That is the other half of this subject, and it is where Privilege escalation via DLL Hijacks picks up.
Ready to learn Cyber Security?
TryHackMe provides free online cyber security training to secure jobs & upskill through a fun, interactive learning environment.
Already have an account? Log in
