Привет, коллега! Сегодня мы поговорим о таком важном компоненте Windows как диспетчер кэша. Узнав теорию и попробовав практику, ты можешь свернуть горы, поверь мне. Нужно лишь знать азы отладки, а остальное освоим вместе.
Что такое кэш и с чем его едят?

Кэш служит хранилищем данных для драйверов файловых систем, которые работают в ОСи. Когда FS что-то пишет на диск или читает с него, данные вначале попадают в кэш, а потом уже реально записываются на диск. Кроме того, замечено, что драйвер ntfs удерживает в кэше MFT, и результаты ее модификации на диске будут видны только после перезагрузки. Это не совсем удобно, если ты хочешь модифицировать данные FS прямо сразу. Короче, будем с головой погружаться во внутренности оси и самого кэша, попутно я буду растолковывать кое-какие понятия, которые хакер в области ядра должен знать, как таблицу умножения. А для тех, кто совсем не в теме, скажу, что доступ к кэшу возможен только из режима ядра, поэтому, если ты незнаком с «ядреной» отладкой, windbg и kernel мод, нужно непременно запастись этими знаниями.

Итак, кэш - это регион в системном адресном пространстве, на который диспетчер кэша проецирует данные файлов, для последующего быстрого доступа к ним. Если X - указатель в кэш, то «MmSystemCacheStart<=X<=MmSystemCacheEnd». Данные в этом регионе разбиты на слоты (кэша) - блоки по 256 Kb. Все весьма подробно расписано у Руссиновича, поэтому остановимся на этом лишь вкратце и уделим больше внимания практической стороне вопроса, – ручному исследованию кэша.

Кэш имеет две важные особенности, которые, суть, следствие того, что внутренняя реализация кэша принадлежит VMM (Virtual Memory Manager). Для проецирования данных файлов на слоты используются разделы, то есть сервисы VMM. Так что, целиком и полностью за подкачку данных отвечает VMM, а кэш входит в системный рабочий набор. Значит, его страницы также могут выгружаться. Эти особенности подчеркивают, что диспетчер кэша точно не знает, какие данные файлов реально находятся в физической памяти (поэтому нужно быть особенно аккуратным при чтении памяти кэша в своем драйвере).

Слоты кэша описываются блоками управления (VACB, Virtual Address Control Block), которые выделяются из резидентного пула. Блоки управления адресуются от CcVacbs. Каждый блок управляет определенным слотом и определяет его состояние. Число блоков указывается в CcNumberVacbs.
Зубрим структуры и готовимся к отладке

VACB описывается структурой:

typedef struct _VACB
{
PVOID BaseAddress; //ptr на слот
PSHARED_CACHE_MAP SharedCacheMap; //ptr на общую карту, см. далее
union
{
LARGE_INTEGER FileOffset; //смещение в файле
USHORT ActiveCount; //счетчик ссылок на представление
} Overlay;
LIST_ENTRY LruList; //VACB объединяются в список через это поле
} VACB;

Существует два списка VACB:
CcVacbFreeList. Список свободных VACB, то есть готовых к использованию. Их BaseAddress обнулен, и они не нуждаются в де-проецировании.
CcVacbLru. Список всех остальных VACB. VACB считается свободным, если его ActiveCount равен 0. При повторном использовании выполняется де-проецирование адреса слота.

Следующая команда windbg подтверждает названные факты. Незнакомым с синтаксисом команд windbg не нужно падать в обморок, – достаточно вбить команду в windbg. Перед этим неплохо подгрузить символы (с помощью команды .reload /s).

Вывод всех элементов VACB в списке CcVacbLru:

r eax=0; !list "-t ntdll!_LIST_ENTRY.Flink -x \"r eax=@eax+1;? @eax;? @$extret-10; dt nt!_VACB @$extret-10\" nt!CcVacbLru"

Evaluate expression: 330 = 0000014a -> порядковый номер
Evaluate expression: -2120961816 = 8194b0e8 ->адрес этого VACB
+0x000 BaseAddress : 0xc6000000 ->адрес слота в кэше
+0x004 SharedCacheMap : 0x817f61a0 _SHARED_CACHE_MAP -> ptr на открытую карту
+0x008 Overlay : __unnamed
+0x010 LruList : _LIST_ENTRY [ 0x819491d8 - 0x81949178 ]

Evaluate expression: 331 = 0000014b
Evaluate expression: -2120969784 = 819491c8
+0x000 BaseAddress : 0xc2040000
+0x004 SharedCacheMap : 0x818c7b08 _SHARED_CACHE_MAP
+0x008 Overlay : __unnamed
+0x010 LruList : _LIST_ENTRY [ 0x8194ad38 - 0x8194b0f8 ]

