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

Код на Delphi, работающий случайно: 5 ловушек порта на FPC

Поднять код CCITT, TIFF, PNG, Flate и буфера потоков на Free Pascal — надёжный способ найти Pascal, который работает на Delphi случайно. Ни один из пяти паттернов ниже не является багом компилятора. Каждый — код, который Delphi исполняет правильно благодаря детали реализации: скрытый параметр результата, алиасящий массив вызывающего; ветка вне диапазона, дальше которой никто никогда не читает; буфер нулевой длины, единственная защита которого — ключ проверки диапазона; смещение с нумерацией от единицы, которое только один путь в коде когда-либо передаёт как 1; и контракт TStream.Read, который стримы в памяти никогда не упражняют. Смените компилятор или скормите тому же коду искажённый файл — и случайность перестаёт держать

Дальше — конкретная форма каждой ловушки, то, как PDF Library for Delphi с ней обращается, и дисциплина за ней: один и тот же исходник должен давать одну и ту же семантику документа на обоих компиляторах, и единственный способ это узнать — проверять на обоих. Соседняя статья про закалку Pascal-парсера PDF против зловредных файлов разбирает разрядность целых, глубину рекурсии и неинициализированные буферы. Эта — про другой класс отказов: код, который был неправильным всё это время, а компилятор его тихо прикрывал

Почему функция, возвращающая динамический массив, работает на Delphi без SetLength?

Потому что Delphi передаёт собственную переменную вызывающего как скрытый параметр результата, так что функция, ни разу не выделившая память под свой результат, всё равно может писать в массив, выделенный вызывающим. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray — это поиск по опорной строке в самом сердце двумерного декодирования Group 3 и Group 4: по текущей позиции a0 и цвету текущего отрезка он ищет меняющиеся элементы предыдущей строки развёртки — те самые b1 и b2 из схемы двумерного кодирования ITU-T T.4 и T.6 — и возвращает их массивом из двух слотов. Соблазнительная реализация пишет Result[0] и Result[1] и ни разу не вызывает SetLength для Result

На первой же записи это должно падать, и на Free Pascal оно падает. На Delphi — нет, пока места вызовов выглядят так: объявляется b: TCCITTIntegerArray, один раз перед циклом по строкам развёртки выполняется SetLength(b, 2), а внутри цикла идёт присваивание b := GetNextChangingElement(a0, IsWhite) и чтение b[0] и b[1]. Руководство по языку Delphi говорит, что функция, результат которой — длинная строка, динамический массив или другой управляемый тип, получает этот результат дополнительным параметром var, а на практике компилятор передаёт адрес цели присваивания. Так что Result внутри функции — это сам b, уже длиной в два элемента, и каждая запись попадает в память, которой владеет вызывающий. Free Pascal вручает функции свежий nil-массив и присваивает его b уже после — и именно это прочтение контракта должно быть тем, против которого код пишется с самого начала

Расхождение при декодировании CCITT в PDFlibPas: Delphi передаёт массив вызывающего b как скрытый var-параметр Result функции GetNextChangingElement, поэтому записи ложатся в память вызывающего и пропущенный поиск оставляет прежние значения, тогда как Free Pascal выдаёт функции свежий nil-массив, который защита по Length обязана расширить через SetLength до первой записи
Delphi подставляет массив вызывающего на место скрытого параметра Result, поэтому незащищённые записи всё равно ложатся в свою память, а Free Pascal приходит с nil, и защита в одну строку превращает падение в задуманное поведение, не меняя путь декодирования на Delphi

У этого псевдонима есть и семантика, на которую декодер опирается. Result[0] присваивается только когда сканирование находит элемент больше a0, а Result[1] — только когда после него есть ещё элемент, так что при промахе слоты сохраняют то, что осталось в b от предыдущей итерации. Очевидное решение — выделить два слота и обнулять их при каждом вызове — уничтожило бы этот перенос и изменило бы декодированный вывод на Delphi. PDF Library for Delphi использует защиту вместо сброса: на Delphi это мёртвый код, и путь декодирования остаётся байт в байт прежним, а на Free Pascal падение превращается в задуманное поведение. Эта асимметрия и есть весь смысл: защита обязана быть no-op на том компиляторе, где код уже выдаёт проверенный вывод

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Сюда Delphi приходит с двухэлементным массивом вызывающего,
  // подставленным как Result, так что здесь это пустышка. FPC приходит с nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] по-прежнему пишутся только при попадании, так что
  // при промахе сохраняются значения предыдущей итерации ровно как раньше
