Technischer Artikel

ZIP-EOCD-Validierung für nicht vertrauenswürdige XLSX-Dateien in Delphi

Eine xlsx-Datei ist ein ZIP-Archiv, und ZIP hat kein einziges maßgebliches Inhaltsverzeichnis. Die HotXLS-Excel-Bibliothek für Delphi und C++Builder behandelt diese Mehrdeutigkeit als Angriffsfläche: Ihr Parser für den End of Central Directory akzeptiert einen Kandidatendatensatz erst, wenn vier unabhängige Gegenprüfungen übereinstimmen, sodass ein gefälschtes Verzeichnis, versteckt in einem ZIP-Kommentar, niemals gewinnt

Das Szenario, das das greifbar macht, ist alltäglich. Ein Server nimmt Tabellenkalkulations-Uploads von Kunden entgegen. Die Datei besteht einen Virenscan, wird in ein Spool-Verzeichnis geschrieben, und der eigene Delphi-Dienst öffnet sie, um drei Spalten herauszuziehen. Alles sieht in Ordnung aus, nur dass Scanner und Parser sich nicht einig waren, was das Archiv enthielt. Der Scanner hat eine Menge an Mitgliedern aufgezählt; der eigene Loader eine andere Menge aus denselben Bytes. Keiner von beiden ist im üblichen Sinne fehlerhaft. Sie haben lediglich eine Mehrdeutigkeit im ZIP-Format in zwei verschiedene Richtungen aufgelöst, und ein Angreifer hat die Bytes so gewählt, dass genau das passiert

Wo liegt die Wahrheit über ein ZIP-Archiv tatsächlich?

Ganz am Ende, in einer 22-Byte-Struktur namens End of Central Directory Record. Eine ZIP-Datei wird nicht von vorn nach hinten gelesen: Jedes Mitglied trägt unmittelbar vor seinen komprimierten Daten einen lokalen Dateiheader, aber das maßgebliche Verzeichnis ist das zentrale Verzeichnis, eine Folge von Datensätzen nahe dem Ende, die jeden Eintrag benennt und den Offset seines lokalen Headers angibt. Um das zentrale Verzeichnis zu finden, muss man zuerst den EOCD finden, denn der EOCD sagt, wo das Verzeichnis beginnt und wie viele Datensätze es enthält. HotXLS bildet ihn als TEndOfCentralDirectoryRecord ab, dessen Felder eins zu eins dem Layout auf der Platte entsprechen: FDiskNumber bei Offset 4, FStartDisk bei 6, FThisDiskEntries bei 8, FTotalEntries bei 10, FSizeOfCD bei 12, FOffsetOfStartCD bei 16 und FCommentLen bei 20. Diese Summe ist FMinSize, im Konstruktor berechnet als 4*3 + 5*2. Danach folgt der Archivkommentar, bis zu 65535 Bytes beliebigen Inhalts, was FMaxSize auf 65557 setzt und bedeutet, dass der Datensatz nicht an einer festen Position liegt. Man muss ihn suchen

Warum reicht rückwärtiges Scannen nach der EOCD-Signatur nicht aus?

Weil die vier Bytes, nach denen man scannt, PK\005\006, legal innerhalb des Archivkommentars, innerhalb komprimierter Daten oder innerhalb eines zweiten, absichtlich von einem Angreifer angehängten EOCD auftauchen können. Ein Parser, der bei der ersten beim Rückwärtslaufen angetroffenen Signatur stoppt, ist trivial steuerbar: Man platziert einen Lock-EOCD nahe dem Ende, und der naive Parser folgt ihm, während ein Parser, der in anderer Reihenfolge scannt, oder der die letzte Signatur in der Datei als maßgeblich behandelt, dem echten folgt. Das ist die Familie der ZIP-Mehrdeutigkeits-Angriffe, und ihr Ertrag ist genau die oben beschriebene Aufspaltung, bei der die Scan-Engine und die konsumierende Anwendung aus einer Datei unterschiedliche Eintragsmengen sehen

