Python · cryptography · pngcheck · exiftool · binwalk · zsteg · SQLite · Pillow · Caddy · FastAPI · curl

Introduction

CSAW CTF Quals 2026 covered a lot of ground: JWT forgery, a hidden ZIP inside a PNG, a database reconciliation puzzle, a three-platform OSINT chase, a geolocation challenge, a header-injection bug in a reverse proxy, a history riddle, and a flag sitting in plain sight on the CTF’s own homepage. Eight challenges, four categories.

Below is all eight, grouped by category and in the order I’d want to read them if I hadn’t solved any of it yet.


Web

TrustDinOIDC: JWT x5c Forgery and Algorithm Confusion

A portal called trustdinoidc-portal federates login through two OIDC-style providers, strataid.example.com and paleoid.example.com, and carries the session as a JWT cookie. Two gated pages exist: a members-only print and an admins-only print, which is presumably where the flag lives.

Decoding the token showed both providers sign with RS256 and embed an x5c field, an X.509 certificate chain, in the header:

1
{ "alg": "RS256", "typ": "JWT", "x5c": ["<base64 DER certificate>"] }

Loading the leaf certificate with cryptography‘s x509.load_der_x509_certificate turned up something worth noticing: the certificate was self-signed (subject equals issuer), and the chain held only that one certificate, no intermediate or root above it. That’s the usual sign of a verifier that pulls the RSA public key straight out of x5c[0] without checking whether the certificate itself is trusted against a pinned root.

Method 1: swap the certificate. If the verifier trusts whatever’s in the header, I can generate my own keypair, self-sign a certificate with the same CN, and sign the JWT with my own key:

1
2
3
4
5
6
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = (x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "strataid.example.com")]))
.issuer_name(same) # self-signed
.public_key(key.public_key())
.sign(key, hashes.SHA256()))

Against StrataID this worked outright: the forged cookie produced a valid logged-in session. Against PaleoID it failed with certificate mismatch, meaning PaleoID actually pins the certificate rather than trusting whatever arrives in the header. One provider hardened, one not.

Method 2: RS256 to HS256. Since a legitimate token also exposes the provider’s real public key, the classic follow-up is switching alg to HS256 and using the RSA public key bytes as the HMAC secret. I tried it against both providers’ real certificates, in three key encodings, and got The specified alg value is not allowed every time. The app enforces an algorithm allowlist globally, not per-issuer, so that path was closed regardless of which key material I used.

Escalating the forged token. With the StrataID bypass confirmed, I edited the payload to sub: admin and set scope to flagosaurus:redeem, guessed by analogy from a legitimately-issued PaleoID token that used freeosaurus:redeem for the member-tier print.

That forged cookie was the last thing tested before time ran out, and the final response from the admin endpoint wasn’t confirmed. I’m including the writeup anyway because the two bypass attempts and the reasoning behind them are the useful part, even without a flag to show for it.

Takeaway: an x5c header is only as trustworthy as whatever chain-of-trust check sits behind it. A verifier that extracts a key from a self-signed, single-entry chain and calls it done is really just trusting the client to say who they are.

Golf Heist: Caddy Forward-Auth Header Injection

An “Avispa Country Club” site running on FastAPI, with a public page, a members’ collection page, a vault gating a “special item,” and an internal engineer dashboard documenting the site’s own infrastructure. Three decoy /pro-shop endpoints return 418 I'm a teapot with hints for an Enigma-style cipher that unlocks a passphrase.

Collecting the rotor hints. Each teapot endpoint returns a base64-encoded, zero-padded number in an X-Golf-Hint header, tagged Rotor I, II, or III:

1
2
3
GET /pro-shop/inventory/clubs -> Rotor I
GET /pro-shop/inventory/balls -> Rotor II
GET /pro-shop/inventory/bags -> Rotor III

Stripping the leading zeros gave rotor positions 18, 14, and 15.

Running the cipher. The engineer dashboard serves main.py in full, cipher and word lookup included, so this was just execution:

1
2
3
4
5
6
7
8
9
10
def enigma(r1, r2, r3):
def f(c, w, o): return w[(ALPHA.index(c)+o)%26]
out = []
for s in ["G","O","L"]:
c=s; c=f(c,W["I"],r1%26); c=f(c,W["II"],r2%26); c=f(c,W["III"],r3%26)
c=W["REF"][ALPHA.index(c)]; c=f(c,W["III"],(26-r3%26)%26)
out.append(c)
return out

