Article technique

Bombes de Decodage PDF en Delphi : Budgets de Chaine HotPDF

Un PDF de 20 Ko qui bloque un processus de service jusqu à ce que l OOM killer l abatte n est pas un bogue dans votre code, c est une bombe de décompression. HotPDF, le composant VCL PDF natif pour Delphi et C++Builder, en borne une avec DecodeBudgetBytes, un plafond par chaîne de filtres qui vaut par défaut 268435456 octets et facture chaque étape de décodage sur un budget unique partagé

Le fichier de 20 Ko qui a dévoré un processus worker

La forme de l incident est toujours la même. Un worker de file d attente qui génère des miniatures récupère un envoi, la mémoire résidente grimpe au-delà de 12 Go en moins de deux secondes, et le processus disparaît sans trace de pile. Le fichier fait 20 Ko. Il a une page, un flux de contenu, et un tableau /Filter de cinq entrées. Chaque nom de ce tableau est un filtre que la spécification définit, chaque étape se décode sans erreur, et rien dans le fichier n est malformé. C est ce qui rend cette classe d entrée délicate : il n y a aucun octet corrompu à rejeter

Ce n est pas le même problème que décoder correctement un seul filtre. Bien gérer LZWDecode et le prédicteur /DecodeParms est son propre sujet, couvert dans la présentation de LZW, des prédicteurs, et de DecodeParms sur les documents chargés. Ici, chaque décodeur est déjà correct. L échec réside dans ce que font des décodeurs corrects quand on en exécute cinq à la suite et que personne ne compte le total. La norme ISO 32000-1 §7.4 est explicite : /Filter peut être un seul nom ou un tableau de noms, et un tableau est appliqué en séquence, première entrée en premier. Elle ne dit rien sur la quantité qu une étape peut faire grossir son entrée, ni sur l agrégat sur toute la chaîne. Une étape ASCIIHexDecode divise environ par deux son entrée, ce qui paraît inoffensif. Une étape FlateDecode sur une suite d octets nuls atteint des ratios de plusieurs milliers. Enchaînez-les et l arithmétique est multiplicative : 20 Ko devient 20 Mo devient 20 Go, et chaque étape individuelle est un décodage conforme d un flux légal

Pourquoi une limite par filtre ne parvient-elle pas à arrêter une bombe de décodage ?

Parce qu une limite par filtre est réarmée à chaque élément du tableau /Filter. Une chaîne de cinq étapes sous un plafond de 256 Mio par étape autorise 1,25 Gio, et la dernière étape commence toujours avec une allocation entièrement fraîche quel que soit ce qu ont produit les quatre précédentes. La limite est appliquée honnêtement et ne contraint rien d important. HotPDF avait exactement cette forme avant la v2.447.0, et il avait un second manque à côté. Le décompresseur LZW portait un plafond MaxOutputBytes et le chemin de prédiction d image comptait ses propres lignes, donc ces deux-là étaient bornés localement. FlateDecode, ASCIIHexDecode, ASCII85Decode, et RunLengthDecode n avaient aucun plafond du tout : chacun écrivait dans un TMemoryStream jusqu à épuiser l entrée ou jusqu à ce que l allocateur abandonne. Une chaîne hostile disposait donc de deux voies. Elle pouvait utiliser un filtre entièrement non protégé, ou elle pouvait utiliser des filtres protégés et simplement en ajouter davantage

Il y a un troisième détail qu une correction naïve manque. Le chiffre qui compte n est pas la taille de la sortie décodée finale. C est le pic, et le pic vit généralement dans un tampon intermédiaire. Une chaîne qui se termine par un flux de contenu modeste de 4 Mo peut allouer 8 Go à l étape trois et rendre quelque chose qui paraît entièrement raisonnable. Vérifier la longueur du résultat après coup ne vous dit rien sur l allocation qui a tué le processus

Un traqueur de budget par chaîne de filtres

La correction dans HotPDF v2.447.0 consiste à faire porter la comptabilité sur la chaîne plutôt que sur l étape. Chaque chaîne de filtres construit un THPDFDecodeBudgetTracker, et chaque décodeur écrit à travers un THPDFBudgetWriteStream qui enveloppe la cible réelle. L enveloppe appelle Budget.Consume(Count) avant de transmettre un seul octet, de sorte que le refus se produit pendant que le flux cible a encore son ancienne taille. Cet ordonnancement est tout l intérêt : une vérification effectuée après que le tampon a déjà grandi est un diagnostic, pas une défense

// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // read the source, do not copy it
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter names the stage and bumps FilterCount; the wrapper
    // stream calls Budget.Consume before writing into NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

Les plafonds locaux n ont pas disparu, ils sont devenus des projections du budget partagé. L étape LZW définit désormais Decoder.MaxOutputBytes := Budget.RemainingBytes, de sorte que son plafond privé est ce qu il reste à la chaîne plutôt qu une allocation indépendante. L étape de prédiction d image s ouvre avec BeginFilter et facture son exigence de ligne via Consume avant d allouer, ce qui signifie que la sortie du prédicteur est facturée au même budget que les filtres génériques qui l ont alimentée. Cela compte particulièrement sur le chemin image, où la chaîne de filtres et le prédicteur sont les deux moitiés d une seule opération, comme couvert dans l extraction d images de documents chargés à travers leurs filtres de décodage

