РАЗБОР Статья

Файлы PDF - внутренний формат и уязвимости

Marylin
Marylin Mod.Assembler · 400 сообщений
Подписаться
247
[ обложка статьи ]
Режим чтения
Иллюстрация-обложка: белый лист документа с красной плашкой PDF и загнутым уголком


Оглавление:
  1. Внутренняя структура документа
  2. Защита и шифрование потоков Stream
  3. Практическая часть - пишем парсер PDF
  4. Выводы

1. Внутренняя структура документа​

Файл PDF - это токсичная смесь текстовых строк и бинарных данных. Парсеры программ-райдеров типа Acrobat ориентируются исключительно на текст, а в двоичном виде хранятся например, шрифты, картинки и прочее, которые по своей природе являются hex-данными. Откройте PDF файл в любом текстовом редакторе (у меня AkelPad), и увидите структуру документа.

Далее мы напишем свой кастомный парсер, который просто вытащит служебную информацию из нёдр файла. Как оказалось - задача эта не из простых, т.к. редакторов PDF в природе много, но при создании файлов каждый из них трактует официальную спеку по своему. Видимо они придерживаются мнения "стандарты для слабаков", в результате чего имеем полный хаос и демократию.

Взять к примеру коды переводов текста на новую строку CR/LF. Согласно ISO-32000 на PDF, конец строки "End-of-Line" может кодироваться аж тремя разными способами, и требует от программ для чтения распознавать любой из вариантов, как один перевод строки:

0x0D = CR или "carriage return": старый формат Mac
0x0A = LF или "line feed": формат для Unix, Linux, macOS
0x0D0A = CR+LF: формат Windows

Если-бы все придерживались этих правил, достаточно было чекнуть сначала строго код 0x0D, а потом 0x0A. Однако на практике встречается и обратная 2-байтовая комбинация 0x0A0D, что сразу в 2 раза увеличивает расходы на анализ. И это лишь единственный пример, а таких косяков в PDF масса. Спецификация существует, чтобы накладывать на них ограничения, но видимо PDF это не касается.

1.1. Объекты и типы данных​

Объект - это контейнер для данных, вокруг которого вращается всё остальное. В больших файлах PDF может быть до 500.000 различных объектов, а потому редакторы стараются как можно плотнее сжимать их содержимое. Например всего 1 лишний пробел внутри одного объекта увеличит размер файла на 0.5 МБ при той-же информационной нагрузке, а 10 пробелов уже на 5 МБ. Это ещё одна причина, почему редакторы местами игнорируют спецификацию.

Объекты делятся на 2 типа - прямой "Direct" (inline), и косвенный "Indirect". Разница между ними в том, что прямой является вложенным в родительский объект, а косвенный - это самостоятельная единица. Каждый косвенный объект имеет свой уникальный номер, и счётчик поколения. Два эти значения указываются непосредственно перед самим объектом obj...endobj, и позволяют ссылаться на него из любого места документа.

В примере ниже строка "Документ PDF" представляет собой прямой объект внутри словаря, а значения ключа /Pages - это ссылка на внешний косвенный объект по его номеру, который расположен здесь во-второй строке, хотя фактически может находиться где угодно. Ссылку можно определить по символу(R) в хвосте, что значит Reference:

Код:
<< /Title (Документ PDF) /Pages 4 0 R >>
4 0 obj << /Type /Pages /Count 1 >> endobj
Обратите внимание, что данный объект имеет в своём теле 2 токена - это ключи /Type и /Count, которые (как и требует спецификация) разделены пробелом. Но в целях экономии каждого байта, редактор может убрать этот пробел, в результате чего словарь в объекте №4 типа "Pages" примет такой вид. Это нужно учитывать при написании своих парсеров:

4 0 obj <</Type /Pages/Count 1>>endobj

В таблице ниже перечислены возможные типы данных в файлах PDF. Пожалуй наиболее важным из них является словарь "Dictionary" - это список пар "ключ-значение", обрамлённых в угловые скобки. Каждую пару принято называть "токен". Ключами словаря всегда являются именованные объекты, а значениями могут быть любые из списка ниже, включая и другие словари. Чтобы различать типы словарей, в них предусмотрен ключ /Type, а иногда и подсказка /Subtype.

