Post

FAT32文件系统安全删除

项目地址: github.com/aaroncomo/FAT32-secure-delete

1 实验介绍

1.1 实验目的

  1. 熟悉 FAT32 文件系统
  2. 编写程序, 寻找给定文件的文件信息 (簇链表、目录项等)
  3. 对给定文件进行安全删除

1.2 实验环境

  • 硬件: MacBook Air 2023

  • 系统: macOS 13.2.1

  • 虚拟机: Windows 11 (ARM)

2 实验流程

2.1 创建实验环境

使用 Windows 磁盘管理工具创建 64 MB 的磁盘分区, 并格式化为 FAT32, 创建如下目录和文件:

1
2
3
4
5
TEST(E:)
├── dir/
│   └── subdir/
│       └── LongFileName.txt
└── file.txt

其中 file.txt 为短文件名文件, LongFileName.txt 为长文件名文件, 他们的内容分别如下:

image-20231018232445692 image-20231018232509083

2.2 FAT32 文件系统分析

  1. DBR (DOS Boot Record)

    DBR 位于整个分区的头部, 共计 512 字节, 记录了整个分区的元数据信息. 通过分析 DBR 我们可以得到分区大小、扇区字节数、扇区总大小、根目录起始簇等关键信息, 便于后续定位文件.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    
     struct DBR {
         BYTE    jmp[3]              // 跳转指令
     	CHAR    OemName[8];			// 原始制造商 OEM
         USHORT  BytesPerSector		// 每扇区字节数
         UBYTE   SectorsPerCluster   // 每簇扇区数
         USHORT  ReservedSectors     // 保留扇区数
         UBYTE   NumberOfFatTables   // FAT 表总数
         USHORT  MaxRootDirEntries   // 根目录项数, FAT32以突破该限制, 无效 
         USHORT  NumberOfSectors16   // 扇区总数, 小于32M使用
         MEDIA   MediaDescriptor     // 存储介质描述
         USHORT  SectorsPerFat16     // 每FAT表占用扇区数 , 小于32M使用
         USHORT  SectorsPerTrack     // 逻辑每磁道扇区数 
         USHORT  HeadsPerCylinder    // 逻辑磁头数 
         ULONG   NumHiddenSectors    // 系统隐含扇区数 
         ULONG   NumberOfSectors32   // 扇区总数, 大于32M使用 
                
         DWORD 	SectorsPerFat32     // 每FAT表扇区数, 大于32M使用
         WORD  	Flag				// 标记 
         WORD  	Version            	// 0
         DWORD 	RootCluster        	// 根目录起始簇
         WORD  	BootBackupStart    	// 备份引导扇区位置
         BYTE  	Reserved[12]       		
            
         BYTE   DriveNumber        	// 驱动版本
         BYTE   ExtBootSignature   	// 扩展引导标志
         DWORD  SerialNumber       	// 序列号
         CHAR   VolumeLabel[11]    	// 卷标
         CHAR   FileSystemLabel[8] 	// 文件系统
         UBYTE  BootCode[tmp]      	// 引导代码
         WORD   EndOfSectorMarker  	// 保留 0xAA55
     }
    
  2. FAT1 和 FAT2 FAT2 是 FAT1 的备份. 每个文件都在此分配一个链表, 每个链表项 4 字节, 记录下一个簇是多少, 结束标志为 0x0FFFFFFF.

  3. FAT 表项

    1. 短目录项 短目录项指文件名小于 8 字节的文件, 定义如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      
       typedef struct {
           CHAR    Name[8]; 		// 文件名, 前6字节大写+'~1'
           CHAR    Extension[3];   // 扩展名
           FAT_ATTR_TYPE Attribute // 6
           UBYTE   Reserved        
           UBYTE   CreateTime10ms; // 创建时间的10ms位
           DOSTIME CreateTime;		// 创建时间
           DOSDATE CreateDate;		// 创建日期
           DOSDATE AccessDate;		// 最后访问时间
           USHORT  HighCluster     // 簇高16位(小端序), 只有FAT32使用
           DOSTIME UpdateTime;		// 更新时间
           DOSDATE UpdateDate;		// 更新日期
           USHORT  Cluster         // 文件开始簇号
           ULONG   FileSizeInBytes // 文件大小(字节数)
       } FAT_SHORTENTRY
      
    2. 长目录项 长目录项是文件名超过 8 字节的文件. 长文件名的 FAT 表由一个短目录项和多个长目录项组成, 长目录项的 UnicodeChar[] 中倒序保存了包含后缀的整个文件名([n] 保存开始的名称, [0] 保存最后一段名称), 未使用的 UnicodeChar[] 数组中会以 0xFF 填充.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      
       typedef struct {
           typedef union ulfn {
               tLFN_RecordSeqNum LFN_RecordSeqNum; // LFN Record Sequence Number
               unsigned char char0
           } ULFN;
           ULFN    LFN;
           wchar_t UnicodeChar1[5]; // 5 UNICODE characters, LFN first part.
           FAT_ATTR_TYPE Attribute  // 0x0F, which indicates an LFN entry.
           UBYTE   Reserved         
           UBYTE   ChkShortName     // Checksum.
           wchar_t UnicodeChar2[6]  // 6 UNICODE characters, LFN 2nd part.
           USHORT  Cluster     	 // Initial cluster number, which is always zero for LFN entries.
           wchar_t UnicodeChar3[2]  // 2 UNICODE characters, LFN 3rd part.
       } FAT_LONGENTRY;
      

