技術文章

Delphi 中對不受信任 XLSX 檔案的 ZIP EOCD 驗證

一份 xlsx 檔案是一個 ZIP 封存檔,而 ZIP 沒有單一權威的目錄。給 Delphi 與 C++Builder 用的 HotXLS Excel Library 把這種模糊性當成攻擊面來對待:它的目錄結尾記錄剖析器只有在四項獨立的交叉檢查全部一致時,才會接受一筆候選記錄,所以藏在 ZIP 註解裡的偽造目錄永遠贏不了

讓這件事具體起來的情境相當平常。一台伺服器接受客戶上傳的試算表。檔案通過防毒掃描,寫進一個暫存目錄,你的 Delphi 服務打開它取出三欄。一切看起來都正常,只是掃描器與你的剖析器對這個封存檔裡裝了什麼並沒有共識。掃描器列舉出一組成員;你的載入器從同一份位元組列舉出不同的一組。兩者在一般意義上都沒有錯。它們只是對 ZIP 格式裡的一個模糊之處,往兩個不同方向解出了結論,而攻擊者選擇位元組的方式,正是要讓它們往那兩個方向去

一份 ZIP 封存檔的真相究竟活在哪裡?

它活在最尾端,一個叫做目錄結尾記錄(end of central directory record)的 22 位元組結構裡。一份 ZIP 檔案不是從頭讀到尾的:每個成員在其壓縮資料前都緊跟著一個本地檔案標頭,但權威索引是中央目錄,一連串位於檔案尾端附近、為每個項目命名並給出其本地標頭偏移量的記錄。要找到中央目錄,你必須先找到 EOCD,因為正是 EOCD 說明了目錄從哪裡開始、有多少筆記錄。HotXLS 把它建模成 TEndOfCentralDirectoryRecord,其欄位與磁碟上的版面一一對應:FDiskNumber 在偏移量 4,FStartDisk 在 6,FThisDiskEntries 在 8,FTotalEntries 在 10,FSizeOfCD 在 12,FOffsetOfStartCD 在 16,FCommentLen 在 20。這個總和就是 FMinSize,在建構函式裡算成 4*3 + 5*2。在它之後是封存檔註解,最多 65535 位元組的任意內容,這讓 FMaxSize 變成 65557,也代表這筆記錄不在固定位置。你必須主動去找它

為何向後掃描 EOCD 簽章還不夠?

因為你在掃描的那四個位元組,PK\005\006,可以合法出現在封存檔註解裡、壓縮資料裡,或是攻擊者刻意附加的第二個 EOCD 裡。一個在向後走訪時遇到第一個簽章就停下的剖析器,是輕易可被操控的:在檔案尾部附近放一個誘餌 EOCD,樸素剖析器就會跟著它走,而以不同順序掃描、或把檔案裡最後一個簽章視為權威的剖析器,則會跟著真正的那個走。這屬於 ZIP 模糊性攻擊家族,它的回報正是上面描述的那種分裂,掃描引擎與消費端應用程式從同一份檔案看到不同的項目集合

TEndOfCentralDirectoryRecord.Parse 確實是向後掃描的。它把 startscan 設成最後一個位元組,把 endscan 鉗制到 lsize - FMaxSize 或零,並以 256 位元組緩衝區走訪這個視窗,緩衝區之間重疊三個位元組,讓一個橫跨緩衝區邊界的簽章絕不會被漏掉。差別在於命中之後發生的事。找到簽章只會產生一個 Candidate 偏移量。HotXLS 接著會讀取該偏移量處的 22 個位元組,用 ReadEOCD 剖析它們,並要求結果欄位在指派 FOffsetEOCD 之前,就已經與它們所宣稱描述的檔案內部一致

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;