Таблица типов данных PDF с колонками: тип, пример, описание


Таблица значений ключа /Type в объектах PDF


1.2. Трейлер и таблица ссылок на объекты "xref"​

Отправной точкой для парсера PDF является ключевое слово "startxref" в самом конце файла, сразу перед маркером окончания %%EOF (end-of-file). Значение после этого слова хранит смещение таблицы ссылок на объекты "Cross-Reference", а сразу после таблицы идёт основной словарь "Trailer", что дословно переводится как прицеп. Такого формата обязаны придерживаться буквально все редакторы, иначе раскрутить движок парсера не получится.

Начало таблицы определяет сигнатура xref с двумя значениями, которые задают диапазон номеров объектов в данной таблице. Первое число - это номер первого объекта (как правило нуль), а второе - общее количество объектов в таблице. В доках 2 эти значения числятся как "First, Count". Далее ровным строем располагаются уже непосредственно ссылки на объекты, и каждая из них размером 20-байт, включая пробелы и перенос строки 0x0D0A. Весь массив ссылок пронумерован по порядку 0,1,2,3,N, хотя эти номера не указываются здесь явно - вместо них используется индекс в таблице.

Выше упоминалось, что общее число объектов в файле может достигать 500.000 штук. Как среди такого кол-ва найти, например, объект под номером 256318 0 obj..endobj? Не сканировать-же весь файл от начала и до конца тупым перебором? Для этого и предусмотрена таблица перекрёстных ссылок xref. Номер затерявшегося объекта будет индексом в таблице, и чтобы найти на него ссылку, достаточно умножить номер объекта на размер одной ссылки 20-байт.

В каждой ссылке по три значения: первое - это собственно 10-значное смещение к объекту от начала файла, второе - счётчик обновлений объекта после модификации, и третье - это флаг свободного f, или активного n объекта. Первая запись всегда в резерве, поэтому её счётчик имеет макс.значение 65535 и флаг(f).

Схема структуры файла PDF с подписью: парсер должен начинать обработку с конца


Теперь мы можем найти беспорядочно разбросанные по всему файлу объекты, но чтобы структурировать их, необходимо получить доступ к корневому объекту Root документа, от которого пляшут уже все остальные ветви. Это можно сравнить с корневым разделом С:\ на жёстком диске. Указатель на Root прописывается в трейлере файла PDF - он всегда идёт сразу за таблицей XREF, и может содержать в себе ещё и следующие линки:

Таблица ключей словаря Trailer: /Size, /Root, /Info и другие


Откроем любой PDF в двоичном редакторе HxD и прокрутив в самый конец, найдём смещение таблицы XREF (см.ключевое слово startxref). Теперь жмём Ctrl+G в редакторе, ставим чекбокс на DEC и прыгаем по указанному оффсету 3076.

Код:
xref
0 12
0000000000 65535 f
0000000015 00000 n
0000000080 00000 n
0000000254 00000 n
0000001139 00000 n
0000001198 00000 n
0000001336 00000 n
0000001807 00000 n
0000002038 00000 n
0000002173 00000 n
0000002362 00000 n
0000002759 00000 n

trailer <<
/Size 12
/Info 2 0 R
/Root 1 0 R  <-------------------
/ID [<31c7...f388> <2161...cb27>]
/Encrypt 11 0 R >>

startxref
3076
%%EOF
Здесь ссылка Root в трейлере смотрит на запись(1) в таблице XREF, а там указано смещение 15 от начала файла. Значит опять Ctrl+G(15), и мы в корневом каталоге документа.

Теперь, чтобы найти страницу, нужно пройти по следующей цепочке объектов, где увидим полный её контент в двоичном потоке Stream. Размер страницы лежит в Length=400, а значение /FlateDecode ключа /Filter говорит о том, что инфо-поток страницы сжат стандартным для PDF алгоритмом zlib/Deflate. Другой вариант фильтра - это ASCIIHexDecode, который подразумевает Base64.

Схема алгоритма поиска страницы в файле PDF по цепочке объектов


1.3. Инкрементные обновления при модификации​


