Препарируем Compound File Binary format (CFB), или начинаем парсить DOC
Под катом краткое описание как Compound File устроен внутри, которое, надеюсь, будет полезно как ликбез и поможет читателю лучше понимать что делают утилиты или про что пишут в статьях про CFB файлы.
Устройтсво CFB файла сильно напоминает устройство диска с FAT. Логически CFB хранит древовидную структуру объектов, подобную файловой системе с директориями (тип объекта в CFB – STORE) и файлами (в CFB — STREAM). Каждый объект имеет имя.
Весь файл разбит на сектора размером 512 байт для v3 и 4096 байт для v4. Это, на мой взгляд, главная разница между v3 и v4. Дальше цифры для примеров я буду приводить для v3 (и в скобках – для v4). В начальном секторе хранится заголовок файла и начало таблицы DIFAT (см. ниже). Значение содержимого других секторов определяется динамически по таблицам DIFAT и FAT (да, она прям так и называется, как в файловой системе), фиксированных значений “сектор n содержит информацию типа t” больше нет.
Всего максимум может быть 0xFFFFFFFA секторов. Номера секторов больше 0xFFFFFFFA являются маркерами и фактически ни на какой сектор не указывают. Например 0xFFFFFFFE (EndOfChain) означает, что текущий сектор – последний в списке.
DIFAT
DIFAT – Double Indirection File Access Table. Это массив 4х байтных номеров секторов, в которых находится FAT. Первые 109 записей хранятся в первом секторе файла (пять из них выделены жёлтым внизу КДПВ). Если файл больше 6.8 Мбайт (436 Мбайт для v4), то следующие сектора DIFAT хранятся как односвязный список. Номер первого сектора из этого списка хранится в заголовке. В кажом из этих секторов хранится 127 (1023) указателей на сектора с FAT, а в последних 4х байтах — номер следующего сектора DIFAT.
FAT
FAT – File Access Table. Это массив из 4х байтных значений. Работает так же, как в одноимённой файловой системе. Каждому сектору в файле (кроме заголовочного) соответствует 4х байтное значение в FAT.
Для чтения потока данных (STREAM) нужно знать длину потока (в байтах) и номер первого сектора этого потока. Номер следующего сектора всегда записан в элементе FAT, соответствующем текущему сектору. В элементе FAT, соответствующем последнему сектору в потоке, будет записано значение 0xFFFFFFFE (EndOfChain).
Для чтения этого потока мы читает данные из сектора №0, расположенного сразу после заголовка файла (сам заголовочный сектор имеет номер -1), читаем номер следующего сектора из FAT[0] = 1, читаем данные из сектора №1 и номер следующего сектора из FAT[1] = 4, читаем данные из сектора №4 и в FAT[4] видим что это последний сектор этого потока (EndOfChain).
Directory
Это структура, которая хранит информацию обо всех остальных объектах в файле, аналог структуры каталогов. Сама она хранится в виде потока данных, длина и первый сектор которого записаны в заголовке файла. Представляет из себя массив 128 байтных записей. Каждая запись соответствует хранимому объекту. Объекты в directory имеют имя и бывают 4х видов:
- ROOT — «корень» дерева каталогов. В directory хранится в элементе №0, имя всегда — «Root Entry». По сути является «директорией», но хранит информацию о потоке данных с MiniStream (см. ниже).
- STORE — «директория». Логически является вместилищем других объектов типа STORE или STREAM. Физически дочерние объекты хранятся в виде списка (ещё точнее — чёрно-красного дерева), а в STORE хранится указатель на один из дочерних объектов. Никакой поток данных к STORE не прицеплен, но есть ClSid — GUID приложения, к которому относится содержимое этой «директории».
- STREAM — «файл». Хранит информацию о потоке данных (первый сектор и длина); дочерних объектов и ClSid нет.
- UNKNOWN — пустой объект. Запись, (почти) забитая 0, чисто технически нужна для дополнения списка Directory до длины, кратной длине сектора.
У каждого объекта есть временные метки: время создания и время модификации, правда часто они просто заполнены 0.
Пример дерева директорий небольшого doc файла:Root_storage:Root Entry 06090200-0000-0000-c000-000000000046
- Stream:x01CompObj[106]
- Stream:x01Ole[20]
- Stream:1Table[4640]
- Stream:Data[374]
- Stream:x05SummaryInformation[172]
- Stream:WordDocument[198510]
- Storage:ObjectPool 00000000-0000-0000-0000-000000000000
- Stream:x05DocumentSummaryInformation[116]
MiniStream и MiniFAT
В принципе описанного выше достаточно для хранения данных, но в CFB предусмотрена ещё оптимизация для хранения коротких потоков данных — меньше 4096 байт.
При хранении потока данных, длина которого не кратна длине сектора, в последнем секторе остаётся некоторое количество неиспользуемых байт. Чем больше сектор — тем больше таких байт в среднем остаётся. Для борьбы с этой проблемой все небольшие (меньше 4096 байт) потоки данных хранятся не в обычных секторах по 512 (4096) байт и FAT, а в отдельном потоке, разбитом на сектора по 64 байта. Этот поток называется MiniStream, а информация для сцепления его секторов в потоки хранится в MiniFAT. Работает это всё совершенно аналогично основной FAT.
И MiniFAT, и MiniStream хранятся как обычные (не Mini) потоки данных. Номер первого сектора и длина MiniFAT хранятся в заголовке, а информация о MiniStream — в объекте «Root Entry» (логической причины хранить именно там не вижу).
Получается такая «матрёшка»: файл разделён на 512 (4096) байтные сектора, а один из потоков, состоящих из этих секторов, рассматривается как последовательность 64 байтных секторов, и в нём уже хранятся «мини» потоки данных.
Откуда читать поток данных: из «большой» FAT или из MiniStream, определяется только по его размеру (меньше 4096 — из MiniStream, иначе — как обычный поток).
Где же текст/картинка/таблица/макрос?
CFB используется как хранилище, позволяющее сохранить данные разных приложений в одном файле. Если вы хотите докопаться до человекочитаемых данных, то надо парсить потоки данных, извлечённые из CFB, в соответствии с форматом данных приложения. Эта тема выходит за пределы данной статьи (но см. ссылки).
Выводы
По идее такая структура файла была разработана для ускорения внесения отдельных изменений в файл без его полной перезаписи. Не думаю, что это свойство ценно сейчас для обычных офисных документов размером несколько мегабайт.
В общем структура файла CFB — просто кладезь для стеганографии, основанной на формате файла. Т. е. сделать совершенно корректный файл, который при открытии в ворде покажет «Hello world», а внутри будет ещё много, которые «правильный» парсер будет считать просто мусором — вообще не проблема.
С другой стороны — огромный простор для ошибок, путаницы и т. п., огромное количество избыточных записей. Например, можно «зациклить» цепочку секторов, образующую поток. «Наивный» парсер при чтении такого файла зависнет.
P.S. Файлы MS Office 2007 и старше (docx, xlsx, pptx, docm,…) имеют совершенно другой формат. Внутри там тоже дерево директорий, только вместо CFB там используется ZIP (да, их можно прям раззиповать). Однако макросы, например, хранятся внутри документа в файле vbaProject.bin, который является Compound файлом.
Ссылки
- Сам CFB очень хорошо (на мой взгляд) документирован: [MS-CFB]: Compound File Binary File Format
- [MS-DOC]: Word (.doc) Binary File Format для тех, кто хочет полноценно разобрать doc.
- [MS-OVBA]: Office VBA File Format Structure о том, как хранятся макросы, в том числе в Office 2007+ (docm, xlsm, …)
- Ещё пара статей на Хабре про парсинг DOC от Rembish:
Текст любой ценой: WCBFF и DOC и Текст любой ценой: Miette - Для практического анализа файлов MS Office (как до 2007 Офиса, так и после), в том числе подозрительных, рекомендую набор утилит OleTools. Их можно использовать и как отдельные программы, и как модули Python.
- Ещё полезный набор утилит OleDump
- CFB extractor, появившийся в процессе написания этой статьи. Из полезного умеет доставать из CFB файла все потоки данных (включая потоки, не имеющие соответствующей записи в директории).