บทความเทคนิค

การตรวจสอบ ZIP EOCD สำหรับไฟล์ XLSX ที่ไม่น่าเชื่อถือใน Delphi

ไฟล์ xlsx คือ ZIP archive และ ZIP ไม่มีตารางเนื้อหาที่เป็นแหล่งอ้างอิงเดียว HotXLS Excel Library สำหรับ Delphi และ C++Builder ถือว่าความคลุมเครือนั้นเป็นพื้นผิวการโจมตี ตัว parser end of central directory ของมันจะยอมรับ record ตัวเลือกก็ต่อเมื่อการตรวจสอบไขว้อิสระสี่อย่างเห็นตรงกันเท่านั้น ดังนั้น directory ปลอมที่ซ่อนอยู่ใน ZIP comment จึงไม่มีทางชนะได้เลย

สถานการณ์ที่ทำให้เรื่องนี้เป็นรูปธรรมนั้นธรรมดามาก เซิร์ฟเวอร์รับไฟล์ spreadsheet อัปโหลดจากลูกค้า ไฟล์ผ่านการสแกนไวรัส ถูกเขียนลงใน spool directory และ Delphi service ของคุณเปิดมันเพื่อดึงสามคอลัมน์ออกมา ทุกอย่างดูปกติ ยกเว้นว่าตัวสแกนกับ parser ของคุณไม่ได้ตกลงกันว่า archive นั้นมีอะไรอยู่ ตัวสแกน enumerate สมาชิกชุดหนึ่ง ตัวโหลดของคุณ enumerate ชุดที่ต่างออกไปจากไบต์เดียวกัน ไม่มีตัวไหนมีบั๊กในความหมายทั่วไป มันแค่แก้ไขความคลุมเครือในรูปแบบ ZIP ไปคนละทาง และผู้โจมตีเลือกไบต์ให้มันเป็นแบบนั้นพอดี

ความจริงเกี่ยวกับ ZIP archive อยู่ที่ไหนจริง ๆ

มันอยู่ที่ท้ายสุด ในโครงสร้าง 22 ไบต์ที่เรียกว่า end of central directory record ไฟล์ ZIP ไม่ได้ถูกอ่านจากหน้าไปหลัง สมาชิกทุกตัวพก local file header ทันทีก่อนข้อมูลที่บีบอัดของมัน แต่ดัชนีที่เป็นแหล่งอ้างอิงคือ central directory ซึ่งเป็นชุด record ใกล้ท้ายไฟล์ที่ระบุชื่อทุก entry และให้ offset ของ local header ของมัน การจะหา central directory ได้ คุณต้องหา EOCD ก่อน เพราะ EOCD คือสิ่งที่บอกว่า directory เริ่มตรงไหนและมีกี่ record HotXLS จำลองมันเป็น TEndOfCentralDirectoryRecord ที่ฟิลด์แม็ปตรงกับ layout บนดิสก์แบบหนึ่งต่อหนึ่ง FDiskNumber ที่ offset 4, FStartDisk ที่ 6, FThisDiskEntries ที่ 8, FTotalEntries ที่ 10, FSizeOfCD ที่ 12, FOffsetOfStartCD ที่ 16 และ FCommentLen ที่ 20 ผลรวมนั้นคือ FMinSize คำนวณใน constructor เป็น 4*3 + 5*2 หลังจากนั้นคือ archive comment สูงสุด 65535 ไบต์ของเนื้อหาใด ๆ ก็ได้ ซึ่งทำให้ FMaxSize เป็น 65557 และหมายความว่า record นี้ไม่ได้อยู่ที่ตำแหน่งคงที่ คุณต้องไปหามัน

ทำไมการสแกนย้อนกลับหา EOCD signature ไม่พอ

เพราะสี่ไบต์ที่คุณสแกนหา PK\005\006 สามารถปรากฏได้อย่างถูกกฎหมายภายใน archive comment ภายในข้อมูลที่บีบอัด หรือภายใน EOCD ตัวที่สองที่ผู้โจมตีต่อท้ายไว้โดยตั้งใจ parser ที่หยุดที่ signature แรกที่มันเจอขณะเดินย้อนกลับสามารถถูกบังคับทิศทางได้อย่างง่ายดาย วาง EOCD หลอกไว้ใกล้ท้ายไฟล์ แล้ว parser แบบไร้เดียงสาจะตามมันไป ในขณะที่ parser ที่สแกนในลำดับที่ต่างออกไป หรือที่ถือว่า signature สุดท้ายในไฟล์เป็นตัวจริง จะตามของจริง นี่คือตระกูลการโจมตีแบบ ZIP ambiguity และผลตอบแทนของมันคือการแยกที่อธิบายไว้ข้างบนพอดี ที่ scanning engine และแอปพลิเคชันที่บริโภคข้อมูลเห็น entry set ต่างกันจากไฟล์เดียวกัน