2.3 程序设计

2.3.1 类封装设计

经过 2.2 对于 FAT32 文件系统的分析, 设计如下类成员, 此处仅展示最核心的子类和成员.

1
2
3
4
5
6
class FAT32():
    class DBR(): # 解析DBR, 保存分区信息
    def handle_path(path: str):	# 处理输入
    def FAT_read(FAT: bytes, p: int): # 读取FAT链表
    def get_cluster_list(self, return_size=False): # 获取文件簇链表
    def secure_delete(self): # 安全删除

2.3.2 DBR 子类设计

DBR 中含有大量分区信息, 当给定待删除的文件后, 需要先分析 DBR, 获取文件所在分区的基本信息, 记录在类成员中, 便于后续操作访问.

image-20231019011717864

对于删除文件的操作, 解析 DBR 中所有信息并保存过于浪费, 只需要上图中的前 6 个数据即可计算出 FAT 表和根目录的起始地址. 需要注意的是 DBR 只有 512 字节, 但是在它后面存在大量未使用的扇区, reserved_sectors 记录了 DBR 和保留扇区的总扇区数 (同时也是 FAT1 表的开始扇区), 此外, 还需要通过保留扇区、FAT 表数目和每个 FAT 表的扇区数计算出根目录的起始扇区地址, 便于后续顺序查找文件.

2.3.3 FAT32 类构造器

image-20231019151939352

对于 FAT32 文件系统, 目录和文件都对应着一个目录项, 给定一个地址, 系统将按照 /\\ 对地址进行切分, 之后进行层级查找.

  • 在构造器中, 使用了 handle_path() 函数对接收的地址进行处理, 将 \\ 统一为 /.

  • 由于短目录项中的文件名均为大写, 且没有后缀, 需要在构造器中进行相应处理

  • 长目录项的每一个子项都是 Unicode 编码的字符, 这里使用 .encode().hex() 方法对路径中每个元素进行 UTF-8 编码

  • Python 的底层是由 C 语言实现的, open() 函数由 WinAPI 中的 CreateFile() 封装而成. 在 MSDN 文档中给出了如下说明:

    You can use the CreateFile function to open a physical disk drive or a volume, which returns a direct access storage device (DASD) handle that can be used with the DeviceIoControl function.

    由此可见在 Windows 中磁盘和分区也是特殊的文件. 可以通过 open("//./partition") 这种特殊格式直接打开.

2.3.4 获取簇链表

这是本程序最为核心的部分, 需要通过 DBR 中解析出的信息搜索给定文件.

  1. 定位 FAT 表

    image-20231019002017463

    • 将第一个簇初始化为 DBR.root_cluster 从根目录的开始开始查找文件目录项
    • 通过保留扇区数和每个扇区数的字节数获得 FAT1 表的起始地址, 将整个 FAT1 表读到内存中
  2. 层级查找目录项

    1. 对于每一层, 将文件名转为大写, 并将内存引用的指针调整到所在簇的起始地址

      image-20231019002604236

    2. 从相对根目录顺序查找文件名

      image-20231019002718350

      • 短文件名文件只有一个表项, 可以直接在内存中查找文件名
      • 长文件名文件由一个短目录项和若干长目录项组成. 短目录项的文件名截取前 6 字节大写字符串与 ~i 进行拼接, i 的取值从 1 到 5, 当存在多个仅末端文件名不同的文件时, 这种做法可以提高查找效率
      • 长文件名文件先找到前六字节组成的短目录项地址, 再从此地址开始倒序查找长目录项, 将文件名拼接完整, 和类中保存的 unicode_name 进行比较, 若相同则找到文件.
    3. 获取文件的首簇号

      image-20231019003404163

      找到短目录项后, 在偏移 14H 的位置可以找到 2 字节的簇高位, 在偏移 1AH 的位置可以找到 2 字节的簇低位, 将两个数字拼接即可得到文件的首簇号

    4. 从 FAT 表获取簇链表

      image-20231019011655212

      在已知首簇号的情况下, 只需不断读取并记录簇链表, 直到遇到结束标志即可.

