每条 MFT 记录都是 1,024 字节。每一条都以相同方式布局。如果你能在十六进制编辑器里读懂一条,你就能读懂所有的;并且能写出一个在恢复损坏记录方面比脆弱的商业工具更强的解析器。这套布局足够小,几个小时配合 010 Editor 就能熟练。
这是我面向那些想停止把 MFT 解析器当作黑盒来用的分析师所做的字节级讲解。
FILE 签名
46 49 4C 45 ("FILE")
前四个字节。ASCII 的 FILE。这就是当 $MFT 自身不可读时,能从原始簇中 carve 出 MFT 记录的关键魔数。按 512 字节边界(较老的 NTFS)或 4096 字节边界(带 4K 记录的 Win10,少见但存在)扫描磁盘镜像,按 46 49 4C 45 进行模式匹配,即使表自身的头被破坏,通常也能恢复出大部分 MFT。
实战中另一个签名是 BAAD(42 41 41 44)。NTFS 在 chkdsk 判定某条记录无法修复时写入它。槽位保留,序列号也保留,但其余字节不可信。把 BAAD 记录视为 chkdsk 曾运行过的证据,以及做周边上下文分析的目标。不要天真地去解析它的属性。
记录头(偏移 0x00 至 0x37 上下)
四个签名字节之后,NTFS 排布的头随 NTFS 版本略有变化。你实际会用到的字段,按相对记录起始处的偏移:
0x00 4 bytes 签名("FILE" 或 "BAAD")
0x04 2 bytes 到 update sequence array(USA)的偏移
0x06 2 bytes USA 以 16 位字计的大小(records=USN+fixup_entries 的个数)
0x08 8 bytes $LogFile 序列号(LSN)
0x10 2 bytes 序列号
0x12 2 bytes 硬链接计数
0x14 2 bytes 到第一个属性的偏移
0x16 2 bytes Flags(bit 0 = IN_USE,bit 1 = DIRECTORY,
bit 2 = QUOTA_CHARGED,bit 3 = HAS_VIEW_INDEX)
0x18 4 bytes 记录已用大小
0x1C 4 bytes 分配大小(标准卷上恒为 1024)
0x20 8 bytes Base file record 引用(扩展记录上非零)
0x28 2 bytes 下一个属性 ID
0x2A 2 bytes (NTFS 3.0 上的对齐/填充)
0x2C 4 bytes 记录号(NTFS 3.1+,自引用)
其中几个值得特别注意。
0x16 处的 Flags。 Bit 0 被清掉表示已删除。Bit 1 置位表示目录(记录承载索引属性而不是 $DATA)。两个 flag 同时有意义这一组合,使得一个字节就能透露很多关于条目的信息。
0x10 处的序列号。 每次槽位被重用时递增。64 位的 文件引用(低 48 位是记录号,高 16 位是序列号)才是一个具体文件存在性的真正唯一标识。其它属性中的引用($FILE_NAME 的父引用、$ATTRIBUTE_LIST 中的条目)都使用这种 64 位形式。一个引用的序列号与当前记录不匹配,表明它指向的是前任,通常是被删除的文件。Sleuth Kit 正是借此遍历已删除目录链。
0x20 处的 base file record 引用。 基础记录上为零;扩展记录上非零(即某个文件的属性溢出到单个槽位之外时)。非零值是该扩展所属基础记录的 64 位引用。解析器必须沿着 $ATTRIBUTE_LIST 链才能拼出完整的文件。
0x08 处的 $LogFile 序列号。 指向 $LogFile。对事务级恢复有用;对日常分析作用较小。值得知道它存在。
fixup(update sequence)数组
NTFS 通过一个小手法来防止 torn writes。每条 1,024 字节的记录被划分为两个 512 字节扇区。在写入之前,NTFS:
- 选取一个 16 位的 update sequence number(USN,尽管缩写相同,但与
$UsnJrnl的 USN 没有关系)。 - 把每个扇区最后两个字节的原值保存到紧随头部之后的数组里。
- 用 USN 本身替换每个扇区的最后两个字节。
读取时,NTFS 校验每个 512 字节扇区的最后两个字节是否等于所选 USN。若相等,则写入是原子的;从 fixup 数组中取回原始字节填回去。若任一扇区末尾不匹配,则写入被撕裂,记录是可疑的。
数组的布局是:一个 USN 字后跟 N 个 fixup 字,其中 N 是扇区数。对 512 字节扇区卷上的 1,024 字节记录,N = 2。因此数组共占 6 字节(USN、fixup_for_sector_0、fixup_for_sector_1)。其偏移由记录 0x04 处的 2 字节值给出(通常按 NTFS 版本是 0x2A 或 0x30)。
实际后果:不做 fixup 直接读取 $MFT 的原始 1,024 字节块时,每条记录的偏移 510 与 1022 处都会是垃圾。跨越这些偏移的常驻 $DATA 也会被破坏。像 MFTECmd、omerbenamram/mft、analyzeMFT 以及 Sleuth Kit 的 fls/istat 这样的解析器都把应用 fixup 作为第一步。如果你在写自己的解析器(这是个不错的练习;见 用 Python 解析 MFT),请在做其它事情之前先做这一步。
属性流
头与 fixup 数组之后,每条记录包含一连串带类型的属性,紧挨着排列,以 8 字节对齐,并以哨兵值 0xFFFFFFFF 结尾,出现在下一个属性的类型码原本应在的位置。
每个属性都以一个简短的标准化头开始:
0x00 4 bytes 属性类型码(0x10 = $STANDARD_INFORMATION 等)
0x04 4 bytes 本属性长度(头 + 数据)
0x08 1 byte 非常驻 flag(0 = 常驻,1 = 非常驻)
0x09 1 byte 名称长度(以字符计;无名称为 0)
0x0A 2 bytes 到名称的偏移(以字符计,从属性起始处)
0x0C 2 bytes Flags(压缩/加密/稀疏)
0x0E 2 bytes 属性 ID
对常驻属性,接下来的字段是内容长度与内容偏移;对非常驻属性则是起始/结束 VCN、runlist 偏移、压缩/分配/真实大小。在所有这些之后,才是实际的数据。名称(如果有)是 Unicode 且未对齐;可以预期实现起来很琐碎。
最简记录承载三个属性:
$STANDARD_INFORMATION(0x10):时间戳、DOS flags、安全 ID。$FILE_NAME(0x30):一个文件名、父目录引用、第二组四个时间戳,以及该名称被设置时的分配/真实大小。一条记录可携带多个(每个硬链接一个,加上启用 8.3 生成卷上的 8.3 短名)。$DATA(0x80):文件内容,能装下就是常驻,否则就是 runlist。一条记录可携带多个$DATA属性;无名的是主数据流,命名的是 备用数据流。
完整的属性目录及各自所在位置见 Master File Table 参考。
这一切为什么在记录受损时重要
稳定的 1,024 字节布局、FILE 签名、fixup 机制、自描述的属性头,它们的组合使得对已删除 NTFS 记录进行 carving 成为可能。即使 $MFT 本身已经消失(损坏、勒索软件涂写、部分擦除),只要底层簇没有被覆写,对原始卷做 46 49 4C 45 边界签名扫描就能恢复出这些记录。Fixup 校验为你提供每扇区的完整性检查;校验失败的记录应被标记,但其属性数据有时仍能部分读取。
这就是 Sleuth Kit 的 mmls+fls、R-Studio 的 "deep scan",以及各种商业恢复套件背后的根基。它们都在走同一套字节布局。自己理解它,才能对它们的输出做合理性检查。
亲手读一条
把抽取出的 $MFT 在 010 Editor 中打开,并应用 NTFS MFT Record 模板。选记录 5(根目录;偏移 5 * 1024 = 5120 自文件起始)。确认:
- 字节 0x00 至 0x03:
46 49 4C 45。 - 0x16 处的 flags:
0x03(IN_USE 与 DIRECTORY 同时置位)。 - 0x14 给出的偏移处的第一个属性类型:
0x10($STANDARD_INFORMATION)。
如果对得上,记录就解析得干净。如果对不上,要么文件已损坏,要么未应用 fixup 数组(010 Editor 的模板会替你应用)。
做过一次之后,MFT 的其余部分对你就是可读的。这些字段不再神秘,而成为一份你能脱离协助阅读的记录。
延伸阅读
- Microsoft,Master File Table。官方、精炼的参考。
- Sleuth Kit 的 NTFS 文档。Brian Carrier 的笔记至今仍是关于记录布局最清晰的单一资料。
- Russon 与 Fledel,NTFS Documentation(linux-ntfs 项目)。每个属性的字段偏移,源自多年逆向工程的直接产物。