Статья NTFS - доступ к файлам через таблицу $MFT

В системе есть подмножество защищённых файлов, которые нельзя скопировать для анализа с рабочей машины. Они открываются\создаются функцией ядра ZwCreateFile() в монопольном режиме с флагом ShareAccess=0, что полностью блокирует доступ из вне, пока родитель по собственной воле не закроет их дескриптор. В данной статье мы рассмотрим универсальный способ, который позволит оперировать абсолютно любыми файлами на диске прямо с прикладного уровня, правда для этого нужно будет отойти от привычных правил и реализовать поиск секторов в ручную, в обход драйвера NTFS.

Содержание:
1. Основная идея​
2. Штатный механизм работы с файлами​
3. Структура файловой системы NTFS - таблицы и метафайлы​
4. Основная база данных $MFT​
5. Практическая часть - софт для вычисления цепочки кластеров​
6. Заключение​



1. Основная идея

При проектировании ОС особое внимание уделяется безопасности. Подсистема Security контролирует все наши действия, с момента входа в окне Winlogon и вплоть до полного выключения машины. Удостоверением личности является здесь Token, который вручает нам сторож LSASS - именно в токене перечисляются наши права и привилегии.

Когда мы создаём объекты ядра от своего имени (а это файлы, процессы, и многое другое), они наследуют наши права. Если объект уже был открыт кем-то до нас, в его дескрипторе безопасности окажутся права родителя, а потому системный монитор SRM (Security Reference Monitor) своей api SeAccessCheck() может отказать нам в общем доступе. То есть при одинаковых правах владельцев, фактически получаем схему "кто первым встал, того и тапки".

В примере ниже моя попытка открыть файл-подкачки Pagefile.sys на безобидное чтение (без записи), но поскольку Win уже эксклюзивно открыла его до меня Shared=0, монитор отвергает мой запрос с кодом "ERROR_SHARING_VIOLATION=20h", хоть и зашёл я в систему как админ. Чтобы расшифровать код ошибки на понятный язык, в исходнике предусмотрена универсальная процедура с функцией FormatMessage() - она возвращает описание любой ошибки в текстовом виде, что удобно на этапе отладки приложения:

C-подобный:
format   pe64 gui
include 'win64ax.inc'
entry    start
;//----------
section '.code' code readable executable
start:   sub     rsp,8

         invoke  CreateFile,<'C:\pagefile.sys',0>,\
                            GENERIC_READ,
                            FILE_SHARE_READ + FILE_SHARE_WRITE, 0,\
                            OPEN_EXISTING, 0,0

        stdcall  GetError        ;//<---- декодер ошибки
         invoke  ExitProcess,0

;//---- Процедура ---------------
proc GetError  uses rax
locals
   errMes  rb  128    ;//<----- буферы в лок.памяти
   buff    rb  128
endl
        invoke  GetLastError
        push    rax
        invoke  FormatMessage, FORMAT_MESSAGE_FROM_SYSTEM,0, rax,0, addr errMes, 128,0
        pop     rax
       cinvoke  wsprintf, addr buff, <'WinError = %#04x',10,'%s',0>, rax, addr errMes
        invoke  MessageBox,0, addr buff, <'Last Error',0>, MB_ICONWARNING
        ret
endp
;//----------
section '.idata' import data readable
library  kernel32,'kernel32.dll',user32,'user32.dll'
include 'api\kernel32.inc'
include 'api\user32.inc'

WinError.webp

Ну не может и ладно.. Не пускают нас в дверь - тогда зайдём с форточки.
Суть в том, что если файл закрыт на амбарный замок, то корневой раздел самого диска открыт всегда, хотя и требует для доступа прав админа. Другими словами, чтобы прочитать абсолютно любой имеющийся на диске файл, мы должны обратиться не напрямую к файлу, а к разделу диска например С:\, D:\, и т.д. где этот файл лежит, после чего искать уже его смещение относительно корня.

При работе с разделами накопителя мин.порцией обмена является сектор в 512-байт, и что важно, для наших целей требуется отключить буферизацию в кэш, которая включена по умолчанию. Механизм кэширования позволяет копить все изменения файла в специальном буфере, чтобы при каждом чихе не обращаться к относительно медленному харду. Диспетчер кэша сбрасывает буфер на диск в трёх случаях: по 8-сек таймеру (отсчёт начинается после момента записи), при переполнении буфера, или по нашему желанию в произвольный момент функцией FlushFileBuffers().

Таким образом, запрос на открытие корневого раздела диска должен выглядеть так, в результате чего система удовлетворит его:

