feat(unity): UnityFS 目录条目越界校验(issue #3)

解析阶段已读取 directory 的 offset/size,但从不校验其是否落在解压
数据区(各 block uncompressed_size 之和)内;截断或损坏的 bundle
会给出越界目录条目并被静默接受。新增 validate_directory_bounds:
累加数据区大小、逐条校验 offset+size 不越界且不溢出 u64,越界即
报错并带上下文,接在 parse_blocks_info 之后。

验证:新增 4 个测试(合法覆盖尾部/越界拒绝/offset+size 溢出/
端到端越界 bundle 被拒);bat-adapters 73 测试全绿,fmt + clippy
-p bat-adapters --all-targets -D warnings 通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:07:40 -07:00
co-authored by Claude Fable 5
parent d9332299ef
commit a150407a14
+122
View File
@@ -86,6 +86,7 @@ impl Unity2021_3Adapter {
flags,
)?;
let (blocks, directories) = Self::parse_blocks_info(&block_info)?;
validate_directory_bounds(&blocks, &directories)?;
Ok(ParsedAssetBundle {
unity_version: header.unity_version.clone(),
@@ -145,6 +146,50 @@ impl Unity2021_3Adapter {
}
}
/// 校验目录条目落在解压数据区内。
///
/// UnityFS 的 directory 是解压后(所有 block 的 uncompressed 数据依次拼接而成的)
/// 连续数据区上的 `[offset, offset + size)` 切片。解析阶段只按结构读取这些数值,
/// 并不保证它们不越界;截断或损坏的 bundle 会给出指向数据区之外的目录条目,
/// 若不校验就静默接受,后续按 offset/size 取数据时才会出错或读到错误内容。
/// 这里把每个目录条目与「各 block 解压大小之和」比对,越界即报错并带上下文。
fn validate_directory_bounds(
blocks: &[UnityFsBlockInfo],
directories: &[UnityFsDirectoryInfo],
) -> Result<(), String> {
let mut data_region_size: u64 = 0;
for (index, block) in blocks.iter().enumerate() {
data_region_size = data_region_size
.checked_add(u64::from(block.uncompressed_size))
.ok_or_else(|| {
format!(
"UnityFS 解压数据区大小溢出:累加到第 {index} 个 blockuncompressed_size={})时超过 u64",
block.uncompressed_size
)
})?;
}
for (index, directory) in directories.iter().enumerate() {
let end = directory
.offset
.checked_add(directory.size)
.ok_or_else(|| {
format!(
"UnityFS 目录条目 {} 的 offset({}) + size({}) 溢出 u64",
directory.path, directory.offset, directory.size
)
})?;
if end > data_region_size {
return Err(format!(
"UnityFS 目录条目 {}(第 {index} 项)越界:offset({}) + size({}) = {} 超过解压数据区大小 {}",
directory.path, directory.offset, directory.size, end, data_region_size
));
}
}
Ok(())
}
fn read_blocks_info_bytes<'a>(
data: &'a [u8],
reader: &mut UnityFsReader<'a>,
@@ -497,4 +542,81 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().contains("signature"));
}
fn block(uncompressed_size: u32) -> UnityFsBlockInfo {
UnityFsBlockInfo {
uncompressed_size,
compressed_size: uncompressed_size,
flags: 0,
compression: UnityFsCompression::None,
}
}
fn directory(offset: u64, size: u64) -> UnityFsDirectoryInfo {
UnityFsDirectoryInfo {
offset,
size,
flags: 0,
path: "CAB-test".to_string(),
}
}
#[test]
fn directory_within_data_region_is_accepted() {
// 两个 block 共 12 字节解压数据区;目录条目正好覆盖尾部,合法。
let result =
validate_directory_bounds(&[block(8), block(4)], &[directory(0, 8), directory(8, 4)]);
assert!(result.is_ok(), "{result:?}");
}
#[test]
fn directory_past_data_region_is_rejected() {
// 解压数据区仅 4 字节,目录声称 [0, 8) 越界,应被拒绝并带上下文。
let error = validate_directory_bounds(&[block(4)], &[directory(0, 8)]).unwrap_err();
assert!(error.contains("越界"), "{error}");
assert!(error.contains("解压数据区大小 4"), "{error}");
}
#[test]
fn directory_offset_size_overflow_is_rejected() {
let error = validate_directory_bounds(&[block(4)], &[directory(u64::MAX, 1)]).unwrap_err();
assert!(error.contains("溢出"), "{error}");
}
#[tokio::test]
async fn test_parse_rejects_out_of_bounds_directory() {
// 构造一个 directory.size 超过 block 解压大小的 bundle,端到端验证被拒。
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, 4); // block uncompressed_size = 4
push_u32(&mut blocks_info, 4);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, 99); // directory size 99 远超数据区
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "CAB-test");
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, UNITYFS_ALIGNMENT);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(b"data");
let adapter = Unity2021_3Adapter::new();
let bundle = RawAssetBundle {
data,
path: Some("out-of-bounds.bundle".to_string()),
};
let error = adapter.parse(&bundle).await.unwrap_err();
assert!(error.contains("越界"), "{error}");
}
}