End;

Счётчик, переживший свои данные: запись каталога TIFF

Когда вы обнуляете массив, счётчик нужно обнулять тем же оператором, иначе счётчику поверит код, который самого массива уже не видит. Запись каталога образа TIFF (TIFF 6.0 §2, 12-байтовая раскладка tag, type, count и value-or-offset) несёт 32-битный счётчик прямо из файла, и PDF Library for Delphi читает каждую через PopDE: TTIFFEntry — запись с полями Tag, TagType, Length, Offset и разобранными массивами IntegerValues и DoubleValues. Наивная проверка диапазона смотрит, не уходит ли Offset + TypeSize * Length за конец файла, и если уходит — ставит оба массива нулевой длины, оставляя Result.Length со значением из файла

Дальше ломаются две вещи. Функция заканчивается запасным вариантом, который читается как «если Length равен нулю, дать записи один нулевой элемент», чтобы вызывающие всегда могли прочитать нулевой элемент. Поскольку на пути выхода за диапазон Length не обнулялся, этот запасной вариант не срабатывал ровно в том единственном случае, для которого и существовал. А вызывающие читают нулевой элемент безусловно: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip и ещё десяток берут E.IntegerValues[0], а таблицы полос делают Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), копируя Length раза по четыре байта из массива, в котором их нет. Обнулённый массив с живым счётчиком строго опаснее непроверенного: непроверенный хотя бы держит байты, которые заявляет

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

Закалка записи каталога TIFF в PDFlibPas: 12-байтовая запись несёт счётчик из файла, наивный порядок выделяет массивы по этому счётчику до проверки диапазона и оставляет Result.Length живым после их обнуления, а безопасный порядок сначала проверяет арифметику Int64 против длины файла, чтобы счётчик обнулялся вместе с массивами
Выделение памяти до проверки диапазона позволяет враждебному счётчику запросить гигабайты и оставляет живой счётчик на опустевшем массиве, поэтому безопасный порядок сначала проверяет смещение и обнуляет Result.Length тем же оператором, что и массивы
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // счётчик уезжает вместе со значениями
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // только теперь
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... ниже запасной вариант добирается до случая, ради которого существует:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Здесь нет ничего от конкретного компилятора, и именно поэтому этот случай попал в список. Ловушка скрыта на Delphi по той же причине, что и на Free Pascal: для неё нужна запись каталога, указывающая за конец файла, а обычные тестовые файлы такой не содержат. Порт её не вскрывает. Вскрывает чтение кода с вопросом «что Delphi делает здесь за меня, чего я не делаю сам»

Что происходит, когда IHDR в PNG заявляет тип цвета, которого формат не определяет?

PDF Library for Delphi отвергает изображение до запуска построчных фильтров. ISO 15948 §11.2.2 определяет чанк IHDR, а Table 11.1 перечисляет шесть легальных комбинаций типа цвета и битовой глубины: grayscale при 1, 2, 4, 8 или 16 битах, indexed color при 1, 2, 4 или 8, и truecolor, grayscale с альфой и truecolor с альфой при 8 или 16. Ловушка — читатель, который валидирует поля метода сжатия и метода фильтрации IHDR, а FColorType и битовую глубину пропускает нетронутыми: нелегальный тип цвета тогда даёт scanline нулевой длины, и циклы unfilter получают пустой буфер

Код построчных фильтров обычно вычисляет все размеры из Case FColorType Of, отображающего каждый тип цвета в количество компонент. Тип цвета вне шести попадает в ветку Else, где SourceComponents равно 0, значит ScanlineByteCount равно 0, значит за SetLength(PreviousScanline, 0) немедленно следует FillChar(PreviousScanline[0], ScanlineByteCount, 0). Индексация нулевого элемента пустого динамического массива — это адрес, вычисленный от nil. С выключенной проверкой диапазона заполнение нулевой длины по этому адресу — тихая пустышка, и декодер шагает дальше по строкам, которых не существует; с включённой это ERangeError на первом же изображении; а идущие следом вызовы Move — в шаге от access violation. Что именно вы получите, зависит от компилятора и ключей сборки, а не от того, что решил декодер, — и это примета того, что декодер не решал ничего