TEndOfCentralDirectoryRecord.Parse สแกนย้อนกลับจริง มันตั้ง startscan ไปที่ไบต์สุดท้าย clamp endscan เป็น lsize - FMaxSize หรือศูนย์ และเดินหน้าต่างนั้นเป็น buffer 256 ไบต์ที่ทับซ้อนกันสามไบต์ เพื่อไม่ให้ signature ที่คร่อมขอบ buffer ถูกพลาดไปเลย ความต่างอยู่ที่สิ่งที่เกิดขึ้นเมื่อเจอ การพบ signature จะให้แค่ offset ตัวเลือกเท่านั้น จากนั้น HotXLS จะอ่าน 22 ไบต์ที่ offset นั้น parse ด้วย 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;

อ่าน predicate นี้เป็นสี่คำกล่าวอ้างแยกกันที่ของปลอมต้องผ่านให้ครบพร้อมกัน Candidate + FMinSize + FCommentLen = lsize เรียกร้องให้ความยาว comment ที่ประกาศไว้ไปถึงจุดจบไฟล์พอดี ซึ่งเป็นสิ่งที่ฆ่ากลลวง decoy-in-the-comment EOCD ปลอมที่ฝังอยู่ภายใน comment จริงไม่สามารถอธิบายทุกไบต์หลังจากตัวมันได้ด้วย FDiskNumber = 0 และ FStartDisk = 0 ปฏิเสธฟิลด์การขยาย multi-disk ที่ xlsx ไม่เคยใช้อย่างถูกกฎหมายเลย และมีอยู่ใน archive ที่สร้างขึ้นเองแค่เพื่อสร้างความสับสน FThisDiskEntries = FTotalEntries ปฏิเสธกลลวง split-count ที่ parser หนึ่งตั้งขนาดลูปจากฟิลด์หนึ่งและอีก parser จากอีกฟิลด์ และ Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate เรียกร้องให้ central directory จบตรงที่ EOCD เริ่มพอดี ดังนั้น directory จึงไม่สามารถถูกชี้ไปยัง blob ที่ไม่เกี่ยวข้องที่อื่นในไฟล์ได้ การ cast เป็น Int64 ในตัวสุดท้ายนั้นสำคัญ ทั้งสอง operand เป็น 32-bit และหากไม่ขยายขนาด คู่ที่สร้างขึ้นเองก็สามารถวนกลับและผ่านการทดสอบทางเลขคณิตได้ในขณะที่ชี้ไปยังที่ไม่มีความหมายเลย

Local header ต้องตรงกับ central directory

การตรวจสอบ EOCD กำหนดว่า directory ไหนเป็นแหล่งอ้างอิง แต่ยังไม่รับประกันว่า directory นั้นบอกความจริงเกี่ยวกับสมาชิกแต่ละตัว ทุก entry ถูกอธิบายสองครั้งในไฟล์ ZIP ครั้งหนึ่งใน central และอีกครั้งใน local header ของมัน และไม่มีอะไรในรูปแบบบังคับให้ทั้งสองคำอธิบายตรงกัน ดังนั้น reader ที่เชื่อ central directory กับ reader ที่เชื่อ local header จึงสามารถแยกเนื้อหาต่างกันออกมาจาก archive เดียวกันได้ TZipEntry.ParseLocalHeader ปิดช่องว่างนั้นด้วยการ parse local header ที่ FCdFile.LocalFileHeaderOffset และเปรียบเทียบสำเนาทั้งสองทีละฟิลด์ คืนรหัสลบที่แตกต่างกันสำหรับความไม่ตรงกันแต่ละแบบ ชื่อ entry ที่ canonicalize แล้ว, วิธีการบีบอัด, general purpose bit flag และเมื่อ data descriptor flag ไม่ถูกตั้ง CRC32 และขนาดทั้งสอง เมื่อ flag นั้นถูกตั้ง สำเนา local อาจเป็นศูนย์ได้ เพราะค่าจริงอยู่ใน descriptor ที่ตามมา แต่ค่า local ที่ไม่เป็นศูนย์ต้องยังคงตรงกัน การตรวจสอบสุดท้ายจะปฏิเสธ entry ที่ข้อมูลของมันจะวิ่งเลยจุดจบของไฟล์ เปรียบเทียบ Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) กับ inputstream.Size ความล้มเหลวใด ๆ จะแพร่กระจายออกจาก TCentralDirectory.Parse เป็นผลลัพธ์ที่ไม่ใช่ 1 และ TZipArchive.OpenArchive จะเปลี่ยนมันเป็น Can't open zip archive แทนที่จะส่งมอบ archive object ที่เชื่อถือได้ครึ่งเดียวให้คุณ เมื่อคุณต้องการรู้แค่ว่าไฟล์มี sheet อะไรบ้าง การรันการตรวจสอบนั้นก่อนการ parse แบบเต็มมีต้นทุนต่ำ และ เส้นทางตรวจสอบ sheet แบบเบา ให้สิ่งนั้นพอดีโดยไม่ต้องสร้างข้อมูลเซลล์

