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

Валидация на ZIP EOCD за недоверени XLSX файлове в Delphi

Xlsx файл е ZIP архив, а ZIP няма единствена авторитетна таблица на съдържанието. HotXLS Excel Library за Delphi и C++Builder третира тази неяснота като повърхност за атака: разборчикът му за end of central directory приема кандидат-запис едва след като четири независими кръстосани проверки се съгласят, така че фалшифицирана директория, скрита в ZIP коментар, никога не печели

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

Къде всъщност живее истината за ZIP архив?

Живее в самия край, в 22-байтова структура, наречена запис end of central directory. 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, вместо да ви подаде полудоверен обект на архив. Когато ви трябва само да знаете кои листове съдържа файл, изпълнението на тази валидация преди пълен разбор е евтино, а лекият път за инспекция на листове дава точно това без материализиране на данни за клетки

Какво се случва, когато самите байтове лъжат?

Структурното съгласие все още не казва нищо за payload-а, така че 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 GiB, ZipMaxTotalUncompressedSize ограничава архива до 4 GiB, а ZipMaxCompressionRatio от 10000 отхвърля всеки deflated запис, чието декларирано разширение надвишава десет хиляди пъти, заедно с изродения случай на ненулев некомпресиран размер, съчетан с нулев компресиран размер. Имената на записите минават през 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 и всеки друг съответстващ четец разрешават един и същ файл до един и същ набор от записи; те не казват нищо за това дали този набор от записи е безобиден. Съгласието на локалните заглавни части спира трика с двата изгледа, не злонамерен payload, който е последователно описан. Проверяваният поток спира съкращаване, преливане и повреда, не идеално добре оформена XML част, която кодира нещо, което не сте очаквали. И нищо от това не докосва макроси: VBA проект вътре в структурно безупречна работна книга все пак е VBA проект, а решението да го запазите, премахнете или откажете принадлежи на вашия слой политика, не на ZIP четеца

Това, което получавате в замяна, е чиста граница на провал. Недоверен xlsx или се отваря като един недвусмислен архив, чиито членове съвпадат с декларираните си размери и контролни суми, или хвърля изключение със съобщение, назоваващо конкретния инвариант, който е нарушил, а вашата услуга може да постави в карантина при изключението, вместо да гадае. ZIP четецът и нивата на разборчика над него се доставят като част от HotXLS Excel Component за Delphi и C++Builder, който не се нуждае нито от Excel, нито от OLE автоматизация на машината, извършваща разбора, и това отсъствие само по себе си е значимо намаляване на това, до което качен файл може да достигне