Artikel Teknis

Validasi ZIP EOCD untuk XLSX Tak Tepercaya di Delphi

File xlsx adalah arsip ZIP, dan ZIP tidak punya satu tabel isi otoritatif tunggal. HotXLS Excel Library untuk Delphi dan C++Builder memperlakukan ambiguitas itu sebagai permukaan serangan: parser end of central directory-nya menerima record kandidat hanya setelah empat cross check independen sepakat, sehingga direktori palsu yang disembunyikan dalam komentar ZIP tidak pernah menang

Skenario yang membuat ini konkret sangat biasa. Server menerima unggahan spreadsheet dari pelanggan. File itu lolos pemindaian antivirus, ditulis ke direktori spool, dan layanan Delphi Anda membukanya untuk menarik tiga kolom. Semuanya terlihat baik-baik saja, kecuali scanner dan parser Anda tidak sepakat tentang apa isi arsip itu. Scanner mengenumerasi satu himpunan anggota; loader Anda mengenumerasi himpunan berbeda dari byte yang sama. Tidak satu pun dari keduanya buggy dalam arti biasa. Mereka hanya menyelesaikan ambiguitas dalam format ZIP ke dua arah berbeda, dan penyerang memilih byte-nya agar itu terjadi

Di mana sebenarnya kebenaran tentang arsip ZIP berada?

Ia berada persis di akhir, dalam struktur 22-byte yang disebut end of central directory record. File ZIP tidak dibaca dari depan ke belakang: setiap anggota membawa local file header persis sebelum data terkompresinya, tapi indeks otoritatifnya adalah central directory, rangkaian record menjelang akhir yang menamai setiap entri dan memberikan offset local header-nya. Untuk menemukan central directory, Anda harus lebih dulu menemukan EOCD, karena EOCD-lah yang menyatakan di mana direktori itu mulai dan berapa banyak record yang dipegangnya. HotXLS memodelkannya sebagai TEndOfCentralDirectoryRecord, yang field-nya memetakan satu-satu ke layout on-disk: FDiskNumber di offset 4, FStartDisk di 6, FThisDiskEntries di 8, FTotalEntries di 10, FSizeOfCD di 12, FOffsetOfStartCD di 16, dan FCommentLen di 20. Totalnya adalah FMinSize, dihitung di constructor sebagai 4*3 + 5*2. Setelah itu datang komentar arsip, hingga 65535 byte konten sembarang, yang membuat FMaxSize 65557 dan berarti record itu tidak berada pada posisi tetap. Anda harus mencarinya

Kenapa memindai mundur untuk signature EOCD tidak cukup?

Karena empat byte yang Anda cari, PK\005\006, secara legal bisa muncul di dalam komentar arsip, di dalam data terkompresi, atau di dalam EOCD kedua yang ditambahkan penyerang secara sengaja. Parser yang berhenti pada signature pertama yang ditemuinya saat berjalan mundur sangat mudah diarahkan: taruh EOCD umpan di dekat ekor dan parser naif mengikutinya, sementara parser yang memindai dalam urutan berbeda, atau yang memperlakukan signature terakhir dalam file sebagai kanonik, mengikuti yang asli. Ini adalah keluarga serangan ambiguitas ZIP, dan hasilnya persis pemisahan yang dijelaskan di atas, di mana mesin pemindai dan aplikasi konsumen melihat himpunan entri berbeda dari satu file

TEndOfCentralDirectoryRecord.Parse memang memindai mundur. Ia mengatur startscan ke byte terakhir, menjepit endscan ke lsize - FMaxSize atau nol, dan berjalan melalui window dalam buffer 256-byte yang tumpang tindih tiga byte sehingga signature yang melintasi batas buffer tidak pernah terlewat. Perbedaannya adalah apa yang terjadi pada kecocokan. Menemukan signature hanya menghasilkan offset Candidate. HotXLS lalu membaca 22 byte pada offset itu, mem-parsingnya dengan ReadEOCD, dan menuntut field hasilnya konsisten secara internal dengan file yang mereka klaim gambarkan sebelum FOffsetEOCD ditetapkan sama sekali

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;