У большинства таких VACB инициализированы открытые карты, и они спроецированы на кэш. То же самое можно проделать для CcVacbFreeList. Если сложить последние номера VACB эти двух списков (то есть, получить количество элементов в обоих списках), то получится в моем примере:

14b+6b3 = 7fe
dd CcNumberVacbs l1
8055f670 000007fe

Виртуальный адрес соответствующего слота будет ссылаться на PTE, указывающий на proto-PTE. Он, в свою очередь, связан с подразделом, описывающим файл (обычно он является одним разделом, связанным с открытой картой, и проецирует файл как бинарный – смотри MmMapViewInSystemCache).

Кэшируемый файл описывается двумя структурами – открытой и закрытой картой кэша (shared cache map, private cache map). Закрытая карта не так интересна, она применяется для опережающего чтения (intelligent ahead-read). А вот открытая очень важна! Открытая карта кэша - структура, которую диспетчер кэша поддерживает для кэширования этого дискового файла. Как и в случае с управляющими областями (control area, структура, используемая VMM для совершения операций I/O для раздела; подробно об этом Rajeev Nagar, NTFS Internals), которые уникальны для дискового файла (одна на проецирование файла как бинарного, вторая как исполняемого образа), открытая карта уникальна и адресуется через SECTION_OBJECT_POINTERS, которую удерживает FSD в FCB соответствующего файла. То есть, диспетчер кэша знает, какой слот какому файлу принадлежит, через VACB, который содержит указатель на открытую карту.

typedef struct _SECTION_OBJECT_POINTERS
{
VOID* DataSectionObject;
VOID* SharedCacheMap; //указатель в открытую карту
VOID* ImageSectionObject;
}SECTION_OBJECT_POINTERS, *PSECTION_OBJECT_POINTERS;

Диспетчер кэша может найти ее для каждого открытого FileObject, так как последний содержит указатель на структуру SECTION_OBJECT_POINTERS (FileObject -> SectionObjectPointer). Открытая карта описывается нехилой структурой. Приведу только важные поля:

typedef struct _SHARED_CACHE_MAP
{

/*0x008*/ union _LARGE_INTEGER FileSize; //размер кэшируемого файла

/*0x018*/ union _LARGE_INTEGER SectionSize;
/*0x020*/ union _LARGE_INTEGER ValidDataLength;

/*0x030*/ struct _VACB* InitialVacbs[4]; //массив индексов VACB
/*0x040*/ struct _VACB** Vacbs; //указывает на предыдущее поле, если file_size <= 1MB
/*0x044*/ struct _FILE_OBJECT* FileObject; //первый связанный с открытой картой объект

/*0x078*/ VOID* Section; //раздел для проецирования файла

/*0x090*/ struct _CACHE_MANAGER_CALLBACKS* Callbacks;

/*0x0D8*/ struct _PRIVATE_CACHE_MAP PrivateCacheMap; //одна закрытая карта
}SHARED_CACHE_MAP, *PSHARED_CACHE_MAP;

Чтобы диспетчер кэша мог быстро находить, какие части данного файла уже спроецированы (для них имеются представления в слотах), открытая карта указывает на массив индексов VACB. Первый элемент массива указывает на первые 256 Кб файла, вторые – на следующие 256 и т.д. В случае если файл имеет размер не больше 1 Mb, то есть может уместиться в четыре слота, в качестве массива индексов выступает массив InitialVacbs из открытой карты. В противном случае массив выделяется в резидентном пуле. В любом случае, указатель на него запоминается в поле Vacbs. Все закрытые карты связаны в список с головой PrivateList (&SharedCacheMap -> PrivateLis, & PrivateCacheMap -> PrivateLinks). Кроме того, все открытые карты также связаны в списки, с помощью SharedCacheMapLinks.

За инициализацию открытой карты (если она еще не была создана), создание раздела и создание закрытой карты для данного FileObject отвечает функция, которую вызывают FSD - CcInitializeCacheMap.

VOID
CcInitializeCacheMap (
__in PFILE_OBJECT FileObject,
__in PCC_FILE_SIZES FileSizes,
__in BOOLEAN PinAccess,
__in PCACHE_MANAGER_CALLBACKS Callbacks,
__in PVOID LazyWriteContext
)

На нее возложены следующие обязанности.

