ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

NTFS元文件与MFT记录解析:从引导扇区到数据恢复实战

2026/9/29 17:03:17 拓冰建站 浏览量
NTFS元文件与MFT记录解析:从引导扇区到数据恢复实战 扒开一块NTFS格式的硬盘最前面那512字节的引导扇区像一道大门真正的骨架却藏在根目录下一批名字以$打头的隐藏文件里。上一篇我们把BPB字段、每簇扇区数、MFT起始簇号这些几何参数理了一遍这次直接把镜头推进到这些被称作元文件metadata file的家伙身上。NTFS对待元文件的态度非常统一它们也是文件也有MFT记录也能被$DATA属性描述只不过资源管理器默认不给你看API层面也做了屏蔽。理解这批文件等于拿到了整个卷的说明书哪个簇被谁占了、文件名存在哪条记录的哪个偏移、一条被删掉的记录还残留什么。这篇文章适合三类人做过数据恢复和取证、想写自己的文件系统解析工具、以及单纯好奇文件名到底存在磁盘的哪个字节的开发者。文中所有偏移和代码我都实测过跟着敲一遍你能从一块裸镜像里把文件清单完整捞出来。1. 元文件到底是什么为什么它们长得像文件却不在资源管理器里1.1 从一切皆文件的设计哲学说起NTFS把文件这个概念用到了极致。普通文件是文件目录是文件连文件系统自己用来记账的那些结构也被做成了文件这就是元文件。它们和普通文件共享同一套存储机制在$MFT里占一条记录有$STANDARD_INFORMATION、有$FILE_NAME、有$DATA属性唯一的区别是记录号被固定在前16个并且$FILE_NAME里的命名空间带了系统标记。这么设计的好处非常实在。文件系统的内部结构一旦也用统一的文件描述符来表达读写代码就只需要一套路径想更新簇分配位图就当作往一个普通文件里写数据想读事务日志就当作顺序读一个文件。内核里不需要为每种元数据单独写一套IO逻辑缓存、锁、日志、事务保护全都能复用。代价是启动时得先自举$MFT描述了自己也从自己里读出来所以它的前几条记录位置被$Boot里的字段硬编码锁死了。另一个后果是元文件不可随意移动。$MFT的第0号记录必须能被引导扇区直接定位$MFTMirr的第1号记录固定镜像前4条记录$LogFile是第2号。这些约定写进了格式规范任何工具想正确挂载都得先认这几条。1.2 十六个系统元文件的全景清单固定编号的元文件一共16个编号0到15其中编号5留给了根目录本身12到15保留未用。实际卷上还会有一个叫$Extend的目录编号11它内部又挂着$Quota、$ObjId、$Reparse、$UsnJrnl几个二级元文件。把这些东西列成一张表比死记硬背强得多。记录号名称作用0$MFT主文件表所有文件记录的容器1$MFTMirrMFT前4条记录的镜像备份2$LogFile事务日志用于崩溃恢复3$Volume卷标、版本、脏标志等卷信息4$AttrDef属性定义表声明每种属性类型5.根目录6$Bitmap簇分配位图一簇一位7$Boot引导扇区实际是卷的前16个扇区8$BadClus坏簇记录稀疏文件形式9$Secure安全描述符数据库10$UpCase大小写转换表65536个UTF-16字符11$Extend扩展元文件目录12-15保留未使用这张表里有两个特别值得留意。一个是$Bitmap它的$DATA流长度直接对应卷的总簇数除以8读到它就能知道整块盘的空间分配全貌。另一个是$BadClus平时它是一个长度等于卷总簇数的稀疏文件被标记为坏道的簇会从稀疏区间里实体化出来指向真正的坏簇位置。用稀疏的方式记账好处是不需要为几千兆的空间额外占用位图坏簇数量通常极少稀疏区间几乎不占空间。提示直接打开C:\$MFT这类路径会被拒绝即使管理员权限也不行。要看它们的内容得用支持原始卷访问的工具或者对整盘做镜像后在镜像文件上操作。2. 把$MFT记录摊开在桌面上记录头的逐字节解读2.1 记录头字段表与三个关键魔数$MFT本身就是一个由固定大小记录组成的巨大数组。记录大小在XP以后默认1024字节也可以在格式化时通过-c参数指定最小1024常见还有4096。判断大小的方法很简单读$Boot偏移0x40处的每MFT记录簇数字段如果是有符号数且为负把它当作2的幂指数处理。比如读到-10就是2的10次方等于1024字节读到正数1就是1个簇。每条记录开头是48字节的头部字段排布如下。偏移长度字段说明0x004魔数正常为FILE全0表示记录未使用0x042USA偏移更新序列数组相对记录起点的偏移0x062USA计数数组的16位元素个数含校验值0x088LSN对应$LogFile的日志序列号0x102序列号记录被复用的次数用于引用校验0x122硬链接计数有多少个$FILE_NAME指向本记录0x142首属性偏移第一个属性在记录内的偏移0x162标志0x01在用0x02目录0x04扩展记录0x184已用大小记录内实际写入的字节数0x1C4分配大小记录总容量通常等于记录大小0x208基记录引用属于扩展记录时指向主记录0x282下一属性ID分配新属性时使用的计数器0x2C4记录号该记录自身在MFT中的下标XP起魔数只有三种有意义FILE表示正常记录BAAD是Windows自带工具在记录损坏时打的标记表示这条记录已经没法用了全零则说明这条记录从头到尾没被分配过。恢复场景里扫描到BAAD记录基本可以直接跳过它内部数据已经被判定无效。2.2 更新序列数组一个防止断电写坏的补丁机制这是NTFS里最容易被误解的一处设计。磁盘的物理扇区通常是512字节而MFT记录经常是1024字节跨越两个扇区。如果写到第二个扇区时突然断电会导致记录一半新一半旧落到解析器眼里就是属性长度字段指向了不存在的位置直接崩掉。NTFS的解法是在每个扇区的最后两个字节写一个相同的更新序列号USN把原本该写在那里的真实数据挪到记录头部的更新序列数组里。读的时候流程固定先取数组第一个16位元素作为比较值然后依次检查每个扇区末尾的两个字节是否等于这个值。相等就把数组中对应的第i个元素回填到扇区末尾恢复真实数据。写的时候反过来先把每个扇区末尾两字节替换成USN再落盘。这样任何一次断电最多让某个扇区末尾的USN不匹配解析器立刻能发现并拒绝使用这条记录而不是带着错误数据继续跑。我来把这个过程写成代码逻辑一目了然。import struct def apply_fixup(record: bytes, usa_offset: int, usa_count: int, bytes_per_sector: int 512) - bytes: 校验并回填更新序列数组返回修复后的记录 if usa_count 2: raise ValueError(USA 计数异常) buf bytearray(record) usn bytes(buf[usa_offset:usa_offset 2]) for i in range(1, usa_count): pos i * bytes_per_sector - 2 if bytes(buf[pos:pos 2]) ! usn: raise ValueError(f第{i}个扇区 USA 校验失败记录可能损坏) # 把数组里的真实数据写回扇区末尾 buf[pos:pos 2] buf[usa_offset i * 2: usa_offset i * 2 2] return bytes(buf)注意如果你在解析自己dump出来的记录时频繁遇到USA校验失败先确认bytes_per_sector是否真的是512。部分4Kn硬盘的物理扇区是4096但逻辑扇区仍报512而NTFS做fixup时是按逻辑扇区来的以$Boot里偏移0x0B的值为准。2.3 属性链的遍历规则与结束哨兵记录头之后就是一条接一条排列的属性。遍历规则非常机械从首属性偏移开始读4字节属性类型和4字节属性长度处理完这条属性后把当前偏移加上长度继续下一条。循环终止的条件是读到类型为0xFFFFFFFF的结束标记。整个链条是一条单向链表没有回指针也不能随机跳转。这里有个细节容易踩坑。属性长度字段是4字节无符号整数但记录总长只有1024理论上不可能超过记录大小。如果解析时读出来一个几十万的偏移增量说明要么fixup没做要么偏移算错了此时应该立刻报错而不是继续往后读否则会一路读到相邻记录里去输出一堆看似合理实则错位的数据。我一般会加一道哨兵检查任何属性长度小于16或者当前偏移加长度超过记录已用大小直接判定解析失败。链上能出现的属性类型有一张固定表其中出现频率最高的是下面这几个0x10标准信息、0x30文件名、0x80数据、0x90索引根、0xB0位图。目录记录里0x90和0xA0索引分配是主力普通文件记录里0x80是主力。一份记录里同类型属性可以出现多次比如一个文件同时有长名和DOS短名就会有两条0x30有多个数据流时会有多条0x80靠属性名区分。3. 属性才是真正的信息载体从$STANDARD_INFORMATION到$DATA3.1 常驻与非常驻属性头的两种形态属性头的前16字节是固定的偏移0x08处有一个字节叫非常驻标志它决定了后面字段的布局也决定了这个属性的数据放在哪。为0表示常驻resident数据就存在属性头后面记录内偏移0x14处的2字节字段给出内容起始偏移为1表示非常驻数据存在记录之外的簇里属性头里给的是起始VCN、结束VCN以及最重要的数据运行列表偏移。常驻这个设计很聪明。小文件、目录的索引根、$DATA之外的小属性全都塞在记录里读MFT就直接读到了内容不需要额外寻道。衡量标准是记录剩余空间够不够一个1024字节的记录头部48字节每个属性头加名称大概几十字节剩下七八百字节就是常驻数据的预算。文件一旦超过这个预算NTFS就把$DATA转成非常驻同时可能触发属性列表0x20$ATTRIBUTE_LIST把部分属性挪到别的扩展记录里去。属性头之后是否跟着名称由偏移0x09的名称长度决定。名称是UTF-16LE编码的长度以字符计而不是字节。常见带名称的属性就是$DATA比如file.txt:stream1这种备用数据流就是$DATA属性带了个叫stream1的名字。3.2 时间戳双胞胎与$FILE_NAME里的父目录引用一个普通文件记录里通常有两组时间戳一组在$STANDARD_INFORMATION0x10里一组在$FILE_NAME0x30里。两组字段都是四个8字节的FILETIME创建、修改、MFT修改、访问。它们经常不一样这不是bug。$STANDARD_INFORMATION是Windows正常读写文件时维护的而$FILE_NAME里的那一组只在文件名相关操作重命名、移动时才更新。取证时对齐两组时间的差异可以推断文件是否被改过名这是个很实用的技巧。$FILE_NAME的内容结构里偏移0x00是8字节的父目录引用低48位是父目录所在MFT记录号高16位是那条记录的序列号。拿到记录号就能顺着$MFT往回找到父目录一路向上爬到根目录重建完整路径。这就是fls这类工具能在没有目录遍历权限的情况下依然列出全部文件路径的原因它根本不走目录项直接靠MFT里的父引用算路径。$FILE_NAME偏移0x40是文件名字符数0x41是命名空间。命名空间取值0到3分别代表POSIX、Win32、DOS、以及Win32和DOS并存。同一个文件如果有长名和8.3短名会存在两条$FILE_NAME命名空间不同硬链接计数也把它们都算进去。硬链接计数因此不等于文件数而是等于指向该记录的$FILE_NAME条目总数。3.3 data runs编码把簇链压缩成几字节非常驻属性的数据运行列表用的是可变长整数编码规则值得单独讲。列表由若干个运行组成每个运行的第一个字节是头字节高4位表示后面长度字段占几个字节低4位表示偏移字段占几个字节。长度为0x00表示列表结束。偏移字段是相对于上一个运行LCN的有符号增量第一个运行的增量相对0计算。如果偏移字段字节数为0表示这是一个稀疏运行不占实际簇。举个例子字节序列0x21 0x08 0x40 0x11 0x10 0x20该怎么读第一个头字节0x21高4位2、低4位1所以长度字段2字节、偏移字段1字节取到长度0x0008和偏移0x40得到第一段从LCN 0x40开始、长8个簇。第二个头字节0x11各1字节长度0x10、偏移0x20相对于上一个LCN累加得到新起点 0x400x200x60长16个簇。整条链就是这样一段段拼出来的。def parse_data_runs(data: bytes, offset: int): runs, lcn, vcn, pos [], 0, 0, offset while pos len(data) and data[pos] ! 0: header data[pos] len_size, off_size header 0x0F, header 4 pos 1 run_len int.from_bytes(data[pos:pos len_size], little) pos len_size if off_size 0: runs.append((vcn, run_len, None)) # 稀疏 else: delta int.from_bytes(data[pos:pos off_size], little, signedTrue) pos off_size lcn delta runs.append((vcn, run_len, lcn)) vcn run_len return runs读文件内容时按运行顺序把每个(vcn, 长度, 起始LCN)换算成字节偏移从卷上定位到LCN * 每簇字节数读到长度 * 每簇字节数的数据依次拼接就是完整文件。遇到LCN为None的运行跳过对应长度的内容在输出流里用零填充这就是稀疏文件的读法。4. 手写一个MFT解析器Python从引导扇区读到文件名4.1 先读引导扇区把几何参数拿到手写工具之前先确保你面对的是镜像文件而不是正在被系统挂载的卷。Linux下可以用dd做只读镜像Windows下至少也要用支持原始读的工具并且全程只读。下面这段代码假设你已经有了一块分区镜像ntfs.img。需要从引导扇区取的字段有四个每扇区字节数偏移0x0B2字节、每簇扇区数偏移0x0D1字节、MFT起始簇号偏移0x308字节、每MFT记录簇数偏移0x40有符号8字节。记录大小的换算就是前面说的负指数规则。import struct def read_boot(img): boot img.read(512) bps struct.unpack_from(H, boot, 0x0B)[0] spc boot[0x0D] mft_lcn struct.unpack_from(Q, boot, 0x30)[0] rec_clusters struct.unpack_from(b, boot, 0x40)[0] rec_size (rec_clusters * spc * bps if rec_clusters 0 else 1 (-rec_clusters)) return bps, spc, mft_lcn, rec_sizerec_clusters读成有符号字节很关键无符号读会把0xF6这种负值读成246结果一算就错得离谱。实际卷上更常见的是0xF6即-10对应1024字节记录4096字节记录会写成0xFC-4。校验方法也简单rec_size必须是2的幂且在1024到65536之间不符合就说明引导扇区读错了位置。4.2 应用fixup并遍历属性拿到MFT起始簇号后MFT的字节偏移就是mft_lcn * spc * bps。从这个位置开始每次读rec_size字节就是一条记录。注意一个坑$MFT本身是一个文件它的数据完全可能不连续如果卷被严重碎片化MFT的簇链会散在盘上。稳妥的做法是先解析第0号记录把$MFT自己的data runs读出来再按运行去读其他记录。手动写工具时为简化可以先假设MFT连续绝大多数的卷在小规模碎片下确实是连续的。def load_record(img, mft_off, index, rec_size): img.seek(mft_off index * rec_size) rec img.read(rec_size) if rec[:4] ! bFILE: return None usa_off, usa_cnt struct.unpack_from(HH, rec, 0x04) rec apply_fixup(rec, usa_off, usa_cnt) first_attr struct.unpack_from(H, rec, 0x14)[0] flags struct.unpack_from(H, rec, 0x16)[0] return {raw: rec, first_attr: first_attr, in_use: bool(flags 1), is_dir: bool(flags 2)}遍历属性时把每条属性的类型、非常驻标志、内容或data runs都记录下来。$STANDARD_INFORMATION和$FILE_NAME的内容结构固定直接按偏移取值即可$DATA需要区分常驻和非常驻分别处理。整个解析函数大概三十行逻辑非常直白这也是NTFS格式设计得比较规整的地方读懂一条记录剩下的只是循环。4.3 解码data runs与时间戳输出完整清单时间戳转换要记住FILETIME的基准是1601年1月1日单位是100纳秒。写成Python就是基准时间加微秒数除以10。时区上我建议统一按UTC输出避免和本地时区混淆取证场景下UTC是默认约定。from datetime import datetime, timedelta EPOCH datetime(1601, 1, 1) def filetime_to_utc(ft: int) - str: if ft 0: return - return (EPOCH timedelta(microsecondsft // 10)).strftime( %Y-%m-%d %H:%M:%S) def read_si(content: bytes): return { created: struct.unpack_from(Q, content, 0x00)[0], modified: struct.unpack_from(Q, content, 0x08)[0], mft_modified: struct.unpack_from(Q, content, 0x10)[0], accessed: struct.unpack_from(Q, content, 0x18)[0], attrs: struct.unpack_from(I, content, 0x20)[0], } def read_fn(content: bytes): parent struct.unpack_from(Q, content, 0x00)[0] 0xFFFFFFFFFFFF name_len content[0x40] name content[0x42:0x42 name_len * 2].decode(utf-16-le) return parent, name把这几段拼起来跑一遍就能列出每条记录的文件名、父目录记录号、逻辑大小和创建时间。第一次跑出来看到几百行整齐的输出那种原来就存在这么几个字节上的感觉挺上头。真正做产品级工具时还要处理$ATTRIBUTE_LIST把属性分散到扩展记录的情况但那是第二阶段的活先用简化版打通主流程。5. 用成熟工具交叉验证Sleuth Kit与ntfsinfo实操5.1 fsstat与ntfsinfo看布局自己写完解析器后一定要用成熟工具对一遍数否则容易自我感觉良好。Sleuth Kit里的fsstat是最省事的入口直接在镜像上跑就能看到卷的几何参数、MFT记录数、元文件位置和大小。fsstat ntfs.img它会输出每簇字节数、总簇数、MFT起始扇区、MFTMirr起始扇区、$LogFile大小等。把这些值和你自己从引导扇区算出来的对一遍一致了说明引导扇区解析没问题。另一条路是ntfsinfo它把每个元文件单独列出来能看到$MFT的实际大小、$Bitmap的簇数。ntfsinfo -m /dev/sdb1 # 只看MFT布局 ntfsinfo -f /dev/sdb1 # 看所有元文件概要注意对真实设备操作前先umount或者用只读方式挂载避免解析工具和系统同时对卷写入。做取证时标准流程是先整盘镜像再分析镜像文件用只读方式打开。5.2 istat与icat看单条记录与取流istat能直接显示某条MFT记录的完整属性列表包括两组时间戳、data runs、属性标志。istat ntfs.img 0 # 看第0号记录也就是 $MFT 自己 istat ntfs.img 5 # 看根目录输出里会列出每条属性的类型名、常驻与否、大小非常驻的还会打印data runs序列。拿这个结果和你自己代码输出的data runs逐段对比能很快定位编码实现的错误。icat则是按记录号取数据流常用来从镜像里直接导出某个文件。icat ntfs.img 5 root.dat # 导出根目录记录内容 icat ntfs.img 27 recovered.docx # 导出27号记录对应的文件icat的好处是它完全绕过目录结构只要记录号还在、data runs还能解出来就能把内容完整取出。文件被删但记录未被复用的情况下这条命令经常能救回文件这也是数据恢复里最基本的操作之一。5.3 删除文件在MFT里留下的痕迹删除操作在NTFS里做的是标记不是抹除。文件被删时$MFT对应记录的标志位里的在用位被清掉同时$Bitmap里对应的簇被标记为空闲$FILE_NAME的父目录引用可能还在索引项被移除。记录本身只要没被新文件复用头48字节、属性链、data runs甚至内容都还留在盘上。所以扫描镜像时找删除文件最有效的方法就是遍历所有记录筛选魔数为FILE但在用位为0的条目。fls就带这个能力。fls -r -d ntfs.img # 递归列出已删除条目 fls -r ntfs.img # 列出所有条目记录被复用的判断依据是序列号新建文件时会优先挑空闲记录把序列号加一旧引用就失效了。一个记录的序列号字段比$FILE_NAME里记录的引用序列号大说明它被复用过了原来那个文件的名字和内容基本无法恢复。反过来序列号没变、内容簇也还没被覆盖恢复成功率就很高。6. 踩过的坑与排查速查表6.1 fixup校验失败与跨扇区读取我自己写解析器时遇到的第一个大坑就是USA校验失败。原因不是记录损坏而是读记录的时候没按扇区对齐。有些镜像文件在被切分或压缩时开头多了或者少了几百字节的偏移导致每条记录跨在错误的扇区边界上fixup自然对不上。排查方法是打印出所有失败记录的下标看是否呈现固定间隔比如每隔一条失败一次通常就是记录大小或者起始偏移算错了。第二个坑是bytes_per_sector取值。规范里这个字段是逻辑扇区大小但市面上有极少数镜像按4096来做fixup。判断方法很简单按512算出USA计数应该是rec_size/512如果这个值和头里的USA计数不一致换成4096再算一遍总有一个是对的。USA计数本身就等于扇区数加1用它可以反向校验扇区大小这个自检逻辑值得写进工具里。6.2 稀疏、压缩与加密属性的干扰解析data runs时稀疏运行偏移字段字节数为0必须特殊处理否则你会把LCN算成0读出来的全是引导扇区的内容看起来像一堆乱码。我在第一次解析$BadClus时就踩了这个坑因为那个文件几乎全是稀疏区间结果读出来一大片重复的$Boot数据。压缩属性更麻烦。$DATA属性的标志位里如果有0x0001说明数据是压缩存储的data runs里会出现一种特殊标记稀疏运行后面跟着一个或几个实体运行实际数量按压缩单元的比例换算。完整的解压逻辑要区分标准压缩单元和稀疏单元规则是稀疏运行的长度等于实际运行长度之和除以15这个除数对应压缩比16:1。手写解压很容易出错建议用icat直接验证你的结果。加密属性EFS则完全没有通用解法属性里只有一个加密头内容对没有密钥的人是随机的遇到就别硬啃了。6.3 常见问题速查表现象可能原因处理方式记录魔数不是FILE偏移算错或读到未被初始化的记录检查MFT起始簇号和记录大小USA校验失败扇区大小判定错误或镜像偏移不对用USA计数反推扇区大小data runs解出负数LCN增量累加初始值没设0或没做符号扩展确认用signed方式读取偏移字段文件名乱码长度字段按字节而非字符处理字符数乘2后再切片时间戳差8小时用了本地时区统一按UTC输出文件大小和资源管理器不符读了分配大小而非实际大小$DATA头偏移0x30是实际大小提示非常驻$DATA头里有两个大小字段偏移0x28是分配大小按簇对齐的偏移0x30是实际大小文件真实长度。计算文件大小时前者用于计算占用空间后者用于读内容。混淆这两个值会导致把一个大小1字节的文件读成4096字节。6.4 跑工具时的环境细节在Linux下挂载NTFS卷做对照实验很方便但有个细节容易被忽略。挂载前先看dmesg有没有报错尤其是ntfs3和ntfs-3g两套驱动的行为差异前者是内核直连性能好但对日志重放的处理更严格后者走FUSE兼容性好但性能一般。做解析实验时我一般只读挂载甚至完全不挂载直接对镜像跑fsstat避免任何写入干扰。如果非要挂载记得用只读选项别让系统在后台给你写$LogFile。有些系统挂载后会在卷上留下恢复日志虽然不改变文件内容但会改动元文件做取证时属于对检材的污染。另一个细节是sync和vfs层面的缓存直接读块设备时内核页缓存可能返回旧数据。用O_DIRECT打开或者读之前fsync一下能避免读到缓存里的陈旧内容这个坑我在对比dmesg和自写工具输出时遇到过现象是自己解析的文件列表比工具少几条实际是缓存没刷。最后再分享一个我自己的使用习惯。分析块大小1024、记录大小1024的镜像时我会先把16条元文件记录全部解析并打印成一张表确认每条记录的属性链都能正常终止、USA都能通过。这一步过了再往下跑全盘扫描效率高很多。如果直接上全盘扫描遇到某条损坏记录导致解析器抛异常退出前面几千条的输出全白跑调试起来很浪费时间。