Article technique

Analyse PDF sécurisée en mémoire : se défendre contre les documents malveillants

Un pipeline d'admission (intake) de documents accepte les fichiers écrits par des inconnus. Factures, numérisations, pièces jointes d'un formulaire Web : chacun prétend être un PDF et porte des centaines de nombres sur lesquels votre analyseur (parser) est censé agir. Longueurs de flux, dimensions d'image, décalages d'octets (byte offsets), références d'objets — chacun a été choisi par la personne qui a produit le fichier, et un téléchargement tronqué ou un document délibérément malformé finira par placer l'un de ces nombres là où il fait des dégâts. La différence entre un analyseur qui survit à ce fichier et un qui plante, ou qui continue de s'exécuter avec une mémoire corrompue, réside dans un petit ensemble d'habitudes qui ne dépendent d'aucune bibliothèque PDF particulière

Les habitudes partagent une prémisse : une valeur lue à partir du fichier est une affirmation, pas une mesure. Elle ne devient utilisable qu'après avoir été vérifiée par rapport à quelque chose que l'analyseur a mesuré lui-même — la taille réelle du fichier, le nombre réel d'octets produits par un décodeur, la profondeur réelle d'une récursivité. Ce qui suit est cette prémisse appliquée aux endroits où les analyseurs de documents se cassent réellement

Une longueur déclarée est une affirmation, pas une mesure

L'inadéquation (mismatch) la plus simple est la longueur du flux. Un objet de flux PDF déclare son nombre d'octets dans la clé /Length, et les données réelles se situent entre les mots-clés stream et endstream. Rien n'oblige les deux à concorder. Un fichier tronqué contient moins d'octets réels que le nombre déclaré ; un fichier provenant d'un générateur cassé peut déclarer une longueur qui dépasse la fin du fichier ou s'étend dans un objet voisin. Allouez à partir de la valeur déclarée et copiez jusqu'à endstream et vous dépassez (overrun) le tampon ; lisez exactement le nombre déclaré sans vérifier la disponibilité et vous sortez de la fin du fichier. Laissez la valeur déclarée piloter l'allocation uniquement après l'avoir fixée (clamping) par rapport à la distance mesurée jusqu'à la fin des données, et traitez un désaccord comme un point de décision — réparez en recherchant endstream, ou rejetez le flux — jamais comme quelque chose à croire silencieusement

Des paramètres d'image qui décrivent un raster plus grand que ce que vous avez alloué

Les flux d'images font monter les enjeux (raise the stakes) car deux ensembles de nombres indépendants décrivent les mêmes pixels. Le dictionnaire d'images porte /Width et /Height, et les tampons raster sont généralement dimensionnés à partir de ceux-ci. Le filtre de décodage porte sa propre géométrie : CCITTFaxDecode prend /Columns, /Rows et /K de ses DecodeParms, où /K sélectionne le schéma du Groupe 3 ou du Groupe 4 et le décodeur émet (Columns + 7) div 8 octets par ligne de balayage (scanline). Un fichier qui déclare /Width 100 mais transmet au filtre /Columns 1728 — la valeur par défaut — fait que le décodeur produit plus de seize fois les octets par ligne que le tampon attend, et le débordement atterrit une ligne de balayage à la fois dans tout ce qui se trouve après l'allocation. Lorsque /Rows est absent, le décodeur s'exécute jusqu'à ce que les données disent d'arrêter, limitez (bound) donc également le nombre de lignes. DCTDecode a la même couture (seam) : les données JPEG portent leur propre largeur et hauteur dans leur marqueur SOF, et rien ne les oblige à correspondre au dictionnaire

La règle défensive est mécanique : calculez la taille de raster attendue à partir des paramètres de décodage validés — les propres /Columns et /Rows du filtre pour CCITT, les dimensions SOF pour DCT — vérifiez-la par rapport à vos limites, allouez à partir d'elle et vérifiez pendant le décodage que la sortie ne dépasse jamais l'allocation. Lorsque le dictionnaire et le filtre ne sont pas d'accord sur la géométrie, réconciliez-les ou rejetez l'image. Ce qu'un analyseur ne doit jamais faire est de dimensionner le tampon à partir d'un ensemble de nombres et de laisser le décodeur s'exécuter avec l'autre

Pièges de l'arithmétique et de l'allocation dans Delphi