把這個判斷式讀成四項獨立的宣稱,偽造品必須同時滿足它們。Candidate + FMinSize + FCommentLen = lsize 要求宣稱的註解長度必須恰好抵達檔案尾端,這正是能拆穿「藏在註解裡的誘餌」把戲的關鍵:一個埋在真正註解裡的偽造 EOCD,無法同時把自己之後的每個位元組都算進去。FDiskNumber = 0FStartDisk = 0 拒絕多磁碟跨卷欄位,任何 xlsx 都從未合法用過這些欄位,它們出現在精心構造的封存檔裡,唯一的目的就是製造混淆。FThisDiskEntries = FTotalEntries 拒絕拆分計數的把戲,也就是一個剖析器從其中一個欄位計算迴圈大小,另一個剖析器從另一個欄位計算。而 Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate 要求中央目錄必須恰好在 EOCD 開始的位置結束,讓目錄不能被指向檔案裡別處某個不相關的 blob。最後這一項上的 Int64 轉型很要緊:兩個運算元都是 32 位元,若不擴寬,一組精心構造的數值配對就可能在算術上繞回,滿足這項測試,卻指向哪裡都不對的地方

本地標頭必須與中央目錄一致

EOCD 檢查固定了哪個目錄是權威的;它們還無法保證那個目錄對個別成員說的是實話。ZIP 檔案裡的每個項目都被描述兩次,一次在中央、一次在自己的本地標頭裡,而格式裡沒有任何東西強制這兩份描述一致,所以一個信任中央目錄的讀取程式和一個信任本地標頭的讀取程式,可以從同一個封存檔裡擷取出不同的內容。TZipEntry.ParseLocalHeader 補上這個缺口,方式是剖析位於 FCdFile.LocalFileHeaderOffset 的本地標頭,逐欄位比對兩份副本,每一種不一致都回傳一個不同的負值代碼:正規化過的項目名稱、壓縮方法、通用位元旗標,以及在資料描述符旗標未設定時的 CRC32 與兩個大小值。旗標設定時,本地副本的值可以是零,因為真正的值活在後面的描述符裡,但任何非零的本地值仍然必須一致。最後一項檢查會拒絕資料範圍會跑出檔案尾端的項目,比較 Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize)inputstream.Size。任何失敗都會以非 1 的結果從 TCentralDirectory.Parse 傳播出去,TZipArchive.OpenArchive 會把它轉成 Can't open zip archive,而不是交給你一個半信任的封存檔物件。當你只需要知道一份檔案裡有哪些工作表時,在完整剖析之前先跑這項驗證成本很低,輕量工作表檢視路徑正好提供了這一點,卻不必實體化儲存格資料

當位元組本身在說謊時,會發生什麼事?

結構上的一致,並不代表酬載本身可信,所以 HotXLS 把每個項目串流包在 TZipVerifiedStream 裡,在呼叫端讀取的同時強制檢查宣稱的大小與 CRC32。這刻意不是一次事後檢查:一個宣稱未壓縮大小是 4 KB、卻膨脹成好幾 GB 的解壓縮炸彈,會在 4 KB 那一刻就被攔下,而不是在傷害造成之後。這個包裝會把每次讀取鉗制到剩餘的宣稱位元組數,若來源提早枯竭就丟出 ZIP entry ended before its declared size,完成時會多探測一個位元組,若還有剩就丟出 ZIP entry exceeds its declared size,最後在 VerifyComplete 裡比對累計的 CRC32,丟出 ZIP entry uncompressed size mismatchZIP 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;

有一個結果值得為此規劃。這個串流依設計是只能向前讀的;Seek 到目前位置以外的任何地方都會丟出 ZIP entry stream is forward-only,唯一的通融是偏移量為零的 soEnd,讓大小查詢仍能運作。這對不受信任的輸入來說是正確的取捨,因為一個可以倒帶的串流,也是一個可以被騙過 CRC 核算的串流,但這也代表期待可尋址串流的消費端程式碼,需要自備一個緩衝區。同樣的只能向前的紀律,也支撐著串流式直接讀取器,這是當上傳的活頁簿大到你不想讓它整個常駐記憶體時該用的 API