1. Создает и инициализирует открытую карту кэша, если она не существует (поле FileObject -> SectionObjectPointer -> SharedCacheMap пустое). SharedCacheMap -> FileObject инициализируется на первый FileObject, для которого создается открытая карта.

2. С помощью MmCreateSection создает раздел, через который потом будут проецироваться части файла на слоты кэша.

3. Создает массив индексов VACB с помощью CcCreateVacbArray. Последняя инициализирует поле Vacbs и SectionSize.
Если файловой системе нужно прочитать через кэш, она вызывает CcCopyRead.

BOOLEAN
CcCopyRead (
__in PFILE_OBJECT FileObject, //файловый объект, который был инициализирован с помощью CcInitializeCacheMap
__in PLARGE_INTEGER FileOffset, //смещение читаемых в файле данных
__in ULONG Length, //длина читаемых данных
__in BOOLEAN Wait, //если true, тогда вызывающий может ожидать подкачку, в противном случае данные уже должны быть в кэше, при их отсутствии возвращается false
__out_bcount(Length) PVOID Buffer, //буфер для копирования
__out PIO_STATUS_BLOCK IoStatus
)

Внутренне диспетчер кэша проецирует части файла с помощью CcGetVirtualAddress, которая возвращает стартовый адрес проекции. Функция обслуживает (проецирует) один VACB и, соответственно, один слот.
К практике

Ладно, хватит грузиться теорией, перейдем к практике. Следующая программа windbg исследует кэш:

.expr /s masm;
.for(r eax=0; @eax < poi(CcNumberVacbs); r eax=@eax+1)
{
r ecx=poi(CcVacbs) + @eax * 0x18;
r ebx=poi(@ecx + 4);
.printf "Vacb #%d 0x%p -> 0x%p\n", @eax, @ecx, poi(@ecx);
.if( @ebx != 0 )
{
r ebx = poi( @ebx + 0x44 );
.if( @ebx != 0 )
{
r ebx = @ebx + 0x30;
.if( poi(@ebx+0x4) != 0 )
{
.printf "\tFile: 0x%p\n\tOffset: 0x%p\n%msu\n\n", @ebx-0x30, poi(@ecx+8)&ffff0000, @ebx
} .else {
}} .else {
}} .else {
}}

Выборочный вывод команды имеет вид:

Vacb #282 0x8194aa70 -> 0xd90c0000
File: 0x818ed338
Offset: 0x00ac0000
\$Mft

Соответственно, вначале указывается адрес структуры VACB, потом адрес слота в кэше, ниже – адрес файлового объекта из открытой карты и смещение, которое попадает в этот слот. Получается, что по адресу 0xd90c0000 скэширован файл $Mft со смещения 0x00ac0000.

Для тех крутых парней, кто интересуется, как на низком уровне диспетчер памяти управляет страницами кэша, исследуем PTE. Для исследований лучше вывести процессор из режима PAE, создав новую строчку в boot.ini и вставив параметр /NOPAE, убрав заодно параметры, включающие DEP.

Слоты кэша представляют собой проекции файла, то есть спроецированные разделы, поэтому если PTE-страница активного слота не валидна, она будет указывать на прототипный PTE (proto PTE).

!pte 0xd90c0000
VA d90c0000
PDE at C0300D90 PTE at C0364300
contains 01D55963 contains 0123EC80
pfn 1d55 -G-DA--KWEV not valid
Proto: FFFFFFFFE148FB00

Этот pte ссылается на прототипный по адресу E148FB00. Вычислим адрес proto-PTE руками (по известной формуле, PrototypePteAddress = MmPagedPoolStart + PrototypeIndex << 2).

0x123EC80 = 10010001111101 1 0 0 1000000 0
|
|->это прототипный
Index=100100011111011000000=123EC0 << 2=48FB00;
MmPagedPoolStart = e1000000;
48FB00+ e1000000 = e148FB00.

Нашли адрес proto-PTE, сдампим его, то есть получим содержимое.

dd e148FB00 l1
e148fb00 87944cd6

Proto-PTE равен

0x87944cd6 = 1 00001111001010001001 1 00110 1011 0
|->PTE указывает на подраздел
|->Описывает маппируемый файл

Вычислим адрес подраздела (по форм. SubsectionAddress = MmSubsectionBase + PrototypeIndex << 3, обычно MmSubsectionBase == MmNonPagedPoolStart ).

Index = 000011110010100010011011 = F289B << 3 = 7944D8; MmNonPagedPoolStart = 81181000; 7944D8 + 81181000 = 819154D8 - адрес подраздела.