Документ PDF может быть доступен для редактирования (см.далее защита), когда страницы из него удаляются/добавляются - это напрямую воздействует и на структуру объектов в файле. При изменении содержимого документ не создаётся заново - просто в него инкапсулируется ещё одна таблица XREF и трейлер. В этом новом блоке регистрируются только изменённые, добавленные, или удалённые объекты, а нетронутые остаются в оригинальной таблице. Удалённые помечаются символом "f" =free в записях таблицы, а новые символом "n" =normal.

Чтобы обозначить факт модификации, в новом трейлере появится ключ /Prev (previous) значение которого будет смотреть на предыдущую таблицу. Эта цепочка может быть сколь угодно длинной, и при сл.модификации документа родится на свет уже третья таблица XREF, четвёртая и т.д.

Обратите внимание на первые 2 числа до самих записей - как упоминалось это диапазон описываемых данной таблицей объектов. Оригинал охватывает 360 штук начиная с нуля, а новая 310 следующих тушканчиков от 360. Итого общее число объектов в файле будет указано в ключе /Size новой таблицы: 360+310=670.

Сравнение таблицы xref: оригинал и инкрементное обновление


5-значное поле в записях таблицы называется "номером поколения" (generation number). Оно увеличивается на 1 только, когда редактор полностью удаляет объект. При этом номер анулированного попадает в список свободных (см.первую строку 65535 в оригин), а соответствующая запись в таблице помечается флагом(f).

Теперь, когда редактор захочет создать новый объект, по флагу(f) он может вытащить обратно старый из листа свободных. В этом случае вновь испечённому присвоится уже следующее поколение +1, а флаг снова сменится на "n". Собственно поэтому механизм и называют в доках "инкрементное обновление".

1.4. Зависимость от версий (таблица и поток xref)​


В контексте PDF есть мерзкая вещь, которая отравляет жизнь парсерам - это зависимость внутренней структуры файла от его версии. Так, начиная с v1.5 вместо классической Xref-Table, может использоваться специальный поток Xref-Stream. Как и все остальные стримы он заворачивается в объект, внутрь которого помещается сразу и словарь трейлера. При таких раскладах, ключевых слов "xref" и "trailer" в файле уже нет, и парсеру нужно переключаться на абсолютно другой алгоритм работы. Единственно, что остаётся всегда непоколебимым - это переменная startxref в подвале файла. Вот пример из жизни:

Словарь объекта XREF-Stream с подсветкой синтаксиса и пояснениями


В устаревшей таблице, записи хранились в виде открытого текста, но теперь это Hex-данные, которые сжимаются алгоритмом /FlateDecode, и в таком виде сохраняются в потоке "stream" прямо внутри самого объекта. Таким образом, если парсер захочет прочитать таблицу ссылок, ему придётся сначала распаковать бинарный поток длинною /Length. Пара ключей /Size и /Index определяют общее кол-во записей уже в восстановленной таблице.

Отдельного внимания заслуживает здесь токен /W [1 3 0]. Без этой информации мы просто не сможем декодировать таблицу Xref-Stream. Каждое из трёх значений в массиве задаёт размер в байтах определённого поля в записях таблицы ссылок. Отметим, что это не константы и в вашем PDF этой триаде редактор может назначить и другие значения, в зависимости от общего кол-ва объектов в файле (обычно среднее значение пляшет от 1 до 4).

Первое поле = 1 байт
Второе поле = 3 байта
Третье поле = 0 байт

Итого, каждая запись в таблице занимает всего 4 байта, хотя в классической таблице это было всегда ровно 20-байт. Как видим профит от нового варианта оформления всё-же есть, а учитывая, что в файлах может быть до 500.000 объектов (и каждый требует своей записи в таблице), этот профит весьма весомый 500.000*16=8МБ.

Теперь для каждой записи поля интерпретируются в зависимости от первого поля: 0 = свободный объект, который помечался раньше флагом "f", иначе объект используется, т.е. бывший флаг "n". Второе поле определяет разрядность самой ссылки - в классическом варианте это была строка из 10-байт, а теперь это 3-байтное Hex число. Ну и последнее поле должно хранить поколение объекта "Generation Number", хотя в моём случае оно не используется, т.к. размер равен нуль.

1.5. Линеаризация Fast-WEB​