Trois comportements de Delphi sapent (undermine) même un analyseur qui a l'intention de valider. Le premier est la multiplication 32 bits : Delphi évalue le produit de deux opérandes Integer à 32 bits indépendamment de la largeur de la destination, de sorte que Width * Height * BytesPerPixel peut boucler (wrap) même lorsque chaque facteur réussit son propre test de cohérence (sanity check). Une numérisation de 30000 par 30000 à trois octets par pixel correspond à 2,7 milliards d'octets, ce qui boucle en négatif dans l'arithmétique 32 bits signée ; des facteurs légèrement différents bouclent sur une petite longueur positive qui alloue et sous-dimensionne le tampon. Forcez toute l'expression à être large (wide) en convertissant (casting) le premier opérande — Size := Int64(Width) * Height * BytesPerPixel — puis comparez à un plafond (cap) explicite avant que quoi que ce soit n'atteigne SetLength

Le second est la vérification des limites (range checking). La configuration de version (release) par défaut de Delphi est livrée avec celle-ci désactivée, de sorte qu'un index hors limites calculé à partir des données de fichier ne se déclenche (raise) pas — il lit ou écrit la mémoire adjacente au tableau (array). Réactivez-le avec {$R+} (et {$Q+} pour le débordement arithmétique (arithmetic overflow)) en haut de chaque unité qui s'indexe avec des valeurs dérivées du fichier. Le coût est incommensurable par rapport aux E/S qu'un analyseur fait de toute façon, et il convertit la corruption silencieuse en une ERangeError capturable

Le troisième est TMemoryStream.SetSize avec un Int64 fourni par un fichier. Sur une RTL actuelle, il alloue tout ce que le fichier a demandé, de sorte qu'un seul flux réclamant quatre gigaoctets devient une défaillance de mémoire (out-of-memory failure) au milieu de l'admission. Sur les RTL plus anciennes, où SetSize prend un Longint, la valeur est d'abord silencieusement rétrécie (narrowed) : un $100000010 déclaré devient 16, l'allocation réussit et l'écriture des données réelles la dépasse de loin. Validez chaque taille par rapport à la taille de la source mesurée et à un plafond strict (hard cap) avant que tout appel d'allocation ne la voie

Décalages (offsets) qui pointent en dehors du fichier

La table de références croisées mappe les numéros d'objet à des décalages d'octets absolus, et l'analyseur cherche (seeks) partout où elle pointe. Dans un fichier endommagé ou hostile, ces décalages atterrissent au-delà de la fin du fichier ou à l'intérieur de structures non liées. TStream rend l'échec silencieux : la définition de Position au-delà de Size n'est pas une erreur, et un simple Read au-delà de la fin renvoie simplement moins d'octets que demandé, de sorte qu'un code qui ignore la vérification du compte continue d'analyser les octets obsolètes (stale bytes) de l'objet précédent. La défense est un point d'étranglement (chokepoint) — un assistant à travers lequel chaque recherche et lecture pilotée par un fichier passe, validant le décalage et le compte par rapport à la taille de fichier mesurée avant que le flux ne se déplace

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB

type
  EPdfBoundsError = class(Exception);

// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
  var Buffer: TBytes);
begin
  if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
     (Offset > Source.Size) or (Count > Source.Size - Offset) then
    raise EPdfBoundsError.CreateFmt(
      'object extent %d+%d exceeds file size %d',
      [Offset, Count, Source.Size]);
  SetLength(Buffer, Count);
  if Count = 0 then
    Exit;
  Source.Position := Offset;
  Source.ReadBuffer(Buffer[0], Count);
end;

Acheminez (Route) les décalages de références croisées, les étendues de flux et les lectures de fichiers intégrés à travers lui, et un mauvais décalage devient un rejet propre qui nomme les nombres au lieu d'une violation d'accès trois appels plus tard

Cycles et profondeur dans le graphe d'objets

Un PDF est un graphe, pas une arborescence (tree). Toute valeur peut être une référence indirecte, une référence peut se résoudre (resolve) en une autre référence — /Length 12 0 R, où l'objet 12 contient 13 0 R — et rien n'empêche une chaîne de se refermer sur elle-même. Un résolveur (resolver) qui suit naïvement les références effectue une récursion jusqu'à ce que la pile (stack) native soit épuisée, et l'épuisement de la pile n'est pas quelque chose que vous attrapez ; il met fin au processus. Les tableaux et dictionnaires profondément imbriqués atteignent la même fin sans aucun cycle du tout