enigma(18, 14, 15) # -> ['W', 'O', 'H']

Indexing the WORDS table with pattern [0, 1, 0] gives the phrase wedge out handicap, which unlocks a “privileged” tier and, inside the response body, a direct pointer to the actual bug:

“The caddy has a known vulnerability. Check GHSA-7r4p-vjf4-gxv4.”

Finding the bypass. The dashboard exposes the site’s own Caddy config:

1
2
3
4
5
6
7
:8000 {
forward_auth 127.0.0.1:9091 {
uri /auth
copy_headers X-User-Id X-User-Role
}
reverse_proxy 127.0.0.1:9092
}

copy_headers is meant to forward identity headers set by the auth service to the backend, but per the linked advisory, Caddy’s forward_auth doesn’t strip those header names from the original client request first. If the auth service doesn’t overwrite them, a client-supplied X-User-Role sails straight through, and the backend trusts it without question:

1
2
3
role = req.headers.get("X-User-Role", "")
if role.lower() == "admin":
return {"success": True, "tier": "admin", ..., "flag": FLAG}

So the fix, from an attacker’s seat, was to just set the header myself:

1
2
3
4
5
curl -s -X POST https://<host>/api/vault/admin-item \
-H "Content-Type: application/json" \
-H "X-User-Role: admin" \
-H "X-User-Id: whoever" \
-d '{"phrase":"wedge out handicap"}'

That returned "tier": "admin" with the flag attached.

Takeaway: copy_headers on a forward-auth proxy only does what its name says: it copies headers from the auth response to the backend. It says nothing about stripping the same header names from the original client request, which means a client can plant a value that survives untouched if the auth service doesn’t happen to overwrite it. Worth checking on any Caddy config using forward_auth.


Forensics

Hemispheres: PNG Polyglot and LSB Steganography

A single 720×400 PNG, the_signal.png, with a prompt that all but spells out the two-layer structure: something hidden in the file itself, locked, and a key hidden separately in the pixels rather than written down anywhere.

Structural inspection with pngcheck -vvv flagged “additional data after IEND chunk.” PNG viewers stop reading at IEND, so anything appended past it is invisible to normal tools while still sitting in the file. exiftool -a -u -g1 surfaced a comment confirming exactly that:

“Look past IEND for the lock. The key is in the pixels, not the words.”

Finding the hidden archive with binwalk:

1
2
3
4
170      0xAA      Zlib compressed data, default compression
8286 0x205E Zip archive data, encrypted, name: flag.txt
8379 0x20BB Zip archive data, encrypted, name: README.txt
8590 0x218E End of Zip archive

An encrypted ZIP had been concatenated after the real PNG’s IEND chunk, a standard polyglot. binwalk -e couldn’t pull the encrypted archive out on its own, so I carved it manually:

1
2
dd if=the_signal.png of=hidden.zip bs=1 skip=8286 count=326
unzip -l hidden.zip

Extracting the key meant sweeping the pixel data rather than the metadata, so I reached for zsteg, which checks every bit-plane, channel, and scan-order combination automatically:

1
zsteg -a the_signal.png

Buried in the noise (repeated characters and spurious signature matches are normal when you’re brute-forcing every plane) was one clean result:

1
b1,b,lsb,xy .. text: "r3ad_b3tw33n_th3_p1x3ls"

Bit-plane 1 of the blue channel, LSB-first, row-major order, which is also a direct echo of the challenge’s own “read between the pixels” framing. That string unlocked the archive:

1
unzip -P 'r3ad_b3tw33n_th3_p1x3ls' hidden.zip

Takeaway: a file that “looks like a perfectly ordinary PNG” to every renderer can still be carrying a second file after IEND, and metadata is worth reading before touching a steganography tool: the hint here told me exactly where not to waste time looking.


OSINT

Roll-Call: SQLite Reconciliation

intake.db is a SQLite case file with five tables: handles, identities, watchlist, escalations, and dm_index. The task is a reconciliation exercise, cross-referencing online handles against real identities and checking where an existing watchlist got it wrong.

