
TryHackMe's Hacker Holiday 2026 · The Byte Lotus Hotel | Day 12
After Hours
Category: Forensics · Windows · Persistence · Reverse Engineering
Difficulty: Medium
Points: 90

The Brief
The scenario dropped me into a familiar spot for anyone who's done incident response: a resort's back-office machines were quietly humming long after the front desk and pool lights had gone dark. Someone — or something — had been logging in during the small hours, well after the night-shift technician clocked out.
The obvious places came back clean. Nothing in Startup, nothing in Scheduled Tasks, nothing in the registry Run keys. That's the tell. When the "front door" persistence mechanisms are empty but the box is clearly still checking in with someone, my mind immediately jumps to WMI Event Subscriptions — one of the classic "living off the land" fileless persistence techniques that doesn't show up in any of the places most junior analysts (or even some tools) think to check.
My itinerary for the day was three items:

Here's how I worked through it, and — more importantly — why I made each choice along the way.
What Am I Actually Looking At?
The archive I was handed contained:

Anyone who's spent time in Windows forensics will recognize this file set immediately — this is not a generic dump. This is the WMI Common Information Model (CIM) repository, normally found on disk at:
C:\Windows\System32\wbem\Repository\
OBJECTS.DATA— the actual object store; every WMI class definition and every class instance (including custom classes, filters, consumers, and bindings) lives inside this file as serialized binary records.INDEX.BTR— a B-tree index intoOBJECTS.DATAso the WMI service can look records up quickly instead of scanning linearly.MAPPING*.MAP— page-mapping files that tell the WMI engine which physical pages inOBJECTS.DATAcorrespond to which logical records. Windows keeps up to three of these (MAPPING1/2/3.MAP) and rotates between them as a crude journaling/corruption-recovery scheme.
Seeing this file layout told me exactly what kind of investigation I was doing before I'd even opened a single file: this is a WMI persistence hunt. The "quietly humming after hours" narrative in the brief lines up perfectly — WMI Event Subscriptions are triggered by conditions (a timer, a logon, a specific event log entry) rather than living as a visible scheduled task or a Run key, which is exactly why the front-line checks in the brief came back clean.
Why WMI Persistence Hides So Well
Before touching a single command, it's worth being explicit about why WMI is such an attractive persistence mechanism for an attacker, because that theory is what drives every subsequent step.
WMI supports Permanent Event Subscriptions, built from three components that all live as class instances inside OBJECTS.DATA:
| Component | Purpose |
|---|---|
__EventFilter | Defines when to fire — a WQL query against a class like __InstanceCreationEvent or a timer (__IntervalTimerInstruction). |
__EventConsumer (and its subclasses) | Defines what happens when the filter fires. The interesting subclasses from an attacker's perspective are CommandLineEventConsumer (runs a command line), ActiveScriptEventConsumer (runs embedded VBScript/JScript), and NTEventLogEventConsumer (writes to the event log — usually benign). |
__FilterToConsumerBinding | Glues a Filter to a Consumer, i.e., "when this condition is true, run this consumer." |
Because these three objects are stored as data inside the CIM repository, not as files on disk and not as registry Run-key entries, they're invisible to:
- Autoruns-style startup enumeration
- Task Scheduler review
- Registry Run/RunOnce key review
The only way to find them is to either query WMI live via wmic/PowerShell (Get-WmiObject -Namespace root\subscription -Class __EventFilter, etc.) on a running system, or — in an offline forensic scenario like this one, where I only have the repository files and not a live host — parse OBJECTS.DATA directly. That's a purpose-built binary format, not something you can just grep and expect clean results from every time (although as I'll show, string-searching absolutely still has a role to play).
This is the theoretical basis for reaching for a dedicated WMI repository parser rather than jumping straight to strings.
Step 1 — Parsing the Repository with PyWMIPersistenceFinder
Why this tool specifically
I chose PyWMIPersistenceFinder.py, a script originally released by David Pany (FireEye/Mandiant) and maintained on GitHub at:
PyWMIPersistenceFinder.py is designed to find WMI persistence via FitlerToConsumerBindings
I went with this tool over writing my own parser or hand-walking the binary format for a few concrete reasons:
- It understands the
OBJECTS.DATAbinary structure natively. The CIM repository format is undocumented and has changed across Windows versions. Rather than reverse-engineer the record layout myself, I leaned on a tool that's already been validated against real-world WMI persistence incidents (it was released alongside Mandiant/FireEye's WMI persistence whitepaper, which is one of the foundational public references on this attacker technique). - It automates exactly the correlation I need. The tool walks
OBJECTS.DATA, extracts every__EventFilter, every__EventConsumer-derived object, and every__FilterToConsumerBinding, and then joins them together so I get human-readable "Filter X triggers Consumer Y" pairs instead of having to manually cross-reference GUIDs/keys between hundreds of raw records myself. - It's built for exactly this offline scenario. It only needs the
OBJECTS.DATAfile (it doesn't strictly require the.MAP/.BTRfiles, since it does its own linear parsing of the object store), which matched the artifact set I was handed.
Running it

What it told me
The tool successfully parsed the repository and found exactly one FilterToConsumerBinding:
- Consumer:
NTEventLogEventConsumernamed "SCM Event Log Consumer" - Filter: "SCM Event Log Filter", querying
select * from MSFT_SCMEventLogEvent
The tool itself flagged this as a common binding based on the naming convention, and noted it's "possibly legitimate." NTEventLogEventConsumer bound to Service Control Manager state-change events is a pattern Windows itself sometimes uses, and it isn't a code-execution consumer type (unlike CommandLineEventConsumer or ActiveScriptEventConsumer) — it just writes an event log entry. There's no command line, no script body, nothing an attacker would use to actually do anything.
This is the pivot point of the whole investigation. If this had been a CommandLineEventConsumer with a suspicious command line, I'd have been done. But it wasn't. That told me the persistence I was hunting for either:
- Wasn't implemented as a standard
__FilterToConsumerBindingat all, or - Was hiding its payload somewhere PyWMIPersistenceFinder's binding-correlation logic wouldn't surface — e.g., stashed as data inside a custom, attacker-defined WMI class, rather than inside the standard filter/consumer/binding triad.
That second possibility is exactly what I went looking for next.
Step 2 — Falling Back to String Searching
Since the structured parser came back with nothing actionable, I dropped down a level and went after the raw bytes of OBJECTS.DATA directly with strings and grep. The goal here was to hunt for the fingerprints of the actual dangerous WMI consumer types and common attacker execution vectors, even if they weren't wired up through a clean binding the parser could recognize.
Why these specific terms: CommandLineEventConsumer and ActiveScriptEventConsumer are the two WMI consumer classes that actually execute attacker-controlled content. powershell, cmd.exe, and WScript are the execution hosts almost every WMI-based dropper/stager shells out to. Grepping for all five in one pass, with -B 5 -A 10 for surrounding context, meant I'd catch any nearby class/property names even if the match itself was buried mid-record.
What I found

Buried in the data was a large Base64 blob. Decoding Base64 first and then converting from UTF-16LE to UTF-8 gave me clean PowerShell source:
Why Base64 and UTF-16LE together: PowerShell's -EncodedCommand convention (and a lot of WMI-embedded script content generally) encodes commands as Base64 of UTF-16LE text, specifically because PowerShell natively speaks UTF-16LE internally. Seeing that double-encoding pattern is itself a strong indicator of deliberate obfuscation/defense evasion — a defender skimming the raw repository for plaintext powershell.exe command lines would see nothing; they'd only see high-entropy Base64.
What the decoded script actually does
Once decoded, the PowerShell logic read something like this (paraphrasing the logic, not the literal source):
- Reach into WMI itself and read a property called
ConfigDataoff a custom class:ROOT\cimv2:Win32_HardwareTelemetry. - Base64-decode that property's value.
- Feed the decoded bytes into a
System.IO.Compression.DeflateStreamin Decompress mode, reading from aMemoryStream. - Stream the decompressed bytes out into a byte array in a loop.
- Call
[Reflection.Assembly]::Load()on the resulting bytes, then invoke the loaded assembly'sEntryPointdirectly via.Invoke().
This is a textbook fileless, in-memory reflective .NET loader:
- The "real" payload never touches disk as a standalone
.exe— it's smuggled as data inside an otherwise innocuous-looking custom WMI class property (Win32_HardwareTelemetrysounds like something a legitimate hardware-inventory agent might create, which is good camouflage). - Compressing it with raw Deflate keeps the embedded Base64 blob smaller and slightly less immediately fingerprintable.
- Loading it via
[Reflection.Assembly]::Load()and invoking the entry point directly means the payload runs entirely inside the PowerShell process's memory space — no dropped binary for on-disk AV/EDR signatures to catch, no new process spawned for the payload itself.
This explained why nothing showed up in Startup, Scheduled Tasks, or Run keys: the trigger was presumably a WMI event subscription (consistent with the repository artifacts I was given), and the payload was never resident on disk in executable form at all — it lived as a data property on a custom WMI class, decoded and executed purely in memory.
Step 3 — Locating and Extracting the Embedded Payload
Knowing the loader script pulls its payload from Win32_HardwareTelemetry's ConfigData property, my next move was obvious: go back into OBJECTS.DATA and specifically hunt for that class and property name, rather than blindly re-searching for generic execution keywords.
I widened the context window here (-A 50) deliberately — property values on custom classes, especially ones holding a compressed binary payload re-encoded as Base64, can be extremely long single "lines" in the strings output. I wanted to be sure I captured the entire blob and didn't truncate it partway through, which would have silently corrupted the decompression step later.
This search surfaced the actual ConfigData value: a large Base64-encoded string sitting right there as a property on the custom class, exactly where the decoded PowerShell script said it would be.

Reversing the encoding chain
The PowerShell loader told me precisely how to reverse this, since decoding is just running the encoding steps backwards:
- Base64-decode the blob → gives compressed bytes.
- Raw Deflate-decompress those bytes (matching
DeflateStream, which — critically — does not include a zlib/gzip header, unlike the more commonzlib.decompress()default).
I wrote a small Python script to do this cleanly rather than trying to chain shell one-liners with binary-safe piping, which gets fragile fast:
Why -zlib.MAX_WBITS specifically: this is the detail that will silently break the whole extraction if you get it wrong, so it's worth explaining properly. zlib.decompress() by default expects a zlib-wrapped Deflate stream, which includes a 2-byte header and a 4-byte Adler-32 checksum trailer. .NET's System.IO.Compression.DeflateStream, however, produces raw Deflate data with none of that wrapper. Passing a negative window-bits value to zlib.decompress() (-zlib.MAX_WBITS = -15) tells zlib "don't expect a header, just decompress the raw Deflate stream." Get this sign wrong and you get an immediate Error -3 while decompressing data: incorrect header check instead of a working binary. Recognizing that the loader script used .NET's DeflateStream (from the decoded PowerShell) is exactly what told me in advance which decompression mode I'd need — I didn't have to guess or trial-and-error it.
Running the script produced a clean PE file: payload.exe.

Step 4 — Analyzing the Extracted Payload
With an actual binary in hand, I went back to basics: pull the readable strings out of it and see what it's telling on itself.
(Again reaching for the UTF-16LE pass alongside — or in this case instead of — plain ASCII, since .NET binaries frequently store their string literals as UTF-16LE internally, consistent with everything else in this chain.)

That surfaced several interesting artifacts:
bytelotusdc— almost certainly a hardcoded domain/hostname string, likely used as an environment check (i.e., the malware verifying it's actually running inside the intended target environment before doing anything).Execution halted: Environment mismatch.— this confirms the theory above. The payload has some kind of guard clause that checks its execution context (domain name, hostname, etc.) against an expected value and bails out with this message if it doesn't match. This is a common anti-analysis / anti-sandbox technique — it keeps the payload from firing (and thus from being easily dynamic-analyzed) outside its intended target, and it also means a generic sandbox run wouldn't reveal the "real" behavior without either matching the environment or patching around the check.cmd.exeand, critically:/c net user patch [REDACTED] /add
That last line is the payload's actual objective, laid completely bare. net user <username> <password> /add creates a new local Windows user account. Here, the attacker is creating an account named patch — a nice bit of thematic naming, since "patch" is also exactly the kind of process/account name that could blend into legitimate-looking system administration activity ("just applying patches") — with a password set to a Base64-looking string.
This is the actual persistence mechanism the whole chain was building toward. The WMI event subscription is the trigger. The reflectively-loaded in-memory payload is the dropper logic. And the dropper's actual payload is dead simple and devastatingly effective: plant a valid local administrator-capable account credential, so that even if every artifact I've walked through so far gets cleaned up, the attacker still has a durable way back in via a legitimate-looking Windows login rather than malware that needs to keep running.
That's the "back-office machines keep humming... someone's been logging in during the small hours" from the original brief, explained end to end.
Step 5 — Decoding the Flag
Standard Base64 alphabet, valid length (a multiple of 4 after padding considerations), no obviously hash-like fixed length (MD5/SHA outputs are hex and fixed-width; this wasn't). Decoding it:
And there's the flag — and a fitting one, given that the entire chain hinged on an account literally named patch opening the backdoor.
Why the Investigation Worked This Way
Looking back at the full chain, a few decisions were load-bearing enough to call out explicitly:
- Recognizing the CIM repository file layout up front (
OBJECTS.DATA/INDEX.BTR/MAPPING*.MAP) immediately scoped the investigation to WMI persistence, before I'd run a single command. That domain knowledge is what justified reaching for a WMI-specific tool instead of generic forensic triage. - Trusting the structured parser (PyWMIPersistenceFinder) first, but not stopping when it came back "clean." A less careful pass might have seen the single, plausibly-legitimate
NTEventLogEventConsumerbinding and moved on. The brief's insistence that nothing showed up in the usual places was the signal to keep digging rather than accept a benign-looking result at face value. - Understanding why WMI stores strings in UTF-16LE was the difference between finding the encoded PowerShell stager and missing it entirely with a plain ASCII
stringspass. - Reading the loader script before trying to extract the payload. The decoded PowerShell told me exactly which class/property to go back and search for (
Win32_HardwareTelemetry/ConfigData), and exactly which decompression mode to use (raw Deflate viaDeflateStream, not zlib-wrapped). Skipping straight to "grab any big Base64 blob and try to decompress it" would have led to a lot of failed guesses. - The final payload's simplicity was the point. After several layers of obfuscation (WMI event subscription → custom class property → Base64 → raw Deflate → reflectively-loaded .NET assembly), the actual malicious action was a single
net user /addcommand. Sophisticated delivery, unsophisticated (but very effective) objective — which is a pattern worth remembering: the complexity of a delivery chain doesn't tell you anything about the complexity of the actual goal.