TEndOfCentralDirectoryRecord.Parse scannt tatsächlich rückwärts. Es setzt startscan auf das letzte Byte, begrenzt endscan auf lsize - FMaxSize oder null, und durchläuft das Fenster in 256-Byte-Puffern, die sich um drei Bytes überlappen, sodass eine Signatur, die eine Puffergrenze überspannt, nie übersehen wird. Der Unterschied liegt darin, was bei einem Treffer geschieht. Das Finden der Signatur ergibt nur einen Candidate-Offset. HotXLS liest dann die 22 Bytes an diesem Offset, parst sie mit ReadEOCD und verlangt, dass die resultierenden Felder intern konsistent mit der Datei sind, die sie zu beschreiben behaupten, bevor FOffsetEOCD überhaupt zugewiesen wird

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;

Man liest das Prädikat als vier getrennte Behauptungen, die eine Fälschung gleichzeitig erfüllen müsste. Candidate + FMinSize + FCommentLen = lsize verlangt, dass die deklarierte Kommentarlänge exakt bis zum Ende der Datei reicht, was den Lock-im-Kommentar-Trick tötet: Ein gefälschter EOCD, vergraben in einem echten Kommentar, kann nicht gleichzeitig jedes Byte danach erklären. FDiskNumber = 0 und FStartDisk = 0 weisen die Mehrfachdatenträger-Felder zurück, die keine xlsx-Datei je legitim genutzt hat und die in präparierten Archiven nur zur Verwirrung existieren. FThisDiskEntries = FTotalEntries weist den Split-Count-Trick zurück, bei dem ein Parser seine Schleife nach dem einen Feld dimensioniert und ein anderer Parser nach dem anderen. Und Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate verlangt, dass das zentrale Verzeichnis genau dort endet, wo der EOCD beginnt, sodass das Verzeichnis nicht auf einen unzusammenhängenden Blob anderswo in der Datei zeigen kann. Die Int64-Umwandlung bei letzterem ist wichtig: Beide Operanden sind 32-Bit, und ohne Erweiterung könnte ein präpariertes Paar überlaufen und den Test arithmetisch erfüllen, während es auf nichts Sinnvolles zeigt

Lokale Header müssen mit dem zentralen Verzeichnis übereinstimmen

Die EOCD-Prüfungen legen fest, welches Verzeichnis maßgeblich ist; sie garantieren noch nicht, dass das Verzeichnis über einzelne Mitglieder die Wahrheit sagt. Jeder Eintrag wird in einer ZIP-Datei zweimal beschrieben, einmal zentral und einmal in seinem lokalen Header, und nichts im Format erzwingt, dass beide Beschreibungen übereinstimmen, sodass ein Reader, der dem zentralen Verzeichnis vertraut, und ein Reader, der lokalen Headern vertraut, aus einem Archiv unterschiedlichen Inhalt extrahieren können. TZipEntry.ParseLocalHeader schließt diese Lücke, indem es den lokalen Header bei FCdFile.LocalFileHeaderOffset parst und die beiden Kopien Feld für Feld vergleicht, mit einem eigenen negativen Code für jede Art von Abweichung: der kanonisierte Eintragsname, die Kompressionsmethode, die General-Purpose-Bit-Flags und, wenn das Data-Descriptor-Flag nicht gesetzt ist, CRC32 und beide Größenangaben. Ist dieses Flag gesetzt, dürfen die lokalen Kopien null sein, da die echten Werte in einem nachfolgenden Deskriptor liegen, aber jeder von null verschiedene lokale Wert muss dennoch übereinstimmen. Eine letzte Prüfung weist Einträge zurück, deren Daten über das Ende der Datei hinausreichen würden, indem Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) mit inputstream.Size verglichen wird. Jeder Fehlschlag pflanzt sich aus TCentralDirectory.Parse als Ergebnis ungleich 1 fort, und TZipArchive.OpenArchive macht daraus Can't open zip archive, statt ein halb vertrauenswürdiges Archivobjekt zu übergeben. Wenn man nur wissen muss, welche Blätter eine Datei enthält, ist es günstig, diese Validierung vor einem vollständigen Parse laufen zu lassen, und der leichtgewichtige Blattinspektionspfad liefert genau das, ohne Zelldaten zu materialisieren