PDF Library for Delphi применяет таблицу из спецификации там, где проверяются остальные поля IHDR: COLOR_GRAYSCALE принимает FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE принимает [1, 2, 4, 8], а COLOR_RGB, COLOR_GRAYSCALEALPHA и COLOR_RGBALPHA принимают [8, 16]; всё остальное сбрасывает ValidImage, и изображение отклоняется с сохранёнными шириной и высотой для диагностики. Чанк pHYs короче своих девяти байт проверяется в том же месте, поэтому читатель DPI никогда не индексирует S[1] по S[8] в строке, которую короткий чанк оставил пустой

Смещение с нумерацией от единицы, с которым обошлись как с указателем от нуля

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString принимает StartPos с нумерацией от единицы, потому что на входе у него AnsiString, а реализация на Delphi адресует вход zlib как @Input[StartPos]. Реализация на Free Pascal, написанная против paszlib, чтобы обе цели под Windows линковали сжатие статически, вынуждена адресовать тот же вход арифметикой указателей, а арифметика указателей — от нуля. Поставьте next_in в PAnsiChar(Input) + StartPos и avail_in в Length(Input) - StartPos, передайте 1 — ровно то, что для этой функции значит «начать с начала» — и сборка на FPC начнёт распаковку со второго байта и оборвёт её за байт до конца

Это легко прячется, потому что вызывающий, которого достигает большинство тестов, — InflateStr, передающий 0. Ноль случайно оказывается корректным смещением от нуля, так что обе сборки согласованы на каждом обычном вызове InflateStr и во всех тестах через него. TPDFDocument.DecodeAllStreams — рутина, которой SaveQDFToFile и ConvertFileToQDF разворачивают одиночные потоки FlateDecode в читаемую форму, — передаёт 1. При живом off-by-one пропущенный zlib-заголовок ломает распаковку, но zlib-поток всё равно сообщает ненулевой Consumed за осмотренные байты, поэтому вызывающий, доверяющий Consumed, принимает пустую нагрузку за успешную распаковку и заменяет каждый content stream пустой строкой. Получившийся QDF имеет правильное число страниц, валидную структуру и ни одной страницы с содержимым — файл, который открывается без ошибок в любом читателе и не показывает ничего

// Ветка FPC в InflateStrFromPosition.
// StartPos нумеруется с единицы, как в ветке Delphi; обрезаем его,
// а в смещение указателя от нуля переводим ровно один раз, на границе.
If (StartPos < 1) Then
  StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
  Exit;
...
strm.next_in  := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;

Проверка, которая это ловит, — самая маленькая из возможных: сжать нагрузку, распаковать её с позиции 0 и с позиции 1 и убедиться, что обе вернули одинаковую нагрузку и обе сообщили Consumed, равный полной длине потока. У потока RFC 1950 есть двухбайтовый заголовок и четырёхбайтовый трейлер Adler-32, так что ошибка на единицу с любого конца — это не тонкая порча, а поток, который либо не запускается, либо не завершается. Урок здесь про границу, а не про zlib: когда параметр функции определён в одной системе индексации, а реализация под ним использует другую, перевод должен жить ровно в одной строке, и тест обязан вызвать функцию со значением, различающим эти две системы

Почему короткое чтение TStream.Read — это не конец потока?

Потому что TStream.Read имеет право вернуть меньше запрошенного по любой причине, и только возврат 0 означает, что больше ничего нет. TMemoryStream и TFileStream на локальном диске почти всегда выполняют запрос целиком, поэтому код, который считает «вернулось меньше, чем я просил» концом файла, проходит все тесты, использующие их. Потоки поверх сети, потоки распаковки и любой наследник TStream, написанный заказчиком, могут вернуть два байта на запрос в шестьдесят четыре тысячи и иметь за собой ещё гигабайты

TPLBuffer — читатель, через который проходит каждый парсер в PDF Library for Delphi; он может обернуть AnsiString, указатель, массив байтов или TStream. Его четыре сканирующих запроса — DistanceToByte, DistanceToOtherByte, DistanceToAnyByte и DistanceToOtherBytes, все возвращающие Int64 — читают источник блоками по 64 КБ в поиске разделителя и сообщают, как далеко он стоит, не сдвигая логическую позицию. Естественный способ закончить такой цикл — Until ReadCount < BlockSize. Для трёх источников в памяти это верно, потому что ReadIntoBuffer всегда доставляет полный блок, кроме последнего. Для источника-потока это значит, что сканирование сдаётся на первом коротком чтении, репортит разделитель отсутствующим, а токенизатор над ним решает, что объект кончился там, где он не кончался, — поэтому TPLBuffer крутит цикл, пока чтение не вернёт 0

