Un fichier xlsx est une archive ZIP, et ZIP n a pas de table des matières unique faisant autorité. HotXLS Excel Library pour Delphi et C++Builder traite cette ambiguïté comme une surface d attaque : son analyseur de fin de répertoire central n accepte un enregistrement candidat qu après que quatre contrôles croisés indépendants s accordent, de sorte qu un répertoire forgé caché dans un commentaire ZIP ne gagne jamais
Le scénario qui rend cela concret est banal. Un serveur accepte des envois de tableurs de la part de clients. Le fichier passe un scan antivirus, est écrit dans un répertoire de spool, et votre service Delphi l ouvre pour en extraire trois colonnes. Tout paraît correct, sauf que le scanner et votre analyseur ne se sont pas accordés sur ce que contenait l archive. Le scanner a énuméré un ensemble de membres ; votre chargeur en a énuméré un ensemble différent à partir des mêmes octets. Aucun des deux n est bogué au sens ordinaire. Ils ont simplement résolu une ambiguïté du format ZIP dans deux directions différentes, et un attaquant a choisi les octets pour que ce soit le cas
Où réside réellement la vérité sur une archive ZIP ?
Elle réside tout à la fin, dans une structure de 22 octets appelée l enregistrement de fin de répertoire central. Un fichier ZIP ne se lit pas du début à la fin : chaque membre porte un en-tête de fichier local immédiatement avant ses données compressées, mais l index faisant autorité est le répertoire central, une série d enregistrements près de la fin qui nomme chaque entrée et donne le décalage de son en-tête local. Pour trouver le répertoire central, vous devez d abord trouver l EOCD, car c est l EOCD qui dit où le répertoire commence et combien d enregistrements il contient. HotXLS le modélise comme TEndOfCentralDirectoryRecord, dont les champs correspondent un à un à la disposition sur disque : FDiskNumber au décalage 4, FStartDisk à 6, FThisDiskEntries à 8, FTotalEntries à 10, FSizeOfCD à 12, FOffsetOfStartCD à 16, et FCommentLen à 20. Ce total est FMinSize, calculé dans le constructeur comme 4*3 + 5*2. Après cela vient le commentaire d archive, jusqu à 65535 octets de contenu arbitraire, ce qui fait de FMaxSize 65557 et signifie que l enregistrement n est pas à une position fixe. Vous devez aller le chercher
Pourquoi balayer en arrière à la recherche de la signature EOCD n est-il pas suffisant ?
Parce que les quatre octets que vous recherchez, PK\005\006, peuvent légalement apparaître à l intérieur du commentaire d archive, à l intérieur de données compressées, ou à l intérieur d un second EOCD qu un attaquant a ajouté exprès. Un analyseur qui s arrête à la première signature qu il rencontre en balayant vers l arrière est trivialement dirigeable : placez un EOCD leurre près de la queue et l analyseur naïf le suit, tandis qu un analyseur qui balaie dans un ordre différent, ou qui traite la dernière signature du fichier comme canonique, suit le vrai. C est la famille d attaques par ambiguïté ZIP, et son gain est exactement la scission décrite plus haut, où le moteur de balayage et l application consommatrice voient des ensembles d entrées différents pour un même fichier
TEndOfCentralDirectoryRecord.Parse balaie effectivement vers l arrière. Il fixe startscan au dernier octet, plafonne endscan à lsize - FMaxSize ou zéro, et parcourt la fenêtre en tampons de 256 octets qui se chevauchent de trois octets afin qu une signature à cheval sur une limite de tampon ne soit jamais manquée. La différence est ce qui se passe en cas de correspondance. Trouver la signature ne produit qu un décalage Candidate. HotXLS lit alors les 22 octets à ce décalage, les analyse avec ReadEOCD, et exige que les champs résultants soient internement cohérents avec le fichier qu ils prétendent décrire avant même que FOffsetEOCD ne soit assigné
Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
SetLength(RecordBuf, FMinSize);
inputstream.Position := Candidate;
if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
begin
ReadEOCD(RecordBuf[0], 0);
if (Candidate + FMinSize + FCommentLen = lsize) and
(FDiskNumber = 0) and (FStartDisk = 0) and
(FThisDiskEntries = FTotalEntries) and
(Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
begin
FOffsetEOCD := Candidate;
Result := FOffsetEOCD;
Exit;
end;
end;
end;
Lisez le prédicat comme quatre affirmations distinctes qu une contrefaçon doit satisfaire simultanément. Candidate + FMinSize + FCommentLen = lsize exige que la longueur de commentaire déclarée atteigne exactement la fin du fichier, ce qui tue le stratagème du leurre-dans-le-commentaire : un faux EOCD enterré à l intérieur d un vrai commentaire ne peut pas aussi rendre compte de chaque octet après lui-même. FDiskNumber = 0 et FStartDisk = 0 rejettent les champs d étalement multi-disque qu aucun xlsx n a jamais légitimement utilisés et qui existent dans des archives forgées uniquement pour semer la confusion. FThisDiskEntries = FTotalEntries rejette le stratagème du décompte scindé où un analyseur dimensionne sa boucle depuis un champ et un autre analyseur depuis l autre. Et Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate exige que le répertoire central se termine précisément là où commence l EOCD, de sorte que le répertoire ne puisse pas être pointé vers un blob sans rapport ailleurs dans le fichier. Le transtypage Int64 sur ce dernier compte : les deux opérandes sont 32 bits, et sans élargissement, une paire forgée pourrait boucler et satisfaire le test arithmétiquement tout en pointant nulle part de sensé
Les en-têtes locaux doivent s accorder avec le répertoire central
Les contrôles EOCD fixent quel répertoire fait autorité ; ils ne garantissent pas encore que le répertoire dit la vérité sur les membres individuels. Chaque entrée est décrite deux fois dans un fichier ZIP, une fois centralement et une fois dans son en-tête local, et rien dans le format ne force les deux descriptions à correspondre, donc un lecteur qui fait confiance au répertoire central et un lecteur qui fait confiance aux en-têtes locaux peuvent extraire un contenu différent d une même archive. TZipEntry.ParseLocalHeader comble cet écart en analysant l en-tête local à FCdFile.LocalFileHeaderOffset et en comparant les deux copies champ par champ, renvoyant un code négatif distinct pour chaque type de désaccord : le nom d entrée canonicalisé, la méthode de compression, les bits de drapeaux à usage général, et, quand le drapeau de descripteur de données est désactivé, le CRC32 et les deux tailles. Avec ce drapeau activé, les copies locales peuvent être nulles, puisque les vraies valeurs vivent dans un descripteur final, mais toute valeur locale non nulle doit quand même correspondre. Une vérification finale rejette les entrées dont les données dépasseraient la fin du fichier, comparant Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) contre inputstream.Size. Tout échec se propage hors de TCentralDirectory.Parse comme un résultat non-1 et TZipArchive.OpenArchive le transforme en Can't open zip archive, plutôt que de vous remettre un objet d archive à moitié fiable. Quand vous avez seulement besoin de savoir quelles feuilles un fichier contient, exécuter cette validation avant une analyse complète est bon marché, et le chemin d inspection léger des feuilles vous le donne exactement sans matérialiser les données de cellule
Que se passe-t-il quand les octets eux-mêmes mentent ?
L accord structurel ne dit toujours rien de la charge utile, donc HotXLS enveloppe chaque flux d entrée dans TZipVerifiedStream, qui applique la taille déclarée et le CRC32 au fur et à mesure que l appelant lit. Ce n est délibérément pas une vérification a posteriori : une bombe de décompression dont la taille non compressée déclarée est de 4 Ko mais qui se dilate en gigaoctets est arrêtée à la marque des 4 Ko, pas après les dégâts. L enveloppe plafonne chaque lecture aux octets déclarés restants, lève ZIP entry ended before its declared size si la source se tarit tôt, sonde un octet supplémentaire à l achèvement et lève ZIP entry exceeds its declared size s il en reste, et enfin compare le CRC32 courant dans VerifyComplete, levant ZIP entry uncompressed size mismatch ou ZIP entry CRC32 mismatch
if Count > 0 then
begin
Result := FSource.Read(Buffer, Count);
if Result <= 0 then
raise Exception.Create('ZIP entry ended before its declared size');
FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
Inc(FPosition, Result);
end
else
Result := 0;
if FPosition = FExpectedSize then
begin
if FSource.Read(Probe, 1) <> 0 then
raise Exception.Create('ZIP entry exceeds its declared size');
VerifyComplete;
end;
Une conséquence mérite d être anticipée. Le flux est à sens unique par conception ; un Seek vers n importe où d autre que la position actuelle lève ZIP entry stream is forward-only, avec une seule concession pour soEnd avec un décalage zéro afin que les requêtes de taille fonctionnent encore. C est le bon compromis pour une entrée non fiable, car un flux que vous pouvez rembobiner est un flux dont vous pouvez déjouer la comptabilité CRC, mais cela signifie que le code consommateur qui attend un flux repositionnable a besoin de son propre tampon. La même discipline à sens unique sous-tend le lecteur direct en flux, qui est l API à privilégier quand le classeur envoyé est suffisamment volumineux pour que vous ne vouliez pas du tout qu il réside en mémoire
Limites de ressources avant l allocation, pas après
Trois constantes dans lxZipArchive bornent ce qu une seule archive peut demander au processus de faire, et TZipEntries.Add les applique pendant que le répertoire central est encore en cours de lecture, avant qu un seul octet de données d entrée ne soit touché. ZipMaxEntryUncompressedSize plafonne un membre à 1 Gio, ZipMaxTotalUncompressedSize plafonne l archive à 4 Gio, et ZipMaxCompressionRatio de 10000 rejette toute entrée déflatée dont l expansion déclarée dépasse un facteur dix mille, ainsi que le cas dégénéré d une taille non compressée non nulle appariée à une taille compressée nulle. Les noms d entrée passent par CanonicalZipEntryName dans le même appel, qui rejette les caractères NUL intégrés, les deux-points, et tout segment de chemin .. avec Invalid ZIP entry name, et qui met en minuscules et normalise les segments de sorte que deux membres différant seulement par la casse ou par des séparateurs redondants entrent en collision comme Duplicate ZIP entry name au lieu de se masquer silencieusement l un l autre
Défense en profondeur au-dessus de la couche ZIP
La couche ZIP est un niveau parmi plusieurs, et le motif se répète partout où HotXLS analyse une structure contrôlée par un attaquant. L exemple le plus clair se trouve dans l analyseur de formules BIFF : TXLSFormula.GetTranslated récurse à travers les jetons tMemFunc, donc un flux de jetons rgce forgé dans un .xls hérité peut s imbriquer arbitrairement profondément et épuiser la pile. La garde est une constante, MaxTranslateDepth = 256, choisie contre un fait amont connu plutôt que devinée. Excel plafonne l imbrication de formules à 64, donc 256 laisse une marge quadruple et ne peut jamais rejeter une formule qu un vrai tableur a produite, tout en terminant un flux malveillant suffisamment tôt avant que la pile ne s épuise
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Notez que la garde renvoie nil plutôt que de lever une exception. Une formule trop profonde pour être authentique ne produit aucun arbre syntaxique, l analyse environnante continue, et le classeur se charge quand même. Cette asymétrie est intentionnelle et mérite d être reprise dans vos propres limites : une borne qui existe pour arrêter un épuisement de ressources devrait dégrader la plus petite unité qu elle peut, pas interrompre le document. Le même raisonnement s applique quand vous étendez la couche de calcul, donc si vous enregistrez vos propres gestionnaires via l API de fonction personnalisée du moteur de formules, donnez-leur leurs propres bornes d argument et de récursion au lieu de supposer que l appelant a déjà vérifié
Ce que ces contrôles ne vous achètent pas
Soyez précis sur la limite. Les quatre contrôles croisés EOCD rendent l index d archive non ambigu, donc HotXLS et tout autre lecteur conforme résolvent le même fichier vers le même ensemble d entrées ; ils ne disent rien sur le fait que cet ensemble d entrées soit bénin. L accord des en-têtes locaux arrête le stratagème des deux vues, pas une charge utile malveillante décrite de façon cohérente. Le flux vérifié arrête la troncature, le débordement et la corruption, pas une partie XML parfaitement bien formée qui encode quelque chose que vous n attendiez pas. Et rien de tout cela ne touche aux macros : un projet VBA à l intérieur d un classeur structurellement impeccable reste un projet VBA, et la décision de le conserver, de le retirer, ou de le refuser appartient à votre couche de politique, pas au lecteur ZIP
Ce que vous obtenez en échange est une limite d échec propre. Un xlsx non fiable s ouvre soit comme une archive non ambiguë unique dont les membres correspondent à leurs tailles et sommes de contrôle déclarées, soit il lève une exception avec un message qui nomme l invariant spécifique qu il a enfreint, et votre service peut mettre en quarantaine sur l exception plutôt que de deviner. Le lecteur ZIP et les niveaux d analyseur au-dessus livrent en tant que partie du composant Excel HotXLS pour Delphi et C++Builder, qui n a besoin ni d Excel ni d automation OLE sur la machine effectuant l analyse, et cette absence est elle-même une réduction significative de ce qu un fichier envoyé peut atteindre