Was geschieht, wenn die Bytes selbst lügen?

Strukturelle Übereinstimmung sagt noch nichts über die Nutzdaten aus, deshalb umschließt HotXLS jeden Eintrags-Stream mit TZipVerifiedStream, der die deklarierte Größe und CRC32 erzwingt, während der Aufrufer liest. Das ist bewusst keine nachträgliche Prüfung: Eine Dekomprimierungsbombe, deren deklarierte unkomprimierte Größe 4 KB beträgt, die aber auf Gigabyte inflatet, wird bei der 4-KB-Marke gestoppt, nicht nach dem Schaden. Der Wrapper begrenzt jeden Lesevorgang auf die verbleibenden deklarierten Bytes, löst ZIP entry ended before its declared size aus, wenn die Quelle vorzeitig versiegt, sondiert bei Abschluss ein zusätzliches Byte und löst ZIP entry exceeds its declared size aus, wenn noch etwas übrig ist, und vergleicht schließlich die laufende CRC32 in VerifyComplete, wobei ZIP entry uncompressed size mismatch oder ZIP entry CRC32 mismatch ausgelöst wird

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;

Eine Konsequenz lohnt es, einzuplanen. Der Stream ist by Design nur vorwärts lesbar; ein Seek zu einer anderen Position als der aktuellen löst ZIP entry stream is forward-only aus, mit einem einzigen Zugeständnis für soEnd mit Offset null, damit Größenabfragen weiterhin funktionieren. Das ist der richtige Kompromiss für nicht vertrauenswürdige Eingaben, denn ein Stream, den man zurückspulen kann, ist ein Stream, dessen CRC-Buchführung sich aushebeln lässt, aber es bedeutet, dass Verbrauchercode, der einen seekbaren Stream erwartet, einen eigenen Puffer braucht. Dieselbe Nur-vorwärts-Disziplin liegt dem Streaming Direct Reader zugrunde, der API, zu der man greift, wenn die hochgeladene Arbeitsmappe so groß ist, dass man sie überhaupt nicht im Speicher vorhalten möchte

Ressourcengrenzen vor der Allokation, nicht danach

Drei Konstanten in lxZipArchive begrenzen, was ein einzelnes Archiv den Prozess zu tun bitten kann, und TZipEntries.Add wendet sie an, während das zentrale Verzeichnis noch gelesen wird, bevor ein Byte an Eintragsdaten berührt wird. ZipMaxEntryUncompressedSize begrenzt ein Mitglied auf 1 GiB, ZipMaxTotalUncompressedSize begrenzt das Archiv auf 4 GiB, und ZipMaxCompressionRatio von 10000 weist jeden deflateten Eintrag zurück, dessen deklarierte Expansion das Zehntausendfache übersteigt, zusammen mit dem entarteten Fall einer von null verschiedenen unkomprimierten Größe gepaart mit einer komprimierten Größe von null. Eintragsnamen durchlaufen im selben Aufruf CanonicalZipEntryName, das eingebettete NUL-Zeichen, Doppelpunkte und jedes ..-Pfadsegment mit Invalid ZIP entry name zurückweist, und das Segmente kleinschreibt und normalisiert, sodass zwei Mitglieder, die sich nur in Groß-/Kleinschreibung oder in redundanten Trennzeichen unterscheiden, als Duplicate ZIP entry name kollidieren, statt einander stillschweigend zu überdecken

Tiefenverteidigung oberhalb der ZIP-Schicht

