
Содержание
Привет, Хабр!
Меня зовут Григорий Гришаев. Я работаю младшим специалистом в департаменте комплексного реагирования на киберугрозы в экспертном центре безопасности Positive Technologies (PT ESC) и занимаюсь разработкой парсеров различных файловых артефактов.
В ходе расследований нашей команде регулярно приходится работать с артефактами, собранными с хостов под управлением Windows. Среди них часто встречаются базы данных в формате ESE (Extensible Storage Engine), который широко используется в экосистеме Microsoft. Мы неоднократно сталкивались с тем, что существующие инструменты для работы с этим форматом имеют ряд ограничений и не всегда корректно обрабатывают реальные данные. В результате возникла необходимость исправлять существующие решения и разрабатывать собственные средства извлечения данных из подобных файлов.
Одна из основных целей статьи — описать общий алгоритм парсинга БД ESE и подсветить важные для разработки и отладки собственного парсера нюансы. Сразу отмечу, что в данной статье будут описаны лишь те детали реализации, которые могут быть важны для корректного получения данных из файла БД, но не для полного дублирования функций движка. Обзор прочих механизмов ESE (транзакций, журналирования, блокировок и прикладного API), не влияющего на офлайн-разбор, здесь отсутствует. Желающие узнать больше могут ознакомиться с публично доступной эталонной реализацией Microsoft на C++: Extensible-Storage-Engine. Имена структур и констант в статье в основном совпадают с этим кодом.
Что такое Extensible Storage Engine (ESE)
Extensible Storage Engine (ESE), или Jet Blue, это разработанный Microsoft и встроенный в ОС Windows движок локального хранения данных, который используется во многих системных и серверных компонентах Windows. Несмотря на историческое родство названий, ESE не имеет прямого отношения к Jet Red (Microsoft Jet Database Engine), применяемому в Microsoft Access и ряде других настольных приложений.
ESE не является SQL СУБД: работа с БД осуществляется посредством вызова функций API и передачи в них соответствующих структур.
Архитектурно ESE представляет собой страничное ISAM-хранилище, использующее B+ деревья. Файл базы данных состоит из страниц фиксированного размера, а таблицы и индексы представлены отдельными деревьями страниц. Записи хранятся в листовых узлах, тогда как внутренние узлы содержат только ключи и ссылки на дочерние страницы. Соседние листовые страницы связаны между собой, что позволяет обходить данные последовательно без необходимости каждый раз подниматься к корню дерева. Отсюда следуют два основных способа доступа, отражённые в самой аббревиатуре ISAM: Indexed — поиск по ключу со спуском от корня к листу; Sequential — последовательный обход цепочки листовых страниц.
Формат ESE встречается в большом количестве интересных для аналитика артефактов Windows. Классические примеры — база Active Directory, кэш Interner Explorer, очередь BITS, индекс Windows Search, ряд служебных баз Exchange и т.д. Ближе к концу статьи мы рассмотрим несколько примеров файлов, полезных при проведения расследования.
В рамках процессов DFIR очень важно иметь средство автоматизированого разбора подобных файлов (для последующей нормализации и написания дектектов). Поэтому, несмотря на удобство GUI программ (одна из которых будет рассмотрена далее), мы обратим свой взор на имеющиеся публично доступные библиотеки для парсинга БД ESE. Мы не будем рассматривать использование стандартного API по двум причинам.
Жёсткая привязка данного метода к ОС и её версии. Например, может не получиться открыть ESE файл, полученный с Windows Server, на клиентской версии Windows в силу отсутствия нужных библиотек или несовпадения их версий.
Затруднительна работа с «битыми» файлами (что не редкость при проведении расследований).
Существует ряд публично доступных библиотек для парсинга, среди которых следует отметить:
libesedb – парсер на языке Python, основан на обратной разработке кода Microsoft и содержит ряд неточностей.
go-ese – часть Velociraptor, парсер на языке Go, имеет примерно те же проблемы, что и
libesedb.
Почти все реализации уверенно читают каталог и строки таблиц, но так или иначе спотыкаются на т.н. long value (неправильно реконструируют ключи для поиска в B+ дереве, не умеют собирать chunk’и или вообще пропускают парсинг LV, используя LID вместо самого значения). Ещё один частый источник ошибок – особенности формата, зависящие от размера страницы: разная структура заголовка, tagged-колонок и флагов на small page (до 8 Киб) и large page (16/32 Киб). Без учёта ряда рассмотренных далее деталей парсер может возвращать некорректные данные и пропускать записи.
Именно поэтому при разработке собственного парсера важно иметь инструменты для анализа структуры и содержимого базы данных. Ниже рассмотрим несколько утилит, полезных при исследовании формата ESE и поиске ошибок в парсере.
Инструменты для исследования БД ESE
Для примеров в рамках статьи мы будем использовать утилиту esentutl, поставляемую в составе ОС Windows и позволяющую работать с файлом базы на низком уровне.
Помимо esentutl существуют ещё две утилиты Microsoft для работы с БД ESE:
Для чтения файла БД подойдёт любая из них. Но для операций, вносящих изменения в БД, следует взять соответствующую утилиту: esentutl для общих ESE файлов Windows, eseutl для Exchange, ntdsutil – для Active Directory, и по возможности той же версии, что и приложение, создавшее файл.
Отдельного упоминания заслуживает программа ESEDatabaseView от NirSoft, предоставляющая графический интерфейс для просмотра содержимого баз данных ESE.

ESEDatabaseView не всегда корректно работает (например, может «упасть» на некоторых файлах) и не совсем удобна для просмотра т.н. separated long value, т.к. показывает не само значение, а лишь его идентификатор – LID, но бывает полезна, когда нужен наглядный пример того, как устроена та или иная таблица в БД.
Логическая и физическая модель базы данных
Начнем наш обзор с рассмотрения того, в каком виде БД представлена на диске. Сразу отметим, что в ESE логический и физический уровни тесно связаны: в отличие от классических SQL СУБД вроде Postgres, здесь при анализе данных почти невозможно полностью абстрагироваться от того, как именно запись лежит на странице и как она связана со внутренними структурами данных.
БД ESE представляет собой файл, состоящий из страниц фиксированного размера (размер задается в заголовке БД), первые две страницы содержат заголовок и его копию (размер заголовка меньше размера страницы, оставшееся на странице место не используется).

