Category: Forensics
Flag: texsaw{you_found_me_at_key}
Challenge Description
I can’t find my original house key anywhere! Can you help me find it? Here’s a picture of my keys the nanny took before they were lost. It must be hidden somewhere!
Analysis
The challenge file was a very large PNG, so the first useful question was whether it was just an image or an image carrying extra data. Running file, sha256sum, and exiftool showed that the file was an 8192 x 5460 RGBA PNG and, more importantly, that ExifTool warned about trailer data after the PNG IEND chunk.
file "/home/rei/Downloads/Temoc_keyring.png"
sha256sum "/home/rei/Downloads/Temoc_keyring.png"
exiftool -G -s -a "/home/rei/Downloads/Temoc_keyring.png"/home/rei/Downloads/Temoc_keyring.png: PNG image data, 8192 x 5460, 8-bit/color RGBA, non-interlaced
d35eda435e35e8c8c0335b537a806421c32acbedf701bf6824357201781591f0 /home/rei/Downloads/Temoc_keyring.png
[ExifTool] Warning: [minor] Trailer data after PNG IEND chunk
[PNG] ImageWidth: 8192
[PNG] ImageHeight: 5460
[XMP] About: uuid:faf5bdd5-ba3d-11da-ad31-d33d75182f1bThat trailer warning was the real lead. pngcheck agreed that there was additional data after IEND, and a strings sweep pulled out names that looked like embedded image paths: key/Temoc_keyring(orig).png and key/where_are_my_keys.png.
pngcheck -v work/Temoc_keyring.png
strings work/Temoc_keyring.png | rg -i "flag|ctf|pass|secret|key|texsaw"
xxd work/Temoc_keyring.png | rg -n "PNG|JFIF|PK|IEND|IHDR|IDAT"pngcheck reported many IDAT chunks
chunk IEND at offset 0x25580a8, length 0
additional data after IEND chunk
ERRORS DETECTED in work/Temoc_keyring.png
strings contained key/Temoc_keyring(orig).pngux and key/where_are_my_keys.pnguxAt that point the right move was to look for an appended archive instead of trying blind PNG steg tricks. binwalk confirmed a ZIP archive starting at offset 39157936 (0x25580B0).
binwalk work/Temoc_keyring.png0x0 PNG image, total size: 39157936 bytes
0x25580B0 ZIP archive, file count: 3, total size: 3924070 bytesThere was also a tiny post-IEND trailer, so I carved it out to see whether it was the payload or just ZIP bookkeeping. The script found a 304-byte trailer that still identified as a ZIP with extra data prepended, which suggested the meaningful payload was the larger appended ZIP that binwalk had already located.
from pathlib import Path
src = Path('/home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/work/Temoc_keyring.png')
out = Path('/home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/results/trailing.bin')
b = src.read_bytes()
marker = b'IEND\xaeB`\x82'
i = b.rfind(marker)
print('IEND index:', i)
print('Total size:', len(b))
if i != -1:
trailer = b[i+8:]
out.write_bytes(trailer)
print('Trailer size:', len(trailer))
print('Wrote:', out)
else:
print('IEND not found')python extract_trailer.pyIEND index: 43081694
Total size: 43082006
Trailer size: 304
Wrote: /home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/results/trailing.bin
file trailing.bin => Zip archive, with extra data prepended
sha256 trailing.bin => 8b6fa8c01e6efc68738e8b8689dc5ef19a13cce855b5f7fb8765eb685347a663The actual payload came from carving the file at the binwalk offset. That produced an appended.zip, and unzip -l showed exactly the filenames hinted at by the earlier strings output: a directory named key/ containing Temoc_keyring(orig).png and where_are_my_keys.png.
from pathlib import Path
src = Path('/home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/work/Temoc_keyring.png')
out = Path('/home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/results/extracted/appended.zip')
b = src.read_bytes()
offset = 39157936
out.write_bytes(b[offset:])
print('Wrote', out, 'size', out.stat().st_size)python carve_zip.pyWrote /home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/results/extracted/appended.zip size 3924070
file => Zip archive data
sha256 => e49674ca5ad8f42bf64897fa495e2f334bd0a61229313910aab6e0957cbff591
unzip -l showed:
key/
key/Temoc_keyring(orig).png
key/where_are_my_keys.pngAfter extracting the archive, both embedded images turned out to be ordinary 1024 x 1024 RGB PNGs with different hashes. One had Content Credentials metadata and the other had Software: PngUnit, which was a nice hint that one image had been modified.
unzip -o appended.zip -d results/extracted
file results/extracted/key/Temoc_keyring\(orig\).png
file results/extracted/key/where_are_my_keys.png
sha256sum results/extracted/key/Temoc_keyring\(orig\).png results/extracted/key/where_are_my_keys.png
exiftool -G -s -a results/extracted/key/Temoc_keyring\(orig\).png
exiftool -G -s -a results/extracted/key/where_are_my_keys.pngTemoc_keyring(orig).png: PNG image data, 1024 x 1024, 8-bit/color RGB, non-interlaced
where_are_my_keys.png: PNG image data, 1024 x 1024, 8-bit/color RGB, non-interlaced
SHA256 orig: de8da2786df11f38e9b6b663fa7d903163c2f6d3980a9810be53c73688abdfca
SHA256 mod : 7cf5f656383287b01f6f90790c8f3d0af41a974b67f889a82905288ea52f202e
exiftool on orig showed JUMBF / Content Credentials metadata
exiftool on mod showed Software: PngUnit http://SharePower.VirtualAve.net/png.htmlI tried zsteg once on both embedded PNGs, but the results were just a pile of false-positive signatures with no coherent flag text. That failure mattered because it justified stopping the generic steg search early and comparing the two images directly instead.
zsteg -a results/extracted/key/Temoc_keyring\(orig\).png
zsteg -a results/extracted/key/where_are_my_keys.pngnumerous candidate signatures such as OpenPGP Secret Key / Public Key / ARJ / BIFF, but no coherent flag textThe image diff was the turning point. A simple PIL script showed that only 131 pixels differed out of 1048576, and the bounding box of all changes was (1, 0, 216, 1). In other words, the difference was confined to a tiny strip in the first row.
from PIL import Image, ImageChops
from pathlib import Path
base = Path('/home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/results/extracted/key')
a = Image.open(base/'Temoc_keyring(orig).png').convert('RGB')
b = Image.open(base/'where_are_my_keys.png').convert('RGB')
diff = ImageChops.difference(a, b)
out = Path('/home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/results/diff.png')
diff.save(out)
print('saved', out)
bbox = diff.getbbox()
print('bbox', bbox)
if bbox:
cropped = diff.crop(bbox)
crop_out = Path('/home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/results/diff_crop.png')
cropped.save(crop_out)
print('saved', crop_out, 'size', cropped.size)
pa = list(a.getdata())
pb = list(b.getdata())
count = sum(1 for x, y in zip(pa, pb) if x != y)
print('diff_pixels', count, 'of', len(pa))python diff_images.pysaved results/diff.png
bbox (1, 0, 216, 1)
saved results/diff_crop.png size (215, 1)
diff_pixels 131 of 1048576
diff.png SHA256: 9e17517fddeaad25944b6ed7f8f7bd34ced6315010f5a39b70a0424140036f0d
diff_crop.png SHA256: d222acdff5bb777d770f5e6296d5832f873802a32c5e6015c273dc5da70ef4e1Listing the changed pixels showed that every difference was on row 0 and only flipped the red channel by +1 or -1. That is exactly the kind of pattern you expect when someone is storing bits rather than altering visible image content.
from PIL import Image
from pathlib import Path
base = Path('/home/rei/Downloads/CTFChan_Forensics_TexSaw2026_Lost_my_keys/results/extracted/key')
a = Image.open(base/'Temoc_keyring(orig).png').convert('RGB')
b = Image.open(base/'where_are_my_keys.png').convert('RGB')
width, height = a.size
row = 0
changes = []
for x in range(width):
pa = a.getpixel((x, row))
pb = b.getpixel((x, row))
if pa != pb:
changes.append((x, pa, pb, tuple((pb[i] - pa[i]) % 256 for i in range(3))))
print('change_count', len(changes))
for item in changes[:200]:
print(item)python list_changes.pychange_count 131
changes only affected row 0 and toggled red values by +1 or -1From there, the hidden message was straightforward: treat each x-coordinate in row 0 as a bit, using 1 when the red channel changed and 0 when it did not. Decoding that mask in 8-bit chunks produced texsaw{you_found_me_at_key} directly, and the same text also appeared when XORing the red-channel LSBs between the two images.
Solution
The final solve was the row-0 red-channel bit decode between the two extracted PNGs.
from PIL import Image
from pathlib import Path
base = Path('results/extracted/key')
a = Image.open(base/'Temoc_keyring(orig).png').convert('RGB')
b = Image.open(base/'where_are_my_keys.png').convert('RGB')
row = 0
width, _ = a.size
bits = []
for x in range(width):
ra = a.getpixel((x, row))[0]
rb = b.getpixel((x, row))[0]
bits.append('1' if rb != ra else '0')
out = []
for i in range(0, len(bits) // 8 * 8, 8):
out.append(chr(int(''.join(bits[i:i+8]), 2)))
print(''.join(out).split('\x00', 1)[0])python solve.pytexsaw{you_found_me_at_key}