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

Захист декодера TIFF у Delphi: BigTIFF та тайловий TIFF

PDFlibPas декодує TIFF власним парсером на Object Pascal, а не обгорткою над libtiff, і версія 3.534.1 точно уточнила, де саме цей парсер відмовляє вхідним даним. BigTIFF із магічним числом 43 тепер відхиляється із зазначенням назви, TileOffsets і TileByteCounts відкидаються під час розбору тегів, а кожен буфер отримує розмір через арифметику Int64 під стелею декодування 256 МіБ

Дефект, який це закриває, ніколи не проявляється в лабораторії. Він проявляється як шлюз сканування, що три роки працював тихо, поки клієнт не пропустить крізь нього геопросторовий архів або медичне зображення цілого слайда. У файлу легітимний заголовок TIFF. Він розбирається. На виході — сторінка смугового шуму або багатогігабайтне виділення памʼяті, що кладе сервіс, і ніщо на цьому шляху так і не оголосило вхідні дані недійсними. Саме з такою формою збою варто боротися інженерно: не аварійне падіння, а впевнено подана неправильна відповідь

Чому II або MM не доводить, що у вас класичний TIFF?

Бо маркер порядку байтів спільний для обох діалектів. Класичний TIFF і BigTIFF обидва починаються з II або MM, а поле, яке насправді їх розрізняє, — це 16-бітне магічне число одразу після нього: 42 для класичного TIFF за специфікацією TIFF 6.0, 43 для BigTIFF із його 64-бітними зміщеннями. Завантажувач, написаний як FValidTIFF := PopWord = 42, щодо класичного TIFF не помиляється, але зливає два дуже різні відхилення в один мовчазний boolean, тож BigTIFF стає невиразним від перейменованого кимось обрізаного JPEG. PDFlibPas тепер розділяє ці випадки і записує кожен окремо в TPDFTIFF.LastError: заголовок коротший за чотири байти, недійсний маркер порядку байтів, магічне число 43 та будь-яке інше магічне значення дають різний текст. Бібліотека досі не декодує BigTIFF, і сказати це прямо — головне. Викликаючий код отримує різницю між «це не TIFF» і «це TIFF, чиє 64-бітне компонування зміщень вбудований декодер не реалізовує», а це різниця між зверненням у підтримку, на яке вистачить однієї відповіді, і таким, що обертається тижнем вгадувань

Завантажувач TIFF у PDFlibPas читає маркер порядку байтів і 16-бітне магічне число окремо, тож короткий заголовок, недійсний маркер, BigTIFF із магією 43 та будь-яке інше магічне значення дають різний текст LastError замість одного мовчазного boolean
Класичний TIFF і BigTIFF починаються з однакового маркера порядку байтів, тому PDFlibPas розділяє чотири випадки відхилення і називає кожен у LastError
var
  Tiff: TPDFTIFF;
  Page: Integer;
begin
  Tiff := TPDFTIFF.Create;
  try
    Tiff.LoadFromFile('inbox\scan-0417.tif');
    if not Tiff.ValidTIFF then
      raise Exception.Create('TIFF rejected: ' + Tiff.LastError);
    if Tiff.PageCount < 1 then
      raise Exception.Create('TIFF carries no decodable page');
    for Page := 1 to Tiff.PageCount do
      Writeln(Format('page %d: %dx%d, %d spp',
        [Page,
         Tiff.PageInfo[Page].Width,
         Tiff.PageInfo[Page].Height,
         Tiff.PageInfo[Page].SamplesPerPixel]));
  finally
    Tiff.Free;
  end;
end;

Тайли — це інша геометрія, а не ще один масив зміщень

PDFlibPas відхиляє тайловий TIFF під час розбору тегів, ще до того, як торкнеться будь-яких піксельних даних. Обхідний шлях, який запрошує цей баг, легко побачити: тег 324 (TileOffsets) і тег 325 (TileByteCounts) — це масиви файлових зміщень і кількості байтів, структурно ідентичні масивам смуг, тож спрямувати наявні поля смуг на них коштує двох рядків і компілюється без жодного зауваження. Це ще й неправильно. Тайли утворюють двовимірну сітку з доповненими крайовими блоками, власним кроком рядка всередині кожного тайла і жодною семантикою RowsPerStrip, як прямо сказано в розділі тайлових зображень TIFF 6.0. Тому подача тайлових даних на вхід смугового декодера не завершується гучним збоєм. SimpleExtract і CompDecode проходять дані з неправильним кроком і видають зображення з правильними розмірами та неправильними пікселями. Старіший код поглиблював це, зберігаючи StripsAreTiles, ColumnsPerTile і RowsPerTile у TTIFFPage: геометрія тайлів, записана декодером, за яким не стоїть жодного збирача тайлів. У 3.534.1 обробники тегів 324 і 325 підіймають помилку тайлів і одразу покидають IFD, тож відмова несе слово «tiled» замість того, щоб спливти за кілька тижнів як скарга на рендеринг