Mapping handles to people. The identities table already resolves some handle pairs to the same person, collapsing 12 tracked handles down to 10 distinct individuals.

Comparing against the watchlist, which held only 8 entries, left a 2-person gap. The trap in this challenge is assuming both belong there without reading further:

  • HALCYONLEAKS turned out to be a genuinely suspicious profile: an account three weeks old, five escalation records, and 25 direct messages sent to investor-relations contacts over two weeks, actively soliciting insider sources. A real gap, and it should have been on the watchlist.
  • still_water_77 looked similar on the surface, flagged with an escalation, but the actual content was 41 posts of quiet, grief-toned material concentrated on boards tied to a tragedy. The one escalation attached to this handle was a routine filler phrase, a false positive. This person is correctly absent from the watchlist.

The flag combines the genuine miss and the correctly-excluded false positive, which is really the point of the challenge: don’t trust the escalation flag without reading what’s actually behind it.

1
csaw{halcyonleaks_still_water_77}

The Vantage Job: A Chase Across Three Platforms

A private auction house in Geneva gets robbed of a hardware wallet holding the keys to a dormant 340 BTC wallet. Two files: a PDF “left by the thief” and a short poem. No instance, just a chain of real platforms to work through under the handle “Ferryman.”

The PDF hides its text as an image. pdftotext and strings came up empty because the whole page is a single embedded raster image, not a text layer. The pixel values were confined to 252 to 255, rendering as near-invisible pale grey, exactly what the poem in the second file was describing (“paper keeps what paper hides, pale as breath on frosted glass”). A full contrast stretch in Pillow revealed the real text, which read differently at full contrast than at a glance (bull became bot, sector became secret):

“A bot has no thumb, no whorls, no line, yet it wears one word as a secret sign… slash the fingerprint, and it’ll know what you mean.”

Translation: find a bot, DM it privately, send the slash command /fingerprint.

Discord → Instagram → X → a TinyURL. DMing ferryman_vt on the CSAW Discord and sending /fingerprint returned a reply pointing at a reused username elsewhere. The persona’s Instagram had a poem signed “-X” where the first letter of each line spelled TOCKFERR, and the signature pointed at X (Twitter) as the next platform.

Mapping the follow graph on X did the rest of the work: @ferryman_vt has exactly one follower, @saatviks28, who in turn follows three accounts: tockferr, ferryman_vt, and a new one, @timepieces_ferr, tagged “Geneva.” Its bio held a string that looked like a Pastebin paste ID but was a dead end, and its actual post linked a TinyURL. That resolved to a large base64 string, which decoded to a raw JPEG whose EXIF UserComment field (UTF-16BE, UNICODE-prefixed per the EXIF convention) held the flag:

1
2
3
4
raw = base64.b64decode(b64_string)
idx = raw.find(b'UNICODE')
flag = raw[idx+8:].decode('utf-16-be')
# -> "csaw{cr0ss_pl4tf0rm_carel3ssness}"

Dead ends worth naming, since the challenge had several deliberately shaped like real leads: a Reddit account replying in character to /fingerprint with a 128-character hex string that resisted every hash/cipher I threw at it; an “Enter X Number” DM gate that turned out to be an ordinary X privacy feature; a “coordinate” an automated decoder flagged that didn’t reproduce and didn’t fit the story; and two digits hidden in ferryman_vt‘s avatar via bright-pixel isolation that were real but unused in the actual solve.

Takeaway: OSINT chases like this one reward reading each piece of “found” content literally before reading it symbolically, and a red herring here was usually shaped exactly like a genuine lead, which is what made ruling each one out worth documenting rather than skipping past.

High Tide: Geolocation from a Single Photo

One image, high_tide.png: a tight crop of a round stone fortification with a French flag flying above it, shot as a Google Maps photo (navigation arrows and watermark visible). No other context.

The title was the whole clue. This challenge follows an earlier web challenge in the same CTF built around a gateway hostname called low-tide-lb.ctf.csaw.io. “High Tide” as the sequel name, paired with a French flag and a stone fortification, points at one landmark where the tide itself is the defining feature: Mont Saint-Michel, the tidal island abbey in Normandy known for the speed of its tidal bore.