Baca predikat itu sebagai empat klaim terpisah yang harus dipenuhi pemalsuan secara bersamaan. Candidate + FMinSize + FCommentLen = lsize menuntut panjang komentar yang dideklarasikan mencapai persis akhir file, yang membunuh trik umpan-di-dalam-komentar: EOCD palsu yang terkubur di dalam komentar sungguhan tidak bisa sekaligus mencakup setiap byte setelah dirinya. FDiskNumber = 0 dan FStartDisk = 0 menolak field multi-disk spanning yang tidak pernah dipakai xlsx mana pun secara sah dan yang ada dalam arsip buatan hanya untuk membingungkan. FThisDiskEntries = FTotalEntries menolak trik split-count di mana satu parser mengukur loop-nya dari satu field dan parser lain dari field lainnya. Dan Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate menuntut central directory berakhir persis di mana EOCD mulai, sehingga direktori tidak bisa diarahkan ke blob tak berhubungan di tempat lain dalam file. Cast Int64 pada yang terakhir itu penting: kedua operand adalah 32-bit, dan tanpa pelebaran, pasangan buatan bisa wrap-around dan memenuhi test secara aritmatik sambil menunjuk ke tempat yang tidak masuk akal

Local header harus sepakat dengan central directory

Pemeriksaan EOCD menetapkan direktori mana yang otoritatif; mereka belum menjamin bahwa direktori itu berkata jujur tentang anggota individual. Setiap entri dideskripsikan dua kali dalam file ZIP, sekali secara sentral dan sekali di local header-nya, dan tidak ada apa pun dalam format yang memaksa kedua deskripsi itu cocok, jadi reader yang mempercayai central directory dan reader yang mempercayai local header bisa mengekstrak konten berbeda dari satu arsip. TZipEntry.ParseLocalHeader menutup celah itu dengan mem-parsing local header di FCdFile.LocalFileHeaderOffset dan membandingkan kedua salinan field demi field, mengembalikan kode negatif berbeda untuk setiap jenis ketidaksepakatan: nama entri yang dikanonikalisasi, metode kompresi, bit flag tujuan umum, dan, saat flag data descriptor tidak diatur, CRC32 dan kedua ukuran. Dengan flag itu diatur, salinan lokal boleh nol, karena nilai sebenarnya berada di descriptor penutup, tapi nilai lokal non-nol apa pun tetap harus cocok. Pemeriksaan terakhir menolak entri yang datanya akan melewati akhir file, membandingkan Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) terhadap inputstream.Size. Kegagalan apa pun merambat keluar dari TCentralDirectory.Parse sebagai hasil bukan-1 dan TZipArchive.OpenArchive mengubahnya menjadi Can't open zip archive, alih-alih menyerahkan Anda objek arsip yang setengah dipercaya. Saat Anda hanya perlu tahu sheet apa yang dikandung sebuah file, menjalankan validasi itu sebelum parse penuh murah, dan jalur inspeksi sheet ringan memberikan Anda persis itu tanpa mewujudkan data sel

Apa yang terjadi saat byte-nya sendiri berbohong?

Kesepakatan struktural masih tidak mengatakan apa pun tentang payload-nya, jadi HotXLS membungkus setiap stream entri dalam TZipVerifiedStream, yang memberlakukan ukuran dan CRC32 yang dideklarasikan seiring pemanggil membaca. Ini sengaja bukan pemeriksaan setelah-fakta: bom dekompresi yang ukuran tak-terkompresi yang dideklarasikannya 4 KB tapi mengembang jadi gigabyte dihentikan pada tanda 4 KB, bukan setelah kerusakan terjadi. Wrapper itu menjepit setiap pembacaan ke byte terdeklarasi yang tersisa, memicu ZIP entry ended before its declared size jika sumbernya habis lebih awal, memeriksa satu byte ekstra saat selesai dan memicu ZIP entry exceeds its declared size jika ada sisa, dan akhirnya membandingkan CRC32 yang berjalan di VerifyComplete, memicu ZIP entry uncompressed size mismatch atau 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;

Satu konsekuensi layak direncanakan. Stream-nya maju-saja (forward-only) secara desain; Seek ke mana pun selain posisi saat ini memicu ZIP entry stream is forward-only, dengan satu konsesi untuk soEnd dengan offset nol sehingga query ukuran tetap berfungsi. Itu adalah trade-off yang tepat untuk input tidak tepercaya, karena stream yang bisa Anda rewind adalah stream yang akuntansi CRC-nya bisa Anda kalahkan, tapi itu memang berarti kode konsumen yang mengharapkan stream seekable butuh buffer sendiri. Disiplin maju-saja yang sama mendasari streaming direct reader, API yang harus Anda raih saat workbook yang diunggah cukup besar sehingga Anda sama sekali tidak ingin menyimpannya di memori

Batas resource sebelum alokasi, bukan sesudahnya

