{"id":"GHSA-xgh8-jp6q-p5x8","summary":"Pillow: ReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes","details":"```\n============================================================================\nReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes\nLocation: src/PIL/XbmImagePlugin.py:58  in XbmImageFile._open()\nCommit:   a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nA crafted 512-byte file makes Pillow's `Image.open()` spend about six minutes of processor time on one core before it gives up with `UnidentifiedImageError`. Anyone who can hand bytes to an application that opens images with Pillow's default settings can trigger this, for example through an upload form, a thumbnailer, or a service that checks whether a blob is an image. A few such files sent at once can tie up a typical pool of worker processes.\n\nThe cause is the `xbm_head` regular expression in `src/PIL/XbmImagePlugin.py:32-40`, which `XbmImageFile._open()` (line 58) runs over the first 512 bytes of any stream whose prefix starts with `#define`. Several adjacent parts of the pattern can match the same characters. `[ \\t]+` overlaps with `.*` in the width and height lines and with `[^_]*` in the optional hotspot lines, and `[\\r\\n]+` closing the hotspot group overlaps with the following `[\\000-\\377]*`. The header sets a run of k spaces after each `#define` and a run of k line terminators after `_y_hot`, then omits the `[]` after `_bits`. That forces Python's backtracking matcher to try every way of splitting those runs and rescan the remaining bytes each time. The cost grows roughly as k to the fifth power: k = 60 measured 375 s, while a well-formed XBM of the same size opens in about 40 ms. Because this happens before any format is identified, none of Pillow's size, pixel-count or decompression-bomb limits apply, and upload size limits do not help because the file is tiny. `ImageFile.Parser.feed()` repeats the cost on every chunk until the stream is identified. The only existing mitigation is passing a `formats=` allowlist that excludes `\"XBM\"`.\n\nThe attached reproducer opens the same header with and without the trailing `[]`. The valid version opens as XBM in 52 ms. The broken version takes 0.1 s, 0.75 s, 3.0 s and 8.6 s at k = 10, 15, 20 and 25, and the slow k = 60 case is not run. This is the same class of bug as CVE-2021-25292 in `PdfParser`, but in a different plugin and reachable from plain `Image.open()`.\n\n## Details\n\n`XbmImageFile._open()` identifies an XBM file by running the module-level regex `xbm_head` over the first 512 bytes of the stream. The pattern has several adjacent quantifier pairs that can consume the same characters, followed by a `[\\000-\\377]*_bits\\[]` tail that rescans the rest of the buffer for every way of splitting them. A 512-byte input built to exercise all of them and then fail the final literal keeps `Image.open()` in `xbm_head.match()` for minutes before it raises `UnidentifiedImageError`.\n\nThis 512-byte input (k = 60) takes 375 s of CPU in `Image.open(io.BytesIO(data))` with default settings, measured on the stock build at this commit:\n\n```python\nk = 60\ndata  = b\"#define\" + b\" \"*k + b\"_width 1\\r\"\ndata += b\"#define\" + b\" \"*k + b\"_height 1\\r\"\ndata += b\"#define\" + b\" \"*k + b\"_x_hot 1\\r\"\ndata += b\"#define\" + b\" \"*k + b\"_y_hot 1\" + b\"\\r\"*k\ndata += b\"_bits\"                      # no \"[]\": the final literal never matches\ndata += b\"A\" * (512 - len(data))      # pad to the 512 bytes _open() reads\n```\n\nCalling `XbmImagePlugin.xbm_head.match(data)` directly gives the same picture: k = 10 → 0.11 s, k = 15 → 0.76 s, k = 20 → 3.0 s, k = 25 → 8.5 s, k = 30 → 19.9 s (roughly ×27 per doubling of k, i.e. about k^4.7). A well-formed 512-byte XBM matches in 10 µs and opens and loads through `Image.open()` in ~40 ms including plugin import; the same k = 60 header with `_bits[]` present matches in 5 µs.\n\nThe regex, `src/PIL/XbmImagePlugin.py:32`:\n\n```python\nxbm_head = re.compile(\n    rb\"\\s*#define[ \\t]+.*_width[ \\t]+(?P\u003cwidth\u003e[0-9]+)[\\r\\n]+\"\n    b\"#define[ \\t]+.*_height[ \\t]+(?P\u003cheight\u003e[0-9]+)[\\r\\n]+\"\n    b\"(?P\u003chotspot\u003e\"\n    b\"#define[ \\t]+[^_]*_x_hot[ \\t]+(?P\u003cxhot\u003e[0-9]+)[\\r\\n]+\"\n    b\"#define[ \\t]+[^_]*_y_hot[ \\t]+(?P\u003cyhot\u003e[0-9]+)[\\r\\n]+\"\n    b\")?\"\n    rb\"[\\000-\\377]*_bits\\[]\"\n)\n```\n\nand the only call site, `src/PIL/XbmImagePlugin.py:58`:\n\n```python\n    def _open(self) -\u003e None:\n        …\n        m = xbm_head.match(self.fp.read(512))\n        if not m:\n            msg = \"not a XBM file\"\n            raise SyntaxError(msg)\n```\n\nFor a well-formed header (`#define im_width 8\\n…static char im_bits[] = {`) each `[ \\t]+` eats the single space, `.*`/`[^_]*` eat the identifier prefix, and `[\\000-\\377]*_bits\\[]` finds the literal on its first backward scan, so the match is linear. The crafted header diverges at every one of those points:\n\n- `[ \\t]+.*_width` and `[ \\t]+.*_height`: the run of k spaces after `#define` can be split between `[ \\t]+` and `.*` in k ways, all leading to the same continuation. Factor k each. (The choice of `\\r` terminators is incidental: the same header with `\\n` terminators is equally slow.)\n- `[ \\t]+[^_]*_x_hot` and `[ \\t]+[^_]*_y_hot` inside the optional `hotspot` group: `[^_]*` also matches spaces, so the same k-way split exists twice more. Factor k each. These two lines are essential: without them the same input is rejected in about 10 ms.\n- `[\\r\\n]+` closing the `hotspot` group, followed by `)?` and `[\\000-\\377]*`: the run of k `\\r` bytes after `_y_hot 1` can be divided between `[\\r\\n]+` and `[\\000-\\377]*` in k ways. Factor k.\n- `[\\000-\\377]*_bits\\[]`: for every combination above, the engine swallows the remaining ~150 bytes of padding and walks back looking for `_bits[]`. The input contains `_bits` but never `[]`, so this always fails and every combination is visited.\n\nCPython's `re` is a backtracking engine without memoisation, so the total work is on the order of k^5 × (512 − header length) steps — about 10^11 for k = 60, consistent with the measured 375 s. The 512-byte read caps k (5k + ~60 ≤ 512), so the cost per `open()` is bounded, but the bound is minutes of CPU.\n\nThe path is the default one. `Image.open()` (`src/PIL/Image.py:3719`) reads a 16-byte prefix and offers it to every registered plugin's accept hook; `XbmImagePlugin._accept()` (`src/PIL/XbmImagePlugin.py:43`) returns true for anything whose left-stripped prefix starts with `#define`, and `_open()` then runs the regex. No size, pixel-count or decompression-bomb check can apply before this point because nothing has been parsed yet, and since the match ultimately fails the file is never identified, so the only outcome for the caller is `UnidentifiedImageError` after the delay. File name and extension are irrelevant because dispatch is by content. A `faulthandler` stack dump taken 20 s into the call shows the interpreter at `XbmImagePlugin.py:58 in _open` ← `ImageFile.py:154 in __init__` ← `Image.py:3699 in _open_core` ← `Image.py:3719 in open`.\n\n`ImageFile.Parser.feed()` (`src/PIL/ImageFile.py:584`) amplifies this further: until the stream is identified it calls `Image.open()` on all accumulated data at every `feed()`, so an application feeding the stream in chunks pays the regex cost once per chunk (measured: k = 15 header fed in 8 chunks → 4.6 s versus 0.76 s for a single `open()`).\n\n## Impact\n\nAnyone who can get 512 bytes in front of `Image.open()` — an upload endpoint, thumbnailer, or any service that uses Pillow to sniff whether a blob is an image — pins one CPU core of the handling process for roughly six minutes per request with the default plugin set and default limits. Nothing large is allocated and no `DecompressionBombWarning`/`Error` fires; application-side upload size limits do not help because the file is tiny, and `Image.MAX_IMAGE_PIXELS` is never consulted because no image is ever identified. A handful of concurrent requests is enough to exhaust a typical worker pool. The per-call cost is bounded by the 512-byte header read, so this is a CPU-exhaustion denial of service of the handling process rather than an infinite loop. Applications that pass an explicit `formats=` allowlist to `Image.open()` that excludes `\"XBM\"` are not affected.\n\nThis is the same bug class as CVE-2021-25292 (catastrophic backtracking in the `PdfParser` regexes, fixed in 8.1.1) and CVE-2021-23437 (`ImageColor.getrgb` ReDoS, fixed in 8.3.2), in a different plugin; unlike the PDF case it is reached from plain `Image.open()` on any byte stream.\n\n## Reproducing\n\n`repro` is a single Python script. It uses the installed Pillow through the public `Image.open()` API and needs no rebuild.\n\nEach input is the 512-byte header from Details: four `#define` lines with k spaces after `#define`, lines ending in `\\r`, k `\\r` bytes after `_y_hot 1`, padding with `A` up to the 512 bytes that `_open()` reads. The only thing that changes between inputs is the tail: `_bits[]` (valid) or `_bits` (the final literal can never match).\n\n1. **Control.** Opens the k = 60 header with the tail `_bits[]`. The regex matches on its first try and Pillow reports `XBM (1, 1)` in about 50 ms. This shows that the same bytes are only slow when the match has to fail.\n2. **Attack.** Opens the header with the tail `_bits` at k = 10, 15, 20 and 25. Each one raises `UnidentifiedImageError`, after about 0.1 s, 0.75 s, 3.0 s and 8.6 s. The time grows polynomially, not linearly. The k = 60 input from Details, which takes about 6 minutes of CPU, is not run.\n\nExpected output on this commit:\n\n```\ncontrol k=60 '_bits[]': XBM (1, 1) in 0.052s\nattack  k=10 '_bits'  : UnidentifiedImageError in 0.105s\nattack  k=15 '_bits'  : UnidentifiedImageError in 0.748s\nattack  k=20 '_bits'  : UnidentifiedImageError in 3.024s\nattack  k=25 '_bits'  : UnidentifiedImageError in 8.561s\nBUG\n```\n\nThe script prints `BUG` only when all of these hold:\n\n- the control header opened as `XBM` in under 1 s;\n- the k = 25 attack input took more than 2 s;\n- the k = 25 attack input took more than 5 times as long as the k = 15 one, i.e. the cost grows super-linearly in k.\n\nOtherwise it prints `OK`. With the attached patch applied, every attack input is rejected at once, so the script prints `OK`.\n\n## Suggested fix\n\nThe attached patch removes the overlap between `[ \\t]+` and the quantifier that matches the identifier prefix in `xbm_head` (`src/PIL/XbmImagePlugin.py:32-40`), so the whitespace after `#define` can be consumed in exactly one way and the identifier cannot run across line terminators. It matches the identifier with `[^ \\t\\r\\n]*` (and `[^ \\t\\r\\n_]*` in the hotspot group, which keeps the existing \"no underscore before `_x_hot`/`_y_hot`\" behaviour); the remaining quantifier pairs are then either over disjoint character classes or reduce to a single linear rescan of at most 512 bytes:\n\n```diff\n xbm_head = re.compile(\n-    rb\"\\s*#define[ \\t]+.*_width[ \\t]+(?P\u003cwidth\u003e[0-9]+)[\\r\\n]+\"\n-    b\"#define[ \\t]+.*_height[ \\t]+(?P\u003cheight\u003e[0-9]+)[\\r\\n]+\"\n+    rb\"\\s*#define[ \\t]+[^ \\t\\r\\n]*_width[ \\t]+(?P\u003cwidth\u003e[0-9]+)[\\r\\n]+\"\n+    rb\"#define[ \\t]+[^ \\t\\r\\n]*_height[ \\t]+(?P\u003cheight\u003e[0-9]+)[\\r\\n]+\"\n     b\"(?P\u003chotspot\u003e\"\n-    b\"#define[ \\t]+[^_]*_x_hot[ \\t]+(?P\u003cxhot\u003e[0-9]+)[\\r\\n]+\"\n-    b\"#define[ \\t]+[^_]*_y_hot[ \\t]+(?P\u003cyhot\u003e[0-9]+)[\\r\\n]+\"\n+    rb\"#define[ \\t]+[^ \\t\\r\\n_]*_x_hot[ \\t]+(?P\u003cxhot\u003e[0-9]+)[\\r\\n]+\"\n+    rb\"#define[ \\t]+[^ \\t\\r\\n_]*_y_hot[ \\t]+(?P\u003cyhot\u003e[0-9]+)[\\r\\n]+\"\n     b\")?\"\n     rb\"[\\000-\\377]*_bits\\[]\"\n )\n```\n\nThis mirrors the `PdfParser` fix for CVE-2021-25292. The patch also adds a `test_redos` case to `Tests/test_file_xbm.py`, in the style of the one in `Tests/test_file_pdf.py`: it builds the k = 60 header from Details and, under `@timeout_unless_slower_valgrind(1)`, expects `XbmImageFile` to raise `SyntaxError`. With the patch applied to a clean checkout and rebuilt, the reproducer prints `OK` and the existing `Tests/test_file_xbm.py` (including `hopper.xbm`, `hopper_underscore.xbm` and the hotspot round-trip) still passes. The change does reject headers whose identifier contains whitespace (`#define foo bar_width 16`), which is not valid C and is not produced by Pillow's own XBM writer.\n\n## The patch\n\nUse more specific regex chars in the XBM header regex to prevent ReDoS in Image.open() on a crafted 512-byte header\n\nApplies to `src/PIL/XbmImagePlugin.py` at the commit above.\n\n```diff\ndiff --git a/Tests/test_file_xbm.py b/Tests/test_file_xbm.py\nindex 1988c417f..67236c795 100644\n--- a/Tests/test_file_xbm.py\n+++ b/Tests/test_file_xbm.py\n@@ -6,7 +6,7 @@ import pytest\n \n from PIL import Image, XbmImagePlugin\n \n-from .helper import hopper\n+from .helper import hopper, timeout_unless_slower_valgrind\n \n TYPE_CHECKING = False\n if TYPE_CHECKING:\n@@ -91,3 +91,17 @@ def test_hotspot(tmp_path: Path) -\u003e None:\n \n     with Image.open(out) as reloaded:\n         assert reloaded.info[\"hotspot\"] == hotspot\n+\n+\n+@timeout_unless_slower_valgrind(1)\n+def test_redos() -\u003e None:\n+    k = 60\n+    malicious = b\"#define\" + b\" \" * k + b\"_width 1\\r\"\n+    malicious += b\"#define\" + b\" \" * k + b\"_height 1\\r\"\n+    malicious += b\"#define\" + b\" \" * k + b\"_x_hot 1\\r\"\n+    malicious += b\"#define\" + b\" \" * k + b\"_y_hot 1\" + b\"\\r\" * k + b\"_bits\"\n+    malicious += b\"A\" * (512 - len(malicious))\n+\n+    # The important thing is it doesn't timeout, cause a ReDoS\n+    with pytest.raises(SyntaxError):\n+        XbmImagePlugin.XbmImageFile(BytesIO(malicious))\ndiff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py\nindex 95729d73d..7435d20f2 100644\n--- a/src/PIL/XbmImagePlugin.py\n+++ b/src/PIL/XbmImagePlugin.py\n@@ -29,12 +29,14 @@ if TYPE_CHECKING:\n     from typing import IO\n \n # XBM header\n+# The identifier before each suffix cannot contain whitespace, so that it\n+# cannot overlap with the preceding [ \\t]+ and cause a ReDoS\n xbm_head = re.compile(\n-    rb\"\\s*#define[ \\t]+.*_width[ \\t]+(?P\u003cwidth\u003e[0-9]+)[\\r\\n]+\"\n-    b\"#define[ \\t]+.*_height[ \\t]+(?P\u003cheight\u003e[0-9]+)[\\r\\n]+\"\n+    rb\"\\s*#define[ \\t]+[^ \\t\\r\\n]*_width[ \\t]+(?P\u003cwidth\u003e[0-9]+)[\\r\\n]+\"\n+    rb\"#define[ \\t]+[^ \\t\\r\\n]*_height[ \\t]+(?P\u003cheight\u003e[0-9]+)[\\r\\n]+\"\n     b\"(?P\u003chotspot\u003e\"\n-    b\"#define[ \\t]+[^_]*_x_hot[ \\t]+(?P\u003cxhot\u003e[0-9]+)[\\r\\n]+\"\n-    b\"#define[ \\t]+[^_]*_y_hot[ \\t]+(?P\u003cyhot\u003e[0-9]+)[\\r\\n]+\"\n+    rb\"#define[ \\t]+[^ \\t\\r\\n_]*_x_hot[ \\t]+(?P\u003cxhot\u003e[0-9]+)[\\r\\n]+\"\n+    rb\"#define[ \\t]+[^ \\t\\r\\n_]*_y_hot[ \\t]+(?P\u003cyhot\u003e[0-9]+)[\\r\\n]+\"\n     b\")?\"\n     rb\"[\\000-\\377]*_bits\\[]\"\n )\n```\n\n## The reproducer\n\n\u003cdetails\u003e\n\u003csummary\u003eReproducer — \u003ccode\u003erepro\u003c/code\u003e, 1,138 bytes (click to open)\u003c/summary\u003e\n\nIts last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`.\n\n```python\n#!/usr/bin/env python3\n# XbmImagePlugin.xbm_head catastrophic backtracking, reached from Image.open()\n# on a \u003c=512-byte stream. Prints BUG if a failing 512-byte header takes\n# super-linearly long while the same-shaped valid header opens instantly.\nimport io, time\nfrom PIL import Image\n\ndef hdr(k, tail):\n    d = b\"\"\n    for name in (b\"_width 1\\r\", b\"_height 1\\r\", b\"_x_hot 1\\r\", b\"_y_hot 1\"):\n        d += b\"#define\" + b\" \" * k + name\n    d += b\"\\r\" * k + tail\n    return d + b\"A\" * (512 - len(d))\n\ndef timed(data):\n    s = time.perf_counter()\n    try:\n        im = Image.open(io.BytesIO(data)); r = f\"{im.format} {im.size}\"\n    except Exception as e:\n        r = type(e).__name__\n    return r, time.perf_counter() - s\n\nctl, tc = timed(hdr(60, b\"_bits[]\"))\nprint(f\"control k=60 '_bits[]': {ctl} in {tc:.3f}s\")\nts = {}\nfor k in (10, 15, 20, 25):\n    r, ts[k] = timed(hdr(k, b\"_bits\"))\n    print(f\"attack  k={k:2d} '_bits'  : {r} in {ts[k]:.3f}s\", flush=True)\n# k=60 (fits easily in 512 bytes) runs for minutes; not executed here.\nbug = ctl.startswith(\"XBM\") and tc \u003c 1 and ts[25] \u003e 2 and ts[25] \u003e 5 * ts[15]\nprint(\"BUG\" if bug else \"OK\")\n```\n\u003c/details\u003e\n\n---","aliases":["CVE-2026-112901"],"modified":"2026-10-21T21:00:06.700667386Z","published":"2026-10-21T20:45:06Z","database_specific":{"cwe_ids":["CWE-1333"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-21T20:45:06Z","nvd_published_at":"2026-10-19T22:17:10Z"},"references":[{"type":"WEB","url":"https://github.com/python-pillow/Pillow/security/advisories/GHSA-xgh8-jp6q-p5x8"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112901"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/commit/2852a3e3d568997ddec745fc800991f138878f4b"},{"type":"PACKAGE","url":"https://github.com/python-pillow/Pillow"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/releases/tag/13.0.0"}],"affected":[{"package":{"name":"pillow","ecosystem":"PyPI","purl":"pkg:pypi/pillow"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"13.0.0"}]}],"versions":["1.0","1.1","1.2","1.3","1.4","1.5","1.6","1.7.0","1.7.1","1.7.2","1.7.3","1.7.4","1.7.5","1.7.6","1.7.7","1.7.8","10.0.0","10.0.1","10.1.0","10.2.0","10.3.0","10.4.0","11.0.0","11.1.0","11.2.1","11.3.0","12.0.0","12.1.0","12.1.1","12.2.0","12.3.0","2.0.0","2.1.0","2.2.0","2.2.1","2.2.2","2.3.0","2.3.1","2.3.2","2.4.0","2.5.0","2.5.1","2.5.2","2.5.3","2.6.0","2.6.1","2.6.2","2.7.0","2.8.0","2.8.1","2.8.2","2.9.0","3.0.0","3.1.0","3.1.0.rc1","3.1.0rc1","3.1.1","3.1.2","3.2.0","3.3.0","3.3.1","3.3.2","3.3.3","3.4.0","3.4.1","3.4.2","4.0.0","4.1.0","4.1.1","4.2.0","4.2.1","4.3.0","5.0.0","5.1.0","5.2.0","5.3.0","5.4.0","5.4.0.dev0","5.4.1","6.0.0","6.1.0","6.2.0","6.2.1","6.2.2","7.0.0","7.1.0","7.1.1","7.1.2","7.2.0","8.0.0","8.0.1","8.1.0","8.1.1","8.1.2","8.2.0","8.3.0","8.3.1","8.3.2","8.4.0","9.0.0","9.0.1","9.1.0","9.1.1","9.2.0","9.3.0","9.4.0","9.5.0"],"database_specific":{"last_known_affected_version_range":"\u003c= 12.3.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-xgh8-jp6q-p5x8/GHSA-xgh8-jp6q-p5x8.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N"}]}