一、概述
F2FS 是三星为 NAND 闪存设计的文件系统,Android 手机上见到的多半是它。
取证上的麻烦不在字段多,而在它的每个设计决定都来自闪存的物理限制。先把这层因果理清,后面的结构就都顺理成章了。
| 闪存特性 |
后果 |
F2FS 的应对 |
| 不能原地覆盖 |
已有数据不能改写,只能擦除后重写 |
日志型结构:新版本写到别处,旧版本等 GC 才擦除 |
| 擦除以"块"为单位 |
擦除粒度远大于写入粒度 |
段(Segment) 作为擦除单位,固定 2 MB |
| 擦写次数有限 |
频繁擦写会损坏闪存 |
冷热数据分离:高频更新的元数据单独放 |
| 顺序写远快于随机写 |
随机写代价高 |
主区顺序写,日志区承接随机写 |
把这四条代进去,F2FS 取证最核心的一个问题就浮出来了:
文件"当前"的内容不写在原位置,而是写在最近一次更新写入的位置。旧版本在垃圾回收(GC)执行前一直留在盘上。
这意味着 F2FS 上"删除"的文件数据可能完整保留——但其位置由 NAT 和当前 checkpoint 决定,不能像 ext4 那样靠 inode 列表直接枚举已删文件。
跟 ext4 对照一下差异所在:
| 维度 |
ext4 |
F2FS |
| 元数据形态 |
inode 数组 + 间接块 |
日志型元数据 + 映射表 |
| 已删文件枚举 |
debugfs lsdel 直接列出 |
无直接等价命令,需解析 NAT + checkpoint |
| 旧版本数据 |
通常被覆盖或仅剩残留 |
GC 前完整保留旧版本 |
| 工具成熟度 |
高(e2fsprogs + TSK) |
低,以专用工具为主 |
二、核心原理
2.1 分区布局:六个区域
F2FS 把整个分区切成六个区域,每一块的起始块地址都在超级块里明写。这张布局表是破案时的导航图——先确认自己在哪一层,再谈解析。
┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────────┐
│ 超级块区 │ 检查点区 │ 段信息区 │ 元数据区 │ 汇总区 │ 主数据区 │
│ (SB) │ (CP) │ (SIT) │ (NAT) │ (SSA) │ (Main Area) │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────────┤
│ 1 段 2MB │ 2 段 4MB │ 2 段 4MB │ 2~34 段 │ 1 段 2MB │ 剩余全部 │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────────┘
| 区域 |
全称 |
作用 |
取证价值 |
| SB |
Superblock |
魔数、版本、各区域块地址、块大小 |
定位一切的起点 |
| CP |
Checkpoint |
文件系统当前状态快照 |
决定"当前"文件映射 |
| SIT |
Segment Information Table |
每段的有效块数、类型、有位图 |
段类型揭示冷热分离 |
| NAT |
Node Address Table |
inode 号 → node 块地址 |
恢复文件内容的核心映射 |
| SSA |
Summary Area |
段内块摘要、当前写指针 |
定位段内最新数据 |
| MA |
Main Area |
实际的用户数据与元数据 |
提取文件内容的区域 |
段(Segment)是擦除单位,固定 2 MB(512 个 4 KB 块)。段的划分让"擦除"这件事有了明确边界。
2.2 超级块:魔数 0xF2F52010
主超级块位于文件系统起始 + 1024 字节(0x400),与 ext 系列的偏移习惯一致。魔数为 F2FS_MAGIC = 0xF2F52010(小端读作 10 20 F5 F2)。
偏移 长度 字段 取证意义
0x00 4 magic = 0xF2F52010 文件系统确认
0x04 4 major_ver 主版本号(与工具兼容性相关)
0x08 4 minor_ver 次版本号
0x0C 4 log_sectorsize log2 扇区大小,典型 9(512 字节)
0x10 4 log_sectors_per_block log2 每块扇区数
0x14 4 log_blocksize log2 块大小,典型 12(4096 字节)
0x18 4 log_blocks_per_seg log2 每段块数,典型 9(512 块 = 2 MB)
0x1C 4 segs_per_sec 每区段数
0x20 4 secs_per_zone 每区扇区数
0x24 4 checksum_offset 校验和偏移
0x28 8 block_count 用户块总数(**全表只有这一项是 8 字节**)
0x30 4 section_count 区总数
0x34 4 segment_count 段总数
0x38 4 segment_count_ckpt 检查点区段数(通常 2)
0x3C 4 segment_count_sit SIT 段数
0x40 4 segment_count_nat NAT 段数
0x44 4 segment_count_ssa SSA 段数
0x48 4 segment_count_main 主区段数
0x4C 4 segment0_blkaddr segment 0 起始块
0x50 4 cp_blkaddr 检查点区起始块
0x54 4 sit_blkaddr SIT 起始块
0x58 4 nat_blkaddr NAT 起始块
0x5C 4 ssa_blkaddr SSA 起始块
0x60 4 main_blkaddr 主区起始块
0x64 4 root_ino 根 inode 号
0x68 4 node_ino node inode 号
0x6C 4 meta_ino meta inode 号
换算公式:块大小 = 2^log_blocksize,段大小 = 2^log_blocks_per_seg × 块大小。这三个 log_ 字段必须实测,不能假设——典型值是扇区 512 / 块 4096 / 段 2 MB,但格式化参数可改。部分实现还在后续块(如 0x1400)放第二份超级块,备份位置依版本而异,需结合实际实现验证。
xxd -s 1024 -l 16 /work/DigiForensics.dd
# 00000400: 1020 f5f2 0100 1000 0900 0000 .. ..... ← 0xF2F52010 ✓
2.3 Checkpoint:定义"当前"状态
| 内容 |
作用 |
checkpoint_ver |
检查点版本号,每次更新递增——比对两个 checkpoint 即可知道文件系统发生过写入 |
user_block_count / valid_block_count |
块总量与有效块量,差值反映垃圾量 |
free_segment_count |
空闲段数,偏小说明 GC 压力存在 |
cur_node_segno / cur_node_blkoff |
当前 node 写位置(node = 元数据块) |
cur_data_segno / cur_data_blkoff |
当前数据写位置 |
ckpt_flags |
是否需要恢复、是否需要回滚 |
next_free_nid |
下一个可用 node ID,累计分配量,可作"曾创建过多少文件对象"的量级参考 |
F2FS 用的是双 checkpoint 机制,两个区交替写,每个 checkpoint 自带版本号和 CRC。
这带来一个很实用的性质:拿到两个 checkpoint 就能判断哪个更新、哪个损坏,也能看出两次写入之间文件系统发生了多大变化。
valid_block_count 下降说明期间发生了删除或覆盖。
ckpt_flags 里出现"需要恢复"标志,说明上次没有正常卸载。此时元数据里可能有未完成的写入,结论须标注"可能存在未完成操作"。
2.4 NAT:inode 到物理块的映射表
NAT 是 F2FS 取证最核心的结构,把 inode 号(ino)映射到该 inode 所在的物理块地址。每条 f2fs_nat_entry 为 9 字节:version(1) | ino(4) | block_addr(4)。
从 inode 到内容的完整链条:
目录项 → ino
↓ 查 NAT
block_addr(inode 所在块)
↓ 读块
f2fs_inode:i_inline? → 内联数据 或 i_addr[0..11] 直接块 + i_nid 间接节点
↓ 逐级解析
direct_node / indirect_node / double_indirect_node
↓
全部数据块地址 → 按序读出 → 拼回文件
链条中任一环缺失即无法还原。恢复文件时要把 NAT 命中情况、inode 是否内联、间接节点是否完整都记录下来——这些就是判断"文件能否完整恢复"的依据。
NAT 里的映射是"当前有效版本"的映射。旧版本的映射在旧 checkpoint 对应的旧 NAT 页里——这正是"GC 前旧数据仍在"的技术原因,也是找回旧版本文件的入口。
2.5 SIT 与段类型:冷热分离的证据
SIT 的每条 f2fs_sit_entry 记录一个段的状态:vblocks(2)(低 10 位 = 有效块数,高 6 位 = 段类型)、valid_map(64)(512 位位图,标记段内哪些块有效)、mtime(8)(该段最后一次 GC 时间)。
| 段类型 |
含义 |
取证含义 |
SUM |
汇总段(SSA) |
系统元数据 |
NODE |
元数据段(inode、目录) |
文件频繁被改的区域 |
DATA |
数据段(文件内容) |
用户文件主体 |
COLD |
冷数据段 |
长期未改的数据(旧照片、视频) |
WARM / HOT |
温 / 热数据段 |
近期被频繁读写 |
UNUSED |
空闲段 |
可能已 GC 或待写入 |
mtime 尤其值得看。某段 mtime 很旧但仍被引用,说明该数据长期未变动;某段 mtime 突然很新,说明该段近期被 GC 过,原有内容已被迁移或擦除。
某段变成 UNUSED,其上的数据很可能已被擦除,残留可能性极低。valid_map 位为 0 的块已经不是该文件的数据了。
SSA 的每条 f2fs_summary 为 7 字节(nid(4) | version(1) | ofs_in_node(2)),作用是"给定一个段,就能知道段内哪一块属于哪个文件、是第几块"——这是把散落块重新拼回文件的关键索引。段摘要区末尾还有 footer(段类型与 CRC),footer 损坏会导致该段摘要全部不可用。
2.6 针对闪存的设计带来的取证局限
| 局限 |
原因 |
实际影响 |
| 旧数据会被 GC 抹除 |
空间不足时 GC 迁移有效块并擦除段 |
回收越彻底,旧版本越不可得;无"永久保存"保证 |
| 无等价的"已删文件列表" |
没有 lsdel 这样的现成结构 |
必须自行解析 NAT + checkpoint,成本高 |
| inline data 藏在小对象里 |
小文件/小目录项直接存在 inode 或 dentry 中 |
容易漏检;小于约 4 KB 的文件默认内联 |
| node 与 data 分段 |
元数据与数据不在同一段 |
只扫数据区会漏掉全部元数据 |
| 工具支持有限 |
TSK 对 F2FS 支持很弱 |
以 f2fs-tools 与专用解析器为主 |
| 移动端镜像常不完整 |
逻辑分区起始位置常需自行搜索 |
不能假设超级块在分区起始 |
GC 时机由空间压力驱动,不由"用户删除"直接驱动。 所以"删除时间"与"GC 时间"不是一回事——GC 只是让旧数据消失的过程,把 GC 时间当作删除时间是错误的推理。
三、操作步骤
3.1 第 1 步:定位并验证 F2FS 超级块
移动设备镜像常是整块闪存转储,F2FS 分区起点未知,需要搜索魔数:
grep -aob $'\x10\x20\xf5\xf2' /work/DigiForensics.dd | head -5
# 1258291200 ← 命中点 1
# 26843545600 ← 命中点 2(需逐一验证)
命中之后必须验证上下文,因为这 4 字节在随机数据中也可能出现:
xxd -s 1258291200 -l 16 /work/DigiForensics.dd
# 00480000: 1020 f5f2 0100 0e00 0900 0000 .. ..... ← magic + 版本 + log_sectorsize=9
xxd -s $((1258291200+0x14)) -l 16 /work/DigiForensics.dd
# 00480014: 0c00 0000 0900 0000 ... ← log_blocksize=12(4096)
验证要点:log_sectorsize 应为 9(512 字节);log_blocksize 通常 12(4096);log_blocks_per_seg 通常 9(512 块 = 2 MB)。出现 0 或明显异常值就是误命中。
3.2 第 2 步:切出 F2FS 分区