2.3.5 文件安全删除设计

在 FAT32 中, 为了节省计算资源和时间的开销, 删除文件只是删除了指向文件的簇链表并更改了文件目录项中的部分内容, 文件的内容并未改变. 被删除的文件即使操作系统无法恢复, 也可以借助第三方工具对磁盘进行分析后恢复内容. 这为文件的安全性留下了隐患. 如果想安全的删除文件, 不仅需要清空簇链表、更改文件目录项, 还需要 “彻底” 抹掉文件的内容. 获得文件簇链表后, 便可以计算出文件内容的起始地址和文件大小, 在删除文件前对文件内容进行覆盖即可.

2.3.6 计算文件内容起始地址

获得簇链表后, 可以通过如下公式计算得到文件内容起始地址:

\[Begin=(RootSector+(FirstCluster-RootCluster) \times SectorsPerCluster) \times BytesPerSector\]

2.3.7 覆写文件内容

  1. OSError

    Python 文件模块的优点在于高度封装带来的简单易用, 不过在便利的同时也使得开发者无法灵活使用原始接口进行文件处理. 磁盘虽是文件, 但涉及到读写权限、共享读写和文件保护等一系列措施的限制, 仅仅使用 python 提供的 open()File.io 等模块无法直接更改 FAT 表、根目录和磁盘扇区数据. 如果直接使用 file.write() 对磁盘内容进行操作, 将会抛出 OSError: [Errno 9] Bad file descriptor 异常. 这个问题由操作系统的文件保护机制引起, Python 无法解决, 只能通过更底层的语言来完成对磁盘内容的修改.

  2. 使用 C 语言调用 WinAPI 实现对磁盘内容的覆写

    1. 创建文件句柄

      image-20231019011422698

      • 设置访问权限为读写
      • 设置共享权限为共享读写
      • 设置打开方式为打开已存在文件
      • 设置标志和属性为正常
    2. 设置文件指针偏移, 使指针指向文件内容起始

      image-20231019011632843

    3. 设置设备属性, 将设备标记为锁定状态, 只允许当前句柄访问, 防止有其他进程同时读写产生错误

      image-20231019011826121

    4. 覆写文件内容

      image-20231019012200203

      使用 00H 将文件内容完全覆盖. 需要注意的是对磁盘的操作需要以扇区为基本单位, 读写的最小单位均为一个扇区, 这里使用的 alignedSize 是文件长度以扇区为单位向上对齐后的大小.

    5. 解锁磁盘访问限制, 恢复磁盘属性

      image-20231019012302760

2.3.8 将 C 程序编译为动态链接库

为使的 Python 能够调用 WinAPI 对磁盘进行写入, 需要将 2.3.7 中写好的 C 程序编译成动态链接库供 Python 调用.

运行如下命令编译库文件:

1
gcc src/handler.c -o lib/handler.so -shared -fPIC
  • -shared 参数通知编译器生成动态链接库
  • -fPIC 参数通知编译器生成与位置无关的代码, 以使得代码能够在内存中的任意位置进行加载和执行

2.3.9 Python 调用动态链接库删除文件内容

使用以下代码即可加载编译好的动态链接库对磁盘进行覆写操作

1
2
3
import ctypes
lib = ctypes.cdll.LoadLibrary('lib/handler.so')
lib.clearFileContent('E:'.encode('utf-8'), begin, size)

2.4 测试

2.4.1 短文件名文件删除

  1. 查看磁盘内容

    • 目录项: 文件的首簇号为 14, 大小 E8 字节, 文件内容从 401800H 开始

      image-20231019134303874

    • 簇链表: 文件只占一个簇

      image-20231019134616823

    • 文件内容: 文件内容从 401800H 到 4018E8H

      image-20231019134738103

  2. 运行命令 python main.py 删除文件得到如下结果. 可以看到程序输出了文件路径上所有文件的簇号, 并成功从 401800H 的位置擦除了 E8H 字节的内容

    image-20231019143022209

  3. 再次查看磁盘内容

    • 目录项: 文件名的第一个字节已经被改为 E5H, 数据名称从 data 变成了 possibleDeletedData

      image-20231019140944883

      image-20231019140652648

    • FAT 表: 簇链表从 0FFFFFFFH 变成了 0H, 成功清空

      image-20231019141102322

    • 文件内容: 从 401800H 开始的内容已经被清空

      image-20231019141227738

