Техническая статья

Проверка ZIP EOCD для недоверенных XLSX в Delphi

Файл xlsx — это ZIP-архив, а у ZIP нет единого авторитетного оглавления. HotXLS Excel Library для Delphi и C++Builder трактует эту неоднозначность как поверхность атаки: его парсер конца центрального каталога принимает кандидата в записи только после того, как согласуются четыре независимые перекрёстные проверки, так что подделанный каталог, спрятанный в комментарии ZIP, никогда не побеждает

Сценарий, делающий это конкретным, обыден. Сервер принимает загрузки электронных таблиц от клиентов. Файл проходит антивирусное сканирование, записывается в спул-каталог, и ваш сервис на Delphi открывает его, чтобы извлечь три столбца. Всё выглядит нормально, за исключением того, что сканер и ваш парсер не согласились о том, что содержал архив. Сканер перечислил один набор элементов; ваш загрузчик перечислил другой набор из тех же байт. Ни один из них не является багованным в обычном смысле. Они просто разрешили неоднозначность формата ZIP в двух разных направлениях, и злоумышленник выбрал байты так, чтобы это произошло

Где на самом деле живёт истина о ZIP-архиве?

Она живёт в самом конце, в 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 = 0 и FStartDisk = 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. Любой сбой распространяется из TCentralDirectory.Parse как результат, не равный 1, и TZipArchive.OpenArchive превращает его в Can't open zip archive вместо того, чтобы вручить вам наполовину доверенный объект архива. Когда вам нужно лишь узнать, какие листы содержит файл, выполнение этой проверки перед полным разбором дёшево, и облегчённый путь проверки листов даёт именно это без материализации данных ячеек

Что происходит, когда лгут сами байты?

Структурное согласие всё ещё ничего не говорит о полезной нагрузке, поэтому HotXLS оборачивает каждый поток записи в TZipVerifiedStream, который принудительно контролирует заявленный размер и CRC32 по мере чтения вызывающим кодом. Это намеренно не проверка постфактум: бомба распаковки, чей заявленный несжатый размер — 4 КБ, но которая распаковывается в гигабайты, останавливается на отметке 4 КБ, а не после нанесения ущерба. Обёртка прижимает каждое чтение к оставшимся заявленным байтам, вызывает ZIP entry ended before its declared size, если источник исчерпывается раньше времени, зондирует один лишний байт по завершении и вызывает ZIP entry exceeds its declared size, если что-то осталось, и, наконец, сравнивает накопленный CRC32 в VerifyComplete, вызывая 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;

Стоит спланировать одно следствие. Поток по замыслу только однонаправленный вперёд; Seek куда-либо кроме текущей позиции вызывает ZIP entry stream is forward-only, с единственной уступкой для soEnd со смещением ноль, чтобы запросы размера всё же работали. Это правильный компромисс для недоверенного ввода, потому что поток, который можно перемотать, — это поток, чей учёт CRC можно обойти, но это действительно означает, что код-потребитель, ожидающий перемещаемый поток, нуждается в собственном буфере. Та же дисциплина только-вперёд лежит в основе потокового прямого читателя, того API, к которому стоит обращаться, когда загруженная книга достаточно велика, чтобы вы вовсе не хотели держать её резидентной в памяти

Ограничения ресурсов до выделения, а не после

Три константы в lxZipArchive ограничивают, что один архив может попросить сделать процесс, и TZipEntries.Add применяет их, пока центральный каталог ещё читается, до того как затронут хотя бы байт данных записи. ZipMaxEntryUncompressedSize ограничивает один элемент 1 ГиБ, ZipMaxTotalUncompressedSize ограничивает архив 4 ГиБ, а ZipMaxCompressionRatio, равный 10000, отклоняет любую deflate-запись, чьё заявленное расширение превышает десятитысячекратное, наряду с вырожденным случаем ненулевого несжатого размера в паре с нулевым сжатым размером. Имена записей проходят через CanonicalZipEntryName в том же вызове, который отклоняет встроенные символы NUL, двоеточия и любой сегмент пути .. с Invalid ZIP entry name, и который переводит в нижний регистр и нормализует сегменты, так что два элемента, различающихся только регистром или избыточными разделителями, сталкиваются как Duplicate ZIP entry name вместо тихого затенения друг друга

Эшелонированная защита выше уровня ZIP

Уровень ZIP — один из нескольких ярусов, и паттерн повторяется везде, где HotXLS разбирает структуру, контролируемую злоумышленником. Самый явный пример живёт в парсере формул BIFF: TXLSFormula.GetTranslated рекурсирует через токены tMemFunc, так что сфабрикованный поток токенов rgce в устаревшем .xls может вкладываться произвольно глубоко и исчерпать стек. Ограничитель — константа, 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 и слои парсера над ней поставляются как часть компонента HotXLS Excel для Delphi и C++Builder, которому не нужны ни Excel, ни автоматизация OLE на машине, выполняющей разбор, и само это отсутствие уже является значимым сокращением того, чего может достичь загруженный файл