Technical Article

ZIP EOCD Validation for Untrusted XLSX Files in Delphi

An xlsx file is a ZIP archive, and ZIP has no single authoritative table of contents. HotXLS Excel Library for Delphi and C++Builder treats that ambiguity as an attack surface: its end of central directory parser accepts a candidate record only after four independent cross checks agree, so a forged directory hidden in a ZIP comment never wins

The scenario that makes this concrete is mundane. A server accepts spreadsheet uploads from customers. The file passes an antivirus scan, gets written to a spool directory, and your Delphi service opens it to pull out three columns. Everything looks fine, except that the scanner and your parser did not agree on what the archive contained. The scanner enumerated one set of members; your loader enumerated a different set from the same bytes. Neither of them is buggy in the ordinary sense. They simply resolved an ambiguity in the ZIP format in two different directions, and an attacker chose the bytes so that they would

Where does the truth about a ZIP archive actually live?

It lives at the very end, in a 22-byte structure called the end of central directory record. A ZIP file is not read front to back: every member carries a local file header immediately before its compressed data, but the authoritative index is the central directory, a run of records near the end that names every entry and gives the offset of its local header. To find the central directory you must first find the EOCD, because the EOCD is what says where the directory starts and how many records it holds. HotXLS models it as TEndOfCentralDirectoryRecord, whose fields map one to one onto the on-disk layout: FDiskNumber at offset 4, FStartDisk at 6, FThisDiskEntries at 8, FTotalEntries at 10, FSizeOfCD at 12, FOffsetOfStartCD at 16, and FCommentLen at 20. That total is FMinSize, computed in the constructor as 4*3 + 5*2. After it comes the archive comment, up to 65535 bytes of arbitrary content, which makes FMaxSize 65557 and means the record is not at a fixed position. You have to go looking for it

Why is scanning backwards for the EOCD signature not enough?

Because the four bytes you are scanning for, PK\005\006, can legally appear inside the archive comment, inside compressed data, or inside a second EOCD that an attacker appended on purpose. A parser that stops at the first signature it meets while walking backwards is trivially steerable: place a decoy EOCD near the tail and the naive parser follows it, while a parser that scans in a different order, or that treats the last signature in the file as canonical, follows the real one. This is the ZIP ambiguity family of attacks, and its payoff is exactly the split described above, where the scanning engine and the consuming application see different entry sets from one file

TEndOfCentralDirectoryRecord.Parse does scan backwards. It sets startscan to the last byte, clamps endscan to lsize - FMaxSize or zero, and walks the window in 256-byte buffers that overlap by three bytes so a signature straddling a buffer boundary is never missed. The difference is what happens on a hit. Finding the signature only produces a Candidate offset. HotXLS then reads the 22 bytes at that offset, parses them with ReadEOCD, and requires the resulting fields to be internally consistent with the file they claim to describe before FOffsetEOCD is assigned at all

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;

Read the predicate as four separate claims that a forgery has to satisfy simultaneously. Candidate + FMinSize + FCommentLen = lsize demands that the declared comment length reach exactly the end of the file, which is what kills the decoy-in-the-comment trick: a fake EOCD buried inside a real comment cannot also account for every byte after itself. FDiskNumber = 0 and FStartDisk = 0 reject the multi-disk spanning fields that no xlsx has ever legitimately used and that exist in crafted archives only to confuse. FThisDiskEntries = FTotalEntries rejects the split-count trick where one parser sizes its loop from one field and another parser from the other. And Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate requires the central directory to end precisely where the EOCD begins, so the directory cannot be pointed at some unrelated blob elsewhere in the file. The Int64 cast on that last one matters: both operands are 32-bit, and without widening, a crafted pair could wrap around and satisfy the test arithmetically while pointing nowhere sane

Local headers must agree with the central directory

The EOCD checks fix which directory is authoritative; they do not yet guarantee that the directory tells the truth about individual members. Every entry is described twice in a ZIP file, once centrally and once in its local header, and nothing in the format forces the two descriptions to match, so a reader that trusts the central directory and a reader that trusts local headers can extract different content from one archive. TZipEntry.ParseLocalHeader closes that gap by parsing the local header at FCdFile.LocalFileHeaderOffset and comparing the two copies field by field, returning a distinct negative code for each kind of disagreement: the canonicalized entry name, the compression method, the general purpose bit flags, and, when the data descriptor flag is clear, the CRC32 and both sizes. With that flag set the local copies may be zero, since the real values live in a trailing descriptor, but any non-zero local value must still match. A final check rejects entries whose data would run past the end of the file, comparing Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) against inputstream.Size. Any failure propagates out of TCentralDirectory.Parse as a non-1 result and TZipArchive.OpenArchive turns it into Can't open zip archive, rather than handing you a half-trusted archive object. When you only need to know which sheets a file contains, running that validation before a full parse is cheap, and the lightweight sheet inspection path gives you exactly it without materializing cell data