2.4.2 长文件名文件删除

  1. 查看磁盘内容

    • 目录项: 从目录项中可以看到, 此文件包含一个短目录项和两个长目录项, 文件首簇号为 7, 大小 DD4H 字节, 文件内容从 400A00H 开始

      image-20231019141736844

    • FAT 表: 文件共占 7、8、9、10、11、12、13 共计 7 簇

      image-20231019142051840

    • 文件内容: 文件内容从 400A00H 开始直到 4017D4H 结束. 因为文件过长, 只展示首尾部分字节

      image-20231019142659127image-20231019142550252

  2. 运行命令 python main.py 删除文件得到如下结果. 可以看到程序输出了文件路径上所有文件的簇号, 并成功从 400A00H 的位置擦除了 DD4H 字节的内容

    image-20231019143922987

  3. 查看磁盘内容

    • 目录项: 短目录项文件名的第一个字节已经被改为 E5H, 数据名称从 data 变成了 possibleDeletedData

      image-20231019143331859

    • FAT 表: 从 7 开始的 7 个链表项已经被清空

      image-20231019143444953

    • 文件内容: 从 400A00H 开始的 DD4H 字节均被清空

      image-20231019143547643image-20231019143624143

2.4.3 列出磁盘所有文件

运行如下命令列出 E 盘下的所有文件和文件夹:

1
tree E:

image-20231019144207622

可以看到删除了两个文件后, 磁盘中只剩下了 dir 和 subdir 两个文件夹

2.5 编译

由于 Windows 终端使用的是 GBK 编码, 因此在编译动态链接库时需要指定输出文件为 GBK 编码格式, 以避免中文乱码出现. 使用如下命令编译程序, 指令完成后可以在 dist/main 中找到编译好的文件 main.exe.

1
2
3
4
pip install pyinstaller
cd FAT32-secure-delete
gcc src/handler.c lib/handler.so -shared -fPIC -fexec-charset=GBK
pyinstaller src/main

3 实验心得

​ 在实验前查资料的时候, 我看到了一篇用纯 C 语言写的访问磁盘并找到给定文件簇链表的代码, 大致扫了一眼, 作者调用了 windows.h 访问 Windows API, 小一千行的代码大部分时间都花在了更改磁盘访问属性、读写权限和处理字符串与指针等基础问题上, 这让我想起了大一上程序设计实验时用 windows.h 的痛苦经历. 我个人觉得 Windows 提供的接口总是过于复杂, 每创建一个句柄都需要大量权限申请和上下文依赖, 而且 C 语言需要手动处理很多字符串的拼接查找问题, 这些都并非本实验的核心. 虽然知道用 C/C++ 写肯定是资料最多也一定能成功的方法, 但还是选择去用更高级的语言摸索一遍. 用 Java 和 Python 分别做了一些简单的验证性实验后, 最终选择了用 Python 作为编程语言.

​ 簇链表查找的过程还是比较顺利的, 用了一天多的时间写了完整的代码, 能够找到任意文件的簇链表. 结果就在我以为马上就能结束的时候出现了问题, Python 始终无法覆写文件的数据. 当时很疑惑, 因为调用 .write() 方法是可以对 DBR 和保留扇区的内容进行写入的, 然而一旦涉及到修改 FAT 表、根目录以及文件数据时就会抛出 OSError: [Errno 9] Bad file descriptor 异常. Google 查了很多资料, 解决方法都是关于文件而非磁盘的, 当时就在想可能是操作系统对磁盘的重要部分设立了权限, 而 Python 的封装使得我既无法得知真正的错误信息, 也没法更改这些权限, 于是只好考虑用 C 去写.

​ 用 C 重写肯定是不可能的, 毕竟只差最后一步了, 推倒重来太麻烦. 那时想到之前复现某篇论文代码的时候曾遇到过一个发生在 OpenCV.so 中的错误, 而 OpenCV 是有 C++ 版本的, 既然 Python 能用 dlib 调用动态链接库, 那么一定有办法调用 C 的代码. 查找一番后找到了 ctypes 这个模块. 于是很顺利的用 C 写了一个覆写数据的函数, 而且因为文件的起始地址、大小等都已经被找到, 因此这个扩展模块可以写的十分简单, 只需要创建句柄找到偏移后写入 0 即可. 之后用 GCC 编译成动态链接库给 Python 调用就好了.

​ 本次实验虽然踩坑很多, 但是也学到了很多有用的知识. 更加熟悉 FAT32 文件系统的工作原理, 也学到了如何用多种语言混合编程, 整个过程还是很有意思的.

This post is licensed under CC BY 4.0 by the author.