Линеаризованные PDF используют для просмотра прямо Web-браузерах. Отличается они от обычного расположением внутренних объектов, что позволяет отображать первую страницу документа ещё до того, как весь файл полностью загрузится с удалённого сервера. С точки зрения парсера, ключевые особенности заключаются в следующем.

1. Наличие словаря /Linearized.

Если в обычном PDF файл начинается с заголовка %PDF-1.х и далее идут объекты документа, то в шапке линейных находится сначала специальный словарь, а затем уже тушка файла с объектами. Этот словарь может располагаться как до, так и после заголовка %PDF-xx (зависит от настроения редактора). Вот его поля:

Код:
3418 0 obj <<
  /Linearized 1    % версия
  /L 767536        % Length - размер файла
  /N 50            % Number - общее кол-во страниц в файле
  /T 699126 >>     % Table  - смещение первой записи в основной таблице Xref
  /H [35652 550]   % Hint   - смещение и размер основного потока подсказок
  /O 3421          % Object - номер объекта страницы(1) документа
  /E 35652         % End    - смещение конца первой страницы
endobj

2. Таблицы подсказок "Hint Tables".

Это главное технологическое отличие для парсера. В дефолте парсер должен прочитать startxref в хвосте файла, чтобы узнать расположение объектов. Собственно поэтому его и приходится скачивать с сервера целиком. А вот в начале линеаризованного находятся упакованные в поток спец.таблицы подсказок "Hint". Они содержат точные байтовые смещения для всех страниц, шрифтов и прочих ресурсов документа, что упрощает навигацию. Так выглядит словать "Hint" в одном из моих файлов - более подробно смотри раздел F3.6 документации "ISO 32000-1:2008".

Код:
51203 0 obj
<< /S 12358      % Shared  - таблица общих элементов внутри потока
   /O 12465      % Outline - для структуры файла
   /I 12513      % Info    - информация о документе
   /E 12481      % Named   - таблица имён
   /L 12497      % Labels  - для меток страниц
   /Length 5083  % Длина потока Stream
   /Filter /FlateDecode >>
