tl;dr
Lumen (web, 613 solves) -
urldecode()runs afterhtmlspecialchars(), so every special character survives as%XX. The CSP is genuinely airtight, so I never touched it: >1000 query params makes PHP print a startup warning beforeheader()ever runs, and the policy is simply never sent.Huddle (web, 430 solves) -
sha256(secret ‖ data)invite tokens fall to hash length extension. Owner unlocks an ffmpeg thumbnailer, which reads arbitrary files through analisexternal data reference. The flag comes back through a lossy JPEG, so you have to encode for the channel.Qfact (forensics) - the ransomware dropper is sitting in the Defender quarantine, and Defender's obfuscation is a published RC4 constant. Decrypt it and the AES parameters are right there in the VBScript.
Whisper (forensics) - the insider used a local Ollama model to write their exfil tool. They wiped
.bash_historyand self-deleted the script. They did not wipe~/.ollama/history, which contains the plan and the passphrase.Popcnt Oracle (crypto) - an RSA decryption oracle that returns only
popcount(x^d mod n). Multiplicative malleability plus a popcount identity walks out the binary expansion ofm/n, one bit per query.
Five challenges, five completely unrelated primitives. That's the thing I liked about this set - you couldn't brute-force it with one skill.
The event
Event | BlackHat MEA Qualification CTF 2026 - challenges by Flagyard |
Format | Jeopardy, dynamic scoring |
Window | Sep 5 2026, 10:00 → Sep 6 2026, 15:30 |
Team size | 3–5 members |
One line in the rules shaped how I played it:
Please consider that point system will be points earned + time of submission.
Dynamic scoring already pushes value toward the challenges nobody solves, and folding submission time into the total means a fast solve on an easy challenge is worth real points. So the order was: sweep for anything solvable quickly, then sink the remaining hours into the hard ones. That's why the first-blood times are quoted throughout - on this scoreboard they're part of the score, not trivia.
Lumen - the CSP that was never sent
Lumen · WEB
Lumen is a read-only document relay with a content security policy locked down on every page, so the operator is confident that even a stray reflection cannot run. Sign in is not your problem. The operator is, and the operator keeps something valuable in their browser. Find the reflection, get it to run despite the policy, and convince the operator to hand it over.
The blurb tells you the shape of it: "Find the reflection, get it to run despite the policy, and convince the operator to hand it over." Three gates, named in order.
Source is one PHP file, and the reflection is not subtle:
Two bugs stacked on top of each other. htmlspecialchars only touches literal < > & " ' - a percent-encoded %22 sails straight through, and the trailing urldecode turns it back into a quote. So every dangerous character is recoverable except the one on the blocklist.
And the blocklist runs per-field while the values are concatenated afterward. Put %3 at the end of dir and c at the start of file, and the concatenation contains %3c:
That gives you exactly one <. I like this detail a lot - it's not an inconvenience, it's the author telling you which payload shape is intended. One < rules out <script>...</script> (the closing tag needs a second one, and unterminated script text swallows the rest of the document as JS). You're getting a single-tag payload whether you like it or not.
Now the policy:
I spent a while trying to break this before accepting that I couldn't. The nonce is bin2hex(random_bytes(16)), and it's emitted in <head> - before the reflection point in <body>. Dangling markup consumes forward, not backward. There is no strict-dynamic, no unsafe-inline, no 'self' in script-src. It's a correct policy.
So stop attacking the policy and attack the thing that installs it. From the runner:
header() is on line 3 of index.php. Anything that emits output before the script runs means the header never gets sent. Exceeding max_input_vars does exactly that - PHP emits the warning during request startup:
No Content-Security-Policy in the response. The policy is perfect and it is simply not there.
Two practical notes. Use bare &a repeats - they're two bytes each, and verbose names like a0=1&a1=1 push you past nginx's URI limit into a 414. And confirm the header drop on its own before combining it with the injection, so you're never debugging two unknowns at once.
For exfil, remember connect-src falls back to default-src 'none', so fetch is dead even to same-origin. What still works is <img> to self and top-level navigation. The app hands you a same-origin sink at ?p=trace that stores a note and reads it back, so:
Report the URL, wait for the bot, read the trace:
Huddle - a video thumbnailer with a filesystem
Huddle · WEB
Huddle is a small team chat workspace. Members post in channels, invite teammates with a shareable link, and the workspace owner can switch on extra features such as video messages. You arrive as an ordinary member. Work your way into the owner's seat, then turn the workspace's own tooling into a window onto the server.
"Work your way into the owner's seat, then turn the workspace's own tooling into a window onto the server." Again, a two-gate spec written in plain English.
The app is a React SPA; all the surface is in the bundle. Ten endpoints, and two of them are interesting: a signed invite link, and /api/files/thumbnail. Grab an invite:
The token is a plain query string, the tag is 64 hex. Either that's an HMAC or it's the classic sha256(secret ‖ data). If it's the latter I can append &role=owner without the key - but I'd be guessing at two unknowns: whether the parser takes the last duplicate key, and the secret length.
The server answers the first one for free. Send a deliberately broken forgery:
That parsed echo is the whole ballgame - the structure parsed and the last role won. Only the length is unknown, and a length is a for loop:
Owner. Flip on video_messages and the upload/thumbnail surface appears.
Before writing a single payload, fingerprint the tool. Upload a real video, pull the resulting JPEG, and read its encoder tag:
ffmpeg 5.1. This mattered enormously - my local ffmpeg is 9.x and has mitigations 5.1 doesn't. Testing locally would have told me the wrong thing about what was exploitable. Always fingerprint the remote from its own output.
Next, find out what the thumbnailer accepts. One real video per container, submitted:
Container | Result |
|---|---|
avi / mkv / webm / ts / flv | 422 |
mp4 / mov / 3gp / m4v | 201 |
Nine requests, and now I know it's ISO-BMFF only - ftyp at offset 4. That single check deletes the entire public ffmpeg AVI/GAB2/HLS/XBIN file-read toolkit, and it's not bypassable by smuggling ftyp into a playlist comment, because hls_probe needs #EXTM3U at offset 0. Mutually exclusive.
I love this kind of closure. It's the author saying: the answer is inside the MP4 container, stop pasting.
MP4 tracks can point their media data at an external file via a dref box. There's a catch that costs you an hour if you miss it: ffmpeg only extracts a path from alis records. A plain url entry is silently ignored and the track falls back to self-contained, which looks like this:
609 bytes is the size of my own file. Not the target.
Build a proper alis record with nlvl_from / nlvl_to (ffmpeg uses them to walk up directories) and let ffmpeg's own debug log tell you when you get the layout wrong:
Two bugs fell out of that one line: the nlvl fields are read from then to, opposite of what I'd assumed, and the path needs an explicit NUL counted inside the record length. Fixed, it reads the external file. Sweep the directory depth against the target:
And then the part that makes this challenge good. The read exits through a lossy JPEG:
That's /etc/passwd. root came back as qlip. The damage is per-channel - RGB→YUV chroma quantization - so voting across reads doesn't save you, because the error is systematic rather than positional.
The fix is to stop sending colour and start sending symbols. Declare depth 8 and ffmpeg treats the track as pal8, where each byte is a palette index. QuickTime's default 256-colour palette has entries far apart in RGB space, so nearest-neighbour recovery survives compression. stco gives read-offset control, which buys per-byte majority voting on top:
Byte-exact, Getting the primitive was not getting the flag, you have to design for the channel!
Qfact - Defender kept the evidence
Qfact · Forensics
A finance employee's workstation was hit by ransomware. All documents were encrypted and a ransom note was left demanding payment in Bitcoin. The employee recalls opening a file they received via email. IT later removed some Defender exclusions during a security audit, and Defender flagged a suspicious file, but it was quarantined, not preserved on disk. A triage package has been collected. Recover the malicious file, figure out how the encryption works, and decrypt the affected files.
Note the phrasing - "quarantined, not preserved on disk." That reads like a setback and is in fact the entire solution: quarantined means Defender still has a copy.
A Windows box got ransomwared. Documents are .enc, there's a ransom note, and - the whole point - Defender quarantined the dropper on its way past.
The note gives you a pivot you'll need later:
Defender obfuscates quarantined files with RC4 under a hardcoded 256-byte key shipped inside mpengine.dll. It's a published constant. There is nothing to crack here; you just have to recognise the container:
Valid ResourceData header (that \x01\x05...\x05\x15 is a Windows SID), so the key is right. The original file sits after the header - scan for printable runs and it's at 0xd4:
An HTA. And it's chatty:
Fx7mK9vL2nQ4wPz!. The PowerShell it builds spells out the rest:
There's a trap in there. $a.Mode=0 is not a valid CipherMode - the enum starts at CBC=1. With $ErrorActionPreference='SilentlyContinue' the assignment fails silently and the mode stays at the .NET default, which is CBC. Read it literally as ECB and you'll spend an hour wondering why your padding is wrong.
The IV needs the exact COMPUTERNAME and USERNAME. The note's unique ID is built from COMPUTERNAME plus the first two characters of the username uppercased, so you have DESKTOP-KLPAT9O and JM - but you need the full username in its original case. The registry hive has it:
Valid PKCS7 on the first candidate. Q3_auth_memo.txt carries a "master authorization code ... encoded below for secure internal transmission", which is the most corporate way imaginable to hide a flag:
Whisper - the LLM remembered everything
Whisper · Forensics
A company's SOC team received a proxy alert after a developer's Linux workstation attempted to upload an encrypted file to an external file-sharing service. The developer claims they were just testing an AI tool for work. A forensic triage package has been collected from the workstation. Investigate the system, determine what AI tool was installed, what it was used for, and recover the data that was attempted to be exfiltrated.
"They were just testing an AI tool for work" is the alibi, and it's also the lead.
A Linux dev workstation, suspected insider data theft. The evidence is a full root filesystem, which is a lot of haystack. So don't read it - look for things that are off-profile for a developer box.
Two jumped out. A data/reports/ directory full of business CSVs, and this:
A local LLM was pulled and served. Which means there's a prompt history.
The first two-thirds is completely ordinary developer chatter - FastAPI middleware, Postgres replication, pytest. Then it turns:
That is the entire operation in the insider's own words, including the passphrase. It also explains why .bash_history is empty - they asked how, and then did it.
The anti-forensics was decent and incomplete. The script deleted itself, but Python doesn't clean up after it:
A bytecode cache with no source next to it. And a hidden virtualenv containing exactly the three libraries the model was asked about:
The archive itself is a dotfile in /tmp wearing a costume:
Exactly 200 KiB, file says data, header is high entropy. Not a gzip, not a tar - the extension is camouflage.
Decrypt it with the recovered passphrase and you get five CSVs. Then do the step that actually makes the case - diff them against what was already sitting on disk in the clear:
Four of five were already staged in plaintext. The fifth exists nowhere else on the filesystem. That's the crown jewel, and it's 39 rows of base64:
The lesson here outlives the CTF. ~/.ollama/history is a new artifact class, and on a box running a local model it may be the most candid thing on disk. People are careful with their shell. They are not careful with their assistant.
Popcnt Oracle - one bit at a time
Popcnt Oracle · Crypto
Poping Shower!
That is the entire description. Four syllables of pun and no hints - which is fair, because the source hands you everything.
The source is short enough to quote in full spirit:
You get n, c, and an unlimited oracle that returns popcount(x^d mod n). Recover m.
It's a raw private-key operation, so it's multiplicatively malleable. Submit x = c · t^e and you get:
So the oracle now speaks about m with a multiplier you control. That reframing is step one of basically every RSA oracle challenge.
Now take t = 2^k. For any y < n, doubling either shifts left (popcount unchanged) or wraps with a subtraction (popcount almost always changes):
Walk t = 2^0, 2^1, 2^2, ... and compare consecutive popcounts, and you are reading off the binary expansion of alpha = m/n. Bit k is 1 exactly when a reduction happened at step k. Recover enough bits and m = round(alpha · n).
Except the test isn't exact. A reduction can coincidentally preserve the popcount - around 1 in 80 for a 2048-bit modulus, so roughly 13 bits go missing across the walk. What saves you is that the error is one-sided: "changed" is always a real 1, only "unchanged" is ambiguous. A contradiction becomes proof rather than noise, which is exactly the property you need for a search.
So run a second chain with multiplier 3. beta = frac(3·alpha) is fully determined by alpha, so any candidate prefix predicts the chain-3 observations, and a candidate predicting 0 where the oracle saw 1 is impossible. Carry a beam of candidates, extend both bits, prune on contradiction.
The first run died at 28 minutes. Three chains at ~0.35 s/query is about 36 minutes of oracle time, and the service drops the connection before that. That's a two-minute calculation I did after the failure instead of before it, which is the most avoidable mistake in this whole writeup. Recount, drop to two chains, raise the beam:
~11 minutes, comfortably inside the connection lifetime.
What actually decided these
Looking back across all five, the same handful of habits kept showing up on the winning side:
Fingerprint the remote from its own output. Lavc59.37.100 in a returned JPEG pinned ffmpeg 5.1. My local install would have lied to me about what was exploitable. Never assume your toolchain matches theirs.
Design discriminating experiments instead of fuzzing. Nine container uploads proved Huddle's ftyp whitelist and deleted an entire branch of the search tree. Compare that to the parameter fuzz I ran earlier in the same challenge against a response whose URL was randomised per call - twenty-five parameters, twenty-five "different" results, zero information. Check that your oracle returns identical output for identical input before you trust it.
Read what the author deliberately closed. Every blocked path is a signpost. The ftyp check in Huddle isn't an obstacle, it's a map: it says the answer is inside the container.
Read failure semantics, not just code. Qfact's $a.Mode=0 is a silent no-op, not ECB.
Budget before you build. Popcnt cost me a 28-minute run because I didn't multiply queries by latency first.
Weight anomalies over volume. Whisper hands you an entire root filesystem. The four artifacts that mattered were all simply things that don't belong on a dev workstation.