Tiga konstanta di lxZipArchive membatasi apa yang boleh diminta satu arsip dari proses, dan TZipEntries.Add menerapkannya saat central directory masih dibaca, sebelum satu byte data entri disentuh. ZipMaxEntryUncompressedSize membatasi satu anggota pada 1 GiB, ZipMaxTotalUncompressedSize membatasi arsip pada 4 GiB, dan ZipMaxCompressionRatio sebesar 10000 menolak entri deflated mana pun yang ekspansi terdeklarasinya melebihi sepuluh-ribu kali lipat, bersama kasus degeneratif dari ukuran tak-terkompresi non-nol yang dipasangkan dengan ukuran terkompresi nol. Nama entri melewati CanonicalZipEntryName dalam pemanggilan yang sama, yang menolak karakter NUL tersemat, titik dua, dan segmen path .. apa pun dengan Invalid ZIP entry name, dan yang me-lowercase serta menormalkan segmen sehingga dua anggota yang hanya berbeda huruf besar/kecil atau separator redundan bertabrakan sebagai Duplicate ZIP entry name alih-alih diam-diam saling menutupi

Pertahanan berlapis di atas layer ZIP

Layer ZIP adalah satu dari beberapa tingkat, dan polanya berulang di mana pun HotXLS mem-parsing struktur yang dikendalikan penyerang. Contoh paling jelas ada di parser formula BIFF: TXLSFormula.GetTranslated berekursi lewat token tMemFunc, jadi stream token rgce buatan dalam .xls lawas bisa bersarang sedalam mungkin dan menghabiskan stack. Gerbangnya adalah konstanta, MaxTranslateDepth = 256, dipilih terhadap fakta hulu yang diketahui alih-alih ditebak. Excel membatasi nesting formula pada 64, jadi 256 menyisakan headroom empat kali lipat dan tidak pernah bisa menolak formula yang dihasilkan spreadsheet sungguhan, sambil tetap menghentikan stream berbahaya cukup lama sebelum stack-nya habis

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

Perhatikan bahwa gerbang itu mengembalikan nil alih-alih memicu exception. Formula yang terlalu dalam untuk sungguhan tidak menghasilkan pohon sintaks, parse di sekitarnya berlanjut, dan workbook-nya tetap dimuat. Asimetri itu disengaja dan layak ditiru dalam limit Anda sendiri: batas yang ada untuk menghentikan kehabisan resource seharusnya mendegradasi unit terkecil yang bisa, bukan membatalkan dokumen. Alasan yang sama berlaku saat Anda memperluas layer kalkulasi, jadi jika Anda mendaftarkan handler Anda sendiri lewat API fungsi kustom mesin formula, beri mereka batas argumen dan rekursi sendiri alih-alih mengasumsikan pemanggil sudah memeriksanya

Apa yang tidak dibeli oleh pemeriksaan ini untuk Anda

Bersikaplah tepat soal batasnya. Empat cross check EOCD membuat indeks arsip tidak ambigu, sehingga HotXLS dan reader lain yang patuh spesifikasi menyelesaikan file yang sama ke himpunan entri yang sama; mereka tidak mengatakan apa pun tentang apakah himpunan entri itu tidak berbahaya. Kesepakatan local header menghentikan trik dua-tampilan, bukan payload berbahaya yang dideskripsikan secara konsisten. Stream terverifikasi menghentikan pemotongan, overflow, dan korupsi, bukan bagian XML yang terbentuk sempurna tapi meng-encode sesuatu yang tidak Anda duga. Dan tidak satu pun dari ini menyentuh makro: proyek VBA di dalam workbook yang struktural sempurna tetaplah proyek VBA, dan keputusan untuk menyimpan, melucuti, atau menolaknya adalah milik layer kebijakan Anda, bukan pembaca ZIP

Yang Anda dapatkan sebagai gantinya adalah batas kegagalan yang bersih. xlsx tidak tepercaya baik terbuka sebagai satu arsip tidak ambigu yang anggota-anggotanya cocok dengan ukuran dan checksum yang dideklarasikan, atau memicu exception dengan pesan yang menamai invarian spesifik yang dilanggarnya, dan layanan Anda bisa mengkarantina berdasarkan exception itu alih-alih menebak. Pembaca ZIP dan tingkat parser di atasnya hadir sebagai bagian dari Komponen Excel HotXLS untuk Delphi dan C++Builder, yang tidak membutuhkan Excel maupun otomasi OLE pada mesin yang melakukan parsing, dan ketiadaan itu sendiri adalah pengurangan berarti atas apa yang bisa dijangkau file yang diunggah