Die ZIP-Schicht ist eine von mehreren Stufen, und das Muster wiederholt sich überall dort, wo HotXLS von einem Angreifer kontrollierte Struktur parst. Das klarste Beispiel liegt im BIFF-Formel-Parser: TXLSFormula.GetTranslated rekursiert durch tMemFunc-Token, sodass ein präparierter rgce-Token-Stream in einer klassischen .xls-Datei beliebig tief verschachtelt sein und den Stack erschöpfen kann. Die Absicherung ist eine Konstante, MaxTranslateDepth = 256, gewählt anhand einer bekannten Tatsache über Excel selbst statt geraten. Excel begrenzt Formelverschachtelung auf 64, sodass 256 vierfachen Spielraum lässt und niemals eine Formel zurückweisen kann, die eine echte Tabellenkalkulation erzeugt hat, während trotzdem ein bösartiger Stream rechtzeitig beendet wird, bevor der Stack ausläuft

const
  MaxTranslateDepth = 256;
begin
  isOuter := FTranslateDepth = 0;
  if isOuter then
    ResetPendingArrays;
  Inc(FTranslateDepth);
  try
    if FTranslateDepth > MaxTranslateDepth then
    begin
      Result := nil;
      Exit;
    end;

Zu beachten ist, dass die Absicherung nil zurückgibt statt eine Exception auszulösen. Eine Formel, die zu tief ist, um echt zu sein, ergibt keinen Syntaxbaum, das umgebende Parsen läuft weiter, und die Arbeitsmappe lädt trotzdem. Diese Asymmetrie ist beabsichtigt und lohnt sich in eigenen Grenzen nachzuahmen: Eine Grenze, die existiert, um Ressourcenerschöpfung zu stoppen, sollte die kleinstmögliche Einheit degradieren, nicht das Dokument abbrechen. Dieselbe Überlegung gilt, wenn man die Berechnungsschicht erweitert, sodass man beim Registrieren eigener Handler über die Custom-Function-API der Formel-Engine diesen eigene Argument- und Rekursionsgrenzen geben sollte, statt anzunehmen, der Aufrufer habe bereits geprüft

Was diese Prüfungen nicht leisten

Man sollte die Grenze präzise ziehen. Die vier EOCD-Gegenprüfungen machen den Archivindex eindeutig, sodass HotXLS und jeder andere konforme Reader dieselbe Datei zur selben Eintragsmenge auflösen; sie sagen nichts darüber, ob diese Eintragsmenge harmlos ist. Die Übereinstimmung lokaler Header stoppt den Zwei-Sichten-Trick, nicht eine bösartige Nutzlast, die konsistent beschrieben ist. Der verifizierte Stream stoppt Abschneiden, Überlauf und Beschädigung, nicht einen völlig wohlgeformten XML-Teil, der etwas Unerwartetes kodiert. Und nichts davon berührt Makros: Ein VBA-Projekt in einer strukturell einwandfreien Arbeitsmappe bleibt ein VBA-Projekt, und die Entscheidung, es zu behalten, zu entfernen oder abzulehnen, gehört in die eigene Policy-Schicht, nicht in den ZIP-Reader

Was man im Gegenzug bekommt, ist eine saubere Fehlergrenze. Eine nicht vertrauenswürdige xlsx-Datei öffnet entweder als ein eindeutiges Archiv, dessen Mitglieder ihren deklarierten Größen und Prüfsummen entsprechen, oder sie löst eine Exception mit einer Meldung aus, die die konkrete verletzte Invariante benennt, und der eigene Dienst kann anhand der Exception in Quarantäne gehen, statt zu raten. Der ZIP-Reader und die darüberliegenden Parser-Schichten sind Teil der HotXLS-Excel-Komponente für Delphi und C++Builder, die weder Excel noch OLE-Automatisierung auf der Maschine benötigt, die das Parsen durchführt, und dieses Fehlen ist selbst eine bedeutsame Verkleinerung dessen, was eine hochgeladene Datei erreichen kann