dt _subsection 819154D8
nt!_SUBSECTION
+0x000 ControlArea : 0x819154a8 _CONTROL_AREA
+0x004 u : __unnamed
+0x008 StartingSector : 0
+0x00c NumberOfFullSectors : 0x1000
+0x010 SubsectionBase : 0xe148d000 _MMPTE
+0x014 UnusedPtes : 0
+0x018 PtesInSubsection : 0x1000
+0x01c NextSubsection : 0x81913660 _SUBSECTION

!ca 0x819154a8

ControlArea @ 819154a8
Segment e13d66c8 Flink 00000000 Blink 00000000
Section Ref 1 Pfn Ref 2b6 Mapped Views 3c
User Ref 0 WaitForDel 0 Flush Count 0
File Object 818ed338 ModWriteCount 0 System Views 3c

Flags (8088) NoModifiedWriting File WasPurged

File: \$Mft

Сегмент имеет вид:

dt _SEGMENT e13d66c8
nt!_SEGMENT
+0x000 ControlArea : 0x819154a8 _CONTROL_AREA
+0x004 TotalNumberOfPtes : 0x1b00
+0x008 NonExtendedPtes : 0x1000
+0x00c WritableUserReferences : 0
+0x010 SizeOfSegment : 0x1b00000
+0x018 SegmentPteTemplate : _MMPTE
+0x01c NumberOfCommittedPages : 0
+0x020 ExtendInfo : (null)
+0x024 SystemImageBase : (null)
+0x028 BasedAddress : (null)
+0x02c u1 : __unnamed
+0x030 u2 : __unnamed
+0x034 PrototypePte : 0x61564d43 _MMPTE
+0x038 ThePtes : [1] _MMPTE

Получим такие же значения по открытой карте кэша:

dt _vacb SharedCacheMap 0x8194aa70
nt!_VACB
+0x004 SharedCacheMap : 0x818c7b08 _SHARED_CACHE_MAP

Выборочный вывод структуры открытой карты с интересующими полями:

dt _SHARED_CACHE_MAP 0x818c7b08
nt!_SHARED_CACHE_MAP
+0x008 FileSize : _LARGE_INTEGER 0x1ae8000
+0x010 BcbList : _LIST_ENTRY [ 0x81913a60 - 0x819138b8 ]
+0x018 SectionSize : _LARGE_INTEGER 0x1b00000
+0x044 FileObject : 0x818ed338 _FILE_OBJECT //совпадает с адресом, указанным в
//control_area (вывод !ca).
+0x078 Section : 0xe13d6698 //соответствующий раздел
dt _SECTION_OBJECT Segment 0xe13d6698
nt!_SECTION_OBJECT
+0x014 Segment : 0xe13d66c8 _SEGMENT_OBJECT //сегмент для проецирования файла как бинарного

PTE кэша начинаются с адреса, который указывается в MmSystemCachePteBase (обычно совпадает с началом таблицы страниц, 0xC0000000).

Модификацию кэша на практике можно использовать для разных задач. Рассмотрим, например, запись в параметр раздела реестра без применения API-функций, через кэш. В отличие от w2k, в которой диспетчер конфигурации хранил данные кустов в подкачиваемом пуле, в wxp он проецирует файлы кустов реестра, используя объекты-разделы.

Попробуем модифицировать данные в разделе HKCU. Файл раздела хранится в \Document and Setting\<account name>\NTUSER>DAT. Если запустить приведенную выше программку windbg, ты сможешь увидеть, что этих файлов смапировано несколько, например, для встроенной учетной записи системы – NetworkService:

Vacb #280 0x819baa40 -> 0xd0180000
File: 0x81765610
Offset: 0x00000000
\Documents and Settings\root\NTUSER.DAT

Root - моя учетная запись

Vacb #289 0x819bab18 -> 0xcf6c0000
File: 0x81666bf8
Offset: 0x00000000
\Documents and Settings\NetworkService\NTUSER.DAT

Этот VACB описывает куст для учетной записи NetworkService.

Воспользуемся первым выводом для записи root. Она показала, что адрес данных в кэше 0xd0180000, при этом проекция начинается с начала файла, смещение ноль. Также воспользуемся командой !fileobj для вывода более подробной информации.

kd> !fileobj 0x81765610

\Documents and Settings\root\NTUSER.DAT

Device Object: 0x81927bb8 \Driver\Ftdisk
Vpb: 0x8192a818
Access: Read Write -> присутствует тип доступа Write, значит, страницы раздела будут сброшены в куст на диске

Flags: 0x140040
Cache Supported
Handle Created
Random Access

