211 lines
16 KiB
Markdown
211 lines
16 KiB
Markdown
# Phase 1 安全关键原型审查(T-101 ~ T-103)
|
||
|
||
> 原始审查范围:`0d6ed05`(T-101 签名清单验签与缓存回退)、`0ffeff6`(T-102 ZIP 安全解压)、`9da72ca`(T-103 原子切换与回滚)。
|
||
> 审查视角:全栈开发工程师 + 安全审计。
|
||
> 原始审查方式:静态代码审计 + 攻击路径推演;原审查环境未安装 Go 工具链。
|
||
> 二次复核:在当前 `7efcab5` 工作区复核 Phase 1 代码及后续 T-201 接线,并执行 `cd core && go vet ./... && go test -count=1 ./...`,全部通过。
|
||
> 日期:2026-07-16
|
||
|
||
## 总体判断
|
||
|
||
Phase 1 三个原型的整体设计质量较高:
|
||
|
||
- T-101 的验签、缓存替换和回退顺序正确,未发现可伪造 Ed25519 签名或把未验证远端内容写入缓存的路径。
|
||
- T-102 对常规 Zip Slip、符号链接、特殊文件、伪造展开大小和解压失败清理处理得较完整。
|
||
- T-103 的**进程崩溃恢复**状态机成立,保持“未经健康检查的新 current 不可信”的核心不变式。
|
||
|
||
但原报告“没有找到路径穿越、zip bomb 或崩溃丢数据路径”的表述过于绝对。二次复核发现:
|
||
|
||
1. T-102 没有处理 Windows 对尾随空格/句点和 DOS 设备名的特殊路径语义,存在 staging 外写入风险。
|
||
2. `archive/zip` 在条目数检查前已经解析中央目录,仍有中央目录/元数据内存 DoS 边界。
|
||
3. P1 不仅缺 rename/journal 的父目录耐久性顺序点,解压后的 payload 文件本身也没有显式 `Sync()`。
|
||
|
||
因此 M2 应调整为**有条件成立**:T-101 与 T-103 的原型目标成立;T-102 必须先关闭 Windows 路径语义缺口,才能作为正式安装链路的可信基线。真实断电耐久性仍须在 T-302/T-601 完成。
|
||
|
||
## 逐模块核验
|
||
|
||
### T-101 · 签名验签与缓存回退(catalog)
|
||
|
||
**核心验签链正确(`verifier.go` + `canonical.go`):**
|
||
|
||
- `parseRestrictedJSON` 拒绝非 UTF-8、重复键、尾随 JSON、非整数数字和非对象根。
|
||
- 流程为:读取顶层 `signature` → 删除该字段 → 对剩余值递归规范化 → Ed25519 验签。嵌套的 package `signature` 不会被误删。
|
||
- 公钥和签名长度显式检查;公钥被复制,不会受调用者后续修改影响。
|
||
- Loader 只有在 verify + validate 全部通过后才写缓存;缓存回退会重新验签,不会盲信磁盘内容。
|
||
|
||
**需要收紧的协议表述:**
|
||
|
||
- 不同字节表示规范化成相同字节是 canonicalization 的预期行为,安全性依赖“发布端和客户端对相同输入得到完全相同语义与规范字节”,而不是规范化函数具有单射性质。
|
||
- Go `encoding/json` 对非法 UTF-16 surrogate escape 不报错,而是替换为 U+FFFD。其他语言或发布器可能选择拒绝,因此应明确拒绝非法 surrogate,或至少用跨实现测试向量冻结行为。
|
||
- `base64.StdEncoding.Strict()` 仍会忽略 CR/LF,不能等同于“拒绝所有非规范 Base64”。如果协议要求唯一文本表示,应先拒绝空白,或解码后重新编码并与原字符串比较。
|
||
- 当前测试签名由客户端同一套 `parseRestrictedJSON`/`canonicalJSON` 生成,证明了实现自洽,但不能代替 `softbox-catalog` 发布端的独立测试向量。`docs/api.md` 已披露该边界,发布前必须补齐。
|
||
|
||
**缓存原子写:** 临时文件、权限、文件 `Sync()`、current/backup rename 和主文件缺失时读取 backup 的方向正确;真实断电下的目录项耐久性归 P1。
|
||
|
||
### T-102 · ZIP 安全解压(installer/extractor)
|
||
|
||
**已经成立的防御:**
|
||
|
||
- 拒绝空名、非 UTF-8、NUL、反斜杠、冒号、绝对路径、显式 `.`/`..` 和非规范 POSIX 相对路径。
|
||
- 拒绝符号链接、特殊文件、加密条目和协议外顶层文件。
|
||
- 大小写折叠查重、全新 staging、文件 `O_CREATE|O_EXCL`、失败清理等防覆盖措施合理。
|
||
- 预检声明大小,复制阶段再用 `LimitReader(remaining+1)` 限制真实展开量,并核对实际展开大小和 ZIP CRC。
|
||
|
||
**高优先级遗漏:Windows 路径段规范化。**
|
||
|
||
原报告认为 `path.Clean` 会拒绝尾随点,该判断不成立。`path.Clean` 使用 POSIX 词法规则,不会删除路径段末尾的 ASCII 空格或句点。例如:
|
||
|
||
```text
|
||
"payload/file."
|
||
"payload/file "
|
||
"payload/.. /escape.exe"
|
||
```
|
||
|
||
这些名称能通过当前 `normalizeArchivePath`。Windows 创建/打开普通 DOS 路径时会忽略名称尾部的 ASCII 空格和句点,`".. "`可能重新取得父目录语义;随后 `filepath.Join`、`MkdirAll`、`OpenFile` 可能把目标解析到 staging 之外。Windows 还保留 `CON`、`PRN`、`AUX`、`NUL`、`COM1`~`COM9`、`LPT1`~`LPT9` 等设备名及其部分扩展/变体。
|
||
|
||
参考:
|
||
|
||
- [Microsoft:文件和目录名尾随空格/句点行为](https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/file-folder-name-whitespace-characters)
|
||
- [Microsoft:Windows 文件、路径和命名空间](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file)
|
||
- [Go:Windows reserved name 处理](https://go.dev/src/internal/filepathlite/path_windows.go)
|
||
|
||
**整改建议:**
|
||
|
||
1. 建立共享的 Windows 安全相对路径校验器,逐段拒绝尾随空格/句点、DOS 设备名、控制字符和 Windows 禁止字符。
|
||
2. 同一校验器用于 ZIP entry、entrypoint、Catalog `entry_exe`、app.json/files.json 和 installed-app 文件路径,避免各层规则漂移。
|
||
3. 增加 `payload/.. /escape.exe`、`payload/file.`、`payload/file `、`payload/NUL`、设备名带扩展和别名冲突用例。
|
||
4. 在 Win7、Win10、Win11 上执行真实文件系统测试;单纯的 `filepath.Clean` 或字符串前缀比较不足以证明 Windows 路径安全。
|
||
|
||
**中央目录资源边界:**
|
||
|
||
条目数检查发生在 `zip.OpenReader` 完成中央目录解析之后。因此当前实现防住了展开数据 bomb,但没有在标准库分配 `archive.File` 列表前限制中央目录大小。该边界已在 `docs/tasks/T-102.md` 执行记录披露,原报告不应概括为“没有 zip bomb 路径”。
|
||
|
||
T-302 应在调用 `zip.OpenReader` 前核对 Catalog 包大小,并增加 ZIP 文件/EOCD/中央目录大小预扫描上限。
|
||
|
||
### T-103 · 原子切换与回滚(installer/switcher + transaction + recovery + layout)
|
||
|
||
**进程崩溃恢复核心不变式成立:**
|
||
|
||
- journal 阶段为 `prepared → current_backed_up → staging_activated → committed`,健康失败进入 `rollback_required`。
|
||
- staging 已成为 current 但 journal 尚未 committed 时,Recover 会恢复旧 backup 或撤销首次安装,不会自动信任新版本。
|
||
- committed 后才清理 backup;非法 current/staging/backup 组合返回 `ErrRecoveryInconsistent`。
|
||
- 主 journal 缺失时可读取中断遗留 backup;journal 和管理目录会拒绝显式 symlink/非预期类型。
|
||
- rename 顺序适配了 Windows 不能直接覆盖已存在目录的行为。
|
||
|
||
现有测试覆盖的是关键步骤之间的**进程退出**,该范围内没有发现状态机错误。真实断电、文件锁、杀毒软件干扰和 Windows 目录项落盘顺序不在这些测试的证明范围内。
|
||
|
||
## 需整改/优化项(二次复核后重新排序)
|
||
|
||
### P0 · Windows 路径语义可能绕过 staging 边界
|
||
|
||
**优先级:** 阻断 T-302 正式安装整合。
|
||
|
||
按 T-102 章节建议建立共享 Windows 安全路径校验器,补真实 Windows 用例。关闭前不得把当前 Extractor 描述为“无路径穿越”。
|
||
|
||
### P1 · 真实断电耐久性未覆盖
|
||
|
||
**现状:**
|
||
|
||
- `file_cache.go`、`transaction.go` 只同步临时文件内容,rename 后未建立父目录耐久性顺序点。
|
||
- current→backup、staging→current 等目录 rename 后没有持久化屏障。
|
||
- Extractor 对 payload 文件执行 `io.Copy` 后直接 `Close`,没有显式 `Sync()`;即使 journal 已 committed,断电后新版本文件数据仍可能未完整耐久。
|
||
|
||
**建议的完整顺序:**
|
||
|
||
1. 写完每个 payload 文件并确认内容耐久。
|
||
2. 确认 staging 目录树元数据达到可接受的耐久级别。
|
||
3. 写 prepared journal 并建立顺序点。
|
||
4. 每次目录 rename 后建立父目录顺序点。
|
||
5. 写新 phase journal并建立顺序点。
|
||
6. committed 耐久后才能删除 backup 和 journal。
|
||
|
||
Windows 不直接提供与 POSIX 完全相同的目录 fsync 语义,需要评估目录句柄/`FlushFileBuffers`、写穿策略或明确的平台恢复保证,并在 T-302/T-601 用 VM/真机断电故障注入验证。
|
||
|
||
### P2 · 中央目录预解析内存 DoS
|
||
|
||
在 `archive/zip` 解析中央目录前限制 ZIP 文件大小、中央目录大小和声明条目数。Catalog `size`、下载完成文件长度和 ZIP 预扫描结果必须同时一致,但不能只依赖 `len(archive.File)`。
|
||
|
||
### P3 · Catalog 规范化的跨实现测试向量
|
||
|
||
发布端必须独立生成固定向量,至少覆盖:
|
||
|
||
- Unicode 键排序、转义字符串、`<>&`、U+2028/U+2029;
|
||
- 合法 surrogate pair 和非法 surrogate 的拒绝策略;
|
||
- `-0`、大整数、嵌套 signature;
|
||
- Base64 padding、CR/LF 和其他空白;
|
||
- 同一语义不同 JSON 表示得到相同签名字节。
|
||
|
||
客户端测试不应继续只用同一个 canonicalizer 生成并验证全部“合法”签名。
|
||
|
||
### P4 · 解压上限可能误伤合法包
|
||
|
||
默认 200:1、4 GiB、10,000 条目是合理的原型硬上限,但需按真实包分布复核。原报告建议把压缩比降为软信号过于宽松:绝对体积虽能限制磁盘占用,仍不能完全限制 CPU/IO 放大。
|
||
|
||
建议继续保留条目数、单条大小、总展开量和压缩比硬边界;根据真实包采样调整阈值,或通过可信发布策略提供受控例外,不要由未验证包内容自行放宽限制。
|
||
|
||
### 已关闭 · 原 P3 channel 校验接线
|
||
|
||
当前 `NewClient` 已用 `NewLoader(verifier, fetcher, cache, parser)` 把 Parser 注册为 DocumentValidator。`TestClientValidatesBeforeReplacingCache` 已覆盖“签名正确但 channel 错误时回退缓存且不调用 Store”。该项不再是待确认风险。
|
||
|
||
### P5 · 文件 mode 来源
|
||
|
||
ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题,但协议语义不够干净。可以统一采用固定落盘权限;可执行入口由已验证 app.json/Catalog 定义。
|
||
|
||
### P6 · 真实健康检查
|
||
|
||
`HealthCheck` 作为 Phase 1 注入接口合理。T-402/T-403 实现启动、探活、超时和进程退出判定时,必须防止“进程短暂启动即视为健康”以及错误工作目录/错误二进制被探活。
|
||
|
||
## 结论与处理顺序
|
||
|
||
建议按以下顺序处理:
|
||
|
||
1. **[阻断 T-302 · T-605]** 修复 Windows 尾随空格/句点、DOS 设备名和路径别名问题,补 Win7/10/11 文件系统测试。
|
||
2. **[阻断 T-302]** 在 `zip.OpenReader` 前增加包大小与中央目录预扫描边界。
|
||
3. **[发布前必做]** 补 payload 文件、目录 rename 和 journal 的完整断电耐久性策略,做 VM/真机故障注入。
|
||
4. **[协议冻结前]** 完成 `softbox-catalog` 与客户端之间的独立 canonicalization/签名测试向量。
|
||
5. **[整合时复核]** 基于真实包采样调整解压硬上限,不默认降级为软信号。
|
||
6. **[实现时重点审]** 审查 T-402/T-403 的真实健康检查。
|
||
7. **[低优先]** 固定解压文件 mode。
|
||
|
||
> 声明:当前 core vet/test 全部通过,证明现有测试覆盖范围内实现一致;它不能替代 Windows 特殊路径测试、中央目录资源攻击测试或真实断电/文件锁故障注入。
|
||
|
||
## 交叉复核裁定(定稿)
|
||
|
||
> 复核视角:Claude 全栈开发工程师,对 Codex 二次复核逐条核验后裁定。
|
||
> 核验方式:重走 Windows 路径逃逸链;对照 `extractor.go`(`path.Clean`/`filepath.Join`)、`preflight` 条目数检查位置、提取循环 `Sync` 缺失确认技术主张;因 WSL 无 Go,未执行测试。
|
||
> 日期:2026-07-16
|
||
|
||
### 承认原审核错误
|
||
|
||
原报告"没有找到路径穿越、zip bomb 或崩溃丢数据路径"的表述**过于绝对,应撤回**。Codex 的头号发现(T-102 Windows 路径语义)是**真实缺陷**,已亲自走通逃逸链验证。
|
||
|
||
### 已确认的事实(可作为后续任务前提)
|
||
|
||
| 项 | 结论 | 证据 |
|
||
| --- | --- | --- |
|
||
| Windows dot-space 路径逃逸 | 属实(原审核漏报) | `normalizeArchivePath` 用 POSIX `path.Clean`;`.. `(dot-dot-空格)不被折叠而通过校验;`filepath.Join` 后**无 destination 包含性兜底**;Windows 内核剥除段尾空格/点使 `.. → ..` 解析到父目录。仅在 Windows 触发,恰是产品目标平台 |
|
||
| 中央目录 DoS 边界 | 属实 | `preflight` 的 `len(archive.File) > MaxEntries` 检查在 `zip.OpenReader` 解析完中央目录并分配 `archive.File` **之后**;严重性受下载层 Catalog 声明 size(T-301)约束 |
|
||
| payload 文件未 Sync | 属实 | 提取循环 `io.Copy` 后直接 `Close`,无 `output.Sync()`;committed 后断电仍可能数据未耐久 |
|
||
| base64 CR/LF 容忍 | 属实 | `base64.StdEncoding.Strict()` 只约束 padding 位,仍跳过 `\r`/`\n`,签名文本非唯一表示(洁净性问题,低危) |
|
||
| 非法 surrogate 替换 | 属实 | Go `encoding/json` 把非法 surrogate escape 换成 U+FFFD 而非报错,跨实现签名向量须冻结此行为 |
|
||
| channel 校验接线 | 已闭合(原 P3 关闭) | `NewClient` 用 `NewLoader(..., parser)` 注册 parser 为 DocumentValidator;`TestClientValidatesBeforeReplacingCache` 覆盖"签名正确但 channel 错误时回退且不 Store" |
|
||
|
||
### 对 Codex 二次复核的采纳与修正
|
||
|
||
- **接受** P0(Windows 路径语义)及整改方向:逐段拒绝尾部空格/点、DOS 设备名(`CON/PRN/AUX/NUL/COM1..9/LPT1..9` 及变体)、控制字符与 Windows 禁止字符;ZIP entry / entrypoint / `entry_exe` / app.json / installed-app 路径共用同一 Windows 安全校验器;在真实 Win7/10/11 文件系统上测。**补充:** 提取时对 `filepath.Join` 结果再加一道"仍在 destination 内"的包含性兜底检查作为纵深防御,成本极低。
|
||
- **接受** P1 扩展(payload 文件 + 目录 rename + journal 的完整断电耐久顺序)、P2(中央目录预扫描上限)。
|
||
- **接受并收回原建议** P4:原报告"把压缩比降为软信号"过松;保留条目数/单条/总量/压缩比硬边界,按真实包采样调阈值,例外走可信发布策略,不由未验证包内容自行放宽。Codex 的反驳成立。
|
||
- **补充威胁模型语境(Codex 遗漏,影响严重性校准而非是否修)**:提取器运行在 **Catalog 签名 + SHA-256 已验证之后**(extractor.go 注释自述)。因此路径逃逸与中央目录 DoS 的**即时可利用性**需要"恶意/被攻陷发布者或签名私钥泄露",非任意 MITM。**"必须修"的结论不变**——声称拒绝穿越的提取器在目标平台未做到即为 must-fix,且是签名管线的纵深防御;但风险定性应为**"签名内容管线的纵深防御缺口"**,而非开放 RCE。P0 阻断 T-302 的定级成立。
|
||
|
||
### 最终处理顺序(定稿)
|
||
|
||
1. **[阻断 T-302 · 最高 · T-605]** 修 Windows 尾部空格/点、DOS 设备名与路径别名:建逐段 Windows 安全路径校验器(各层共用)+ 提取时 destination 包含性兜底检查;补 Win7/10/11 真实文件系统用例。关闭前不得把 Extractor 描述为"无路径穿越"。
|
||
2. **[阻断 T-302]** `zip.OpenReader` 前增加包大小与中央目录/EOCD 预扫描边界;与 Catalog `size`、下载完成长度三者一致,不只依赖 `len(archive.File)`。
|
||
3. **[发布前必做]** 完整断电耐久策略:payload 文件 Sync → staging 目录元数据 → journal 顺序点 → 每次 rename 后父目录顺序点 → committed 耐久后才删 backup/journal;评估 Windows 目录句柄 `FlushFileBuffers`/写穿方案;T-302/T-601 用 VM/真机断电注入验证。
|
||
4. **[协议冻结前]** `softbox-catalog` 与客户端独立 canonicalization/签名测试向量:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate 拒绝策略、`-0`/大整数/嵌套 signature、base64 padding/CR/LF、同语义不同表示同签名字节。
|
||
5. **[整合时复核]** 按真实包采样调解压硬上限,保留硬边界,不降级为软信号。
|
||
6. **[实现时重点审]** T-402/T-403 真实健康检查:防"进程短暂启动即判健康"、错误工作目录/错误二进制被探活。
|
||
7. **[低优先]** 固定解压落盘 mode,可执行入口由已验证 app.json/Catalog 定义。
|
||
|
||
> 裁定:Codex 二次复核成立且纠正了原审核的绝对化表述与一处过松建议;M2 调整为"有条件成立"(T-101/T-103 原型目标成立;T-102 须先关闭 Windows 路径语义缺口才能作为正式安装链路可信基线)。上述顺序为双方交叉复核后的共识。
|