Технічна стаття

Валідація 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, тож каталог не можна спрямувати на якийсь непов'язаний блоб деінде у файлі. Приведення до 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 відхиляє будь-який дефлейтований запис, чиє заявлене розширення перевищує десятитисячократне, разом з виродженим випадком ненульового нестисненого розміру, поєднаного з нульовим стисненим розміром. Імена записів проходять через 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-автоматизації на машині, що виконує розбір, і ця відсутність сама по собі — значуще зменшення того, чого може досягти завантажений файл