Mont Saint-Michel’s ramparts include seven named round towers, and the one in the photo, thick and relatively unadorned, filling most of the frame, best matched Tour Gabriel, documented as the largest tower on the circuit. Since the flag format asked for the name of the place rather than a specific feature, the site-level answer was the one to submit:

1
csaw{mont_saint_michel}

The more specific csaw{tour_gabriel} was ready as a fallback, but the site-level name was correct on the first try.


Misc

Comradery and Brotherhood: A History Riddle

No files, no instance, just a riddle:

“Comradery and brotherhood, fighting side by side. All put to an end over a clouded fight. Two men fought, one left distraught. Find the name of the other man.”

The clues that matter: the two men were real allies in actual combat before things went wrong, “clouded fight” is ambiguous between a controversial fight and something literally clouded, the confrontation was personal and one-on-one, and the flag format allows a title, not just a name.

First guess, and wrong: boxing. Larry Holmes and Muhammad Ali fit the emotional shape (Holmes called their bond brotherhood, then dominated an ailing Ali in a fight many call one that should never have happened, and wept afterward). Both csaw{muhammad_ali} and csaw{larry_holmes} were rejected, because “fighting side by side” doesn’t actually hold for sparring partners who never fought together against a common opponent.

Re-reading “clouded” literally, as wine rather than metaphor, points at Alexander the Great and Cleitus the Black. Cleitus saved Alexander’s life at the Battle of the Granicus, cutting down a Persian noble about to kill him from behind, genuine side-by-side combat. Their bond ended at a banquet in Maracanda where, per Arrian, the wine was “strong and plentiful.” A drunken argument escalated until Alexander grabbed a spear and killed Cleitus on the spot, then spent days refusing food and had to be physically restrained from suicide.

Alexander is clearly “the one left distraught,” which makes the other man Cleitus, known by his epithet Cleitus the Black, which is exactly what the flag format’s allowance for a “title” was hinting at.

1
csaw{cleitus_the_black}

Sanity Check: Hiding in Plain Sight

Prompt: “Read the rules, and admire the page!” No files, no instance.

Dead ends first. Given how often this CTF hid data in unusual places, I checked the obvious ones: the raw challenge JSON for an unrendered HTML comment (clean), a /rules or /page/rules route on the platform (both 404), and a commented-out stars.webp background image found via view-source. That last one got a full pass, RIFF chunk parsing for trailing data, a pixel-histogram check for a suspicious value band, heavy downsampling for a hidden watermark, and it came back clean on every axis. Genuine red herring, reusing a “hide it in the media” motif that paid off elsewhere in this CTF specifically to bait that investigation here.

The actual solve was in the Discord rules text, where one line stands out as grammatically odd for a moderation policy:

“DMing a moderator/organizer will get your question ignored and swap heist_completed to heist_started.”

Nothing about DMing a moderator would plausibly swap anything, which is the tell that it’s a planted instruction. Separately, the CTFd homepage (not /challenges, which doesn’t render this section) has a themed hero banner:

1
crew@vault:~$ ./crack --target mainframe && cat csaw{heist_completed}

That’s “admire the page,” but it’s the bait version. The Discord line is the actual instruction: take the visible string and swap heist_completed for heist_started.

1
csaw{heist_started}

Takeaway: the two halves of the hint, “read the rules” and “admire the page,” were never separate leads pointing at separate flags. They were one instruction split across two places on the platform, and neither half means anything without the other.


Closing Thoughts

Eight challenges, and the pattern across most of them was the same: don’t trust the first thing you find, and read what’s actually in front of you instead of what a tool tells you should be there. Golf Heist handed over its own vulnerable config in an “engineer dashboard” and named the CVE outright, but the fix still meant reading copy_headers closely enough to notice what it doesn’t do. Hemispheres and The Vantage Job both hid real content behind near-invisible pixel values, and both told you exactly where to look if you read the accompanying text instead of skipping straight to the file. Sanity Check’s two-part hint and TrustDinOIDC’s two providers with two different security postures made the same point from opposite directions: the interesting part of a system is rarely the part that behaves the same everywhere.

TrustDinOIDC is the one loose end. The StrataID bypass worked, and the forged admin token was sent, but I didn’t get confirmation back before time ran out. I’m leaving it in as written rather than filling in an ending that didn’t happen.