PDFlibPas порівнює смугове компонування, де повносмугові ділянки поділяють один крок рядка, з тайловим компонуванням — двовимірною сіткою з доповненими крайовими блоками — і відхиляє теги 324 і 325 під час розбору тегів, ще до торкання піксельних даних
Тайлові масиви структурно ідентичні смуговим, саме тому їх подача на вхід смугового декодера дає правильні розміри й неправильні пікселі

Обмеження однієї розмірності — це не бюджет памʼяті

Обмеження ширини й висоти кожної величиною 65 535 необхідне, але далеко не достатнє, бо кількість, що керує виділенням памʼяті, — це добуток. RowsPerStrip * Width * SamplesPerPixel може переповнити 32-бітну арифметику задовго до того, як будь-який із множників сягне власної межі, і навіть без переповнення назвати таке виділення, якого жоден сервіс не повинен робити. PDFlibPas обчислює байти рядка в Int64 і застосовує три стелі водночас: 65 535 на розмірність, 32 колірні компоненти та 256 МіБ декодованих байтів

const
  PDFLIB_TIFF_MAX_IMAGE_DIM = 65535;
  PDFLIB_TIFF_MAX_COLOR_COMPONENTS = 32;
  PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES = 256 * 1024 * 1024;

// всередині TPDFTIFF.ValidatePageForDecode
BitsPerPixel := Int64(P.BitsPerSample) * P.SamplesPerPixel;
RowBytes := (Int64(P.Width) * BitsPerPixel + 7) div 8;
if (RowBytes < 1) or
   (RowBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
   (Int64(P.Height) > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES div RowBytes) then
  Exit(False);

DecodedBytes := RowBytes * P.RowsPerStrip;
if (DecodedBytes < 1) or
   (DecodedBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
   (DecodedBytes > MaxInt) then
  Exit(False);

Три деталі тут важливіші за самі константи. Перевірку висоти написано як ділення, а не множення, тож надмірний добуток узагалі ніколи не формується. RowsPerStrip менший за 1 або більший за висоту зображення спершу нормалізується до висоти — це те, що читання однією смугою в TIFF 6.0 уже має на увазі, і саме це заважає ворожому тегу роздути буфер смуги. А підпрограма спільна: ValidatePageForDecode виконується наприкінці розбору тегів і знову на вході як SimpleExtract, так і CompDecode, тож код, який виходить на декодер напряму, не може обійти бюджет. Це те саме правило, якого PDFlibPas дотримується при розборі недовірених графів PDF-обʼєктів, адже ліміт, застосований на одних дверях з трьох, — не ліміт

PDFlibPas розміряє кожен буфер TIFF через арифметику Int64, перевіряє висоту зображення діленням, щоб надмірний добуток ніколи не формувався, і виконує ту саму підпрограму ValidatePageForDecode і при розборі тегів, і на обох входах декодерів
Три стелі, арифметика байтів рядка в Int64 і одна спільна підпрограма перевірки, доступна з усіх трьох дверей, бо ліміт на одних дверях з трьох — не ліміт

Що має перевірити викликаючий код перед читанням PageInfo?

Спершу перевіряйте ValidTIFF, потім PageCount і лише тоді індексуйте PageInfo. Відхилений файл може залишити PageCount нульовим, а GetPageInfo відповідає на позамежовий індекс неініціалізованим записом TTIFFPage, тож шлях обробки помилки, який по дорозі до звіту про збій читає роздільну здатність або кількість семплів, зрештою читає шум. Версія 3.534.1 виправила обох викликаючих усередині бібліотеки: шлях імпорту зображень читає XRes і YRes лише всередині валідної гілки, а TPDFlib.GetImagePageCount вимагає ValidTIFF замість того, щоб самому довіряти ненульовій кількості сторінок. Нижче за течією аргумент Options у AddImageFromFile — це номер сторінки з одиниці для багатосторінкового TIFF, тож GetImagePageCount має бути надійним ще до початку циклу, а не після. Нуль сторінок тепер — справжня відповідь, що означає «тут нема чого декодувати», а не випадковість раннього виходу, що найважливіше, коли ви колажуєте та чергуєте дуплексні пакети сканів і один тихо неправильно декодований аркуш опиниться не на своєму місці

var
  Pdf: TPDFlib;
  Pages, I, ImageID: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pages := Pdf.GetImagePageCount('inbox\scan-0417.tif');
    if Pages < 1 then
      Exit;  // пошкоджений заголовок, BigTIFF, тайлове компонування або перевищення бюджету
    Pdf.NewDocument;
    for I := 1 to Pages do
    begin
      Pdf.NewPage;
      ImageID := Pdf.AddImageFromFile('inbox\scan-0417.tif', I);
      if ImageID > 0 then
      begin
        Pdf.SelectImage(ImageID);
        Pdf.DrawImage(0, 0, 595, 842);
      end;
    end;
    Pdf.SaveToFile('scan-0417.pdf');
  finally
    Pdf.Free;
  end;
end;

Будувати декодер власноруч чи лінкувати libtiff?

PDFlibPas залишає вбудований декодер, і вирішальним чинником є охоплення платформ, а не авторство. Приблизно 1 873 рядки Object Pascal компілюються скрізь, куди доходить компілятор: Win32, Win64, macOS, iOS, Android і FPC на Linux. libtiff 4.7.1 — це близько 30 000 рядків C у 34 одиницях трансляції tif_*.c, а наявні сьогодні заздалегідь зібрані обʼєктні файли покривають лише Windows. Прийняття його означало б обмін повного покриття TIFF на список підтримуваних платформ, що звужується до машин, здатних запустити C-тулчейн, плюс прохід лінкера, який ще ніхто не проходив

Ціна цього варта викладу без прикрас. Вбудований декодер опрацьовує те, що робота зі сканованими документами справді породжує: CCITT Group 3 одновимірний і двовимірний, Group 4, LZW, Deflate, PackBits і JPEG-in-TIFF, у фотометріях WhiteIsZero, BlackIsZero, RGB, палітровій і CMYK з Predictor 1 і 2. Ці корисні навантаження збігаються з PDF-фільтрами в ISO 32000-1 §7.4.4 і §7.4.6, саме тому TIFF-фронтенд несе таку вагу в конвеєрі сканування. Чого він не опрацьовує, то це BigTIFF, тайли, Predictor 3 з плаваючою комою, PixarLog і SGILog, старомодне JPEG-стиснення 6 та піраміди sub-IFD. З 3.534.1 кожен із них — це іменована відмова, а не неправильне зображення, і бібліотека тримає письмовий список тригерів для повторного відкриття рішення щодо libtiff:

  • клієнт повідомляє про файл BigTIFF і потребує рідної підтримки, а не кроку конвертації
  • клієнт повідомляє про тайловий TIFF з медичних, ГІС або промислових джерел і потребує його декодування на місці
  • клієнт повідомляє про TIFF із Predictor 3 плаваючої коми
  • опублікована вразливість зачіпає вбудовані шляхи декодування CCITT або LZW
  • аргумент кросплатформності перестає діяти — або бо підтримку macOS, iOS і Android припинено, або бо повторно використовувана інтеграція libtiff вже покриває macOS і Linux

Сама міграція має окреслені межі, а не гіпотетична: умовна компіляція USE_LIBTIFF зберегла б публічну поверхню TPDFTIFF недоторканою, спрямувала б LoadFromStream через TIFFClientOpen зі стрімерними колбеками і лишила б парсер Pascal як запасний варіант для не-Windows. Поки один із цих тригерів справді не спрацює, підтримка двох декодерів і подвоєної тестової матриці не купує нічого, що клієнт міг би відчути. Відкладення витрати з уже записаним шляхом відступу — це інша річ, ніж ігнорування

Що це означає для конвеєра сканованих документів

Сприймайте TPDFTIFF як ворота, а не конвертер. Завантажте файл, прочитайте ValidTIFF і журналюйте LastError дослівно щоразу, коли він хибний, бо цей рядок тепер найкоротший шлях від польового звіту до діагнозу. Файли, що не пройшли ворота, залишаються відновлюваними конвертацією на верхньому рівні — це практична відповідь для джерел BigTIFF і тайлових сьогодні. Для вхідних даних поза TIFF узагалі PDFlibPas іде окремим маршрутом через свій шлях входу зображень AVIF, HEIF і JPEG XL, тож питання, який декодер володіє яким форматом, лишається явним, а не виникає само собою

Усе це стоїть за звичайним API зображень, тож конвеєр документів отримує щільнішу межу, не змінюючи жодного рядка викликаючого коду, крім перевірки кількості сторінок, яку він і так мав би перевіряти. Якщо ви зважуєте рідний шлях TIFF-to-PDF для Delphi чи C++Builder, повний компонент та його роботу з зображеннями документація описує на сторінці PDF Library for Delphi