stream
hЮм[{| Uѕ... endstream
endobj

2. Защита и шифрование потоков Stream​

Сжатие бинарных данных (картинок, форм, таблиц) внутри потоков позволяет уменьшить размер файла на выходе. Но если в документе лежит конфиденциальная инфа, то на сжатие можно сверху наложить и шифрование. Такого рода бутерброд предлагают почти все современные редакторы PDF. Более того, можно урезать права юзера на совершение какого-либо действия с документом, например печать или копирование текста в буфер обмена.

2.1. Разбор словаря /Encrypt​


Если в трейлере файла присутствует ключ /Encrypt, значит владелец документа (owner) применил шифрование, и вся техническая информация об используемом алгоритме будет находится в словаре объекта "Encrypt". Значит читаем, как обычно, из подвала переменную starxref, далее ищем трейлер, и в нём ключ /Encrypt. Значение этого ключа и будет ссылкой на нужный нам объект.

Схема доступа к словарю Encrypt с разбором ключей шифрования


Здесь словарь шифрования начинается с токена /Filter /Standard, который определяет дефолтное для PDF имя дескриптора безопасности. При наличии плагинов поддерживаются и сторонние фильтры, из которых можно выделить: Rot13 (~3 штуки баксов за копию), FileOpen, и SoftLock. Впрочем, в подавляющем большинстве используется именно Standard, а остальные встречаются крайне редко.

В ключах /V + /R лежит версия и ревизия алгоритма, который использует фильтр для шифрования. Один и тот-же фильтр может применять множество разных алгоритмов, и эти поля позволяют определить текущий. В свою очередь токен /Length задаёт размер ключа-шифрования в битах, а две 32-байтные строки хранят хэши паролей владельца документа /Owner и пользователя /User. Все эти поля носят сугубо информативный характер и менять их не следует.

Ключ /P - это 32-битный флаг "Permission". По правилам должен иметь отрицательное значение, чтобы старшие (неиспользуемые) биты маски принимали значение 1, хотя не все редакторы разделяют этого мнения, и встречаются положительные флаги. Он отвечает за политику доступа к документу, при этом взведённый бит разрешает действие. А что, если попробовать установить все биты в 1, сняв таким образом все запреты? Увы, ничего не получится. В Adobe сидят далеко не идиоты, и значение ключа /P они запихали в алгоритм расчёта хэша пароля владельца.

Таблицы версий и ревизий алгоритмов шифрования PDF


2.2. Как получить ключ шифрования для RC4/AES​


После того-как парсер узнал алгоритмы хэширования и шифрования потоков по значениям V:R, на сл.этапе ему нужно вычислить ключ для RC4 или AES. Подробное изложение можно найти в спеке на PDF, а здесь мы представим лишь базу. На схеме выше доступа к словарю я выделил серым фоном те значения, которые понадобятся нам для производственных нужд.
  1. Берём U-пароль, и соединяя его операцией конкатенации (объединения), дополняем до 32-байт следующей Hex-строкой. Пароль юзера(U) как-правило пустой, поэтому получается только эта константа:
    28BF4E5E4E758A4164004E56FFFA01082E2E00B6D0683E802F0CA9FE6453697A
  2. В хвост полученной последовательности дописываем сначала 32-байтный хэш O-пароля, далее 4-байтное значение флага /P (младшим байтом вперёд, Little-Endian), и замыкает цепочку 16-байтное значение ключа /ID в трейлере. Если-бы значение флага /Р не принимало участия в этом каламбуре, можно было свободно снимать атрибуты защиты доступа к документу, а так увы.. В общем итого получается: 32+32+4+16=84 байтное значение.
  3. Остаётся пропустить итоговую последовательность через алгоритм MD5, и на выходе получим хэш фиксированного размера 16-байт. Ключом для шифрования будут первые 5-байт = 40 бит, из сгенерированных 16-ти.
  4. Если номер ревизии в ключе /R имеет значение(3) и выше, то полученный хэш из предыдущего пункта нужно повторно пропустить через MD5 ровно 50-раз, и от финального хэша взять столько байт, сколько указано в ключе /Length словаря Encrypt.
Отметим, что добытый таким образом ключ парсеры используют только для расшифровки потоков stream в объектах, чтобы отобразить на экране содержимое файла. Если-же владелец документа защитил его паролем, то мы не сможем изменить значения флага /P для снятия ограничений на печать или копирование. Программный подход заключается в вызове функции BCryptHashData() из либы bcrypt.dll где генератора хэшей, и BCryptDecrypt() для непосредственной расшифровки данных.

3. Практическая часть - кастомный парсер​


Я собрал всё сказанное выше под один капот и получил такую утилиту, которая запрашивает произвольный файл PDF, и выводит о нём базовую инфу. Так можно будет узнать, заточен-ли документ под Web-линеаризацию, дополнялся или нет данными уже после создания, метод шифрования, если таковое использовалось, права доступа, и прочее.

Столкнувшись с такой задачей на практике я узнал много нового, и как говорится "первый брак не всегда удачный, зато почти всегда показательный". Это была просто тренировка, чтобы не потерять форму, и может когда-нибудь расширю его функционал. Исходник на fasm'e, и экзе для тестов положу в скрепку, а здесь лишь несколько скринов для демо.

Три окна утилиты PDF Parser v1.0 с результатами разбора файлов


4. Заключение​


Как оказалось, в формате файла PDF есть столько нюансов, что не возможно перечислить их в одной статье. Спека здесь больше мешает, чем помогает в исследованиях, поскольку в ней утверждается одно, а на практике видим совсем иное. Поэтому достичь дзена можно только сравнением нескольких десятков разных файлов, что окажет потом посильную помощь в бою. Зато это оставляет после себя мощное послевкусие.

Скачать архив с файлами

Ссылки по теме:

Официальный стандарт на PDF v1.7
https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf

Стандарт на последнюю версию 2.0 (требуют ввести личные данные, хотя можно левые)
https://pdfa.org/resource/iso-32000-2/

Примеры файлов с разной структурой, для исследования в HEX-редакторах
GitHub - pdf-association/pdf20examples: PDF 2.0 example files
http://www.cs.cmu.edu/~dst/Adobe/Gallery/anon21jul01-pdf-encryption.txt

Разное
Шифрование PDF
https://avepdf.com/optimize-pdf-for-web
Центр загрузки | Для Windows, Linux и Mac OS X
Все инструменты - 100% бесплатно
Полезно · 1

Комментарии

0