Заголовок базы определяет, как именно интерпретировать остальной файл. В нём хранится сигнатура базы, состояние, версия формата, размер страницы и еще много информации (см. DBFILEHEADER). Поле le_cbPageSize в DBFILEHDR задаёт размер страницы в байтах; значение 0 означает страницу 4 КиБ по умолчанию. ESE допускает только следующие размеры страниц: 2048, 4096, 8192, 16384 и 32768 байт (2, 4, 8, 16 и 32 КиБ). На практике чаще встречаются 4, 8, 16 и 32 КиБ; 2 КиБ – устаревший вариант. При создании новой БД через API нижняя граница – 4 КиБ (g_cbPageMin), верхняя – 32 КиБ (g_cbPageMax).
Страницы в ESE делятся на два типа в зависимости от размера: small page (<= 8 КиБ) и large page (> 8 КиБ). При разборе БД от этого зависят:
заголовок страниц (
PGHDRилиPGHDR2);расположение флагов для записей B+ дерева;
интерпретация tagged-колонок.
PGHDR2 представляет собой расширенную версию PGHDR с добавлением нескольких дополнительных полей (например, поля pgno, в явном виде хранящего номер данной страницы). Последние два пункта будут подробнее разобраны далее.
Получить информацию из заголовка можно с помощью esentutl с флагом /mh:
esentutl /mh .\examples\qmgr.01.db Extensible Storage Engine Utilities for Microsoft(R) Windows(R) Version VER_PRODUCTMAJORVERSION.VER_PRODUCTMINORVERSION Copyright (C) Microsoft Corporation. All Rights Reserved. Initiating FILE DUMP mode... Database: .\examples\qmgr.01.db DATABASE HEADER: Checksum Information: Expected Checksum: 0xf0ed3521 Actual Checksum: 0xf0ed3521 Fields: File Type: Database Checksum: 0xf0ed3521 Format ulMagic: 0x89abcdef Engine ulMagic: 0x89abcdef Format ulVersion: 0x620,300,620 (attached by 9620) Engine ulVersion: 0x620,300,620 (efvCurrent = 9620) Created ulVersion: 0x620,20 DB Signature: Create time:04/21/2026 10:06:17.018 Rand:78577349 Computer: cbDbPage: 16384 dbtime: 24505 (0x5fb9) State: Clean Shutdown ... Operation completed successfully in 0.15 seconds.
Часть вывода утилиты опущена в угоду наглядности, а в качестве примера использована база данных службы BITS, которая начиная с Windows 7 перешла на использование ESE вместо более раннего бинарного формата.
Наибольший интерес для парсинга представляет значение cbDbPage, отражающее размер страницы БД.
Как уже упоминалось ранее, первые две физические страницы файла содержат заголовок БД и его копию; они не входят в нумерацию pgno, которая повсеместно используется в БД. Логические страницы начинаются с pgno 1 (это третья страница файла). Первые номера заняты служебными структурами: system root (pgno 1), space trees для экстентов (pgno 2–3), каталог MSysObjects (pgno 4).
Дальше файл – набор страниц, принадежащих B+ деревьям разных типов: для таблицы одно data tree (оно же по построению будет являться primary index этой таблицы), для каждого вторичного индекса – своё index tree, плюс при необходимости long value tree и space tree. Каждое дерево однозначно определяется своим PgnoFDP, где FDP (Father Data Page) – это страница, выступающая корнем соответствующего B+ дерева.
Разбор БД ESE почти всегда начинается с чтения таблицы MSysObjects (PgnoFDP = 4), представляющей собой каталог базы данных. Это системная таблица, в которой хранится схема всей базы: список таблиц, колонок, индексов вместе с их атрибутами (включая PgnoFDP соответствующих B+ деревье).
С логической точки зрения БД ESE представляет собой привычную совокупность индексов и таблиц, содержащих столбцы и строки. Прикладной программист может взаимодействовать с БД, используя функции из esent.dll. Краткий обзор средств и возможностей ESE представлен в статье в журнале RSDN Magazine #1-2007: Extensible Storage Engine. Краткий обзор.
Уделим немного времени тому, чтоб разобраться с основной структурой данных, используемой под капотом ESE и многих других СУБД.
B+ дерево: ликбез
B+ дерево – сбалансированное многоуровневое дерево поиска с высокой степенью ветвления. Оно относится к семейству B-деревьев, но отличается тем, что все пользовательские записи хранятся только в листьях; внутренние узлы содержат лишь ключи-разделители и ссылки на дочерние страницы (в случае ESE –pgno дочерних страниц). Кроме того, листовой узел включает в себя указатель на следующий листовой узел для ускорения последовательного доступа (в реализации B+ дерева, используемой в ESE, в заголовке страницы за это отвечают поля pgnoPrev и pgnoNext).
Узлы делятся на два класса:
internal (branch) – упорядоченный набор пар (
key,child_pgno). Ключ задаёт верхнюю границу поддерева, на которое указывает ссылка (точная семантика сравнения зависит от схемы индекса).leaf – упорядоченный набор пар (
key,payload). Здесь лежат данные: строка таблицы, индексная запись, сегмент long value и т.д.
Инварианты, которые должны выполняться у корректного дерева:
Сортировка – ключи внутри каждого узла идут в неубывающем порядке.
Разделение диапазонов – для internal-узла все ключи в левом поддеревье меньше соответствующего разделителя, все в правом – не меньше.
Балансировка – все листья находятся на одной глубине; высота дерева одинакова для любого пути от корня до листа.
Рассмотрим пример подобного дерева. На схеме представлено B+ дерево для таблицы Sessions, где SessionId является primary key. В данном случае ключ для наглядности строится по одной колонке, но в общем случае возможно использование композитного ключа; такой ключ сравнивается лексикографически по сегментам слева направо (сначала первый сегмент, при равенстве — следующий и т.д.).

Все данные ([row]) находятся только в листовых узлах, промежуточные узлы содержат только разделители и указатели на страницы из следующего уровня.
Разберем несколько полезных для парсинга БД ESE операций над B+ деревом.
Поиск по ключу – спуск от корня: на каждом internal-узле линейным поиском выбирается дочерняя ссылка, пока не достигнут листовой узел; в листовом узле выполняется финальный поиск записи с тем же ключом.
Последовательный обход – обход всех листьев слева направо. В классической реализации листья связаны списком соседних страниц; в ESE у leaf-страницы в заголовке есть поле pgnoNext/NextPageNumber, по которому продолжается сканирование без возврата к корню, что делает удобным получение диапазонов значений. К примеру, если нам надо получить все записи, где SessionId >= 1450 && SessionId <= 2670, то для этого достаточно один раз спуститься по дереву pgno 100 (FDP) -> pgno 50 -> pgno 14, а затем начать обходить страницы через NextPageNumber: pgno 14 -> pgno 16 -> pgno 17.
Здесь мы не станем разбирать прочие операции над деревом (например, вставку или удаление узлов) – для парсера подобная функциональность излишня. За дополнительной информацией можно обратиться к работе Organization and maintenance of large ordered indices, в которой было впервые предложено использование B-деревьев (B+ деревья появились позже как развитие базовой идеи).
Типы B+ деревьев в ESE
ESE хранит информацию в четырёх типах B+ деревьев:
data tree – основное место хранения табличных данных;
index tree – используется для secondary index (primary index’ом является само data tree таблицы);
long value tree – хранит в себе значения, которые потенциально могут быть больше размера одной страницы БД;
space tree – необходимо для учёта свободного места в файле БД.
Больше всего нам интересны data tree и long value tree, поскольку именно в них содержится полезная для расследования информация, преимущественно их мы и будем обсуждать дальше.
Тип дерева задаётся в заголовке каждой страницы, вместе с ролью страницы в дереве: root, internal/branch, parent of leaf, leaf.
Для парсера эти различия означают, что одна и та же физическая структура страницы будет интерпретироваться по-разному в зависимости от выставленных флагов:

