NTFS 总体布局
NTFS 用于移动硬盘和手动格式化的 U盘。识别特征:分区引导扇区 0x03 处为 "NTFS "(带4空格),0x1FE 为 55 AA。与 FAT 不同,NTFS 没有 FAT 表和数据区目录项的概念,核心思想是一切皆文件——包括文件系统自己的管理数据,也全部以文件形式存在,由一个总表 $MFT(Master File Table)统一管理。
分区起点
0x0000 ┌──────────────────────────────┐
│ $Boot(引导扇区,512字节) │ ← 含 BPB,记录 $MFT 起始簇号
├──────────────────────────────┤
│ $MFT 区(主文件表) │ ← 每条记录通常1024字节,一个文件占一条
│ (通常从簇4或卷中部开始) │
├──────────────────────────────┤
│ 系统文件区($MFTMirr等) │ ← $MFT 的镜像备份在卷中部
├──────────────────────────────┤
│ 数据区(普通文件/目录内容) │ ← $Bitmap 记录哪些簇已用
└──────────────────────────────┘
前 16 个元文件(系统文件)
$MFT 的前 16 条记录被固定保留给元文件,记录号即表内序号,不会因删除/新建而改变:
| MFT记录号 | 文件名 | 作用 |
|---|---|---|
| 0 | $MFT | 主文件表自身(它的 $DATA 描述所有 MFT 记录位置) |
| 1 | $MFTMirr | MFT 损坏时用) |
| 2 | $LogFile | 日志文件,记录元数据事务(可回放操作历史) |
| 3 | $Volume | 卷信息:卷标、序列号、NTFS 版本 |
| 4 | $AttrDef | 属性类型定义表 |
| 5 | .(根目录) | 根目录,所有文件路径的起点 |
| 6 | $Bitmap | 簇分配位图,1位=1簇,1=已用 |
| 7 | $Boot | 引导扇区(其 $DATA 就是分区头几个扇区) |
| 8 | $BadClus | 坏簇文件,把所有坏簇”占用”起来 |
| 9 | $Secure | 安全描述符存储(NTFS 权限/ACL) |
| 10 | $UpCase | 大小写转换表(Unicode 文件名比较用) |
| 11 | $Extend | 扩展功能目录(Quota 等在其下) |
| 12~15 | (保留) | 未使用,全0 |
取证意义:定位到 $MFT 后按 1024 字节逐条翻,前 16 条一定是上述文件,可用于校验解析是否正确。
$Boot(引导扇区,512字节)
Boot 里最关键的取证字段是 $MFT 起始簇号(0x30) 和 每MFT记录簇数(0x40),由它们算出 MFT 的位置和记录大小。
typedef struct _NTFS_BOOT { // 共512字节
BYTE Jmp[3]; // 0x00 跳转指令 EB 52 90
CHAR OemID[8]; // 0x03 "NTFS " (4个空格补齐8字节)
WORD BytesPerSector; // 0x0B 每扇区字节数,通常 512 (0x0200)
BYTE SectorsPerCluster; // 0x0D 每簇扇区数,通常 8 (即4KB簇)
WORD ReservedSectors; // 0x0E 保留扇区数,必须为0
BYTE Unused1[5]; // 0x10 全0 (FAT中此处是FAT表个数等,NTFS不用)
BYTE MediaDescriptor; // 0x15 介质描述符,硬盘/U盘为 0xF8
WORD Unused2; // 0x16 全0 (FAT中此处是每FAT扇区数)
WORD SectorsPerTrack; // 0x18 每磁道扇区数,通常 63 (0x3F)
WORD NumberOfHeads; // 0x1A 磁头数,通常 255 (0xFF)
DWORD HiddenSectors; // 0x1C 隐藏扇区数 = 分区前扇区数
DWORD Unused3; // 0x20 全0,NTFS 不使用
DWORD Unused4; // 0x24 NTFS 不使用,通常填 0x00800080
QWORD TotalSectors; // 0x28 卷总扇区数(8字节,小端)
QWORD MftStartLCN; // 0x30 $MFT 起始簇号(8字节) ← 重点
QWORD MftMirrStartLCN; // 0x38 $MFTMirr 起始簇号(8字节,卷中部)
LONG ClustersPerFileRecord; // 0x40 每MFT记录簇数(4字节,有符号) ← 重点
LONG ClustersPerIndexBuffer; // 0x44 每索引缓冲区簇数(4字节,同上规则)
QWORD VolumeSerialNumber; // 0x48 卷序列号(8字节,注意是FAT的2倍长)
DWORD Checksum; // 0x50 校验和
BYTE BootCode[426]; // 0x54 引导代码,恶意软件可能藏身
WORD Signature; // 0x1FE 固定 0x55AA
} NTFS_BOOT;每MFT记录簇数(0x40)的负数规则
该字段为有符号数:正数直接表示”每条记录占几个簇”;若为负数(如 0xF6 = -10),表示记录大小 = 2^|值| 字节:
| 值 | 含义 |
|---|---|
| 0xF6(-10) | 2^10 = 1024 字节/记录(最常见;MFT 记录最小就是1024字节) |
| 正数 N | N × 每簇字节数 = 记录大小(如512字节簇的卷上填 2) |
0x44 的每索引缓冲区簇数遵守同样的规则,常见正数 1(=1簇)或 0xF4(-12,=4096字节)。手工定位公式:
$MFT 文件偏移 = 分区起点 + MftStartLCN × SectorsPerCluster × BytesPerSector
MFT 记录大小 = ClustersPerFileRecord > 0 ? 该值×每簇字节数 : 2^(-该值)
手工解码示例
$Boot 前 0x54 字节片段:
EB 52 90 4E 54 46 53 20 20 20 20 00 02 08 00 00
00 00 00 00 00 F8 00 00 3F 00 FF 00 00 08 00 00
00 00 00 00 80 00 80 00 3F BF 7C 00 00 00 00 00
04 00 00 00 00 00 00 00 BF CB 07 00 00 00 00 00
F6 00 00 00 01 00 00 00 8C 3A 1B 2C 5A 1B 2C 9A
逐字段(小端):
- 0x03:
4E 54 46 53 20 20 20 20="NTFS " - 0x0B:
00 02= 512 字节/扇区;0x0D:08= 8 扇区/簇(4KB簇) - 0x1C:
00 08 00 00= 隐藏扇区 0x800(与 MBR 分区表 LBAStart 一致,见 1 MBR分区表) - 0x28:
3F BF 7C 00 ...= 卷共 0x7CBF3F 扇区 - 0x30:
04 00 00 00 ...= $MFT 起始簇 0x04 → MFT 偏移 = 4 × 8 × 512 = 0x4000 - 0x40:
F6= -10 → 每条 MFT 记录 1024 字节 - 0x48:
8C 3A 1B 2C 5A 1B 2C 9A= 卷序列号(8字节,取证明”该盘插过该机”时可关联注册表)
MFT 记录头(每条记录通常1024字节)
$MFT 是一个数组,每条记录描述一个文件或目录。记录头固定,属性从头之后顺序排列:
typedef struct _MFT_RECORD_HEADER { // 记录内偏移
CHAR Signature[4]; // 0x00 "FILE";坏记录为 "BAAD"
WORD FixupOffset; // 0x04 修正数组偏移,通常 0x30
WORD FixupCount; // 0x06 修正数组项数 = 扇区数+1,1024字节记录为3
QWORD Lsn; // 0x08 $LogFile 序列号(LSN),日志恢复用
WORD SequenceNumber; // 0x10 序列号,记录每被删除重用一次就+1 ← 重点
WORD HardLinkCount; // 0x12 硬链接数
WORD FirstAttrOffset; // 0x14 第一个属性的偏移,通常 0x38 ← 重点
WORD Flags; // 0x16 标志 ← 重点
DWORD UsedSize; // 0x18 记录实际使用大小(字节)
DWORD AllocatedSize; // 0x1C 记录分配大小,通常 1024 (0x400)
QWORD BaseRecordRef; // 0x20 基本记录引用(文件引用,见下)
WORD NextAttrID; // 0x28 下一个可用属性ID
WORD Unused; // 0x2A 未使用(NTFS 3.1以下)
DWORD RecordNumber; // 0x2C 本记录的MFT记录号(NTFS 3.1+)
// 0x30 起为修正数组(Update Sequence Array)
} MFT_RECORD_HEADER;记录头标志(0x16)取值:
| 值 | 含义 |
|---|---|
| 0x01 | 记录使用中(In Use) |
| 0x02 | 是目录 |
| 0x00 | 已删除(0x01 位被清除,但内容通常还在) |
文件引用(File Reference,8字节):低6字节是 MFT 记录号,高2字节是该记录的序列号。引用方(如 $FILE_NAME 中的父目录引用)会同时记录两者,若序列号与目标记录当前序列号不一致,说明目标记录已被删除重用——这是判断”悬空引用”的依据。
修正数组(Fixup / Update Sequence)
MFT 记录跨多个 512 字节扇区,为防止写入一半断电导致”半新半旧”,NTFS 在写盘时把每 512 字节末尾的 2 字节替换为修正值(Update Sequence Number),原值保存在记录头的修正数组中:
修正数组布局(记录偏移 0x30 起):
[0] 修正值 USN(2字节)
[1] 第1扇区末尾2字节的原值(应写回记录内 0x1FE)
[2] 第2扇区末尾2字节的原值(应写回记录内 0x3FE)
(1024字节记录共3项,FixupCount=3)
手工校验/修复方法:
- 读出记录内 0x1FE、0x3FE 处的 2 字节,应都等于修正数组 [0] 的 USN;不等 → 记录损坏或被篡改。
- 修复时把 [1] 写回 0x1FE、[2] 写回 0x3FE,才能看到原始数据。
- 修复后记得把修正数组”用掉”,否则再按原样解析会出错。010 Editor 的 NTFS 模板自动做这步,手工解析时必须自己还原,否则 0x1FE/0x3FE 处读到的是假数据。
属性通用头
记录头之后是一个个属性(Attribute),直到遇到 0xFFFFFFFF 结束标志。每个属性以通用头开始,分常驻(Resident,内容直接在记录内)和非常驻(Non-Resident,内容在外部簇,记录内只存 Data Run 位置表)两种。
typedef struct _ATTR_HEADER_COMMON { // 两种属性共有的前16字节
DWORD TypeCode; // 0x00 属性类型码(见下表) ← 重点
DWORD TotalLength; // 0x04 本属性总长度(含头,字节)
BYTE NonResidentFlag; // 0x08 0=常驻 1=非常驻 ← 重点
BYTE NameLength; // 0x09 属性名长度(Unicode字符数),无名为0
WORD NameOffset; // 0x0A 属性名偏移(相对属性头)
WORD Flags; // 0x0C 0x0001压缩 0x4000加密 0x8000稀疏
WORD AttributeID; // 0x0E 属性ID
} ATTR_HEADER_COMMON;
typedef struct _ATTR_RESIDENT { // 常驻属性头,共0x18字节
ATTR_HEADER_COMMON Common; // 0x00 通用头
DWORD ContentLength; // 0x10 属性内容长度(字节)
WORD ContentOffset; // 0x14 内容偏移(相对属性头) ← 顺此找内容
BYTE IndexedFlag; // 0x16 是否被索引
BYTE Padding; // 0x17 对齐
} ATTR_RESIDENT;
typedef struct _ATTR_NONRESIDENT { // 非常驻属性头,共0x40字节
ATTR_HEADER_COMMON Common; // 0x00 通用头
QWORD StartVCN; // 0x10 起始VCN(本段在文件内的起始虚拟簇号)
QWORD LastVCN; // 0x18 结束VCN
WORD DataRunOffset; // 0x20 Data Run 偏移(相对属性头) ← 重点
WORD CompressionUnit; // 0x22 压缩单位(2的N次方簇),无压缩为0
DWORD Padding; // 0x24 对齐
QWORD AllocatedSize; // 0x28 内容占用磁盘大小(字节)
QWORD RealSize; // 0x30 内容实际大小(字节) ← 文件真实大小
QWORD InitializedSize; // 0x38 已初始化大小(字节)
} ATTR_NONRESIDENT;属性类型码表(0x00,4字节)
| 类型码 | 属性名 | 说明 |
|---|---|---|
| 0x10 | $STANDARD_INFORMATION | 标准信息:4个时间戳+文件属性(所有文件必有) |
| 0x20 | $ATTRIBUTE_LIST | 属性列表(记录放不下所有属性时的索引) |
| 0x30 | $FILE_NAME | 文件名+父目录引用+又一组时间戳(必有,可多个) |
| 0x40 | $OBJECT_ID | 对象ID(GUID) |
| 0x50 | $SECURITY_DESCRIPTOR | 安全描述符(ACL,新版移至 $Secure) |
| 0x60 | $VOLUME_NAME | 卷标(仅 $Volume 有) |
| 0x70 | $VOLUME_INFORMATION | 卷版本(仅 $Volume 有) |
| 0x80 | $DATA | 文件内容(无名;也可有 ADS 命名数据流) |
| 0x90 | $INDEX_ROOT | 目录索引根(目录必有,存小目录的索引项) |
| 0xA0 | $INDEX_ALLOCATION | 目录索引分配(大目录的 B+ 树节点,非常驻) |
| 0xB0 | $BITMAP | 位图(目录索引/Bitmap 用) |
| 0xC0 | $REPARSE_POINT | 重解析点(符号链接/挂载点) |
| 0x100 | $LOGGED_UTILITY_STREAM | 加密日志流(EFS) |
| 0xFFFFFFFF | 结束标志 | 属性列表终止 |
属性按类型码升序排列,解析时按 TotalLength 逐个跳。
关键属性详解
$STANDARD_INFORMATION(0x10,常驻)
内容布局(偏移相对属性内容起点,共 0x48 字节):
typedef struct _STANDARD_INFORMATION { // 共0x48字节
QWORD CreateTime; // 0x00 创建时间(FILETIME)
QWORD ModifyTime; // 0x08 内容最后修改时间
QWORD MftChangeTime; // 0x10 MFT记录最后改变时间 ← 重点
QWORD AccessTime; // 0x18 最后访问时间
DWORD FileAttributes; // 0x20 文件属性(只读/隐藏/系统等)
DWORD MaxVersion; // 0x24 最大版本号
DWORD VersionNumber; // 0x28 版本号
DWORD ClassID; // 0x2C 类ID
DWORD OwnerID; // 0x30 所有者ID
DWORD SecurityID; // 0x34 安全ID(对应$Secure索引)
QWORD QuotaCharged; // 0x38 配额计费
QWORD USN; // 0x40 更新序列号(对应$UsnJrnl)
} STANDARD_INFORMATION;FILETIME:8 字节小端无符号数,表示从 1601-01-01 UTC 起的 100 纳秒数。换算 Unix 时间戳:(FILETIME - 116444736000000000) / 10000000。全 0 是合法的(表示未设置),不要当成解析错误。
取证价值:**SI 不可信,必须与 $FILE_NAME 交叉验证(见取证要点)。
$FILE_NAME(0x30,常驻)
typedef struct _FILE_NAME { // 内容偏移,头部共0x42字节
QWORD ParentRef; // 0x00 父目录文件引用(低6字节记录号+高2字节序列号)
QWORD CreateTime; // 0x08 创建时间(FILETIME)
QWORD ModifyTime; // 0x10 内容修改时间
QWORD MftChangeTime; // 0x18 MFT记录改变时间
QWORD AccessTime; // 0x20 最后访问时间
QWORD AllocatedSize; // 0x28 分配大小(字节)
QWORD RealSize; // 0x30 实际大小(字节) ← 文件大小
DWORD Flags; // 0x38 文件属性
DWORD ReparseValue; // 0x3C 重解析/EA
BYTE NameLength; // 0x40 文件名长度(字符数)
BYTE NameSpace; // 0x41 命名空间
WCHAR Name[]; // 0x42 Unicode文件名(UTF-16LE,无0结尾)
} FILE_NAME;命名空间(0x41)取值:
| 值 | 含义 |
|---|---|
| 0 | POSIX(区分大小写) |
| 1 | Win32(长文件名) |
| 2 | DOS(8.3 短文件名) |
| 3 | Win32 & DOS(长短名相同时合并) |
取证重点:FN 的时间晚于 $SI),高度疑似 timestomp 反取证篡改。
$DATA(0x80)与 Data Run
$DATA 存文件内容。常驻:内容直接跟在属性头后,按 ContentOffset(0x14)取 ContentLength(0x10)字节即为文件数据。非常驻:内容在卷的其他簇,记录内只存 Data Run(簇位置表),从 DataRunOffset(0x20)处解析。
Data Run 是变长编码,每条结构:
首字节: 高4位 = 偏移字段字节数(O),低4位 = 长度字段字节数(L)
随后: L 字节长度(本段占几个簇,小端)
随后: O 字节偏移(LCN,小端,有符号)
- 第一条 Data Run: 绝对 LCN
- 后续条: 相对前一条的差值(可正可负,负数用补码)
结束: 0x00
手工解码示例
某非常驻 $DATA 的 Data Run 区为:31 05 34 12 00
- 首字节
0x31:高4位=3(偏移3字节),低4位=1(长度1字节) - 长度(1字节):
05→ 本段 5 个簇 - 偏移(3字节):
34 12 00→ 小端 LCN = 0x001234 - 下个字节
00→ Data Run 结束
→ 文件内容 = 从 LCN 0x1234 起连续 5 簇。文件偏移 = 分区起点 + 0x1234 × 每簇字节数。
再如两条 Run 的情况 21 05 34 12 21 03 02 00:
- 第一条:首字节
0x21(L=1, O=2)→ 长度05= 5 簇,偏移34 12= LCN 0x1234(绝对值) - 第二条:首字节
0x21→ 长度03= 3 簇,偏移02= +2(相对值)→ LCN 0x1234+2 = 0x1236 00结束
→ 文件内容 = LCN 0x1234 起 5 簇 + LCN 0x1236 起 3 簇。后续 Run 的偏移是相对前一条的差值(可正可负,负数用补码,如 FE = -2 表示往前 2 簇)——文件可能不连续存放,必须逐段解析。
常驻/非常驻判断与小文件恢复
- 判断依据:属性头 0x08 的 NonResidentFlag。0=常驻,1=非常驻。
- 经验值:1024 字节记录下,内容不超过约 600~700 字节的文件通常常驻(记录内要塞下 FN + $DATA 三个属性)。CTF 中小 flag 文件几乎一定常驻。
- 恢复思路:
- 常驻文件被删 → 记录头 Flags 的 0x01 位被清,但属性内容仍在记录里,直接按 ContentOffset 把 $DATA 内容抠出来即可。
- 非常驻文件被删 → 只要 MFT 记录未被覆写,Data Run 还在,按 Data Run 到对应簇读原始数据;$Bitmap 中这些簇已标记空闲,但数据被覆写前均可恢复。
- 彻底搜索:删除后记录被重用覆盖了?用文件内容特征(魔数/flag 前缀)全盘 carve,或用 $MFTMirr 找回前 4 条系统记录。
取证要点
- Timestomp 检测:对比同一记录的 FILE_NAME 两组时间戳。FN 晚于 LogFile/$UsnJrnl 里的原始记录佐证。
- 删除恢复:删除文件 = 记录头 Flags(0x16)清掉 0x01 位 + $Bitmap 释放簇,记录本身和内容通常原样保留。按 1024 字节对齐扫描
"FILE"签名(排除"BAAD"),校验 Fixup 后恢复属性。记录内 0x10 的序列号 +1 也说明该记录经历过删除重用。 - 操作历史回放:Extend$UsnJrnl 记录每次变更(含被删文件名、时间、原因码),是还原”谁删了什么”的核心来源;UsnJrnl 条目对应。
- 卷序列号:$Boot 0x48 处为 8 字节(FAT 是 4 字节,别搞混),可与注册表
MountedDevices、LNK 文件关联,证明盘机接触。 - 藏数据位置:MFT 区与 BadClus 占用的”坏簇”、常驻 DATA 属性,dir /R 才可见)都是 CTF 高频考点。
- 解析自检:Boot 的 HiddenSectors 应与 1 MBR分区表 中分区 LBAStart 一致,不一致说明找错了分区起点或镜像有多个分区。