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$MFTMirrMFT 损坏时用)
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字节)
正数 NN × 每簇字节数 = 记录大小(如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)

手工校验/修复方法:

  1. 读出记录内 0x1FE、0x3FE 处的 2 字节,应都等于修正数组 [0] 的 USN;不等 → 记录损坏或被篡改。
  2. 修复时把 [1] 写回 0x1FE、[2] 写回 0x3FE,才能看到原始数据。
  3. 修复后记得把修正数组”用掉”,否则再按原样解析会出错。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)取值:

值含义
0POSIX(区分大小写)
1Win32(长文件名)
2DOS(8.3 短文件名)
3Win32 & 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 条系统记录。

取证要点

  1. Timestomp 检测:对比同一记录的 FILE_NAME 两组时间戳。FN 晚于 LogFile/$UsnJrnl 里的原始记录佐证。
  2. 删除恢复:删除文件 = 记录头 Flags(0x16)清掉 0x01 位 + $Bitmap 释放簇,记录本身和内容通常原样保留。按 1024 字节对齐扫描 "FILE" 签名(排除 "BAAD"),校验 Fixup 后恢复属性。记录内 0x10 的序列号 +1 也说明该记录经历过删除重用。
  3. 操作历史回放:Extend$UsnJrnl 记录每次变更(含被删文件名、时间、原因码),是还原”谁删了什么”的核心来源;UsnJrnl 条目对应。
  4. 卷序列号:$Boot 0x48 处为 8 字节(FAT 是 4 字节,别搞混),可与注册表 MountedDevices、LNK 文件关联,证明盘机接触。
  5. 藏数据位置:MFT 区与 BadClus 占用的”坏簇”、常驻 DATA 属性,dir /R 才可见)都是 CTF 高频考点。
  6. 解析自检:Boot 的 HiddenSectors 应与 1 MBR分区表 中分区 LBAStart 一致,不一致说明找错了分区起点或镜像有多个分区。