Bài viết kỹ thuật

Xác thực ZIP EOCD cho file XLSX không tin cậy trong Delphi

Một file xlsx là một kho lưu trữ ZIP, và ZIP không có một bảng mục lục quyền uy duy nhất. HotXLS Excel Library cho Delphi và C++Builder coi sự mơ hồ đó là một bề mặt tấn công: trình phân tích end of central directory của nó chỉ chấp nhận một bản ghi ứng viên sau khi bốn phép kiểm tra chéo độc lập đồng thuận, nên một thư mục giả giấu trong comment ZIP không bao giờ thắng

Kịch bản khiến điều này trở nên cụ thể khá đời thường. Một server chấp nhận upload bảng tính từ khách hàng. File vượt qua quét antivirus, được ghi vào một thư mục spool, và dịch vụ Delphi của bạn mở nó để lấy ra ba cột. Mọi thứ trông ổn, ngoại trừ việc trình quét và trình phân tích của bạn không đồng thuận về những gì kho lưu trữ chứa. Trình quét liệt kê một tập thành viên; trình tải của bạn liệt kê một tập khác từ cùng những byte đó. Không cái nào trong hai cái đó có lỗi theo nghĩa thông thường. Chúng đơn giản chỉ giải quyết một sự mơ hồ trong định dạng ZIP theo hai hướng khác nhau, và kẻ tấn công đã chọn các byte để điều đó xảy ra

Sự thật về một kho lưu trữ ZIP thực sự nằm ở đâu?

Nó nằm ở tận cuối cùng, trong một cấu trúc 22 byte gọi là bản ghi end of central directory. Một file ZIP không được đọc từ đầu tới cuối: mỗi thành viên mang một local file header ngay trước dữ liệu nén của nó, nhưng chỉ mục quyền uy là central directory, một chuỗi bản ghi gần cuối đặt tên mọi mục và cho offset của local header của nó. Để tìm central directory bạn trước tiên phải tìm EOCD, vì EOCD là thứ nói directory bắt đầu ở đâu và có bao nhiêu bản ghi. HotXLS mô hình hóa nó thành TEndOfCentralDirectoryRecord, các trường của nó ánh xạ một-một lên bố cục trên đĩa: FDiskNumber tại offset 4, FStartDisk tại 6, FThisDiskEntries tại 8, FTotalEntries tại 10, FSizeOfCD tại 12, FOffsetOfStartCD tại 16, và FCommentLen tại 20. Tổng đó là FMinSize, được tính trong constructor là 4*3 + 5*2. Sau đó là comment kho lưu trữ, tối đa 65535 byte nội dung tùy ý, khiến FMaxSize là 65557 và nghĩa là bản ghi không nằm ở một vị trí cố định. Bạn phải đi tìm nó

Vì sao quét ngược để tìm chữ ký EOCD là không đủ?

Vì bốn byte bạn đang quét tìm, PK\005\006, có thể hợp pháp xuất hiện bên trong comment kho lưu trữ, bên trong dữ liệu nén, hoặc bên trong một EOCD thứ hai mà kẻ tấn công cố tình nối thêm. Một trình phân tích dừng lại ở chữ ký đầu tiên nó gặp khi đi ngược là dễ dàng bị điều khiển: đặt một EOCD mồi nhử gần đuôi và trình phân tích ngây thơ đi theo nó, trong khi một trình phân tích quét theo thứ tự khác, hoặc coi chữ ký cuối cùng trong file là chính thức, lại theo chữ ký thật. Đây là họ tấn công mơ hồ ZIP, và cái giá của nó chính xác là sự chia tách mô tả ở trên, nơi engine quét và ứng dụng tiêu thụ thấy các tập mục khác nhau từ cùng một file

TEndOfCentralDirectoryRecord.Parse thực sự quét ngược. Nó đặt startscan tại byte cuối cùng, kẹp endscan tại lsize - FMaxSize hoặc không, và duyệt cửa sổ theo các buffer 256 byte chồng lấn ba byte nên một chữ ký nằm vắt qua ranh giới buffer không bao giờ bị bỏ sót. Điều khác biệt là những gì xảy ra khi có một lần khớp. Tìm thấy chữ ký chỉ tạo ra một offset Candidate. HotXLS sau đó đọc 22 byte tại offset đó, phân tích chúng bằng ReadEOCD, và yêu cầu các trường kết quả phải nhất quán nội tại với chính file mà chúng khai báo mô tả trước khi FOffsetEOCD được gán

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;