C-подобный:
invoke CreateFile,<'\\.\C:',0> ,\
                 GENERIC_READ,\                            ;// открыть только на чтение (в записи могут отказать)
                 FILE_SHARE_READ + FILE_SHARE_WRITE, 0,\   ;// при этом другие могут читать и записывать в него
                 OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, 0  ;//<---- флаг откл.кэша!

ErrOk.webp

Функция DeviceIoControl() имеет мощный аргумент FSCTL_GET_RETRIEVAL_POINTERS. По дескриптору открытого файла, он возвращает номера его кластеров на диске. Круто! ..однако данная api нам не подходит, т.к. ожидает на входе именно дескриптор, который мы не сможем получить, пока не откроем файл, с чем собственно у нас и проблема.

Но здесь интересно другое. Как оказалось, NTFS знает, какие кластеры занимает файл с именем(X) на диске, а вот обратную связь вычислить уже не способна. То-есть она не в состоянии определить имя файла по номеру кластера! Этот нюанс позволит нам обходить системный монитор безопасности SRM, который стоит как злой Цербер на страже файлов. Хотя штатная утилита Win fsutil.exe справляется всё-таки с задачей, но к монитору это не имеет отношения:

Код:
C:\> fsutil volume querycluster D:\ 0x2d
     Кластер 0x002d используется P---D  \pagefile.sys::$DATA

Флаги:
---------------------------------------------
P - Pagefile     C - Compressed (сжатый)
R - Read-Only    E - Encrypted (зашифрованный)
H - Hidden       D - Поток $DATA внутри файла
S - System


2. Штатный механизм работы с файлами

На рис.ниже представлена взаимосвязь структур при открытии процессом файлов. Все они собираются в таблицу дескрипторов Handle, на которую указывает поле "ObjectTable" в родительской структуре ядра EPROCESS. Отметим, что в этой таблице лежат дескрипторы не только файлов, но и прочих объектов ядра, например исполняемых потоков Thread, открытых ALPC-портов, ключей реестра Key, объектов синхронизации типа Mutant\Event\Mutex, различных устройств, и прочее. То-есть это всё, что открыл или создал данный процесс. Если дескриптор олицетворяет файл на диске, он будет указывать на структуру FILE_OBJECT.

Расширение !handle отладчика WinDbg возвращает список всех дескрипторов из таблицы, если передать ей адрес структуры EPROCESS. Из лога ниже видно, что у процесса Word.exe всего 459 открытых объектов, и чтобы найти среди них только файлы, можно использовать маску File в последнем аргументе (кстати чувствительна к регистру).

Код:
0: kd> !process 0 0 winword.exe
PROCESS fffffa80117dab00
    Session: 1          Cid: 0620    Peb: 7efdf000     ParentCid  : 05a0
    DirBase: 1a3653000  ObjectTable: fffff8a00300bf90  HandleCount:  459
    Image  : WINWORD.EXE

0: kd> !handle 0 3 fffffa80117dab00 File
       Searching for handles of type File

052c:  Object: fffffa800c8db580   GrantedAccess: 00120089  Entry: fffff8a00d5fc4b0
       Header: fffffa800c8db550   HandleCount  : 1         PointerCount: 1
       Name  : \Program Files (x86)\Common Files\microsoft shared\PROOF\MSSP3RU.LEX

Так мы получим адрес одного из файловых объектов процесса, и можно вскормить его расширению !fileobj для последующего анализа. Здесь видим, что файл взаимодействует с драйвером менеджера разделов диска Volmgr.sys, имеется лист его атрибутов Read + SharedReadWrite, а так-же указатель на структуру VPB или "Volume Parameters Block", где найдём описание раздела например С:\.

Код:
0: kd> !fileobj fffffa800c8db580
       \Program Files (x86)\Common Files\microsoft shared\PROOF\MSSP3RU.LEX

   DevObj:  0xfffffa800f924cd0   \Driver\volmgr
   Vpb   :  0xfffffa800f92a820
   Access:  Read SharedRead SharedWrite

Flags:  0xc0042
        Synchronous IO
        Cache Supported
        Handle Created
        Fast IO Read

FsContext :  0xfffff8a00423bc70   ;<---- линк на FSRTL_ADVANCED_FCB_HEADER
FsContext2:  0xfffff8a0029471d0
Cache Map :  0xfffffa800d035180
ByteOffset:  2e14b1

0: kd>

Struct.webp

Файловая система привязывает к каждому открытому файлу свою структуру FCB, (file control block). Если 2 разных процесса открывают один общий файл, то FCB для них так-же будет общей, а вот файловых объектов диспетчер в/в создаст уже два, и поле "FsContext" каждого из них будет смотреть на эту общую FCB.