เกิดอะไรขึ้นเมื่อไบต์เองโกหก

ความสอดคล้องเชิงโครงสร้างยังไม่บอกอะไรเกี่ยวกับ payload เลย ดังนั้น HotXLS จึงห่อทุก entry stream ด้วย TZipVerifiedStream ซึ่งบังคับใช้ขนาดที่ประกาศไว้และ CRC32 ขณะที่ผู้เรียกอ่าน นี่ตั้งใจไม่ให้เป็นการตรวจสอบภายหลัง decompression bomb ที่ประกาศขนาดไม่บีบอัดเป็น 4 KB แต่คลายออกเป็นหลายกิกะไบต์จะถูกหยุดที่จุด 4 KB ไม่ใช่หลังความเสียหายเกิดขึ้น ตัวห่อจะ clamp การอ่านแต่ละครั้งให้ไม่เกินไบต์ที่ประกาศไว้ที่เหลือ raise ZIP entry ended before its declared size ถ้า source หมดเร็วเกินไป ตรวจสอบไบต์พิเศษอีกหนึ่งไบต์เมื่อเสร็จสิ้น และ raise ZIP entry exceeds its declared size ถ้ายังเหลืออะไรอยู่ และสุดท้ายเปรียบเทียบ CRC32 ที่กำลังคำนวณอยู่ใน VerifyComplete raise ZIP entry uncompressed size mismatch หรือ 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;

มีผลลัพธ์หนึ่งที่ควรวางแผนล่วงหน้า stream นี้เดินหน้าทางเดียวโดยการออกแบบ การ Seek ไปยังที่ใดก็ตามที่ไม่ใช่ตำแหน่งปัจจุบันจะ raise ZIP entry stream is forward-only โดยมีข้อยกเว้นเดียวคือ soEnd ที่ offset ศูนย์ เพื่อให้การสอบถามขนาดยังใช้งานได้ นั่นคือการแลกเปลี่ยนที่ถูกต้องสำหรับข้อมูลนำเข้าที่ไม่น่าเชื่อถือ เพราะ stream ที่คุณ rewind ได้คือ stream ที่คุณเอาชนะการคิดบัญชี CRC ได้ แต่มันหมายความว่า consumer code ที่คาดหวัง stream แบบ seekable ต้องมี buffer ของตัวเอง วินัยเดินหน้าทางเดียวแบบเดียวกันนี้อยู่ใต้ streaming direct reader ซึ่งเป็น API ที่ควรใช้เมื่อ workbook ที่อัปโหลดใหญ่พอที่คุณไม่ต้องการให้มันอยู่ในหน่วยความจำทั้งหมดเลย

ขีดจำกัด resource ก่อนการ allocate ไม่ใช่หลัง

ค่าคงที่สามตัวใน lxZipArchive จำกัดสิ่งที่ archive เดียวสามารถขอให้ process ทำได้ และ TZipEntries.Add ใช้มันในขณะที่ central directory ยังอยู่ระหว่างการอ่าน ก่อนที่ไบต์ของข้อมูล entry จะถูกแตะแม้แต่ตัวเดียว ZipMaxEntryUncompressedSize จำกัดสมาชิกตัวเดียวไว้ที่ 1 GiB, ZipMaxTotalUncompressedSize จำกัด archive ไว้ที่ 4 GiB และ ZipMaxCompressionRatio ที่ 10000 ปฏิเสธ entry ที่ deflate ใด ๆ ที่การขยายที่ประกาศไว้เกินหมื่นเท่า พร้อมกับกรณีเสื่อมสภาพของขนาดไม่บีบอัดที่ไม่เป็นศูนย์คู่กับขนาดบีบอัดที่เป็นศูนย์ ชื่อ entry ผ่าน CanonicalZipEntryName ในการเรียกเดียวกัน ซึ่งปฏิเสธตัวอักษร NUL ที่ฝังอยู่ เครื่องหมายทวิภาค และ path segment ใด ๆ ที่เป็น .. ด้วย Invalid ZIP entry name และแปลงเป็นตัวพิมพ์เล็กและ normalize segment ดังนั้นสมาชิกสองตัวที่ต่างกันแค่ตัวพิมพ์เล็กใหญ่หรือตัวคั่นซ้ำซ้อนจะชนกันเป็น Duplicate ZIP entry name แทนที่จะบดบังกันอย่างเงียบ ๆ