What happens when the bytes themselves lie?

Structural agreement still says nothing about the payload, so HotXLS wraps every entry stream in TZipVerifiedStream, which enforces the declared size and CRC32 as the caller reads. This is deliberately not a post-hoc check: a decompression bomb whose declared uncompressed size is 4 KB but which inflates to gigabytes is stopped at the 4 KB mark, not after the damage. The wrapper clamps each read to the remaining declared bytes, raises ZIP entry ended before its declared size if the source runs dry early, probes for one extra byte on completion and raises ZIP entry exceeds its declared size if anything is left, and finally compares the running CRC32 in VerifyComplete, raising ZIP entry uncompressed size mismatch or 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;

One consequence is worth planning for. The stream is forward-only by design; a Seek to anywhere other than the current position raises ZIP entry stream is forward-only, with a single concession for soEnd with offset zero so size queries still work. That is the right trade for untrusted input, because a stream you can rewind is a stream whose CRC accounting you can defeat, but it does mean consumer code that expects a seekable stream needs a buffer of its own. The same forward-only discipline underpins the streaming direct reader, which is the API to reach for when the uploaded workbook is large enough that you do not want it resident in memory at all

Resource limits before allocation, not after

Three constants in lxZipArchive bound what a single archive can ask the process to do, and TZipEntries.Add applies them while the central directory is still being read, before a byte of entry data is touched. ZipMaxEntryUncompressedSize caps one member at 1 GiB, ZipMaxTotalUncompressedSize caps the archive at 4 GiB, and ZipMaxCompressionRatio of 10000 rejects any deflated entry whose declared expansion exceeds ten-thousandfold, along with the degenerate case of a non-zero uncompressed size paired with a zero compressed size. Entry names go through CanonicalZipEntryName in the same call, which rejects embedded NUL characters, colons, and any .. path segment with Invalid ZIP entry name, and which lowercases and normalizes segments so that two members differing only in case or in redundant separators collide as Duplicate ZIP entry name instead of silently shadowing each other

Defense in depth above the ZIP layer

The ZIP layer is one tier of several, and the pattern repeats wherever HotXLS parses attacker-controlled structure. The clearest example sits in the BIFF formula parser: TXLSFormula.GetTranslated recurses through tMemFunc tokens, so a crafted rgce token stream in a legacy .xls can nest arbitrarily deep and exhaust the stack. The gate is a constant, MaxTranslateDepth = 256, chosen against a known upstream fact rather than guessed. Excel caps formula nesting at 64, so 256 leaves fourfold headroom and can never reject a formula a real spreadsheet produced, while still terminating a malicious stream long enough before the stack runs out

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

Note that the gate returns nil rather than raising. A formula too deep to be genuine yields no syntax tree, the surrounding parse continues, and the workbook still loads. That asymmetry is intentional and worth copying in your own limits: a bound that exists to stop resource exhaustion should degrade the smallest unit it can, not abort the document. The same reasoning applies when you extend the calculation layer, so if you register your own handlers through the formula engine custom function API, give them their own argument and recursion bounds instead of assuming the caller already checked

What these checks do not buy you

Be precise about the boundary. The four EOCD cross checks make the archive index unambiguous, so HotXLS and any other conforming reader resolve the same file to the same entry set; they say nothing about whether that entry set is benign. Local header agreement stops the two-views trick, not a malicious payload that is consistently described. The verified stream stops truncation, overflow, and corruption, not a perfectly well-formed XML part that encodes something you did not expect. And none of this touches macros: a VBA project inside a structurally impeccable workbook is still a VBA project, and the decision to keep, strip, or refuse it belongs to your policy layer, not the ZIP reader

What you get in exchange is a clean failure boundary. An untrusted xlsx either opens as one unambiguous archive whose members match their declared sizes and checksums, or it raises with a message that names the specific invariant it broke, and your service can quarantine on the exception rather than guessing. The ZIP reader and the parser tiers above it ship as part of the HotXLS Excel Component for Delphi and C++Builder, which needs neither Excel nor OLE automation on the machine doing the parsing, and that absence is itself a meaningful reduction in what an uploaded file can reach