Hãy đọc vị từ đó như bốn tuyên bố riêng biệt mà một hàng giả phải thỏa mãn đồng thời. Candidate + FMinSize + FCommentLen = lsize đòi hỏi độ dài comment khai báo phải chạm đúng đến cuối file, đó là điều giết chết chiêu mồi nhử-trong-comment: một EOCD giả chôn bên trong một comment thật không thể đồng thời giải thích được mọi byte sau chính nó. FDiskNumber = 0FStartDisk = 0 từ chối các trường trải nhiều đĩa mà không xlsx nào từng dùng hợp pháp và tồn tại trong các kho lưu trữ chế tạo chỉ để gây rối. FThisDiskEntries = FTotalEntries từ chối chiêu đếm-tách nơi một trình phân tích tính kích thước vòng lặp của nó từ trường này còn trình phân tích khác từ trường kia. Và Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate yêu cầu central directory phải kết thúc chính xác nơi EOCD bắt đầu, nên directory không thể bị trỏ vào một khối dữ liệu không liên quan nào đó ở nơi khác trong file. Việc ép kiểu Int64 trên biểu thức cuối đó quan trọng: cả hai toán hạng đều là 32-bit, và không mở rộng thì một cặp giá trị chế tạo có thể cuộn vòng và thỏa mãn phép kiểm tra về mặt số học trong khi trỏ vào nơi vô nghĩa

Local header phải khớp với central directory

Các phép kiểm tra EOCD xác định directory nào là chính thức; chúng chưa đảm bảo rằng directory nói thật về từng thành viên. Mỗi mục được mô tả hai lần trong một file ZIP, một lần ở trung tâm và một lần trong local header của nó, và không có gì trong định dạng buộc hai mô tả đó phải khớp nhau, nên một trình đọc tin tưởng central directory và một trình đọc tin tưởng local header có thể trích xuất nội dung khác nhau từ một kho lưu trữ. TZipEntry.ParseLocalHeader đóng khoảng hở đó bằng cách phân tích local header tại FCdFile.LocalFileHeaderOffset và so sánh hai bản sao theo từng trường, trả về một mã âm riêng biệt cho mỗi kiểu bất đồng: tên mục đã chuẩn hóa, phương thức nén, các cờ bit mục đích chung, và, khi cờ data descriptor tắt, CRC32 và cả hai kích thước. Với cờ đó bật, các bản sao cục bộ có thể bằng không, vì giá trị thật nằm trong một descriptor theo sau, nhưng bất kỳ giá trị cục bộ khác không nào vẫn phải khớp. Một phép kiểm tra cuối cùng từ chối các mục mà dữ liệu của chúng sẽ chạy vượt quá cuối file, so sánh Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) với inputstream.Size. Bất kỳ thất bại nào lan ra khỏi TCentralDirectory.Parse như một kết quả khác 1 và TZipArchive.OpenArchive biến nó thành Can't open zip archive, thay vì đưa cho bạn một đối tượng kho lưu trữ nửa-tin-cậy. Khi bạn chỉ cần biết một file chứa những sheet nào, chạy xác thực đó trước khi phân tích đầy đủ là rẻ, và đường thanh tra sheet nhẹ cho bạn chính xác điều đó mà không cần vật chất hóa dữ liệu ô

Điều gì xảy ra khi chính các byte nói dối?

Sự thống nhất cấu trúc vẫn không nói gì về payload, nên HotXLS bọc mọi stream mục trong TZipVerifiedStream, hàm này thực thi kích thước và CRC32 đã khai báo khi bên gọi đọc. Đây cố tình không phải một phép kiểm tra hậu kỳ: một bom giải nén có kích thước chưa nén khai báo là 4 KB nhưng giải nén thành nhiều gigabyte bị chặn tại mốc 4 KB, không phải sau khi thiệt hại xảy ra. Wrapper kẹp mỗi lần đọc vào số byte đã khai báo còn lại, raise ZIP entry ended before its declared size nếu nguồn cạn sớm, thăm dò một byte thêm khi hoàn tất và raise ZIP entry exceeds its declared size nếu còn gì sót lại, và cuối cùng so sánh CRC32 đang chạy trong VerifyComplete, raise ZIP entry uncompressed size mismatch hoặc 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;

Có một hệ quả đáng lên kế hoạch trước. Stream này chỉ đi tới theo thiết kế; một Seek đến bất cứ đâu khác vị trí hiện tại raise ZIP entry stream is forward-only, với một nhượng bộ duy nhất cho soEnd với offset không để các truy vấn kích thước vẫn hoạt động. Đó là sự đánh đổi đúng cho đầu vào không tin cậy, vì một stream bạn có thể tua lại là một stream mà việc tính CRC của bạn có thể bị đánh bại, nhưng điều đó có nghĩa code tiêu thụ mong đợi một stream có thể seek cần buffer riêng của nó. Cùng kỷ luật chỉ-đi-tới đó là nền tảng cho trình đọc trực tiếp dạng stream, API cần dùng khi workbook được upload đủ lớn để bạn không muốn nó thường trú trong bộ nhớ chút nào

Giới hạn tài nguyên trước khi cấp phát, không phải sau đó

