跳转到内容
She11code Blog
Go back

NTFS解析与武器化

Edit page

本文将深入解析 NTFS 文件系统的底层结构,帮助你理解 Windows 是如何组织和访问磁盘数据的。

概述

NTFS 是什么

NTFS(New Technology File System)是 Windows 的原生文件系统,自 Windows NT 3.1 引入以来,一直作为 Windows 的默认文件系统。相比 FAT32,NTFS 支持更大的文件和分区、提供更好的安全性(ACL)、支持文件压缩和加密等特性。

四大核心组件

NTFS 可以抽象为四个主要部分:

NTFS 卷

├── Boot Sector(引导扇区)
│   └── 提供卷参数,告诉系统 $MFT 在哪里

├── $MFT(主文件表)
│   └── 记录卷上每个文件和目录的"档案卡"

├── Data Area(数据区)
│   └── 承载非驻留数据

└── $MFTMirr(主文件表镜像)
    └── MFT 局部损坏时提供恢复能力

用一句话概括:

Boot Sector 负责定位,$MFT 负责编目,Data Area 负责承载实际非驻留数据。


引导扇区(Boot Sector)

引导扇区位于卷的第一个扇区,包含了卷的基本参数和 $MFT 的位置信息。

关键字段

偏移字段名说明
0x0Bbytes_per_sector每扇区字节数(通常为 512)
0x0Dsectors_per_cluster每簇扇区数
0x30mft_lcn$MFT 起始簇号

C 语言结构定义

typedef struct NTFS_BOOT_SECTOR_512 {
    uint8_t  jump[3];                   // 0x00: 跳转指令
    char     oem_id[8];                 // 0x03: OEM ID,通常为 "NTFS    "

    uint16_t bytes_per_sector;          // 0x0B: 每扇区字节数
    uint8_t  sectors_per_cluster;       // 0x0D: 每簇扇区数
    uint16_t reserved_sectors;          // 0x0E: 保留扇区数

    // ... 其他字段 ...

    uint64_t total_sectors;             // 0x28: 总扇区数
    uint64_t mft_lcn;                   // 0x30: $MFT 起始簇号
    uint64_t mftmirr_lcn;               // 0x38: $MFTMirr 起始簇号

    int8_t   clusters_per_file_record;  // 0x40: 每 Record 簇数
    int8_t   clusters_per_index_block;  // 0x44: 每索引块簇数

    uint64_t volume_serial_number;      // 0x48: 卷序列号
    uint32_t checksum;                  // 0x50: 校验和

    uint8_t  boot_code[426];            // 0x54: 引导代码
    uint16_t end_marker;                // 0x1FE: 结束标记 0xAA55
} NTFS_BOOT_SECTOR_512;

定位 $MFT 的公式

$$\text{MFT_Offset} = \text{mft_lcn} \times \text{sectors_per_cluster} \times \text{bytes_per_sector}$$

其中:


主文件表 $MFT

$MFT(Master File Table)是 NTFS 的核心,它是一张”总索引表”,每条 Record 描述一个文件、目录或元数据文件。

16 个系统元数据文件

$MFT 的前 16 条 Record 保留给系统元数据文件:

Record文件名用途
0$Mft主文件表本身
1$MftMirrMFT 前 4 条记录的镜像,用于恢复
2$LogFile日志文件,用于恢复一致性
3$Volume卷信息(卷标、版本等)
4$AttrDef属性定义表
5.(根目录)根文件夹
6$Bitmap簇位图,标记空闲/已用簇
7$Boot引导扇区
8$BadClus坏簇记录
9$Secure安全描述符
10$Upcase大写转换表
11$Extend扩展元数据目录
12-15(保留)供将来使用

Record 的角色

每条 Record 可以理解为一张”档案卡”:


Record 结构详解

$MFT 由许多固定大小的 Record 槽位组成。理解 Record 的结构是解析 NTFS 的关键。

结构概览

Record

├── 固定头字段区(Header)

├── USA / Fixup 区

├── Attribute 区

└── 未使用尾部

Header 固定字段

typedef struct NTFS_FILE_RECORD_HEADER {
    char     magic[4];              // 固定为 "FILE"
    uint16_t usa_offset;            // USA 区偏移
    uint16_t usa_count;             // USA 项数量

    uint64_t lsn;                   // 日志序列号

    uint16_t sequence_number;       // Record 复用次数
    uint16_t hard_link_count;       // 硬链接数

    uint16_t first_attr_offset;     // 第一个 Attribute 偏移
    uint16_t flags;                 // 0x0001=使用中,0x0002=目录

    uint32_t used_size;             // 已使用字节数
    uint32_t allocated_size;        // 分配的总字节数

    uint64_t base_file_record;      // 基础记录引用(扩展记录用)
    uint16_t next_attr_id;          // 下一个属性 ID
    uint16_t reserved;              // 保留
    uint32_t mft_record_number;     // Record 编号
} NTFS_FILE_RECORD_HEADER;

关键字段说明

USA 完整性保护机制

USA(Update Sequence Array)用于保护 Record 的完整性。

typedef struct NTFS_USA {
    uint16_t usn;                   // 更新序列号
    uint16_t sector_end_data[];     // 每个扇区原末尾 2 字节的备份
} NTFS_USA;

工作原理

  1. 写入时

    • 把 Record 覆盖的每个扇区末尾 2 字节替换为 usn
    • 原值保存到 sector_end_data[]
  2. 读取时

    • 检查每个扇区末尾是否等于 usn
    • 如果一致,恢复原值
    • 如果不一致,说明该扇区数据损坏

Attribute 区域

Attribute 区域通过 first_attr_offset 定位,存放了多个 Attribute 块。


Attribute 体系

每个 Attribute 描述文件的一个”方面”,如数据内容、文件名、时间戳等。

公共头部

typedef struct NTFS_ATTR_HEADER_COMMON {
    uint32_t type;                  // 属性类型
    uint32_t length;                // 整个 Attribute 长度
    uint8_t  non_resident;          // 0=驻留,1=非驻留
    uint8_t  name_length;           // 属性名长度
    uint16_t name_offset;           // 属性名偏移
    uint16_t flags;                 // 压缩、加密等标志
    uint16_t attr_id;               // 属性 ID
} NTFS_ATTR_HEADER_COMMON;

常见属性类型

类型值名称说明
0x10$STANDARD_INFORMATION时间戳、只读/隐藏等标志
0x30$FILE_NAME文件名、父目录引用
0x80$DATA文件内容

Resident vs NonResident

根据数据大小,Attribute 分为两种类型:

Resident(驻留)

数据直接存放在 Record 内:

typedef struct NTFS_ATTR_HEADER_RESIDENT {
    NTFS_ATTR_HEADER_COMMON common;
    uint32_t value_length;          // 数据长度
    uint16_t value_offset;          // 数据偏移
    uint8_t  indexed_flag;          // 是否可索引
    uint8_t  reserved;
} NTFS_ATTR_HEADER_RESIDENT;

适用场景:小文件、短文件名、时间戳等

NonResident(非驻留)

数据存放在 Record 外,Record 内只保留映射信息:

typedef struct NTFS_ATTR_HEADER_NONRESIDENT {
    NTFS_ATTR_HEADER_COMMON common;

    uint64_t lowest_vcn;            // 起始 VCN
    uint64_t highest_vcn;           // 结束 VCN

    uint16_t mapping_pairs_offset;  // Runlist 偏移
    uint8_t  compression_unit;      // 压缩单位
    uint8_t  reserved[5];

    uint64_t allocated_size;        // 分配大小
    uint64_t data_size;             // 逻辑大小
    uint64_t initialized_size;      // 已初始化大小
    uint64_t compressed_size;       // 压缩后大小(可选)
} NTFS_ATTR_HEADER_NONRESIDENT;

适用场景:大文件、目录索引等

Runlist 与 VCN→LCN 映射

对于 NonResident 属性,需要通过 Runlist 找到数据的真实位置。

概念

数据读取步骤

  1. 通过 mapping_pairs_offset 找到 Runlist
  2. 解析 Runlist,建立 VCN → LCN 映射
  3. 根据 LCN 计算真实磁盘地址: $$\text{DataOffset} = \text{LCN} \times \text{ClusterSize}$$

Runlist 解析示例

若某条 Run 表示 RunLength = 8, LCN = 36,则: $$\text{VCN } 0 \sim 7 \rightarrow \text{LCN } 36 \sim 43$$

即这个属性流的前 8 个逻辑簇,实际存放在卷上的第 36 到 43 号簇。


目录索引结构

要遍历目录,需要理解目录索引的数据结构。

INDEX_ENTRY_HEADER

typedef struct NTFS_INDEX_ENTRY_HEADER {
    NTFS_FILE_REFERENCE file_reference; // 指向目标 MFT Record
    uint16_t entry_length;              // 当前 Entry 总长度
    uint16_t key_length;                // Key 长度(通常是 FILE_NAME 属性)
    uint16_t flags;                     // 0x0001=有子节点, 0x0002=最后一项
    uint16_t reserved;
} NTFS_INDEX_ENTRY_HEADER;

关键字段file_reference 指向子文件/子目录的 MFT Record。

FILE_REFERENCE(文件引用号)

这是一个 64 位值,需要拆分为两部分:

typedef uint64_t NTFS_FILE_REFERENCE;

// 低 48 位 = MFT Record 编号
static inline uint64_t ntfs_ref_record_number(NTFS_FILE_REFERENCE ref) {
    return ref & 0x0000FFFFFFFFFFFFULL;
}

// 高 16 位 = 序列号
static inline uint16_t ntfs_ref_sequence_number(NTFS_FILE_REFERENCE ref) {
    return (uint16_t)(ref >> 48);
}
部分位数含义
FileRecordNumber低 48 位MFT Record 编号
SequenceNumber高 16 位序列号(用于验证)

序列号的作用:确保引用的有效性。如果 Record 被复用,序列号会变化,旧引用将失效。

FILE_NAME_ATTR

索引项中携带的文件名属性:

typedef struct NTFS_FILE_NAME_ATTR {
    NTFS_FILE_REFERENCE parent_directory; // 父目录引用

    uint64_t creation_time;       // 创建时间
    uint64_t modified_time;       // 修改时间
    uint64_t mft_changed_time;    // MFT 修改时间
    uint64_t read_time;           // 访问时间

    uint64_t allocated_size;      // 已分配大小
    uint64_t real_size;           // 实际大小
    uint32_t flags;               // 文件/目录标志
    uint32_t reparse;             // 重解析值

    uint8_t  name_length;         // 文件名长度(Unicode 字符数)
    uint8_t  name_namespace;      // 名字空间

    // 后面紧跟: uint16_t name[name_length]
} NTFS_FILE_NAME_ATTR;

文件操作实现原理

理解了 NTFS 的数据结构后,我们来看看常见文件操作的底层实现。

路径遍历

所有操作的第一步都是解析路径。路径遍历的本质是:从根目录开始,一层一层在目录索引中按名字查找。

路径: C:\Users\test.txt

1. 从根目录 mft[5] 开始
2. 在索引中找 "Users" → 得到 file_reference
3. 跳转到对应 Record
4. 在索引中找 "test.txt" → 得到 file_reference
5. 跳转到目标 Record

核心步骤

浏览目录(ls)

找到目标目录的 Record 后,读取 INDEX_ROOT / INDEX_ALLOCATION 属性,枚举所有 Entry,输出文件名。

读取文件(cat)

找到目标文件的 Record 后,读取 $DATA 属性:

创建文件(touch)

本质:创建一个新的 Record,并插入父目录索引。

1. 找到父目录
2. 分配新的 MFT Record
3. 初始化属性:
   - $STANDARD_INFORMATION
   - $FILE_NAME
   - 空的 $DATA
4. 在父目录索引里插入新 Entry
5. 更新日志和元数据

创建目录(mkdir)

本质:创建一个新的目录 Record,并插入父目录索引。

1. 定位父目录 Record
2. 分配新的 MFT Record
3. 初始化属性:
   - $STANDARD_INFORMATION
   - $FILE_NAME
   - $INDEX_ROOT
4. 在父目录索引中插入新 Entry
5. 更新位图、MFT、日志

删除文件(rm)

本质:从父目录中移除名字,并回收资源。

1. 找到目标文件 Record
2. 在父目录索引里删除对应 Entry
3. 减少硬链接计数
4. 如果硬链接计数 = 0:
   - 标记 Record 为未使用
   - 回收 $DATA 占用的簇
   - 更新 $Bitmap
5. 记录日志

删除目录(rmdir)

本质:删除一个空目录。

rm 的区别:

移动/重命名(mv)

本质:改目录索引 + 改 $FILE_NAME。

同目录改名

  1. 在父目录索引中修改 Entry 的名字
  2. 更新 Record 中的 $FILE_NAME 属性

跨目录移动

  1. 在旧父目录索引中删除 Entry
  2. 在新父目录索引中插入 Entry
  3. 更新 $FILE_NAME 里的父目录引用

同一卷内移动,通常不需要搬实际 $DATA 簇。

复制文件(cp)

本质:创建新的 Record + 复制数据。

mv 的区别:cp 一定会创建新的 Record 和新的数据分配。

1. 读取源文件 $DATA
2. 创建新的 Record
3. 分配新的 $DATA
4. 写入数据
5. 在目标目录插入新 Entry

写文件

本质:修改目标文件的 $DATA 属性。

1. 找到目标 Record
2. 找到 $DATA
3. 如果写入变大,可能 Resident → NonResident
4. 为新数据分配簇
5. 更新 Runlist
6. 更新 allocated_size、data_size
7. 更新 $Bitmap 和日志

查看文件信息(stat)

本质:读取 Record 里的元数据属性。

不是读数据内容,而是读:

操作总结

操作本质
ls读取 INDEX_ROOT/ALLOCATION,枚举 Entry
cat读取 $DATA(Resident 直接取,NonResident 解 Runlist)
touch新建 Record + 插入父目录索引
mkdir新建目录 Record + 初始化索引属性
rm删除 Entry + 硬链接为 0 时回收资源
rmdir删除空目录的 Entry + 回收 Record
mv改目录索引 + 改 $FILE_NAME(同卷不搬数据)
cp新建 Record + 复制数据
write修改 $DATA + 可能转 NonResident + 分配新簇
stat读取元数据属性

总结

NTFS 的核心设计理念是”一切皆属性”:

  1. 定位链:Boot Sector → $MFT → Record → Attribute → Data
  2. Record:文件的”档案卡”,包含多个 Attribute
  3. Attribute:描述文件的不同方面,分为 Resident 和 NonResident
  4. 索引:目录通过 Index Entry 组织,通过 File Reference 定位子项

理解这些结构后,你就可以:


Edit page
Share this post on:

Next Post
企业网络安全建设checklist