Здесь важно понять, что ядро ОС хранит атрибуты доступа к файлу в структуре FILE_OBJECT, в то время как эти-же атрибуты NTFS прячет в собственную свою FCB. Это потому, что файловая система живёт своей жизнью, ведь диск может быть отформатирован не только в NTFS, но и в FAT32 или вообще ExFAT64. Фактически атрибуты в FCB имеют приоритет перед системными, ведь только на этом уровне возможен физ.контроль общего доступа к файлам.

К сожалению NTFS не документирована, т.е. на неё нет официальной спецификации от Microsoft, а потому все принадлежащие ей структуры являются внутренними, и не разглашаются в публичных символах отладки *.pdb драйвера Ntfs.sys. Как результат, даже WinDbg на запрос "покажи fcb" отвечает "не найдено", хотя заголовок Header отображает без проблем, т.к. им оперирует диспетчер в/в ядра, а не Ntfs.

Код:
0: kd> dt _FSRTL_ADVANCED_FCB_HEADER 0xfffff8a00423bc70
nt!_FSRTL_ADVANCED_FCB_HEADER
   +0x000 NodeTypeCode      : 0n1797
   +0x002 NodeByteSize      : 0n496
   +0x004 Flags             : 0x40
   +0x005 IsFastIoPossible  : 0x2
   +0x006 Flags2            : 0x2
   +0x007 Version           : 0y0001
   +0x008 Resource          : 0xfffffa80`10878d00  _ERESOURCE
   +0x010 PagingIoResource  : 0xfffffa80`10878da8  _ERESOURCE
   +0x018 AllocationSize    : 0x2e2000
   +0x020 FileSize          : 0x2e14b5
   +0x028 ValidDataLength   : 0x2e14b5
   +0x030 FastMutex         : 0xfffffa80`10878cc8  _FAST_MUTEX
   +0x038 FilterContexts    : _LIST_ENTRY
   +0x048 PushLock          : _EX_PUSH_LOCK
   +0x050 ContextSupportPtr : 0xfffff8a0`0423bc60

0: kd> dqs 0xfffff8a00423bc70 + 0x58    ;<----- по сути это должна быть структура FCB,
fffff8a0`0423bcc8  fffff8a0`0423bde0    ;\      т.е. начинаться сразу после заголовка,
fffff8a0`0423bcd0  fffff8a0`0423bb70    ; \     но это не точно, т.к. документации нет.
fffff8a0`0423bcd8  fffff8a0`0423bb70    ;  \
fffff8a0`0423bce0  fffff8a0`0423bb40    ;   >--- массив каких-то указателей (валидны)
fffff8a0`0423bce8  fffffa80`0fc2d180    ;__/
fffff8a0`0423bcf0  00000000`10000230    ;<------ возможно флаги атрибутов
fffff8a0`0423bcf8  00000002`00000001
fffff8a0`0423bd00  00000001`00000001

0: kd>


3. Структура файловой системы NTFS - таблица и метафайлы

NTFS v1.0 родилась на свет в 1993 году, далее v2 как сквозь землю провалилась (ничего о ней не известно), в нулевых появилась сразу третья версия для Win2k, и наконец с 2001 года нашими хардами погоняет v3.1, которая и считается актуальной по сей день. За это время из неё было удалено много лишнего, а ещё больше добавлено, например поддержка шифрования Encrypt, сжатия данных Compressed, отказ от хранения на диске больших массивов нулей Sparse, отдельная база дескрипторов безопасности в виде метафайла $Secure, а так-же индексация записей FileRecord для быстрого поиска.

Архитектурно состоит из 11-ти функциональных модулей, которые принято называть метафайлами. Имена их начинаются со-знака($), а номера кластеров на диске хранятся в глобальной базе MFT (master file table). У каждого раздела\тома диска своя база MFT, поэтому в терминологию было введено такое понятие как LCN (logical cluster number). Отсчёт LCN ведётся с нуля относительно начала раздела, а не самого диска. Например LCN(0) разделов С:\ и D:\ будут указывать на разные секторы диска.

Помимо логических, в структурах файловой записи используются ещё и вирт.кластеры VCN - это смещение от начала файла. Такой подход сокращает разрядность указателей на кластеры, и вместо 64-битных значений можно использовать 32-битный дворд. Кластер - это мин.единица данных в NTFS, в то время как на физ.уровне диск и все системные WinAPI оперируют исключительно 512-байтными секторами с адресацией LBA (logical block address).