Ba hằng số trong lxZipArchive giới hạn những gì một kho lưu trữ đơn có thể yêu cầu tiến trình làm, và TZipEntries.Add áp dụng chúng khi central directory vẫn đang được đọc, trước khi một byte dữ liệu mục nào bị chạm vào. ZipMaxEntryUncompressedSize giới hạn một thành viên ở 1 GiB, ZipMaxTotalUncompressedSize giới hạn cả kho lưu trữ ở 4 GiB, và ZipMaxCompressionRatio là 10000 từ chối bất kỳ mục deflate nào có độ mở rộng khai báo vượt quá mười nghìn lần, cùng với trường hợp suy biến của một kích thước chưa nén khác không ghép với kích thước nén bằng không. Tên mục đi qua CanonicalZipEntryName trong cùng lệnh gọi, hàm này từ chối ký tự NUL nhúng, dấu hai chấm, và bất kỳ đoạn đường dẫn .. nào với Invalid ZIP entry name, và chuyển thành chữ thường cùng chuẩn hóa các đoạn để hai thành viên chỉ khác nhau về hoa thường hoặc về dấu phân cách dư thừa xung đột như Duplicate ZIP entry name thay vì âm thầm che khuất lẫn nhau

Phòng thủ theo chiều sâu bên trên lớp ZIP

Lớp ZIP chỉ là một trong nhiều tầng, và khuôn mẫu này lặp lại ở bất cứ đâu HotXLS phân tích cấu trúc do kẻ tấn công kiểm soát. Ví dụ rõ ràng nhất nằm trong trình phân tích công thức BIFF: TXLSFormula.GetTranslated đệ quy qua các token tMemFunc, nên một chuỗi token rgce được chế tạo trong một file .xls kiểu cũ có thể lồng sâu tùy ý và làm cạn stack. Cổng chặn là một hằng số, MaxTranslateDepth = 256, được chọn dựa trên một sự thật thượng nguồn đã biết chứ không phải đoán mò. Excel giới hạn việc lồng công thức ở 64, nên 256 để lại dư địa gấp bốn lần và không bao giờ có thể từ chối một công thức mà một bảng tính thật đã tạo ra, trong khi vẫn kết thúc một stream ác ý đủ lâu trước khi stack cạn kiệt

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

Lưu ý rằng cổng chặn trả về nil thay vì raise. Một công thức quá sâu để là thật cho ra không cây cú pháp, phần phân tích xung quanh tiếp tục, và workbook vẫn tải được. Sự bất đối xứng đó có chủ đích và đáng để bạn sao chép lại trong các giới hạn của riêng bạn: một ràng buộc tồn tại để chặn cạn kiệt tài nguyên nên làm suy giảm đơn vị nhỏ nhất mà nó có thể, không phải hủy bỏ cả tài liệu. Cùng lý luận đó áp dụng khi bạn mở rộng lớp tính toán, nên nếu bạn đăng ký các trình xử lý của riêng bạn qua API hàm tùy chỉnh của formula engine, hãy cho chúng giới hạn tham số và đệ quy riêng thay vì giả định bên gọi đã kiểm tra sẵn

Những phép kiểm tra này không mua được gì cho bạn

Hãy chính xác về ranh giới. Bốn phép kiểm tra chéo EOCD khiến chỉ mục kho lưu trữ không mơ hồ, nên HotXLS và bất kỳ trình đọc tuân thủ nào khác giải quyết cùng một file thành cùng một tập mục; chúng không nói gì về việc tập mục đó có vô hại hay không. Sự đồng thuận local header chặn chiêu hai-góc-nhìn, không chặn một payload ác ý được mô tả nhất quán. Stream đã xác thực chặn cắt cụt, tràn, và hỏng hóc, không chặn một phần XML được hình thành hoàn hảo mã hóa thứ gì đó bạn không ngờ tới. Và không điều nào trong số này chạm đến macro: một dự án VBA bên trong một workbook hoàn hảo về cấu trúc vẫn là một dự án VBA, và quyết định giữ, tước bỏ, hay từ chối nó thuộc về lớp chính sách của bạn, không phải trình đọc ZIP

Những gì bạn nhận lại là một ranh giới thất bại sạch sẽ. Một xlsx không tin cậy hoặc mở như một kho lưu trữ không mơ hồ duy nhất mà các thành viên của nó khớp với kích thước và checksum đã khai báo, hoặc raise với một thông báo nêu tên bất biến cụ thể mà nó vi phạm, và dịch vụ của bạn có thể cách ly dựa trên exception thay vì đoán mò. Trình đọc ZIP và các tầng phân tích bên trên nó đi kèm trong HotXLS Excel Component cho Delphi và C++Builder, không cần Excel hay OLE automation trên máy đang phân tích, và sự vắng mặt đó tự nó là một sự giảm thiểu có ý nghĩa về những gì một file được upload có thể với tới