Обработка коротких чтений в буфере потока PDFlibPas: DistanceToByte сканирует блоки по 64 КБ, старый цикл считал Until ReadCount < BlockSize концом данных и сдавался на первом коротком чтении, а исправленный цикл идёт до ReadCount равного нулю, находит разделитель и восстанавливает позицию в блоке finally
Поток может вернуть два байта на запрос в шестьдесят четыре тысячи, поэтому ноль — единственный признак конца данных, которому сканирование может доверять, а блок finally восстанавливает логическую позицию, когда разделитель найден и цикл выходит досрочно
// Цикл сканирования TPLBuffer.DistanceToByte.
// Ноль — единственный признак конца данных, который определяет TStream.Read.
TempPosition := FPosition;
Try
  Repeat
    ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
    For TestPos := 0 To ReadCount - 1 Do
      If TempBuffer[TestPos] = Value Then
      Begin
        Result := TotalSkipped + TestPos;
        Exit;
      End;
    Inc(TotalSkipped, ReadCount);
  Until ReadCount = 0;
Finally
  FPosition := TempPosition;   // подглядывание не должно двигать читателя
End;

Способ зафиксировать это — наследник TMemoryStream, чей переопределённый Read ограничивает каждый запрос двумя байтами. Оберните в него строку aaaaaX, выставьте позицию буфера в 1 — и все четыре запроса обязаны сообщить расстояние 4 до X, оставить позицию после себя равной 1 и вернуть -1 для байта, которого там нет. Сканирование, остановившееся на первом коротком чтении, видит два байта, заключает, что поток исчерпан, и возвращает -1. Блок finally здесь значит не меньше условия цикла: Exit изнутри сканирования — это нормальный путь успеха, и логическую позицию нужно восстанавливать и на нём, а не только когда цикл добегает до конца

Один исходник, два компилятора, один набор проверок

Дисциплина за этими пятью случаями: «сборка на Delphi проходит» — свидетельство о Delphi, а не об исходнике. Стоящий кросс-компиляторный чек гонит одну рутину под обоими компиляторами: собрать двухстраничный документ со сжатым содержимым через TPDFlib, сохранить его, сохранить снова как QDF через SaveQDFToFile, починить QDF через RepairQDFFile, зашифровать обычный файл AES-128 через EncryptFile с маской разрешений из EncodePermissions, а затем перечитать каждый артефакт и проверить на обоих компиляторах одно и то же: число страниц 2, заголовок выжил, текст второй страницы извлекается целым из обычного, починенного и зашифрованного файлов, неверный пароль отвергается с ненулевым LastErrorCode, EncryptionStrength равно 128, EncryptionAlgorithm равно 2, а отдельные биты разрешений из GetUserPermissions возвращаются ровно как были закодированы

Сравнивайте нормализованную семантику, а не байты. Шифрование тянет случайные соли, а писатель назначает идентификаторы документа, поэтому от двух сборок не ждут одинаковых файлов; от них ждут файлов, которые означают одно и то же, и проверки сформулированы на этом уровне. Ветвь с QDF заслужила место из-за ловушки со смещением: QDF с двумя страницами и без содержимого проходит проверку числа страниц и валится на проверке извлечения текста, так что проверяйте вторую. Любое изменение, которое пустышка на одном компиляторе и смена поведения на другом — а это описание четырёх из пяти случаев выше, — обязано проходить одни и те же проверки на обоих

Вторая половина того же порта, на этапе линковки — заставить OMF-объекты Delphi и ожидания COFF от Free Pascal договориться, — это отдельная история в статье про линковку статических объектов FPC Win32 из OMF в COFF, а структурная закалка того же читателя TIFF против BigTIFF и тайловых файлов — в заметках о встроенном декодере TIFF. Декодеры из этой статьи поставляются в составе PDF Library for Delphi для Delphi, C++Builder и Free Pascal, где от одного исходника ждут, что он заслужит одинаковый результат на каждом целевом компиляторе, а не получит его в подарок от одного из них