При работе с NTFS, в качестве отправной точки используется загрузочный сектор раздела, который числится в штате как метафайл $Boot. То-есть открываем раздел функцией CreateFile(), и через ReadFile() читаем сразу первый-же его сектор LBA=0. Если по смещению(3) от начала сектора найдём строку с сигнатурой "NTFS", значит это точно наш клиент, и можно переходить к глобальной базе MFT - её кластер будет указан в поле "MftLcn".

Код:
struct BOOT_SECTOR             ;<---------- Первый сектор раздела! = Ntfs $BOOT
  JumpBootLoader    rb  3      ; 00. инструкция jmp на вторичный загрузчик
  FileSystemId      rb  8      ; 03. строка с сигнатурой "NTFS"
  BytePerSector     dw  0      ; 0B. размер сектора в байтах (обычно 512, но для дисков NVM может быть 4096)
  SectorPerCluster  db  0      ; 0D. секторов в кластере (обычно 8, что даёт размер кластера 512*8=4КБ)
  ReservedSector    dw  0      ; 0E. резервных секторов (при исправном диске = нуль)
  Reserved1         rb  5      ; 10.
  Media             db  0      ; 15. тип диска: 0xF8 = HDD, 0xFA = NVMe
  Reserved2         dw  0      ; 16.
  SectorPerTrack    dw  0      ; 18. секторов в треке\цилиндре (всегда 63, актуально только для HDD)
  Heads             dw  0      ; 1A. всего головок чтения\записи (255, только для HDD)
  HiddenSector      dd  0      ; 1C. смещение от начала диска к этому разделу
  Reserved3         dq  0      ; 20.
  SectorCount       dq  0      ; 28. всего секторов в разделе (по факту его размер)
  MftLcn            dq  0      ; 30. кластер таблицы $MFT   <---------------------- линк на глобальную базу!
  MftMirrLcn        dq  0      ; 38. кластер таблицы бэкапа $MFTMirr (копия первых 4-х записей из оригинала)
  BytePerMftEntry   db  0      ; 40. размер одной записи FileRecord  (для NTFS v3.1 это всегда 1024 байт, код = F6)
  Reserved4         rb  3      ; 41.
  BytePerIndexBuff  db  0      ; 44. размер одной записи FileIndex
  Reserved5         rb  3      ; 45.
  VolumeSN          dq  0      ; 48. серийный номер тома
  BpbChecksum       dd  0      ; 50. контрольная сумма: 0 = ОК!
;<-----------------------------  54. здесь начинается код вторичного загрузчика ОС
ends

Как только мы узнаем номер кластера MFT, остальное уже дело техники.
MFT - это централизированная база буквально всех файлов на данном разделе диска, в том числе и самих метафайлов. База состоит из массива структур FILE_RECORD размером 1024 байт каждая - одна структура описывает отдельно взятый файл на диске. Таким образом, размер базы MFT напрямую зависит от кол-ва файлов.

Под свои метафайлы, NTFS резервирует первые 12 записей FileRecord, и располагаются они в строго определённом порядке, как указано ниже. После служебных файлов идёт зарезервированное свободное пространство, а пользовательские файлы начинаются в MFT с индекса Idx=24.

AttrDef.webp

Когда диск отформатирован в NTFS, файл может иметь до 16-ти внутренних атрибутов. Не нужно путать их с атрибутами типа: скрытый, сжатый, системный, и прочие. Например сведения об основном потоке данных хранится в атрибуте $DATA, хотя NTFS поддерживает ещё и скрытые от глаз альтернативные потоки ADS (alternate data stream), которые будет описывать расширенный атрибут $EA. В свою очередь имя файла прописывается в атрибут $FILE_NAME, а общие сведения в $STANDARD_INFO.

В записи FileRecord не обязательно должны присутствовать буквально все перечисленные выше атрибуты от 1 до 16, однако в минимальной комплектации три выделенных зелёным атрибута с идентификаторами типа 0x10,30,80 мы найдём всегда. Остальные 13 прилагаются в нагрузку, и для нас они сейчас не представляют никакого интереса.


4. Основная база данных $MFT

Так, мелкими шагами мы вплотную подошли к тому, чтобы утянуть любой системный файл с диска без указания его имени, ведь иначе сторожа Win не хотят нам предоставлять к нему легальный доступ. На данном этапе имеет смысл скачать крутой двоичный редактор WinHEX, который из коробки имеет всё необходимое для понимания столь сложной темы. В частности, по своим готовым шаблонам он может отображать содержимое метафайлов $Boot, $MFT, структур FileRecord, и многое другое. В подвале статьи есть ссылки для скачивания.

