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 最大的两点区别:

  1. 簇大小/扇区大小用”移位值”表达(2 的幂),不再直接存字节数;
  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^9512 字节
BytesPerSectorShift = 12每扇区字节数 = 2^124096 字节(4Kn 盘)
SectorsPerClusterShift = n每簇扇区数 = 2^nn=5 → 32扇区/簇 = 16KB(512扇区时)

即:每簇字节数 = 2^(BytesPerSectorShift + SectorsPerClusterShift)。手工解析时一切大小换算都先查这两个移位值,不要默认 512/簇。

VolumeFlags(0x6A,2字节,位域)

位含义
bit 0ActiveFAT:0=使用第一份FAT;1=第二份(仅 TexFAT 有意义)
bit 1VolumeDirty:介质脏,卷被非正常卸载/正在写入时置1
bit 2MediaFailure:媒体故障,发现坏簇/坏块时置1
bit 3ClearToZero:无实际含义,必须为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坏簇
0xFFFFFFFFEOC,簇链结束
其他下一个簇号

与 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 相同:bit3125=年(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 位校验和。一般无需关心内容,但大写表损坏或其校验和不符会导致整卷无法挂载——属于系统结构篡改点。

取证要点

  1. 卷序列号在引导扇区 0x64(4字节),主、备引导区各一份,可与 Windows 注册表 MountedDevices、LNK 文件中的卷序列号互相关联,证明”这个U盘插过这台电脑”。
  2. 校验和扇区是天然的反篡改点:改引导区域任何字段(除 VolumeFlags/PercentInUse)都要重算第 12 扇区的 32 位校验和,否则系统拒绝挂载。反过来,拿到镜像先校验,对不上就说明被手工改过,重点排查被改的字段。
  3. NoFatChain 让连续文件恢复极其简单:找到残留的 0xC0 项(删除后 0x40),读出 FirstCluster + DataLength,按公式直接连续 dump,不依赖 FAT 完整性——即使 FAT 被清空也能完整恢复。
  4. 删除只清类型码高位:搜目录区的 05 / 40 / 41 开头的 32 字节块即可定位被删文件的三项组;长文件名要把多个 0x41 项的 UCS-2 拼接。
  5. 时间戳精度高:exFAT 有 10ms 粒度字段和时区偏移,做时间线分析时精度可达 0.01 秒,注意时区偏移(15分钟单位)会影响 UTC 换算。
  6. VolumeDirty(0x6A bit1)=1 说明卷是非正常卸载的,本身即是”写入中途被拔盘”的痕迹。
  7. 备份引导区(扇区12~23)与主引导区应一致,两处比对找差异可以快速定位被篡改的字段。