OSINTFortID 2026
Flag from the Past
A raw PNG on the organiser's blog declares 60 fewer scanlines than its IDAT data actually holds. Restoring the height field and the IHDR CRC reveals a hidden strip of pixels with the flag.
What this one teaches
- How to detect a PNG cropped by editing its header: divide the decompressed IDAT length by the scanline stride (1 + width * bytes_per_pixel) and compare the row count against the declared height.
- Why decoders never show the extra rows: they stop after the declared height, so scanlines past it sit in the zlib stream unread and raise no error.
- Using a Wayback Machine CDX listing to enumerate a company's blog posts published after an event.
- Spotting the one image on a page that skips the site's image pipeline, since a re-encoded image cannot carry byte-level hidden data and only the untouched one can.
Hints
Stuck? Open one hint, then go back and try again before reading the next.
Hint 1 of 3Show
The flag is in a post on the organiser's own blog, published after last year's contest.
Hint 2 of 3Show
Look at how each image on the page is served. One of them is different from the rest.
Hint 3 of 3Show
The image holds more pixel data than its header admits to.
Full solution below. Spoilers ahead.
Task description
If you stumbled upon one of our flags after last year’s contest, now is the time to submit it. If not, I guess you’ll just have to use your skills and find it, it should still be around… 👀
There is no handout and no service. The platform lists it as OSINT, and the OSINT part is finding the right page. The rest is a small piece of PNG forensics. The organiser’s real domain is replaced throughout with <company-domain>.
Finding the post
The description says a flag was published somewhere public after last year’s contest and “should still be around,” so the target is a post-contest artefact on the organiser’s own property. A Wayback Machine CDX listing (the archive’s index of captures) of the company domain surfaces its blog. One post about last year’s CTF was first archived about a month after that contest ended, and it is still live today.
curl -s "http://web.archive.org/cdx/search/cdx?url=<company-domain>*&output=txt&collapse=urlkey" | grep blog
The post’s last paragraph confirms the place. It says that anyone who got this far might want to look more closely at the post, because there could be a flag hidden in it that “might even come in handy a year from now.” That is the task description’s promise, so I was reading the right page.
Spotting the odd image
The visible text, the links and the HTML comments held no FortID{, so the flag was in an asset rather than the markup. I listed every <img> in the article and looked at where each was served from. Four of them came from the site’s /_astro/*.webp path, meaning the Astro build pipeline had re-encoded them as WebP. A re-encode rewrites every pixel, so it destroys any byte-level payload, and those four could be dismissed. One image, the final scoreboard, was served byte-for-byte as a raw PNG from /img/, untouched by the pipeline, and it carried the tooltip “Wonder who got 6th place?”. The rendered scoreboard stops after 5th place, so the tooltip is a direct nudge: there is a 6th row, and it is not being shown.
The PNG declares fewer rows than it holds
curl -s https://<company-domain>/img/wonder_who_got_6th_place.png -o score.png
The file is plain: IHDR, six IDAT chunks and IEND, with no text or ancillary chunks and nothing after IEND. The header declares 2412 x 1472, 8-bit RGB, non-interlaced. The check that cracks it is to decompress the concatenated IDAT payload and compare its length against what that header implies.
A row of raw PNG pixel data is one filter-type byte followed by width * bytes_per_pixel bytes, so for 8-bit RGB the stride is 1 + 2412 * 3 = 7237 bytes per row. The declared 1472 rows should therefore account for 1472 * 7237 = 10,652,864 bytes. The stream holds more:
decompressed IDAT bytes : 11,087,084
expected for 1472 rows (1472 * 7237) : 10,652,864
rows actually present (11,087,084 / 7237) : 1532
Those figures divide exactly (7237 * 1532 = 11,087,084, with no remainder), so the stream carries precisely 60 whole scanlines more than the header admits. That exactness is itself the evidence: a corrupted or truncated stream would not land on a clean row boundary. Someone lowered the height field from 1532 to 1472, and the strip below the 5th-place row stayed in the file, unseen.
Why a PNG can carry rows past its declared height
A PNG decoder reads the IHDR width and height first, then pulls exactly height filtered scanlines out of the zlib stream in the IDAT chunks and stops. If the stream contains more rows than the header asks for, almost every decoder ignores the surplus without complaint, because it never tries to read them. Lowering the height field (and recomputing the IHDR CRC so strict decoders still accept the file) visually crops the image while leaving the cropped pixels intact in the bytes. It is the same class of bug as the 2023 “aCropalypse” issue, where cropped screenshots from some phone and desktop tools still contained the original pixels, just unreferenced.
To recover the true height without guessing, divide the decompressed IDAT length by the scanline stride 1 + width * bytes_per_pixel. If it divides cleanly to a larger number than the declared height, that quotient is the real row count.
Restoring the height
Setting the height field back to 1532 and recomputing the IHDR CRC (so strict decoders accept the file) makes the whole image render:
import struct, zlib
d = bytearray(open('score.png', 'rb').read())
ihdr = bytearray(d[16:29]) # IHDR chunk data (13 bytes)
ihdr[4:8] = struct.pack('>I', 1532) # height: 1472 -> 1532
d[16:29] = ihdr
d[29:33] = struct.pack('>I', zlib.crc32(b'IHDR' + bytes(ihdr)))
open('score_fixed.png', 'wb').write(d)
The IHDR chunk data sits at bytes 16 to 28 (8-byte PNG signature, then the 4-byte length and 4-byte type of the first chunk, then its 13 bytes of data), with the chunk’s CRC in the following four bytes. The CRC is computed over the chunk type plus its data, which is why b'IHDR' is prepended before zlib.crc32. Without fixing the CRC, a lenient decoder would still show the image but a strict one would reject it as corrupt.
A white strip appears under the 5th-place row with one line of monospace text, which is the flag. To avoid misreading 0 against O and 1 against l, I decoded rows 1472 to 1531 straight from the raw scanlines (undoing the PNG row filters by hand) and enlarged them 3x. At that scale the zeros show the dotted centre of a monospace zero and the 1 has a serif base, which settled the ambiguous characters.
The flagReveal
FortID{H0ard1ng_Fl4gz_4ll_Y3ar_R0und}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.