การป้องกันเชิงลึกเหนือชั้น ZIP

ชั้น ZIP คือหนึ่งในหลายชั้น และรูปแบบนี้ซ้ำทุกที่ที่ HotXLS parse โครงสร้างที่ผู้โจมตีควบคุมได้ ตัวอย่างที่ชัดที่สุดอยู่ใน BIFF formula parser TXLSFormula.GetTranslated recurse ผ่าน token tMemFunc ดังนั้น token stream rgce ที่สร้างขึ้นเองในไฟล์ .xls รุ่นเก่าสามารถซ้อนลึกได้ตามอำเภอใจและทำให้ stack หมด ตัวป้องกันคือค่าคงที่ MaxTranslateDepth = 256 ที่เลือกจากข้อเท็จจริงต้นทางที่รู้อยู่แล้วแทนที่จะเดา Excel จำกัดการซ้อนสูตรไว้ที่ 64 ดังนั้น 256 จึงเหลือ headroom สี่เท่าและไม่มีวันปฏิเสธสูตรที่ spreadsheet จริงสร้างขึ้น ในขณะที่ยังยุติ stream ที่เป็นภัยได้นานพอก่อนที่ stack จะหมด

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

สังเกตว่าตัวป้องกันคืน nil แทนที่จะ raise สูตรที่ลึกเกินกว่าจะเป็นของจริงจะไม่ให้ syntax tree เลย การ parse ที่อยู่รอบข้างยังดำเนินต่อไป และ workbook ยังคงโหลดได้ ความไม่สมมาตรนั้นตั้งใจและควรค่าแก่การลอกไปใช้กับ limit ของคุณเอง ขอบเขตที่มีไว้เพื่อหยุด resource exhaustion ควรลดระดับหน่วยที่เล็กที่สุดที่มันทำได้ ไม่ใช่ยกเลิกทั้งเอกสาร เหตุผลเดียวกันนี้ใช้ได้เมื่อคุณขยาย calculation layer ดังนั้นถ้าคุณลงทะเบียน handler ของคุณเองผ่าน formula engine custom function API ให้ handler เหล่านั้นมีขอบเขตอาร์กิวเมนต์และ recursion ของตัวเอง แทนที่จะสันนิษฐานว่าผู้เรียกตรวจสอบไปแล้ว

การตรวจสอบเหล่านี้ไม่ได้ให้อะไรคุณ

ควรชัดเจนเกี่ยวกับขอบเขต การตรวจสอบไขว้ EOCD ทั้งสี่ทำให้ดัชนี archive ไม่คลุมเครือ ดังนั้น HotXLS และ reader ที่สอดคล้องตามมาตรฐานอื่นใดจะแก้ไฟล์เดียวกันให้ได้ entry set เดียวกัน แต่มันไม่ได้บอกอะไรเลยว่า entry set นั้นไม่เป็นภัยหรือไม่ ความสอดคล้องของ local header หยุดกลลวง two-views ไม่ใช่ payload ที่เป็นภัยที่ถูกอธิบายไว้อย่างสอดคล้องกัน stream ที่ตรวจสอบแล้วหยุดการตัดทอน overflow และความเสียหาย ไม่ใช่ส่วน XML ที่มีรูปแบบถูกต้องสมบูรณ์ที่เข้ารหัสบางอย่างที่คุณไม่คาดคิด และไม่มีข้อใดเลยที่แตะเรื่อง macro โปรเจกต์ VBA ภายใน workbook ที่มีโครงสร้างสมบูรณ์แบบก็ยังคงเป็นโปรเจกต์ VBA อยู่ดี และการตัดสินใจว่าจะเก็บ ตัดออก หรือปฏิเสธมันเป็นของ policy layer ของคุณ ไม่ใช่ของ ZIP reader

สิ่งที่คุณได้แลกมาคือขอบเขตความล้มเหลวที่สะอาด ไฟล์ xlsx ที่ไม่น่าเชื่อถือจะเปิดเป็น archive เดียวที่ไม่คลุมเครือซึ่งสมาชิกของมันตรงกับขนาดและ checksum ที่ประกาศไว้ หรือไม่ก็ raise พร้อมข้อความที่ระบุ invariant เฉพาะที่มันละเมิด และ service ของคุณสามารถกักกันไว้ตาม exception แทนที่จะต้องเดา ZIP reader และชั้น parser เหนือมันมาพร้อมกับ HotXLS Excel Component สำหรับ Delphi และ C++Builder ซึ่งไม่ต้องการ Excel หรือ OLE automation บนเครื่องที่ทำการ parse เลย และการไม่ต้องมีสิ่งนั้นเองก็เป็นการลดพื้นที่ที่ไฟล์ที่อัปโหลดเข้ามาสามารถเข้าถึงได้อย่างมีนัยสำคัญ