Для начала посмотрим на формат структуры FileRecord - нам интересны всего 2 выделенных плюсом поля: смещение в байтах к первому Ntfs-атрибуту, а так-же флаги, что позволит искать (или наоборот пропускать) кластеры удалённых с диска файлов:

Код:
struct  FILE_RECORD            ;<---------------- размер 38h
  Magic             db 'FILE'  ; 00.   сигнатура
  upsOffset         dw  0      ; 04.   смещение к "последовательности обновлений" (updata sequency)
  upsSize           dw  0      ; 06.      ..и её размер в словах.
  LSN               dq  0      ; 08.   порядковый номер в $LogFile (logfile seq.number)
  SequenceNum       dw  0      ; 10.   порядковый номер записи
  HardLinkCount     dw  0      ; 12.   счётчик ссылок на этот файл из вне
  firstAttrOffset   dw  0      ; 14. + смещение к первому атрибуту
  Flags             dw  0      ; 16. + флаги: 0=удалён, 1=запись активна, 2=каталог
  fRecordRealSize   dd  0      ; 18.   реальный размер данной записи FileRecord,
  fRecordAllocSize  dd  0      ; 1C.      ..и выделенный для неё размер (обычно 1024 байта = 2 сектора)
  ReferBaseRecord   dq  0      ; 20.   ссылка на базовую запись в цепочке (см.поле#2)
  NextAttrId        dw  0      ; 28.   ID сл.атрибута
  Padding1          dw  0      ; 2A.   (выравнивание)
  MftRecordIdx      dd  0      ; 2C.   индекс этой записи в базе MFT (пользовательские начинаются с 24)
  upsNumber         dw  0      ; 30.   значение "последовательности обновлений"
  Padding2          rb  6      ; 32.   (выравнивание)
  ;// первый атрибут.....
ends

Прочитав линк на первый атрибут по смещению 0x14, мы упрёмся в структуру STANDARD_INFO, поскольку именно она имеет первый из возможных Type=0х10. Ядро файловой системы выстраивает атрибуты в записях FileRecord по их индексам в метафайле $AttrDef, которые совпадают с идентификатором типов, например: 1=0x10, 2=0x20, и т.д. Это говорит о том, что NTFS разрабатывалась грамотно (а не абы как), с учётом всех мелочей.

Если содержимое атрибута полностью помещается в одну 1024-байтную запись FileRecord, его называют "Резидентным". Но для некоторых атрибутов этого пространства явно не достаточно, тогда тело его выносится за пределы FileRecord, а внутри записи остаётся лишь ссылка на внешний кластер - такие атрибуты считаются "Не резидентными". Ярким представителем нерезидентого является $DATA, поскольку в качестве его тела выступает собственно содержимое файла, например видео, картинка, исполяемый файл, и прочее. Посмотрите ещё раз на таблицу атрибутов выше - в столбце флагов указывается как-раз тип атрибута, резидентный он или нет.

Все 16 атрибутов имеют одинаковый для всех заголовок StaticHeader размером 16-байт, после которого идёт его продолжение в зависимости от резидентности, и лишь потом начинается уже непосредственно тело атрибута. Первые 2 поля в статическом хидере определяют тип и длину атрибута, а поскольку атрибуты в записи FileRecord плотно прижаты друг к другу, зная длину можно будет перемещаться к следующему. Маркером последнего атрибута в цепочке является значение 0xFFFFFFFF. Здесь я так-же выделил ключевые для нас поля плюсом:

Код:
struct STATIC_HEADER           ;<---- Размер = 10h
  Type              dd  0      ; 00.  + тип атрибута = 0x10,30,80, и т.д.
  AttrLen           dd  0      ; 04.  + ВАЖНО! его длина в байтах
  NonResidentFlag   db  0      ; 08.  + не резидентный(1), резидентный(0)
  AttrNameLen       db  0      ; 09.      длина Unicode-имени атрибута (игнор)
  AttrNameOffset    dw  0      ; 0A.      смещение к имени
  Flags             dw  0      ; 0C.  + флаги файла: 0=Simple, 1=Compressed, 0x4000=Encrypted, 0x8000=Sparse
  AttrId            dw  0      ; 0E.      ID атрибута (игнор)
ends

struct RESIDENT_HEADER         ;<---- Размер = 18h (продолжение заголовка)
  StaticHeader      STATIC_HEADER
  AttribLength      dd  0      ; 10.  + длина тела атрибута
  AttribOffset      dw  0      ; 14.  + смещение к атрибуту
  Indexed           db  0      ; 16.      флаг индексации (игнор)
  Unused            db  0      ; 17.      выравнивание
;// тело резидентного...
ends

struct NON_RESIDENT_HEADER     ;<---- Размер = 40h (внешний атрибут, например $DATA)
  StaticHeader      STATIC_HEADER
  FirstCluster      dq  0      ; 10.  + первый кластер VCN атрибута
  LastCluster       dq  0      ; 18.  + последний кластер
  DataRunsOffset    dw  0      ; 20.  + <------------- ВАЖНО! смещение к массиву DataRuns
  CompressionUnit   dw  0      ; 22.  + флаг сжатия данных
  Unused            dd  0      ; 24.      выравнивание
  AttribAllocated   dq  0      ; 28.      выделенный размер в байтах
  AttribSize        dq  0      ; 30.      реальный размер трибута
  StreamDataSize    dq  0      ; 38.  + размер именованного\безымянного потока NTFS (см.EA)
;// тело нерезидентного...              ^^^^^^ а по факту размер файла в байтах
ends

К сожалению переменный размер заголовков заставляет нас ипользовать 3 структуры вместо 1, зато реализовать программный доступ к NTFS теперь намного проще. Вот формат первых двух атрибутов, которые потребуются нам в практической части, при этом в стандартном 0х10 нет ничего интересного, а время можно взять из 0х30, т.к. оно дублируется. Напомню, что оба этих атрибута резидентные, т.е. их тело полностью находится в файловой записи FileRecord:

Код:
struct ATTR_STD_INFO_10        ;<------ размер 0x60
  Header            RESIDENT_HEADER
  fCreationTime     FILETIME   ; 18.  время создания
  fAlteredTime      FILETIME   ; 20.  изменения
  mftChangedTime    FILETIME   ; 28.  изменения MFT
  fReadTime         FILETIME   ; 30.  время последнего доступа
  fPermissions      dd  0      ; 38.  атрибуты файла (см.ниже FILE_TYPE_xx)
  MaxVerNum         dd  0      ; 3C.  макс.версия файла
  VerNum            dd  0      ; 40.  текущая
  ClassId           dd  0      ; 44.  ID класса ???
  OwnerId           dd  0      ; 48.  ID родителя
  SecurityId        dd  0      ; 4C.  ID безопасности
  QuotaCherged      dq  0      ; 50.  квота в байтах
  USN               dq  0      ; 58.  Update Sequence Number
ends

struct ATTR_FILE_NAME_30       ;<------ размер зависит от длины имени файла
  Header            RESIDENT_HEADER
  ParentFileRecord  dq  0      ; 18.     ссылка на род.каталог
  fCreationTime     FILETIME   ; 20.     время создания
  fAlteredTime      FILETIME   ; 28.     время изменения
  mftChangedTime    FILETIME   ; 30.     время изменения MFT
  fReadTime         FILETIME   ; 38.     время последнего доступа
  AllocaFileSize    dq  0      ; 40.     выделенный под атрибут размер
  RealFileSize      dq  0      ; 48.     реальный размер
  FileFlags         dd  0      ; 50. + атрибуты файла (см.ниже FILE_TYPE_xx)
  Reserved          dd  0      ; 54.     резерв
  FileNameLen       db  0      ; 58. + длина имени файла
  NameSpace         db  0      ; 59. + формат имени (dos 8.3, win, unix)
  FileName          db  0      ; 5A. + Unicode имя файла
ends

FILE_TYPE_READ_ONLY   =  0x0001   ;  флаги Permissions и FileFlags
FILE_TYPE_HIDDEN      =  0x0002
FILE_TYPE_SYSTEM      =  0x0004
FILE_TYPE_ARCHIVE     =  0x0020
FILE_TYPE_DEVICE      =  0x0040
FILE_TYPE_NORMAL      =  0x0080
FILE_TYPE_TEMPORARY   =  0x0100
FILE_TYPE_SPARSE      =  0x0200
FILE_TYPE_REPARSE     =  0x0400
FILE_TYPE_COMPRESSED  =  0x0800
FILE_TYPE_OFFLINE     =  0x1000
FILE_TYPE_NOINDEXED   =  0x2000
FILE_TYPE_ENCRYPTED   =  0x4000
FILE_TYPE_DIRECTORY   =  0x10000000

А вот реальный дамп из WinHEX, где каждый атрибут я выделил в блок. Как и указано в структуре статического заголовка выше, первый дворд хранит тип атрибута, а во втором лежит его размер. Таким образом, прибавляя этот размер к текущему адресу, мы попадаем в следующий и далее, пока не встретим маркер последнего 0xFFFFFFFF. В данном случае запись FileRecord принадлежит метафайлу $MFT, у которого всего 4 атрибута - это 0x10,30,80,B0.

FileRecord.webp


4.1. Атрибут $DATA - полезная нагрузка файла

И наконец рассмотрим, как получить номера кластеров, чтобы собрать из них готовый к употреблению файл. Для этого нужно разобрать на винтики атрибут $DATA, а точнее его цепочку указателей на кластеры под названием "DataRuns". По большому счёту, это и есть сердце всего механизма хранения данных в NTFS, а потому требует к себе особого внимания (и понимания). Кстати если отбросить все украшательства, разница между NTFS и FAT кроется именно здесь.

Возьмём серый блок из примера выше, и спроецируем на него уже знакомую нам структуру заголовка NON_RESIDENT_HEADER. В первую очередь проверяем, что это точно нерезидент, т.к. если файл размером до 600 байт, его тело может находится прямо внутри атрибута $DATA. В данном случаем по смещению(8) флаг=1, а значит тушка файла лежит где-то снаружи, а где именно - указывает как-раз цепочка DataRuns по смещению(40h).

Обратите внимание, что номер последнего вирт.кластера VCN по смещению(18h), и размер потока оффсет(38h) имеют одинаковые значения, просто в первом случае указано в кластерах, а во-втором в байтах. Формула перевода кластеров в байты будет иметь такой вид:
Байты = Кластеров*4096.

Код:
00.  0х00000080  =  атрибут $DATA
04.  0х00000048  =  ...его длина
08.  0х01        =  не резидентный
09.  0х00        =  длина имени атрибута (нет имени)
0А.  0х0040      =  смещение к имени (игнор, т.к. имени нет)
0С.  0х0000      =  флаг обычного файла Simple
0E.  0х0001      =  id атрибута

10.  0х00000000`00000000  = вирт.кластер начала данных (всегда нуль)
18.  0х00000000`00005ABF  = вирт.кластер конца
20.  0х0040               = смещение к массиву DataRuns (всегда 40h)
22.  0х0000               = данные не сжаты (флаг)
24.  0х00000000           = байты выравнивания на 8
28.  0х00000000`05AС0000  = выделенный размер в байтах
30.  0х00000000`05AС0000  = реальный размер
38.  0х00000000`05AС0000  = размер потока-данных в файле

40.  32 C0 5A 00 00 0C 00 00  = запись DataRun

Записи DataRuns имеют довольной сложный формат, а ещё сложнее реализовать их парсинг программно.
Первый байт называют "InfoByte" и разделён он на 2 тетрады по 4-бита. В младшей кодируется длина в байтах "счётчика последовательности", а в старших - разрядность указателя на кластер LCN в байтах. В данном случае этот байт имеет значение 0x32, поэтому берём следующие 2 байта после него и получаем счётчик C05A=0x5AC0 кластеров, а после них ещё 3 байта 00000C=0x0C0000, и это будет номером начального кластера. Итого нам нужно через ReadFile() прочитать 23.232 кластера, начиная с LCN=0x0C0000.

Здесь повезло, что в цепочке всего одно звено, т.к. следующий инфо-байт после имеет терминальное значение 0x00. Однако если файл на диске фрагментирован (т.е. хранится не целиком, а частями в произвольных кластерах), то кол-во звенье в массиве DataRun уже увеличится, ведь одна запись описывает один именно непрерывный блок кластеров. Сумма двух тетрад инфо-байта определяет длину своей DataRun, значит следующий инфо-байт найдём через 3+2=5 позиций, и если там нуль - это последний.

Более того, начальные кластеры одной цепочки могут быть разбросаны по поверхности диска хаотично, например: 40-200-500-120-60. Здесь видно, что нам нужно сделать первые три шага вперёд, а вот дальше 120-60 указывают уже назад в область между кластерами 40-200. Поэтому в записях DataRun номер начального кластера указывается как число со знаком - если значение положительное (сброшен старший бит), то это смещение вперёд от текущего кластера, а если отрицательное - назад.

Важно понять, что номера кластеров в DataRuns указываются как смещение относительно предыдущего, т.е. это не абсолютный кластер LCN. На физ.кластер указывает только первое звено цепи, а все остальные нужно будет прибавлять к предыдущему. Такой подход позволяет экономить на разрядности номера кластера в записях Runs. Надеюсь схема ниже внесёт немного ясности в этот кавардак:

DataRuns.webp

Когда файл содержит в себе фрагменты из более 4КБ нулей (1 кластер), NTFS не выделяет под них место на диске, а просто взводит в атрибуте файла флаг "Sparse=0x0200" (разряжённый, см.выше константы FILE_TYPE). При этом в записи DataRun хранится только счётчик забитых нулями кластеров, экономя таким образом свободное пространство диска. На Хабре есть готовая статья с подробным описанием этой фишки, поэтому повторяться не буду.


5. Практическая часть - софт для вычисления цепочки кластеров

Основная проблема здесь в том, чтобы программно вычислить знак числа в DataRuns, ведь значение указателя на кластер может быть произвольным от 1 до 8 байт, а потому нельзя предугадать его заранее. Если-бы разрядность была фиксирована 16\32\64-бита, достаточно было просто чекнуть старший бит or eax,eax --> js, но как быть с 24 или 40-битными значениями с длинною в 3 или 5-байт? Значит нужно придумать способ расширения любого числа до 64-бита.

Эту проблему решает первый инфо-байт в записи Run - достаточно умножить старшую его тетраду на 8, как получим разрядность указателя на кластер в битах. Далее, среди инструкций процессоров х86 есть BT (бит-тест), которая чекает как-раз бит в указанной позиции. Значение тестируемого бита отправляется прямиком во-флаг процессора CF, и если он окажется взведён, значит число отрицательное и нужно прыгать назад от текущего кластера. Этот момент представлен в записи Run(3) на правом скрине выше.

Я написал небольшую демку, которая ищет первые 4 раздела на дисках, читает их загрузочные секторы в лице метафайла $Boot, и собирает сведения о записях FileRecord самой базы MFT каждого из найденных разделов в отдельности. Обратите внимание на сл.нюансы:

1. Как оказалось, глобальная база MFT на всех томах распологается по фиксированному кластеру 6.291.456.
2. Размер базы MFT напрямую зависит от кол-ва файлов, а не от размера самого тома, и расширяется база динамически.
3. Размеры секторов и кластеров обычно равны 512 и 4096 байт соответственно, но если диск не хард, а NVM, то могут быть больше.
4. Начиная с NTFS v3.1 длина записей FileRecord должна быть всегда 1024 байта. Если у себя вы обнаружите другое значение, плиз дайте мне знать.
5. Диапазон вирт.кластеров VCN - это всегда сумма лог.кластеров LCN, т.к. оба они определяют размер файла на диске в кластерах.

Result.webp

Я специально убрал в этом коде пимпу "Сохранить", чтобы вы реализовали бэкап самостоятельно. Поскольку в окне уже есть номера кластеров в трёх DataRuns, остаётся умножить их на размер одного кластера 4096, чтобы перевести в LBA для функции ReadFile(). Так получим недоступную обычным способом резервную копию базы MFT прямо с работающей машины, для анализа уже в офлайн.

По такой-же схеме можно узнать кластеры абсолютно любого файла на диске. В реальной ситуации, для оптимизации поиска сначала копируют в свой буфер всю MFT, и уже в буфере ищут структуру FileRecord нужного файла с шагом в 1024-байта. Иначе потребуются сотни тысяч обращений к медленному харду, и поиск будет проходить со скоростью улитки.


6. Заключение

NTFS можно сравнить с небольшой операционной системой, которая работает внутри Win, имеет своё личное ядро Ntfs.sys, и 16 внешних модулей в виде метафайлов с префиксами($). Она способна восстанавливать файлы при сбоях, для чего в штате числится журнал $LogFile. Суть в том, что перед тем-как произвести над файлом какое-либо действие, ядро NTFS записывает это действие сначала в журнал, и лишь потом меняет непосредственно файл. Поэтому если в момент правки отключится питание в сети и т.п., всегда будет возможность восстановить оригинал из лога. В общем говорить о ней можно бесконечно, а потому лучше поставить здесь точку.

В архив положил инклуд для fasm'a с описанием структур Ntfs, исходник моей демки, а так-же готовый экзе для тестов. Всем удачи, пока!

Несколько ссылок по теме:

Лучшее описание NTFS, которое можно найти в сети
Attributes - NTFS Documentation

PDF от энтузиастов, т.к. официальной спеки нет
https://dubeyko.com/development/FileSystems/NTFS/ntfsdoc.pdf

Разбор NTFS от Криса Касперски
https://konyakov.ru/pubs/books/kris-kaspersky-r_i_p/kris-kaspersky-20.pdf

Двоичный редактор WinHEX и шаблоны для него
WinHex Hex Editor
Additional Templates for WinHex & X-Ways Forensics
GitHub - kacos2000/WinHex_Templates: X-Ways Forensic/ WinHex templates
 

Вложения

Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🧭 Навигатор · ИБ 2026
Не знаешь, какой трек твой?
5 направлений ИБ, реальные зарплаты и точка входа для каждого — в одном треде.
JuniorSenior+
100K → 600K+ ₽ /мес
Открыть навигатор →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab