exFAT 文件系统
exFAT(Extended FAT)是微软为闪存设计的文件系统,大于 32GB 的U盘/SD卡默认格式化成它(SDXC 标准覆盖 >32GB~2TB 的卡并强制 exFAT;恰好 32GB 属于 SDHC,通常仍是 FAT32)。突破了 FAT32 单文件 4GB 的限制,单卷最大可到 128PB。分区表中它的类型码是 0x07(与 NTFS 相同,见 1 MBR分区表),必须到引导扇区看 0x03 处的 "EXFAT "(带3空格)才能确认。
与 FAT32 最大的两点区别:
- 簇大小/扇区大小用”移位值”表达(2 的幂),不再直接存字节数;
- 目录项不再定长定格式,而是 32 字节一项、靠首字节类型码区分的一整套条目。
总体布局
整个卷分为四个大区域,所有偏移都是相对分区起点的扇区号(分区起点由 1 MBR分区表 的 LBAStart 给出):
分区起点(相对0)
│ ┌──────────────────────────────────────────┐
│ │ 扇区 0 主引导扇区 (Main Boot Sector) │ ← 0x03 "EXFAT "
│ │ 扇区 1~8 扩展引导扇区×8 (Extended Boot) │
│ │ 扇区 9 OEM 参数区 │
│ │ 扇区 10 保留区 │
│ │ 扇区 11 主校验和扇区 (Checksum) │
│ └──────────────────────────────────────────┘ ← 主引导区域(共12扇区)
│ ┌──────────────────────────────────────────┐
│ │ 扇区 12~23 备份引导区域(结构同上,完整复制) │ ← 主引导区损坏时的恢复来源
│ └──────────────────────────────────────────┘
├─▶┌──────────────────────────────────────────┐
│ │ FAT 区 (FatOffset 处开始, 4字节/项) │
│ └──────────────────────────────────────────┘
├─▶┌──────────────────────────────────────────┐
└─▶│ 数据区/簇堆 (Cluster Heap) │
│ 簇 2 开始编号, 内含: │
│ 分配位图簇 / 大写表簇 / 根目录簇 / 文件 │
└──────────────────────────────────────────┘
要点:
- 主、备两个引导区域各 12 个扇区,结构完全相同,备份区是主区的逐字节复制(实时同步)。
- FAT 区通常只有 1 份 FAT(FAT32 一般 2 份)。
- 数据区按**簇(Cluster)**分配,簇号从 2 开始(0、1 保留),根目录也是一个簇,位置记在引导扇区里。
主引导扇区(扇区0,共512字节)
#pragma pack(1)
typedef struct _EXFAT_BOOT_SECTOR { // 共512字节
BYTE JumpBoot[3]; // 0x00 跳转指令, 固定 EB 76 90
char FileSystemName[8]; // 0x03 固定 "EXFAT " (带3个空格)
BYTE MustBeZero[53]; // 0x0B 必须全0, 刻意占住FAT32的BPB区域
QWORD PartitionOffset; // 0x40 分区偏移(卷前的隐藏扇区数, 小端)
QWORD VolumeLength; // 0x48 卷总扇区数
DWORD FatOffset; // 0x50 FAT区偏移(相对分区起点的扇区数)
DWORD FatLength; // 0x54 FAT区长度(扇区数)
DWORD ClusterHeapOffset; // 0x58 簇堆偏移(相对分区起点的扇区数)
DWORD ClusterCount; // 0x5C 簇堆中簇的总数
DWORD FirstClusterOfRootDir; // 0x60 根目录起始簇号(通常为4)
DWORD VolumeSerialNumber; // 0x64 卷序列号 ← 取证关联点
WORD FileSystemRevision; // 0x68 版本号: 高字节=主版本,低字节=次版本(0x0100=1.0)
WORD VolumeFlags; // 0x6A 卷标志(见下表)
BYTE BytesPerSectorShift; // 0x6C 每扇区字节数移位值: 9=512, 12=4096
BYTE SectorsPerClusterShift; // 0x6D 每簇扇区数移位值: 如5=每簇32扇区(16KB)
BYTE NumberOfFats; // 0x6E FAT份数: 1(无TexFAT)/2
BYTE DriveSelect; // 0x6F 驱动器号: 通常0x80
BYTE PercentInUse; // 0x70 已用簇百分比(0~100, 0xFF=不可用)
BYTE Reserved[7]; // 0x71 保留
BYTE BootCode[390]; // 0x78 引导代码
WORD BootSignature; // 0x1FE 固定 0x55AA(盘上字节 55 AA)
} EXFAT_BOOT_SECTOR;自查偏移累加:0x0B+53=0x40 ✓;0x40+8=0x48 ✓;0x48+8=0x50 ✓;之后每4字节一项直到 0x64;0x64+4=0x68 ✓;0x68+2=0x6A ✓;0x6A+2=0x6C ✓;0x71+7=0x78 ✓;0x78+390=0x1FE ✓。
关键点:exFAT 用移位值表达大小,这是与 2 FAT文件系统 的关键区别。
| 字段 | 含义 | 换算 |
|---|---|---|
| BytesPerSectorShift = 9 | 每扇区字节数 = 2^9 | 512 字节 |
| BytesPerSectorShift = 12 | 每扇区字节数 = 2^12 | 4096 字节(4Kn 盘) |
| SectorsPerClusterShift = n | 每簇扇区数 = 2^n | n=5 → 32扇区/簇 = 16KB(512扇区时) |
即:每簇字节数 = 2^(BytesPerSectorShift + SectorsPerClusterShift)。手工解析时一切大小换算都先查这两个移位值,不要默认 512/簇。
VolumeFlags(0x6A,2字节,位域)
| 位 | 含义 |
|---|---|
| bit 0 | ActiveFAT:0=使用第一份FAT;1=第二份(仅 TexFAT 有意义) |
| bit 1 | VolumeDirty:介质脏,卷被非正常卸载/正在写入时置1 |
| bit 2 | MediaFailure:媒体故障,发现坏簇/坏块时置1 |
| bit 3 | ClearToZero:无实际含义,必须为0 |
bit 1 和 bit 2 常被出题人利用——非正常拔盘留下的 VolumeDirty=1 本身就是取证痕迹。
校验和扇区(扇区11 / 备份区扇区23)
主引导区域前 11 个扇区(引导扇区+扩展引导扇区+OEM参数+保留区)按字节计算一个 32 位校验和,把该校验和重复填充整个扇区(512/4=128 份)构成第 12 扇区。备份引导区域有自己的校验和扇区。
- 计算时跳过引导扇区的 0x6A、0x6B(VolumeFlags)和 0x70(PercentInUse) 三个字节(它们是运行时会变的状态位)。
- 算法:每读一个字节,
checksum = (checksum >> 1 | checksum << 31) + byte,32 位无符号循环右移累加。
作用:防篡改检测。引导区域任何字段(除上述3字节)被改动后校验和就对不上,系统会拒绝挂载。CTF 改了引导扇区字段后必须重算校验和,这是 exFAT 题的高频考点,也是判断”镜像是否被手工改过”的快速手段。
FAT 表项(4字节/项)
FAT 区由连续的 32 位表项组成,第 n 项对应簇 n(项 0、1 保留,簇 2 对应项 2):
| 值 | 含义 |
|---|---|
| 0x00000000 | 空闲簇(实际分配状态以分配位图为准) |
| 0xFFFFFFF7 | 坏簇 |
| 0xFFFFFFFF | EOC,簇链结束 |
| 其他 | 下一个簇号 |
与 FAT32 的本质区别:exFAT 的 FAT 只在文件碎片时才使用。 每个文件的”流扩展项”里有一个 NoFatChain 标志:置位表示该文件所有簇连续存放,系统直接按”起始簇+文件长度”计算全部数据位置,根本不查 FAT。闪存设备上绝大多数文件都是连续存放的,所以 exFAT 的 FAT 表经常是”摆设”——这对取证反而是好事(见取证要点)。
目录项体系(每项固定32字节,首字节类型码区分)
exFAT 抛弃了 FAT32 的 32 字节定长目录项,改为类型码 + 条目集的体系。所有目录项都是 32 字节,靠首字节 EntryType 区分:
| EntryType | 含义 | 删除后变为 |
|---|---|---|
| 0x81 | 分配位图 (Allocation Bitmap) | 0x01 |
| 0x82 | 大写表 (Up-case Table) | 0x02 |
| 0x83 | 卷标 (Volume Label) | 0x03 |
| 0x85 | 文件项 (File Entry) | 0x05 |
| 0xC0 | 流扩展项 (Stream Extension) | 0x40 |
| 0xC1 | 文件名项 (File Name) | 0x41 |
规律:未被删除的项类型码 bit7=1(0x80+),删除就是把高位清零(bit7 清0)。所以恢复删除文件的第一步就是在目录区搜 05 40 41(0x85/0xC0/0xC1 各清高位)开头的 32 字节块。
一个文件 = 一套三项组:0x85 文件项 + 0xC0 流扩展项 + 若干 0xC1 文件名项,在目录中连续排列,顺序固定。三项组的完整性靠文件项里的校验和保护。
文件项(0x85,32字节)
typedef struct _EXFAT_FILE_ENTRY { // 共32字节
BYTE EntryType; // 0x00 类型码 0x85(删除后0x05)
BYTE SecondaryCount; // 0x01 后续附属项数 = 1(0xC0) + 文件名项数
WORD SetChecksum; // 0x02 本套三项组的校验和 ← 反篡改点
WORD FileAttributes; // 0x04 属性(同FAT32: 0x10目录/0x20存档等)
WORD Reserved1; // 0x06 保留
DWORD CreateTimestamp; // 0x08 创建时间(32位DOS时间)
DWORD LastModifiedTimestamp; // 0x0C 修改时间(32位DOS时间)
DWORD LastAccessedTimestamp; // 0x10 访问时间(32位DOS时间)
BYTE Create10msIncrement; // 0x14 创建时间的10ms粒度补充(0~199)
BYTE LastModified10msIncrement; // 0x15 修改时间的10ms粒度补充(0~199)
BYTE CreateUtcOffset; // 0x16 创建时间的时区偏移(见下注)
BYTE LastModifiedUtcOffset; // 0x17 修改时间的时区偏移
BYTE LastAccessedUtcOffset; // 0x18 访问时间的时区偏移
BYTE Reserved2[7]; // 0x19 保留
} EXFAT_FILE_ENTRY;- 32 位 DOS 时间格式与 FAT32 相同:bit31
25=年(1980起),bit2421=月,bit2016=日,bit1511=时,bit105=分,bit40=秒/2。 - exFAT 比 FAT32 多了 10ms 粒度字段(0~199,即精确到 0.01 秒)和时区偏移字段(bit7=该字段有效标志;低7位是带符号二进制补码,单位15分钟:UTC+8 → 480/15=32=0x20,存 0xA0;UTC-8 → -32 的7位补码为 0x60,存 0xE0)。时间线取证时比 FAT32 精度高得多。
- SetChecksum 是对整套三项组(含所有 0xC1)计算的 16 位校验和(计算时跳过 SetChecksum 自身这 2 字节),改任何一项都要重算,否则整组作废。
流扩展项(0xC0,32字节)
typedef struct _EXFAT_STREAM_EXT { // 共32字节
BYTE EntryType; // 0x00 类型码 0xC0(删除后0x40)
BYTE GeneralSecondaryFlags; // 0x01 标志: bit1=NoFatChain(连续存储)
BYTE Reserved1; // 0x02 保留
BYTE NameLength; // 0x03 文件名长度(字符数, 决定后面有几个0xC1)
WORD NameHash; // 0x04 文件名哈希(加速查找用)
WORD Reserved2; // 0x06 保留
QWORD ValidDataLength; // 0x08 有效数据长度(实际写入的字节数)
DWORD Reserved3; // 0x10 保留
DWORD FirstCluster; // 0x14 起始簇号(0=空文件)
QWORD DataLength; // 0x18 数据长度(文件占用空间, 8字节!突破4GB)
} EXFAT_STREAM_EXT;- GeneralSecondaryFlags bit1 = NoFatChain:置位表示文件连续存储,无需查 FAT 链;清位才需要顺着 FAT 找后续簇。
- DataLength 是 8 字节,这就是 exFAT 突破 FAT32 单文件 4GB 限制的原因。
- ValidDataLength ≤ DataLength:两者差值部分是分配了但没写完的空间(slack),取证中可能藏有旧数据。
文件名项(0xC1,32字节)
typedef struct _EXFAT_FILE_NAME { // 共32字节
BYTE EntryType; // 0x00 类型码 0xC1(删除后0x41)
BYTE GeneralSecondaryFlags; // 0x01 标志(通常为0)
WORD FileName[15]; // 0x02 文件名, UCS-2编码, 每项最多15字符
} EXFAT_FILE_NAME;- 文件名用 UCS-2(小端 UTF-16) 编码,每项最多存 15 个字符。
- 文件名超过 15 字符就顺接下一个 0xC1 项,最多 17 项(255 字符)。文件名项数 = ceil(NameLength / 15),可由流扩展项的 NameLength 算出。
- 恢复删除的长文件名文件时,要把连续的多个 0x41 项拼接起来才能拿到完整文件名。
手工解码示例
目录区看到连续三项(每项32字节):
85 02 9A 35 20 00 00 00 ... (文件项: 附属项数=2, 属性0x20存档)
C0 02 00 08 E7 33 00 00 00 20 00 00 00 00 00 00
00 00 00 00 06 00 00 00 00 20 00 00 00 00 00 00 (流扩展项)
C1 00 66 00 6C 00 61 00 67 00 2E 00 74 00 78 00 74 00 00 00 ... (文件名项)
解读:
85 02→ 文件项,后面跟 2 个附属项(1 个 0xC0 + 1 个 0xC1)。- 0xC0 项 0x01 处标志 = 0x02 → bit1 置位 = NoFatChain,文件连续存放;NameLength=0x08(8字符);FirstCluster(0x14 偏移处)= 0x06;DataLength = 0x2000(8KB)。
- 0xC1 项 UCS-2 解码
66 00 6C 00 ...→ “flag.txt”。 - 结论:根目录下有一个 flag.txt,从簇 6 开始连续存放 8KB,直接按公式算偏移读取即可,不用查 FAT。
簇号 → 偏移换算公式
数据扇区号(相对分区起点) = ClusterHeapOffset + (簇号 - 2) << SectorsPerClusterShift
文件偏移(字节) = 扇区号 × 2^BytesPerSectorShift
注意簇号从 2 开始,所以要减 2。例:ClusterHeapOffset=0x100、SectorsPerClusterShift=5、512 字节扇区,簇 6 的偏移 = (0x100 + (6-2)×32) × 512 = (0x100+0x80)×512 = 0x180×0x200 = 0x30000(相对分区起点)。
分配位图与大写表
根目录中有两个特殊项(0x81、0x82)指向两个系统级结构,它们的数据也在簇堆里(项内 0x14 处记起始簇、0x18 处记数据长度):
- 分配位图(Allocation Bitmap,0x81):记录每个簇”已分配/空闲”的位图,1 bit 对应 1 个簇。exFAT 中簇是否被占用以位图为准,而不是 FAT。恢复删除文件后,只要位图对应的位还没被新数据覆盖,数据就还在原地。
- 大写表(Up-case Table,0x82):文件名哈希计算时用的一张”小写→大写”映射表(文件名不区分大小写就靠它统一),项内 0x04 处还存着表本身的 32 位校验和。一般无需关心内容,但大写表损坏或其校验和不符会导致整卷无法挂载——属于系统结构篡改点。
取证要点
- 卷序列号在引导扇区 0x64(4字节),主、备引导区各一份,可与 Windows 注册表
MountedDevices、LNK 文件中的卷序列号互相关联,证明”这个U盘插过这台电脑”。 - 校验和扇区是天然的反篡改点:改引导区域任何字段(除 VolumeFlags/PercentInUse)都要重算第 12 扇区的 32 位校验和,否则系统拒绝挂载。反过来,拿到镜像先校验,对不上就说明被手工改过,重点排查被改的字段。
- NoFatChain 让连续文件恢复极其简单:找到残留的 0xC0 项(删除后 0x40),读出 FirstCluster + DataLength,按公式直接连续 dump,不依赖 FAT 完整性——即使 FAT 被清空也能完整恢复。
- 删除只清类型码高位:搜目录区的
05/40/41开头的 32 字节块即可定位被删文件的三项组;长文件名要把多个 0x41 项的 UCS-2 拼接。 - 时间戳精度高:exFAT 有 10ms 粒度字段和时区偏移,做时间线分析时精度可达 0.01 秒,注意时区偏移(15分钟单位)会影响 UTC 换算。
- VolumeDirty(0x6A bit1)=1 说明卷是非正常卸载的,本身即是”写入中途被拔盘”的痕迹。
- 备份引导区(扇区12~23)与主引导区应一致,两处比对找差异可以快速定位被篡改的字段。