先設資源上限,再分配

lxZipArchive 裡有三個常數,約束一個單一封存檔能要求行程做多少事,TZipEntries.Add 會在中央目錄還在讀取時就套用它們,早在任何一個位元組的項目資料被碰到之前。ZipMaxEntryUncompressedSize 把單一成員限制在 1 GiB,ZipMaxTotalUncompressedSize 把整個封存檔限制在 4 GiB,ZipMaxCompressionRatio 值為 10000,拒絕任何宣稱膨脹超過一萬倍的 deflate 項目,也包括非零未壓縮大小配上零壓縮大小的退化情況。項目名稱在同一次呼叫裡會經過 CanonicalZipEntryName,它會拒絕內嵌的 NUL 字元、冒號,以及任何 .. 路徑區段,回傳 Invalid ZIP entry name,並把區段轉成小寫、正規化,讓兩個僅在大小寫或多餘分隔符上不同的成員碰撞成 Duplicate ZIP entry name,而不是悄悄互相遮蔽

ZIP 層之上的縱深防禦

ZIP 層只是好幾層裡的一層,這個模式在 HotXLS 剖析攻擊者可控結構的每個地方都會重複出現。最清楚的例子在 BIFF 公式剖析器裡:TXLSFormula.GetTranslated 遞迴走訪 tMemFunc token,所以舊版 .xls 裡一段精心構造的 rgce token 串流可以無限巢狀,耗盡堆疊。這裡的關卡是一個常數,MaxTranslateDepth = 256,是根據一項已知的上游事實挑選出來的,而不是憑空猜測。Excel 把公式巢狀上限訂在 64,所以 256 留了四倍的餘裕,絕不可能拒絕一份真正試算表產生的公式,同時仍能在堆疊耗盡前很久就終止一條惡意串流

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

注意這個關卡回傳的是 nil,而不是丟出例外。一個深到不可能是真的的公式不會產生語法樹,周圍的剖析繼續進行,活頁簿依然能載入。這種不對稱是刻意的,也值得抄下來用在你自己的限制上:一個為了阻止資源耗盡而存在的界限,該退化成它能退化的最小單位,而不是中止整份文件。同樣的道理也適用在你擴充計算層的時候,所以如果你透過公式引擎自訂函式 API註冊自己的處理常式,給它們自己的引數與遞迴界限,不要假設呼叫端已經檢查過了

這些檢查買不到的東西

要精確描述這個邊界。四項 EOCD 交叉檢查讓封存檔索引不再有歧義,所以 HotXLS 與任何其他遵循規範的讀取程式,會把同一份檔案解析成同一組項目;它們沒有說這組項目是否無害。本地標頭一致性擋住的是雙重視角的把戲,而不是一份被一致描述的惡意酬載。已驗證串流擋住的是截斷、溢位與損毀,而不是一段格式完全正確、卻編碼了你意想不到內容的 XML 部分。而這一切都不會碰觸巨集:一份結構上無懈可擊的活頁簿裡的 VBA 專案,依然是一個 VBA 專案,保留、剝除或拒絕它的決定屬於你的政策層,不屬於 ZIP 讀取器

換來的是一條乾淨的失效邊界。一份不受信任的 xlsx,要嘛以一個成員與其宣稱大小及校驗碼相符、毫無歧義的封存檔開啟,要嘛丟出一個指名它違反了哪個特定不變量的訊息,你的服務可以依例外做隔離處理,而不必用猜的。ZIP 讀取器與其上層的剖析階層,都隨 Delphi 與 C++Builder 版的 HotXLS Excel Component 一併出貨,而它在做剖析的機器上既不需要 Excel,也不需要 OLE 自動化,這種欠缺本身就是對一份上傳檔案能觸及範圍的一次有意義的縮減