Que voit l appelant quand le budget refuse ?

Au fond de la pile, un refus lève EHPDFDecodeBudgetError. Au-dessus, la réponse dépend du contrat que l API appelante avait déjà. Les méthodes de lecture haut niveau qui signalaient un échec via False ou nil continuent de faire exactement cela, car transformer un résultat booléen documenté en exception casserait les appelants qui géraient déjà correctement une entrée malformée. Le chemin de contenu de page chargé est l exception délibérée : il relève EHPDFDecodeBudgetError plutôt que de laisser un flux de contenu tronqué s afficher comme une page simplement vide. Cette conception signifie qu un False nu est ambigu à lui seul, donc le budget publie un enregistrement de diagnostic à côté : THotPDF.GetLastDecodeBudgetInfo renvoie l état de la dernière chaîne que l instance a décodée

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // tighter than the default
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

Lisez ces champs ensemble et ils séparent les deux formes d attaque. Quand PeakStageBytes est proche de DecodedBytes, une seule étape a fait tous les dégâts et vous regardez un filtre à ratio élevé unique. Quand PeakStageBytes est une petite fraction de DecodedBytes et FilterCount est élevé, aucune étape individuelle n était scandaleuse et la chaîne s est accumulée pour dépasser le plafond, ce qui est précisément le cas qu une limite par filtre ne peut pas voir. Une mise en garde à écrire dans votre gestionnaire : GetLastDecodeBudgetInfo renvoie False tant que l instance n a décodé au moins un filtre, donc un False venant de là n est pas une preuve que le document était propre

Où le budget se réinitialise, et quand zéro est la réponse honnête

DecodeBudgetBytes borne une chaîne de flux, pas un document, et cette frontière est délibérée mais facile à mal lire. Chaque flux de contenu, chaque fichier intégré, chaque flux de références croisées et chaque flux d objets démarre avec un nouveau plafond de 256 Mio. Un document de 4 000 pages a donc 4 000 chances indépendantes de dépenser le plafond complet, et les flux d objets multiplient encore ce nombre car chacun est lui-même un conteneur compressé contenant de nombreux objets, comme décrit dans les notes sur les flux d objets et les mises à jour incrémentielles. Si votre besoin réel est une borne sur la mémoire totale du processus, cette propriété est une entrée parmi d autres, pas la totalité, et elle devrait se trouver derrière un plafond au niveau du job ou du conteneur

Zéro signifie illimité, et c est un réglage légitime plutôt qu une échappatoire. Définissez-le quand vous possédez l entrée : un pipeline de retraitement d archives sur des documents que votre propre système a produits, ou une étape de rastérisation où une seule chaîne de numérisation couleur 600 dpi a réellement besoin de plus que n importe quel plafond que vous seriez à l aise de coder en dur. Les valeurs négatives sont rejetées d emblée avec ERangeError, car un budget négatif n a aucun sens cohérent et le plafonner silencieusement masquerait un bogue de configuration

// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0;              // explicit unlimited

// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Configuration mistakes fail loudly instead of clamping
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

Choisir le chiffre mérite plus de soin qu on ne lui en accorde habituellement, car un budget fixé trop bas est une panne auto-infligée. Exécutez votre corpus existant avec la valeur par défaut, enregistrez PeakStageBytes et DecodedBytes pour chaque chaîne, et fixez le plafond au-dessus du maximum observé avec une vraie marge. Un chiffre rond choisi parce qu il semblait sûr rejettera un grand scan légitime au pire moment possible, et l échec ressemblera exactement à une attaque dans vos journaux

La copie qui ne se produit plus

Faire passer chaque étape par une enveloppe de budget s est avéré rendre la chaîne moins coûteuse plutôt que plus coûteuse. Quand un flux a des filtres, la première étape lit désormais directement le flux source au lieu de copier d abord les octets encodés dans un tampon de travail, et à partir de là seuls deux tampons sont vivants à la fois : l entrée actuelle et la sortie de l étape en cours d écriture. La copie brute survit dans les deux cas qui en ont besoin, à savoir un flux sans aucun filtre et une image où l appelant veut que le dernier encodage soit préservé, car les deux rendent un flux que l appelant possède et peut positionner indépendamment. La version non protégée de ce code allouait davantage et bornait moins, ce qui est la relation habituelle entre les deux. Mais il vaut la peine de le dire clairement : rien de tout cela ne rend un PDF arbitraire sûr à charger. Cela ferme un vecteur de déni de service spécifique et très bon marché, celui où un petit fichier achète une grande allocation via des filtres imbriqués. Le dépassement d entier dans la comptabilité d octets est protégé séparément, et la question plus large de l analyse de documents hostiles sans faire confiance à leurs décalages internes est une discipline différente. Un budget de décodage est une borne parmi plusieurs, et sa valeur tient au fait que c est celle que vous pouvez régler depuis une seule propriété avant même de toucher le fichier

Le budget par chaîne, son enregistrement de diagnostic, et les chemins de décodage de documents chargés qu il protège sont tous livrés dans le composant lui-même, sans dépendance de décompression externe à configurer ou corriger. Si vous évaluez comment borner une entrée PDF non fiable dans un service Delphi ou C++Builder, la page composant PDF Delphi HotPDF liste la boîte à outils de documents chargés à laquelle ces limites s appliquent