на branch-странице узлы содержат ключи и номера дочерних страниц;
на leaf-странице data tree узлы содержат записи таблицы;
на leaf-странице long value tree узлы содержат сегменты длинных значений (или служебную информацию для
LVroot);на leaf-странице index tree узлы содержат первичные ключи записей.
Как устроена страница БД ESE
Страница БД ESE содержит три основные сущности:
Заголовок страницы (page header);
Область данных (data area);
Массив тегов (tags) в хвосте страницы.
Заголовок страницы – структура PGHDR (для large page в начале лежит тот же PGHDR, затем расширение PGHDR2). Определение в коде Microsoft:
struct PGHDR { XECHECKSUM checksum; DBTIME dbtimeDirtied; PGNO pgnoPrev, pgnoNext; OBJID objidFDP; USHORT cbFree, cbUncommittedFree; USHORT ibMicFree; USHORT itagState; // itagMicFree + ctagReserved ULONG fFlags; // тип дерева и роль страницы };
Поле fFlags – битовая маска. Константы для парсера удобно разделить на две группы.
Роль страницы в B+ дереве (куда спускаться и как читать узлы):
Флаг |
Значение |
Назначение флага |
|---|---|---|
|
|
Root (может быть и branch, и leaf) |
|
|
Leaf — полезные данные в payload узла |
|
|
Branch непосредственно перед leaf-узлом |
|
|
пустая страница |
|
|
заготовка, данных ещё нет |
Если fPageLeaf не выставлен и страница не Empty / PreInit, ESE трактует её как internal/branch: в esentutl это называется «Internal page». Leaf и ParentOfLeaf не могут быть выставлены одновременно.
Тип дерева (что означают key/data на leaf):
Флаг |
Значение |
Дерево |
|---|---|---|
(нет битов ниже) |
— |
data tree |
|
|
space tree |
|
|
secondary index |
|
|
long value tree |
Разберемся теперь с тем, как информация хранится на странице. Единицей хранения данных на странице является LINE, представляющая собой блоб «ключ + полезные данные» и находящаяся в области данных. Данные растут вперед от конца заголовка, а массив тегов растет назад от конца страницы, почти как стек и куча на большинстве платформ. LINE хранятся на странице неупорядоченно, их порядок и границы задаются массивом тегов, каждый TAG указывает на соответствующую LINE.
Количество тегов задаётся полем itagState в PGHDR (рядом с ibMicFree). Это 16-битное значение, содержащее два счётчика:
itagMicFree– младшие 12 бит (ITAG_MIC_FREE_MASK = 0x0fff): индекс первого свободного слота в массиве тегов. Заняты слоты TAG 0 ... TAG (itagMicFree− 1), всегоitagMicFreeзаписей в хвосте страницы — включая зарезервированные.ctagReserved– следующие 3 бита (сдвиг 12): сколько из этих слотов (TAG 0 ... TAG ctagReserved) отведено под page external header (обычно 1).
Число пользовательских узлов B+ дерева на странице (строка Nodes: N в выхлопе esentutl):
Clines = itagMicFree − ctagReserved
На следующей схеме представлена одна страница из БД ESE:

Строки на странице не обязаны лежать впритык, вполне обыденная ситуация, когда между строками находится свободное пространство (полученное, например, при удалении записи).
Уделим немного внимания т.н. page external header (зачастую содержится в TAG 0). Он представляет собой дополнительный контекст страницы, зависящий от типа дерева и роли страницы. Мы увидим его практическое применение, когда будем обсуждать механизм prefix compression.
Как устроена запись в БД ESE
Разберемся с тем, как следует парсить запись из БД. Следует сразу оговориться, что тут мы будем разбираться только с записями в data tree, поскольку они представляют наибольший интерес для форензики.
В исходниках Microsoft присутствует структура KEYDATAFLAGS представляющая собой совокупность ключа, флагов и данных записи:
class KEYDATAFLAGS { public: KEY key; DATA data; FLAGS fFlags; ... };
Есть два важных момента, в которых KEYDATAFLAGS отличается от LINE:
Наличие флагов, которых может и не быть в
LINE.Полнота ключа, при включенной компрессии ключей
LINEсодержит только суффикс ключа.
То есть KEYDATAFLAGS представляет собой стандартизированное логическое представление элемента в B+-дереве, не зависящее от особенностей хранения данных на диске.
Источник fFlags зависит от формата страницы:
на small page (
<= 8 КиБ) флаги извлекаются из старших битовTAG;на large page (
16/32 КиБ) флаги читаются из началаLINE(то есть из начала полезных данных, на которые указываетTAG).
На уровне LINE и KEYDATAFLAGS есть следующие флаги:
const INT fNDVersion = 0x01; const INT fNDDeleted = 0x02; const INT fNDCompressed = 0x04;
fNDVersion– узел имеет версионное состояние (используется механизмом version store/MVCC, например при незакоммиченных изменениях).fNDDeleted– узел логически удалён (дальнейшая судьба записи зависит от состояния транзакций и очистки).fNDCompressed– включено сжатие ключа на странице: у узла хранится только суффикс ключа и длина общего префикса. Он не означает сжатие пользовательских данных.
Чуть подробнее рассмотрим флаг fNDCompressed, поскольку он критически важен для разбора БД.
В ESE полный ключ KEY собирается как prefix || suffix; сравнение в B+-дереве идёт по этой конкатенации. На диске в LINE зачастую лежит не весь ключ, а лишь suffix. Разберемся, как хранится ключ в разных случаях.
Когда fNDCompressed не выставлен, prefix пустой, а весь ключ записан в suffix внутри LINE. Если флаг выставлен, то в начале блоба узла идут два little-endian USHORT с длинами префикса и суффикса: cbPrefix и cbSuffix. Дальше – байты suffix (уникальная часть ключа) и затем данные. Сами байты общего префикса в LINE не дублируются: в cbPrefix хранится только длина; тело префикса берётся из page external header (обычно TAG 0), оттуда читается заданное количество байт prefix.
Для парсера это значит, что по одной LINE без page external header ключ не восстановить. Следует его извлечь и затем собрать key = external_header[0:cbPrefix] || suffix.
Удобнее всего проследить логику расположения флагов и сжатия ключей на иллюстрации, где приведены все возможные варианты:

Поскольку с ключом и флагами мы разобрались, перейдем к самим данным. Запись в data хранит только значения и структурные смещения; какую именно колонку мы читаем и как интерпретировать байты, задается снаружи, в ранее упомянутом каталоге MSysObjects. Для этого в ESE есть два уровня идентификации:
COLUMNID(JET_COLUMNID, 32 бита) – компактный идентификатор колонки в API. В младших 16 битах лежитFID(field id):FidOfColumnid(columnid)в исходниках – это просто приведение кFID. По числовому диапазонуFIDсразу видно класс хранения в записи: fixed1...127, variable128...255, tagged256...JET_ccolMost. В старшем битеCOLUMNIDможет стоять флаг template-колонки (0x80000000): колонка из шаблонной таблицы, наследуемой схемой.coltyp(JET_COLTYP) – тип колонки, определяющий как интерпретировать байты (Short,Double,Long,LongLong,Text,Binaryи т.д.).
Поле data листового узла data tree – это сериализованный объект REC (record). Первые байты любой строки – заголовок RECHDR.
struct RECHDR { BYTE fidFixedLastInRec; // До какого fixed-FID в строке есть слоты (0 — fixed-данных нет) BYTE fidVarLastInRec; // До какого variable-FID есть offset-слоты (`127` / `fidlimNone` — variable-секции нет) RECOFFSET ibEndOfFixedData; // Смещение от начала REC до конца fixed-области (включая null bitmap fixed) };
cbRecordMin / cbRecordHeader равны sizeof(RECHDR)– 4 байта; пустая минимальная запись состоит только из этого заголовка.
Cериализованная запись REC выглядит следующим образом:

Всего есть три класса хранения колонок:
fixed;
variable;
tagged.
Разберем отдельно каждый из вариантов.
Fixed-колонки
Fixed-колонки занимают заранее известный объем места в записи и идут компактно одна за другой в фиксированной части строки. Для NULL используется отдельная битовая карта: колонка не исчезает, но её значение помечается как отсутствующее (бит равный 1 в PbFixedNullBitMap).
Fixed-секция удобна тем, что смещения в ней можно восстановить без поиска по всей строке: достаточно знать порядок колонок и их SpaceUsage.
Variable-колонки
Variable-колонки не занимают фиксированный размер. После fixed-части (и её null bitmap) идёт таблица из CVarColumns() слов по 2 байта (REC::VAROFFSET), затем пул variable-данных, затем пул tagged-данных. Каждое слово таблицы — кумулятивный конец соответствующей колонки относительно начала пула, начало колонки — конец предыдущей (для первой колонки – 0).
Для NULL в variable используется специальный null-bit в старшем бите того же 2-байтового слова (0x8000; младшие 15 бит – смещение, максимальное смещение – 32 КиБ). При NULL конец равен началу, в пуле байтов место не занимается.
В заголовке REC поле fidVarLastInRec задаёт, сколько variable-значений реально есть в этой строке. Значение 127 означает: variable-секции нет, таблица смещений и пул variable-данных отсутствуют. Все variable-колонки схемы тогда для строки трактуются как не представленные (NULL или другое значение по умолчанию). Это и есть экономия: при не заданных variable-значениях не выделяем n × 2 байтов на хранение таблицы смещений. Если же в записи есть хотя бы одна variable-колонка, fidVarLastInRec поднимается до старшего FID среди них, и слоты от первого variable-значения до этого FID идут подряд без пропусков – промежуточные NULL всё равно стоят по 2 байта на слот.
Variable-поля нельзя корректно читать «по месту» без знания структуры заголовка строки. Сначала парсер должен:
прочитать
fidVarLastInRecи понять, есть ли variable-значения вообще;при
CVarColumns() > 0– вычислить начало offset table (IbEndOfFixedData) и пула данных (PbVarData);пройти таблицу в порядке возрастания
FID;для каждого слота проверить null-bit и вычислить длину как дельту концов.
Tagged-колонки
Tagged-колонки – наиболее гибкий и сложный тип. Они могут отсутствовать в записи вообще, не занимая места. Если они присутствуют, то кодируются через компактную структуру tagged-value descriptors и могут быть:
обычными одиночными значениями;
NULL;multi-value;
two-value;
long value;
separated long value;
compressed long value.
Tagged-секция начинается сразу после variable-пула (REC::PbTaggedData()). Она есть, только если PbTaggedData() < конец записи – иначе tagged-колонок в строке нет (они не занимают места).
Вся tagged-область называется TAGFIELDS: сначала массив дескрипторов TAGFLD, затем общий data pool с телами значений.
Каждый TAGFLD – 4 байта (little-endian): FID + смещение/флаги.
Смещение в дескрипторе (Ib) – количество байт от начала TAGFIELDS, где начинаются данные этой колонки. Длина этих данных — до смещения следующего TAGFLD или до конца tagged-секции. Число дескрипторов: N = Ib первого TAGFLD / 4 (первый элемент задаёт границу между массивом и data pool).
Маска смещения зависит от размера страницы (TAGFLD::InitStaticMembersWithPageSize):
small page (
<= 8 КиБ):maskIb = 0x1FFF;large page (
16/32 КиБ):maskIb = 0x7FFF.
Остальные биты того же 16-битного слова — флаги дескриптора (не путать с TAGFLD_HEADER ниже):
Бит |
Имя |
Значение |
|---|---|---|
|
|
колонка из template-таблицы |
|
|
|
|
|
в значении есть байт-заголовок |
Итого, первичный алгоритм разбора tagged-ceкции:
Вычислить
tagged_startиcbTagged = конец REC − tagged_start.Прочитать
TAGFLD[0..N−1]: для каждогоFIDузнаём смещение начала payload.Вырезать значение между соседними смещениями.
Если в дескрипторе
fNullSmallPage– колонка NULL.Иначе, если это large page или в дескрипторе есть
fExtendedInfo– первый байт полезной нагрузки представляет собойTAGFLD_HEADER, а дальше лежат данные; иначе вся полезная нагрузка является single value без заголовка.По битам
TAGFLD_HEADERвыбрать форму и декодировать байты поcoltypколонки.
TAGFLD_HEADER – опциональный один байт-заголовок в начале полезной нагрузки. Он задаёт формат тела значения (одиночное, two/multi, long value, NULL и т.д.), а не тип колонки – тип по-прежнему берётся из coltyp в каталоге.
Когда байт присутствует:
на large page (
16/32 КиБ) – всегда: первым байтом значения являетсяTAGFLD_HEADER, дальше идут данные;на small page (
<= 8 КиБ) – только если в дескриптореTAGFLDвыставленfExtendedInfo(0x4000).
Если байта нет (small page без fExtendedInfo), отдельного заголовка в записи нет: весь буфер — одиночное inline-значение. NULL в этом случае кодируется только в дескрипторе (fNullSmallPage, 0x2000), а не в TAGFLD_HEADER.
На large page явный NULL – бит fNull (0x20) в TAGFLD_HEADER; payload после байта заголовка может быть пустым.
Флаги TAGFLD_HEADER в исходниках Microsoft:
enum { fLongValue = 0x01 }; enum { fCompressed = 0x02 }; enum { fSeparated = 0x04 }; enum { fMultiValues = 0x08 }; enum { fTwoValues = 0x10 }; enum { fNull = 0x20 }; enum { fEncrypted = 0x40 };
В рамках статьи мы разберем сочетание флагов fLongValue + fSeparated как наиболее интересный случай при парсинге БД ESE.
Обычная запись ESE должна помещаться в страницу. Но некоторые значения могут быть существенно больше (например, данные в AD или Exchange). Решением этой проблемы является вынесение длинных значений (long value, далее – LV) из строки в отдельное long value дерево.
Работает это следующим образом:
значению присваивается
LID(long value ID);значение разбивается на куски (chunk), индексируемые ключом вида
LID+ offset;полученные куски записываются в long value дерево, связанное с данной таблицей;
вместе с ними записывается в дерево т.н. root-узел данного значения, индексируемый просто через
LID;в исходную строку записывается
LIDэтого значения.
Ключи LV-дерева на диске записаны в big-endian. Chunk-узел индексируется парой LID + offset: для legacy-формата LID32 это LVKEY32 (4 + 4 байта, всего 8), для LID64 — LVKEY64 (8 + 4, всего 12). Корень того же LV — ключ из одного LID (4 байта для LID32 или 8 для LID64).
struct LVKEY64 { UnalignedBigEndian<_LID64> lid; UnalignedBigEndian<ULONG> offset; }; struct LVKEY32 { UnalignedBigEndian<_LID32> lid; UnalignedBigEndian<ULONG> offset; }; // старший бит первого байта LID в BE-ключе (поле fLid64 в LVKEY_BUFFER): const _LID64 lid64Flag = 1ULL << 63; // LID64; у LID32 этот бит 0
Отличить LID32 от LID64 можно по одному биту: у 64-битного идентификатора выставлен 63-й бит (lid64Flag); в ключе на диске это старший бит первого байта LID.
Структура root-узла LV:
struct LVROOT { UnalignedLittleEndian<ULONG> ulReference; UnalignedLittleEndian<ULONG> ulSize; }; typedef BYTE LVROOTFLAGS; const LVROOTFLAGS fLVEncrypted = 0x01; struct LVROOT2 { UnalignedLittleEndian<ULONG> ulReference; UnalignedLittleEndian<ULONG> ulSize; LVROOTFLAGS fFlags; };
Наиболее полезным в root-узле для нас является поле ulSize – по нему мы сможем понять, собрали мы все куски LV или следует продолжить поиск chunk’ов.
Карабкаемся по дереву с помощью esentutl
Попробуем с помощью утилиты esentutl получить полезные данные из файла БД. Будем использовать файл кеша браузера Internet Explorer и попробуем получить информацию из таблицы Container_2. Здесь мы не будем отдельно обходить каталог MSysObjects и реконструировать схему таблицы, предположим, что мы уже знаем, какие в ней есть колонки (например, из ESEDatabaseView).
Для начала следует с помощью флага /mm узнать PgnoFDP соответствующей таблицы:
esentutl /mm .\examples\WebCacheV01.01.dat Extensible Storage Engine Utilities for Microsoft(R) Windows(R) Version VER_PRODUCTMAJORVERSION.VER_PRODUCTMINORVERSION Copyright (C) Microsoft Corporation. All Rights Reserved. Initiating FILE DUMP mode... Database: .\examples\WebCacheV01.01.dat ******************************* META-DATA DUMP ******************************* Name Type ObjidFDP PgnoFDP PgnoFDPLastSetTime ============================================================================================================= .. Container_2 Tbl 45 677 04/21/2026 10:51:02.799 UTC <Long Values> LV 46 678 04/21/2026 10:51:02.801 UTC HashEntryIdIndex Pri 45 677 ... ****************************************************************************** Operation completed successfully in 0.125 seconds.
В этом выводе мы видим сразу несколько интересных вещей:
PgnoFDPискомой таблицы –677.Primary index таблицы
Container_2имеет имяHashEntryIdIndex. Оно задаётся при создании схемы, по нему можно предположить, что индекс композитный и построен по колонкамUrlHashиEntryId. В общем случае, чтобы не гадать, можно обратиться к полюKeyFidIDsдля соответствующего индекса в каталогеMSysObjects, в нем указаныFID’ы колонок, по которым построен индекс.Таблица
Container_2имеет связанное с нейLVдерево, которое начинается на странице678.
Попробуем теперь с помощью комбинации флагов /mm и /p[pgno] получить информацию с FDP таблицы:
esentutl /mm .\examples\WebCacheV01.01.dat /p677 Extensible Storage Engine Utilities for Microsoft(R) Windows(R) Version VER_PRODUCTMAJORVERSION.VER_PRODUCTMINORVERSION Copyright (C) Microsoft Corporation. All Rights Reserved. Initiating FILE DUMP mode... Database: .\examples\WebCacheV01.01.dat Page: 677 HEADER checksum = 0xE6D2192D9D9E25C7:0x00000000000002A5:0x00000000000002A5:0x0018FFE700C602B6 logged data checksum = 4e00556b36de3ffc checksum <0x0000020D746F0000, 8>: -1814360016168278585 (0xE6D2192D9D9E25C7) dbtimeDirtied <0x0000020D746F0008, 8>: 20951 (0x51D7) pgnoPrev <0x0000020D746F0010, 4>: 0 (0x0) pgnoNext <0x0000020D746F0014, 4>: 0 (0x0) objidFDP <0x0000020D746F0018, 4>: 45 (0x2D) cbFree <0x0000020D746F001C, 2>: 32503 (0x7EF7) cbUncommittedFree <0x0000020D746F001E, 2>: 0 (0x0) ibMicFree <0x0000020D746F0020, 2>: 141 (0x8D) itagState <0x0000020D746F0022, 2>: 4107 (0x100B) ctagReserved: 1 itagMicFree: 11 fFlags <0x0000020D746F0024, 4>: 75781 (0x12805) rgChecksum[0] <0x0000020D746F0028, 8>: 677 (0x2A5) rgChecksum[1] <0x0000020D746F0030, 8>: 677 (0x2A5) rgChecksum[2] <0x0000020D746F0038, 8>: 7036767056560822 (0x18FFE700C602B6) pgno <0x0000020D746F0040, 4>: 677 (0x2A5) Parent of leaf Internal page Root page FDP page New external header format Space header flag presents AutoInc flag presents Multiple Extent Space (ParentFDP: 1, pgnoOE: 687) Auto increment maximum: 1377 Primary page New record format New checksum format PageFlushType = 2 TAG 0: cb:0x0019,ib:0x0000 offset:0x0050-0x0069 flags:0x0000 ( ) TAG 1: cb:0x000d,ib:0x001f prefix:cb=0x0000 suffix:cb=0x0007 data:cb=0x0004 offset:0x006f-0x007c flags:0x0000 ( ) pgno: 679 (0x2a7) TAG 2: cb:0x000c,ib:0x002c prefix:cb=0x0000 suffix:cb=0x0006 data:cb=0x0004 offset:0x007c-0x0088 flags:0x0000 ( ) pgno: 680 (0x2a8) TAG 3: cb:0x000c,ib:0x0038 prefix:cb=0x0000 suffix:cb=0x0006 data:cb=0x0004 offset:0x0088-0x0094 flags:0x0000 ( ) pgno: 681 (0x2a9) TAG 4: cb:0x000c,ib:0x0044 prefix:cb=0x0000 suffix:cb=0x0006 data:cb=0x0004 offset:0x0094-0x00a0 flags:0x0000 ( ) pgno: 682 (0x2aa) TAG 5: cb:0x000d,ib:0x0050 prefix:cb=0x0000 suffix:cb=0x0007 data:cb=0x0004 offset:0x00a0-0x00ad flags:0x0000 ( ) pgno: 683 (0x2ab) TAG 6: cb:0x000c,ib:0x005d prefix:cb=0x0000 suffix:cb=0x0006 data:cb=0x0004 offset:0x00ad-0x00b9 flags:0x0000 ( ) pgno: 684 (0x2ac) TAG 7: cb:0x000c,ib:0x0069 prefix:cb=0x0000 suffix:cb=0x0006 data:cb=0x0004 offset:0x00b9-0x00c5 flags:0x0000 ( ) pgno: 685 (0x2ad) TAG 8: cb:0x000c,ib:0x0075 prefix:cb=0x0000 suffix:cb=0x0006 data:cb=0x0004 offset:0x00c5-0x00d1 flags:0x0000 ( ) pgno: 686 (0x2ae) TAG 9: cb:0x000c,ib:0x0081 prefix:cb=0x0000 suffix:cb=0x0006 data:cb=0x0004 offset:0x00d1-0x00dd flags:0x0000 ( ) pgno: 689 (0x2b1) TAG 10: cb:0x0006,ib:0x0019 prefix:cb=0x0000 suffix:cb=0x0000 data:cb=0x0004 offset:0x0069-0x006f flags:0x0000 ( ) pgno: 690 (0x2b2) Nodes: 10 min, ave, max, total Logical Key Sizes: 0, 5.6, 7, 56 Node Data Sizes: 4, 4.0, 4, 40 Operation completed successfully in 0.16 seconds.
Видим, что страница не является листовой, но страница следующего уровня — уже листовая (флаг Parent of leaf).
Спустимся дальше, на страницу 679:
esentutl /mm .\examples\WebCacheV01.01.dat /p679 Extensible Storage Engine Utilities for Microsoft(R) Windows(R) Version VER_PRODUCTMAJORVERSION.VER_PRODUCTMINORVERSION Copyright (C) Microsoft Corporation. All Rights Reserved. Initiating FILE DUMP mode... Database: .\examples\WebCacheV01.01.dat Page: 679 HEADER checksum = 0x691396EC184482FD:0x5531553111D392A3:0x2FCF2FCF47D2EE05:0x007F007F44A100C1 logged data checksum = f0358e44fabd055d checksum <0x000002237E670000, 8>: 7571561339303527165 (0x691396EC184482FD) dbtimeDirtied <0x000002237E670008, 8>: 20688 (0x50D0) pgnoPrev <0x000002237E670010, 4>: 0 (0x0) pgnoNext <0x000002237E670014, 4>: 680 (0x2A8) objidFDP <0x000002237E670018, 4>: 45 (0x2D) cbFree <0x000002237E67001C, 2>: 12574 (0x311E) cbUncommittedFree <0x000002237E67001E, 2>: 0 (0x0) ibMicFree <0x000002237E670020, 2>: 19998 (0x4E1E) itagState <0x000002237E670022, 2>: 4125 (0x101D) ctagReserved: 1 itagMicFree: 29 fFlags <0x000002237E670024, 4>: 43010 (0xA802) rgChecksum[0] <0x000002237E670028, 8>: 6138781436323533475 (0x5531553111D392A3) rgChecksum[1] <0x000002237E670030, 8>: 3445024807271460357 (0x2FCF2FCF47D2EE05) rgChecksum[2] <0x000002237E670038, 8>: 35747868654502081 (0x7F007F44A100C1) pgno <0x000002237E670040, 4>: 679 (0x2A7) Leaf page Primary page New record format New checksum format PageFlushType = 1 TAG 0: cb:0x0012,ib:0x0000 offset:0x0050-0x0062 flags:0x0006 ( dc) TAG 1: cb:0x02b0,ib:0x0012 prefix:cb=0x0000 suffix:cb=0x0012 data:cb=0x029c offset:0x0062-0x0312 flags:0x0000 ( ) TAG 2: cb:0x0658,ib:0x02c2 prefix:cb=0x0000 suffix:cb=0x0012 data:cb=0x0644 offset:0x0312-0x096a flags:0x0000 ( ) TAG 3: cb:0x016d,ib:0x091a prefix:cb=0x0000 suffix:cb=0x0012 data:cb=0x0159 offset:0x096a-0x0ad7 flags:0x0000 ( ) TAG 4: cb:0x047a,ib:0x0a87 prefix:cb=0x0000 suffix:cb=0x0012 data:cb=0x0466 offset:0x0ad7-0x0f51 flags:0x0000 ( ) TAG 5: cb:0x02a6,ib:0x0f01 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0295 offset:0x0f51-0x11f7 flags:0x0004 ( c) TAG 6: cb:0x02fe,ib:0x11a7 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x02ed offset:0x11f7-0x14f5 flags:0x0004 ( c) TAG 7: cb:0x01e6,ib:0x14a5 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x01d5 offset:0x14f5-0x16db flags:0x0004 ( c) TAG 8: cb:0x02a8,ib:0x168b prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0297 offset:0x16db-0x1983 flags:0x0004 ( c) TAG 9: cb:0x0296,ib:0x1933 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0285 offset:0x1983-0x1c19 flags:0x0004 ( c) TAG 10: cb:0x0254,ib:0x1bc9 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0243 offset:0x1c19-0x1e6d flags:0x0004 ( c) TAG 11: cb:0x0276,ib:0x1e1d prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0265 offset:0x1e6d-0x20e3 flags:0x0004 ( c) TAG 12: cb:0x0260,ib:0x2093 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x024f offset:0x20e3-0x2343 flags:0x0004 ( c) TAG 13: cb:0x02fe,ib:0x22f3 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x02ed offset:0x2343-0x2641 flags:0x0004 ( c) TAG 14: cb:0x0276,ib:0x25f1 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0265 offset:0x2641-0x28b7 flags:0x0004 ( c) TAG 15: cb:0x0277,ib:0x2867 prefix:cb=0x0006 suffix:cb=0x000c data:cb=0x0267 offset:0x28b7-0x2b2e flags:0x0004 ( c) TAG 16: cb:0x0289,ib:0x2ade prefix:cb=0x0006 suffix:cb=0x000c data:cb=0x0279 offset:0x2b2e-0x2db7 flags:0x0004 ( c) TAG 17: cb:0x02d5,ib:0x2d67 prefix:cb=0x0012 suffix:cb=0x0000 data:cb=0x02d1 offset:0x2db7-0x308c flags:0x0004 ( c) TAG 18: cb:0x0284,ib:0x303c prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0273 offset:0x308c-0x3310 flags:0x0004 ( c) TAG 19: cb:0x029a,ib:0x32c0 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0289 offset:0x3310-0x35aa flags:0x0004 ( c) TAG 20: cb:0x02fe,ib:0x355a prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x02ed offset:0x35aa-0x38a8 flags:0x0004 ( c) TAG 21: cb:0x02dc,ib:0x3858 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x02cb offset:0x38a8-0x3b84 flags:0x0004 ( c) TAG 22: cb:0x02ac,ib:0x3b34 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x029b offset:0x3b84-0x3e30 flags:0x0004 ( c) TAG 23: cb:0x0260,ib:0x3de0 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x024f offset:0x3e30-0x4090 flags:0x0004 ( c) TAG 24: cb:0x0244,ib:0x4040 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x0233 offset:0x4090-0x42d4 flags:0x0004 ( c) TAG 25: cb:0x02fe,ib:0x4284 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x02ed offset:0x42d4-0x45d2 flags:0x0004 ( c) TAG 26: cb:0x02fe,ib:0x4582 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x02ed offset:0x45d2-0x48d0 flags:0x0004 ( c) TAG 27: cb:0x02a0,ib:0x4880 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x028f offset:0x48d0-0x4b70 flags:0x0004 ( c) TAG 28: cb:0x02fe,ib:0x4b20 prefix:cb=0x0005 suffix:cb=0x000d data:cb=0x02ed offset:0x4b70-0x4e6e flags:0x0004 ( c) Nodes: 28 min, ave, max, total Logical Key Sizes: 18, 18.0, 18, 504 Key Compression: 5, 5.6, 18, 135 (nodes=24) Node Data Sizes: 345, 696.7, 1604, 19507 Operation completed successfully in 0.15 seconds.
Тут можно заметить два интересных момента:
Ненулевое поле
pgnoNext, с помощью которого мы можем начать обход всех строк.Включенное сжатие ключа на некоторых записях (флаг
c).
Давайте ради интереса посмотрим на записи со сжатым ключом, для этого вызовем утилиту с флагами /mm и опцией /n[pgno]:[tagno]. Тут есть одно важное замечание: поскольку TAG 0 является служебным, утилита не даст нам его получить. Флаг /n679:0 покажет нам содержимое TAG 1, а вызов /n679:28 завершится с ошибкой.
Для просмотра TAG 5:
esentutl /mm .\examples\WebCacheV01.01.dat /n679:4 Extensible Storage Engine Utilities for Microsoft(R) Windows(R) Version VER_PRODUCTMAJORVERSION.VER_PRODUCTMINORVERSION Copyright (C) Microsoft Corporation. All Rights Reserved. Initiating FILE DUMP mode... Database: .\examples\WebCacheV01.01.dat Node: 679:4 Flags: 0x0004 =========== Compressed Key Prefix: 5 bytes =========== 00000000 7fccd617 7a \ ....z Key Suffix: 13 bytes =========== 00000000 004777a7 7f800000 00000005 5d \ .Gw.........] Data: 661 bytes =========== 00000000 ... Fixed Columns: 17 ================= 1 (0x1): [LongLong ] EntryId 00000000 5d050000 00000000 \ ]....... 2 (0x2): [LongLong ] ContainerId 00000000 02000000 00000000 \ ........ 3 (0x3): [LongLong ] CacheId 00000000 00000000 00000000 \ ........ 4 (0x4): [LongLong ] UrlHash 00000000 a7774700 7a17d64c \ .wG.z..L 5 (0x5): [UnsignedLong ] SecureDirectory 00000000 00000000 \ .... 6 (0x6): [LongLong ] FileSize 00000000 00000000 00000000 \ ........ 7 (0x7): [UnsignedLong ] Type 00000000 01002000 \ .. . 8 (0x8): [UnsignedLong ] Flags 00000000 00000000 \ .... 9 (0x9): [UnsignedLong ] AccessCount 00000000 01000000 \ .... 10 (0xa): [LongLong ] SyncTime 00000000 5e59083f 06b8db01 \ ^Y.?.... 11 (0xb): [LongLong ] CreationTime 00000000 00000000 00000000 \ ........ 12 (0xc): [LongLong ] ExpiryTime 00000000 88c6c58d 74ccdb01 \ ....t... 13 (0xd): [LongLong ] ModifiedTime 00000000 1a32083f 06b8db01 \ .2.?.... 14 (0xe): [LongLong ] AccessedTime 00000000 5e59083f 06b8db01 \ ^Y.?.... 15 (0xf): [LongLong ] PostCheckTime 00000000 00000000 00000000 \ ........ 16 (0x10): [UnsignedLong ] SyncCount 00000000 01000000 \ .... 17 (0x11): [UnsignedLong ] ExemptionDelta 00000000 00000000 \ .... Variable Columns: 0 ================= Tagged Columns: ================= TAGFIELDS array begins at offset 0x77 from start of record. 256 (0x100): [LongText ] Url >> itag 1: 320 bytes (offset 0x80): 00000000 ... 260 (0x104): [LongBinary ] ResponseHeaders >> itag 1: 212 bytes (offset 0x1c1): 00000000 ... Operation completed successfully in 0.15 seconds.
В самом начале мы видим:
Flags: 0x0004 =========== Compressed Key Prefix: 5 bytes =========== 00000000 7fccd617 7a \ ....z Key Suffix: 13 bytes =========== 00000000 004777a7 7f800000 00000005 5d \ .Gw.........]
То есть 5 байт ключа были взяты из page external header (TAG 0).
Также обратим внимание, что тут поля Url (тип LongText) и ResponseHeaders (тип LongBinary) находятся напрямую в записи.
Попробуем теперь посмотреть содержимое записи, содержащей separated LV (т.е. где значение вынесено в отдельное дерево):
esentutl /mm .\examples\WebCacheV01.01.dat /n679:2 Extensible Storage Engine Utilities for Microsoft(R) Windows(R) Version VER_PRODUCTMAJORVERSION.VER_PRODUCTMINORVERSION Copyright (C) Microsoft Corporation. All Rights Reserved. Initiating FILE DUMP mode... Database: .\examples\WebCacheV01.01.dat Node: 679:2 Flags: 0x0000 =========== Key Prefix: 0 bytes =========== Key Suffix: 18 bytes =========== 00000000 7fc1268c 34348a34 0c7f8000 00000000 04df \ ..&.44.4.......... Data: 345 bytes =========== 00000000 ... Fixed Columns: 17 ================= 1 (0x1): [LongLong ] EntryId 00000000 df040000 00000000 \ ........ 2 (0x2): [LongLong ] ContainerId 00000000 02000000 00000000 \ ........ 3 (0x3): [LongLong ] CacheId 00000000 00000000 00000000 \ ........ 4 (0x4): [LongLong ] UrlHash 00000000 0c348a34 348c2641 \ .4.44.&A 5 (0x5): [UnsignedLong ] SecureDirectory 00000000 00000000 \ .... 6 (0x6): [LongLong ] FileSize 00000000 00000000 00000000 \ ........ 7 (0x7): [UnsignedLong ] Type 00000000 01002000 \ .. . 8 (0x8): [UnsignedLong ] Flags 00000000 00000000 \ .... 9 (0x9): [UnsignedLong ] AccessCount 00000000 01000000 \ .... 10 (0xa): [LongLong ] SyncTime 00000000 b6ddb3d6 53addb01 \ ....S... 11 (0xb): [LongLong ] CreationTime 00000000 00000000 00000000 \ ........ 12 (0xc): [LongLong ] ExpiryTime 00000000 24727125 c1c1db01 \ $rq%.... 13 (0xd): [LongLong ] ModifiedTime 00000000 b6ddb3d6 53addb01 \ ....S... 14 (0xe): [LongLong ] AccessedTime 00000000 b6ddb3d6 53addb01 \ ....S... 15 (0xf): [LongLong ] PostCheckTime 00000000 00000000 00000000 \ ........ 16 (0x10): [UnsignedLong ] SyncCount 00000000 01000000 \ .... 17 (0x11): [UnsignedLong ] ExemptionDelta 00000000 00000000 \ .... Variable Columns: 0 ================= Tagged Columns: ================= TAGFIELDS array begins at offset 0x77 from start of record. 256 (0x100): [LongText ] Url >> itag 1: 4 bytes (offset 0x80): separated 00000000 03000000 \ .... 260 (0x104): [LongBinary ] ResponseHeaders >> itag 1: 212 bytes (offset 0x85): 00000000 ... Operation completed successfully in 0.31 seconds.
А тут поле Url содержит не само значение, а LID, для разрешения которого мы должны сходить в соответствующее LV дерево на странице 678 (что мы узнали, когда искали PgnoFDP таблицы в самом начале этого раздела):
esentutl /mm .\examples\WebCacheV01.01.dat /n678:4 Extensible Storage Engine Utilities for Microsoft(R) Windows(R) Version VER_PRODUCTMAJORVERSION.VER_PRODUCTMINORVERSION Copyright (C) Microsoft Corporation. All Rights Reserved. Initiating FILE DUMP mode... Database: .\examples\WebCacheV01.01.dat Node: 678:4 Flags: 0x0000 =========== Key Prefix: 0 bytes =========== Key Suffix: 4 bytes =========== 00000000 00000003 \ .... Data: 8 bytes =========== 00000000 01000000 b40c0000 \ ........ Operation completed successfully in 0.16 seconds.
Перебором на этой странице нашли LV root для LID = 00 00 00 03. Видно, что ulReference = 1, ulSize = 0x0CB4 = 3252.
Само значение:
esentutl /mm .\examples\WebCacheV01.01.dat /n678:5 Extensible Storage Engine Utilities for Microsoft(R) Windows(R) Version VER_PRODUCTMAJORVERSION.VER_PRODUCTMINORVERSION Copyright (C) Microsoft Corporation. All Rights Reserved. Initiating FILE DUMP mode... Database: .\examples\WebCacheV01.01.dat Node: 678:5 Flags: 0x0000 =========== Key Prefix: 0 bytes =========== Key Suffix: 8 bytes =========== 00000000 00000003 00000000 \ ........ Data: 3252 bytes =========== 00000000 ... Operation completed successfully in 0.15 seconds.
Таким образом, несмотря на то что утилита esentutl не предоставляет удобного логического представления БД в виде таблиц и столбцов, она остаётся крайне полезным инструментом для отладки и низкоуровневого анализа данных, что особенно важно при разработке и отладке собственного парсера формата ESE.
Значимые артефакты в формате ESE
Выше мы разобрали общий формат БД ESE: деревья, страницы, записи. На практике же парсер всегда применяется к конкретному файлу — кэшу браузера, базе домена, очереди загрузок. У каждого подобного артефакта своя схема таблиц и колонок, хотя формат один и тот же.
Общие замечания по работе с файлами БД ESE
Очень часто при проведении расследований артефакты забираются с «живой» системы, что иногда может привести к повреждению данных в БД. Поэтому перед анализом имеет смысл проверить состояние базы (esentutl /mh): Clean Shutdown / Dirty Shutdown. Dirty Shutdown не всегда означает повреждение, но повышает риск наличия несогласованных данных и усложняет интерпретацию.
Рядом с основным файлом часто лежат журналы транзакций — пары .log / .jfm (или *.chk). Они могут содержать ещё не сброшенные на диск изменения. Для полного снимка состояния БД иногда копируют весь каталог целиком, а не только .dat / .edb / .dit.
Если работа с файлом будет осуществляться стандартными средствами (через API или утилиту esentutl), то важно убедиться, что БД находится в состоянии Clean Shutdown. Этого можно добиться с помощью утилиты esentutl и флагов /r и /p (recovery/repair). Однако следует учитывать, что часть информации из БД может быть утрачена в процессе восстановления.
Список артефактов
Артефакт |
Типичный путь |
Контекст |
|---|---|---|
NTDS.dit |
%SystemRoot%\NTDS\ |
Active Directory на контроллере домена |
WebCacheV01.dat |
%LocalAppData%\Microsoft\Windows\WebCache\ |
Кэш Internet Explorer и старых версий Edge |
qmgr.db |
%ProgramData%\Microsoft\Network\Downloader\ |
BITS (очередь фоновых загрузок) |
Windows.edb |
%ProgramData%\Microsoft\Search\Data\Applications\Windows\ |
Индекс Windows Search |
DataStore.edb |
%SystemRoot%\SoftwareDistribution\DataStore\ |
Центр обновления Windows |
SRUDB.dat |
%SystemRoot%\System32\sru\ |
SRUM на рабочих станциях (Windows 8+) |
Current.mdb, {GUID}.mdb |
%SystemRoot%\System32\LogFiles\Sum\ |
UAL на Windows Server 2012+ |
Формат ESE также встречается в Exchange, Outlook, Windows Mail и других компонентах Microsoft.
Ниже расскажу кратко о каждом из перечисленных в таблице файлов. Детальный разбор каждого артефакта выходит за рамки статьи, поэтому я приведу ссылки на материалы для дальнейшего изучения.
Active Directory (NTDS.dit). Главная база каталога на контроллере домена: учётные записи, группы, организационные подразделения, групповые политики, атрибуты объектов AD. В расследованиях используют для реконструкции структуры домена, членства в группах и извлечения хэшей паролей.
WebCache (WebCacheV01.dat). Начиная с IE10, история и кэш браузера перешли с index.dat на ESE. В базе — посещённые URL, cookies, заголовки HTTP-ответов, метаданные кэшированных файлов и ссылки на содержимое в каталоге INetCache. Записи разнесены по таблицам Container_*; их назначение (History, Cookies, Content и т.д.) указано в справочной таблице Containers.
✅ Подробнее: How to analyze Internet Explorer Logs.
BITS (qmgr.db). Очередь Background Intelligent Transfer Service: фоновые загрузки и обновления по HTTP/HTTPS/SMB. В записях содержится URL источника, локальный путь назначения, размер, состояние задания, временные метки.
✅ Подробнее: A BITS of a Problem: Investigating BITS Jobs.
Windows Search (Windows.edb). Индекс службы поиска Windows: пути к файлам, фрагменты проиндексированного текста, метаданные документов. Может сохранять следы содержимого файлов, в том числе уже удалённых с диска, если они успели попасть в индекс.
DataStore (DataStore.edb). База службы Windows Update: история установки и удаления обновлений, идентификаторы пакетов, коды результата операций, временные метки. Помогает восстановить хронологию патчей и обновлений ПО на хосте.
✅ Подробнее: Windows Update database format (libesedb-kb).
SRUM (SRUDB.dat). System Resource Usage Monitor — телеметрия использования ресурсов на клиентской Windows: сетевой трафик и энергопотребление по приложениям, длительность активности, привязка к пользователю. Данные накапливаются за несколько недель.
✅ Подробнее: SRUM Database (SRUDB.dat).
SUM / UAL (
Current.mdb,{GUID}.mdb). User Access Logging на Windows Server с 2012 года: кто и с какого IP обращался к ролям сервера — файловый доступ, DNS, AD DS, IIS и др.Current.mdbхранит последние сутки; годовые снимки{GUID}.mdbсохраняют историю до примерно трёх лет.✅ Подробнее: SUM UAL: Investigating Server Access with User Access Logging.
Общий алгоритм парсинга БД ESE
На основе рассмотренных в статье фактов можно сформулировать общий алгоритм парсинга базы данных ESE:
Прочитать заголовок файла базы данных и определить размер страницы.
-
Разобрать каталог
MSysObjects, получив:список таблиц с описанием их колонок и соответствующих
FDP;список
LV-деревьев, связанных с таблицами.
На основе полученной схемы базы данных подготовить необходимые структуры для последующего сохранения извлекаемых данных.
Для каждой таблицы определить её
FDPи выполнить обход соответствующего B+-дерева.-
Для каждого листа B+-дерева:
отделить ключ от данных записи;
прочитать заголовок REC и декодировать секции fixed, variable и tagged-колонок;
при необходимости разрешить ссылки на
LV-структуры и собрать long value из отдельных chunk’ов.
Заключение
В рамках данной статьи была предпринята попытка представить достаточно полный, но при этом не перегруженный избыточными деталями обзор внутреннего устройства БД ESE.
Формат ESE представляет значительный интерес с технической точки зрения, поскольку работа с ним требует понимания большого количества внутренних структур и взаимосвязей между ними.
Для практического использования первостепенное значение имеет построение структурного парсера, который при необходимости может быть дополнен точечным карвингом — например, при повреждённом каталоге и наличии известных FID/LID. Такой подход значительно надёжнее карвинга, основанного только на эвристических сигнатурах артефактов.
Источники
Документация Microsoft
Microsoft, Extensible Storage Engine
https://learn.microsoft.com/en-us/windows/win32/extensible-storage-engine/extensible-storage-engineMicrosoft, esentutl
https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/hh875546(v=ws.11)Microsoft, eseutl
https://learn.microsoft.com/en-us/previous-versions/tn-archive/aa996953(v=exchg.65)Microsoft, ntdsutil
https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/cc753343(v=ws.11)
Исходный код
Microsoft, Extensible-Storage-Engine
https://github.com/microsoft/Extensible-Storage-EngineVelocidex, go-ese
https://github.com/Velocidex/go-eseFox-IT, dissect.esedb
https://github.com/fox-it/dissect.esedb
Инструменты
NirSoft, ESEDatabaseView
https://www.nirsoft.net/utils/ese_database_view.html
Статьи и публикации
Microsoft, “ESE Deep Dive: Part 1: The Anatomy of an ESE database”
https://techcommunity.microsoft.com/blog/askds/ese-deep-dive-part-1-the-anatomy-of-an-ese-database/400496Joachim Metz, “Extensible Storage Engine (ESE) Database File (EDB) format” (libesedb)
https://github.com/libyal/libesedb/blob/main/documentation/Extensible%20Storage%20Engine%20(ESE)%20Database%20File%20(EDB)%20format.asciidocRSDN Magazine #1-2007, “Extensible Storage Engine. Краткий обзор”
https://rsdn.org/article/db/JetBlue.xmlD. Comer, “Organization and maintenance of large ordered indices”
https://infolab.usc.edu/csci585/Spring2010/den_ar/indexing.pdfMoai’s Computer Story, “Basic structure of ESE Database”
https://moaistory.blogspot.com/2016/08/digital-forensic-investigation-of-ese_5.html
Maxpiter
Мощнейшая сложная статья! Спасибо дружище! Огромный труд.