Utilisez deux gardes ensemble : un compteur de profondeur explicite limite le cas honnête mais profond à une limite qu'aucun fichier légitime n'approche, et un ensemble visité attrape un cycle véritable lors de sa deuxième visite, le transformant en une erreur précise et signalable au lieu d'un déclenchement de limite (limit trip)

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain

type
  EPdfStructureError = class(Exception);

  TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
    pvDictionary, pvStream, pvReference);

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // meaningful when Kind = pvReference
    // ... payload fields for the remaining kinds
  end;

// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
  Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
  if Depth > MAX_RESOLVE_DEPTH then
    raise EPdfStructureError.Create('reference chain exceeds depth limit');
  if Visited.ContainsKey(ObjNumber) then
    raise EPdfStructureError.CreateFmt(
      'circular reference through object %d', [ObjNumber]);
  Visited.Add(ObjNumber, True);
  try
    Result := LoadObject(ObjNumber);
    if Result.Kind = pvReference then // e.g. /Length 12 0 R
      Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
  finally
    Visited.Remove(ObjNumber); // siblings may legally share this object
  end;
end;

La décompression est un amplificateur

Quelques kilo-octets d'entrée FlateDecode peuvent se gonfler (inflate) en gigaoctets ; la compression à usage général récompense les textes bruts répétitifs, et un attaquant peut les rendre au maximum répétitifs. Plafonnez la taille gonflée de chaque flux à ce dont son consommateur peut avoir besoin de manière plausible, et conservez un deuxième budget par document : cinq cents flux chacun juste en dessous du plafond par flux épuisent la mémoire aussi sûrement qu'un seul flux géant. La vérification appartient à l'intérieur de la boucle de gonflage, en comptant les octets de sortie tels qu'ils sont produits et en abandonnant (aborting) en cas de violation, et non après la boucle lorsque la mémoire est déjà dépensée. Un budget de document exprimé sous forme de multiple de la taille du fichier compressé fonctionne bien, car les documents légitimes se regroupent bien en dessous des ratios qu'atteint un flux conçu (crafted stream)

La défense en profondeur au-delà de vos propres unités

Les mêmes classes de défauts vivent à l'intérieur des bibliothèques. Deux études de cas sur ce blog parcourent des instances réelles : les boucles d'entiers (integer wraps), la récursion illimitée et les tampons non initialisés fermés dans un moteur Pascal natif dans le Renforcement d'un analyseur PDF Pascal contre les fichiers malveillants, et les risques de convention d'appel, de largeur d'entier et de propriété (ownership) de la liaison (binding) d'un moteur C dans le Renforcement d'une liaison (binding) de composant PDFium. Pour une admission (intake) véritablement non fiable — un formulaire de téléchargement public, une boîte aux lettres non authentifiée — exécutez également le travail d'analyse et de décodage dans un processus distinct à faibles privilèges, de sorte que le fichier qui vainc toutes les gardes en cours de processus (in-process) coûte un travail échoué au lieu d'un service interrompu (downed service)

Une liste de contrôle (checklist) de pré-vol

Avant la prochaine expédition (ships) de compilation (build), parcourez l'analyseur par rapport à cette liste : chaque tampon de flux (stream buffer) dimensionné à partir d'une longueur fixée (clamped) plutôt que de la longueur déclarée ; chaque raster dimensionné à partir de paramètres de décodeur validés et vérifié par rapport à la sortie du décodeur ; chaque produit de dimension évalué dans Int64 et comparé à un plafond explicite ; {$R+} actif dans chaque unité qui s'indexe avec des valeurs dérivées du fichier ; chaque recherche (seek) vérifiée par rapport à la taille mesurée du fichier ; chaque résolution de référence limitée en profondeur et vérifiée en cycle ; chaque boucle de gonflage (inflation loop) comptant la sortie par rapport aux budgets par flux et par document. Aucune de ces vérifications ne coûte de temps mesurable sur un document légitime, et chacune convertit la corruption de la mémoire en un rejet propre et enregistrable (loggable)

Remarque : le Composant HotPDF losLab, la bibliothèque PDF Delphi PDFlibPas et le Composant PDFium appliquent ces vérifications de limites, ces limites de profondeur et ces plafonds d'expansion (expansion caps) en interne, de sorte qu'un pipeline d'admission (intake pipeline) construit sur eux part d'une ligne de base (baseline) renforcée