FsContext: 0xe1632d90 FsContext2: 0xe3efdc18
Private Cache Map: 0x816750e0
CurrentByteOffset: 0
Cache Data:
Section Object Pointers: 81812d84 -> принадлежит FSD
Shared Cache Map: 81675008 File Offset: 0 in VACB number 0
Vacb: 819baa40 -> наш VACB
Your data is at: d0180000 -> и адрес данных в кэше

Структура куста – это тема для отдельного доклада, поэтому быстренько пробегаемся по памяти и ищем какие-нибудь данные.

Нашли для параметра по умолчанию раздела HKEY_CURRENT_USER\AppEvents\EventLabels\.Default его значение - Default Beep.

Изменим его, например, так:

eb d018142e 70/eb d0181430 70.

Нужно подождать, пока содержимое кэша сбросится в файл. Это может произойти сразу, а может через некоторое время, в любом случае после перезагрузки данные в файле на диске будут изменены. В этом примере изменения сразу отобразились в regedit (после F5).

Модифицирование данных через windbg хорошая практика, но в боевых условиях нужно писать драйвер, который это будет делать более-менее автоматически. Рассмотрим один из вариантов. При открытии кустов для мапинга, их дескрипторы сохраняются в системной таблице дескрипторов, доступ к которой можно получить, например, в контексте процесса System. Мы можем перебрать там все дескрипторы (смотри мануалы Ms-remа на wasm) и найти указатели на соответствующие file objectы, которые нам нужны (сравнивая полный путь к кусту с тем, что указано в FileName файлового объекта). Зная адрес файлового объекта, мы выходим на структуру _SECTION_OBJECT_POINTERS, а затем выходим на открытую карту. То есть, FileObject -> SectionObjectPointer -> SharedCacheMap. В SHARED_CACHE_MAP анализируем, с какого смещения проецируется файл, используя указатель на массив VACB - _SHARED_CACHE_MAP -> Vacbs. Если по требуемому смещению, например, нулевому, что будет соответствовать _SHARED_CACHE_MAP -> Vacbs[0] будет указатель на VACB, то из него мы и получаем указатель на слот с данными. Кстати, чтобы найти количество элементов в массиве, нужно разделить значение, указанное в SHARED_CACHE_MAP.FileSize на 256 Кб. Напомню, запись из драйвера на страницы кэша нужно осуществлять с предельной осторожностью и только в случае, если страница присутствует в памяти. В более сложном варианте нужно анализировать поля PTE и proto-PTE.

Завершающий experience по этому поводу. Я нашел file object для своего \Documents and Settings\root\NTUSER.DAT. Далее:

kd> dt _file_object 0x8169b3c0 SectionObjectPointer
ntdll!_FILE_OBJECT
+0x014 SectionObjectPointer : 0x81826b6c _SECTION_OBJECT_POINTERS

kd> dt _SECTION_OBJECT_POINTERS 0x81826b6c SharedCacheMap
ntdll!_SECTION_OBJECT_POINTERS
+0x004 SharedCacheMap : 0x816efd18 -> открытая карта уникальна для дискового файла

kd> dt _SHARED_CACHE_MAP 0x816efd18 Vacbs
nt!_SHARED_CACHE_MAP
+0x040 Vacbs : 0x81868e78 -> 0x819baa88 _VACB -> массив VACB

Кроме того:

kd> dt _SHARED_CACHE_MAP 816efd18 FileSize
nt!_SHARED_CACHE_MAP
+0x008 FileSize : _LARGE_INTEGER 0x140000

0x140000 / 0x40000 = 5 элементов в массиве указателей VACB.

Теперь смело дампим его:

kd> dd 0x81868e78 l8
81868e78 819baa88 819baba8 819bab60 819babd8
81868e88 819baa70 00000000 00000000 00000000

Видим, что в кэше присутствуют все части файла 5 * 256 * 1024 = 0x140000 байт.

Если нам нужны данные со смещения ноль, то выполняем команду:

kd> dt _VACB 819baa88 BaseAddress
nt!_VACB
0x000 BaseAddress : 0xcfb40000

Это, собственно, и есть данные с начала файла ntuser.dat.
Теперь все!

Для модифицирования нужных данных необходимо иметь парсер реестра и знать, с какого смещения в кусте начинаются данные. А дальше - по вышеприведенной схеме. Аналогичным образом модифицируются и данные в MFT, но об этом я расскажу в другой раз, после того, как скушаешь и переваришь эту информацию :). На этой ноте кланяюсь и еще раз желаю удачи в отладке!