RuView CSI 反序列化边界安全审查:wifi-densepose-core与wifi-densepose-cli加固验证(ADR-172)
【免费下载链接】RuViewπ RuView turns commodity WiFi signals into real-time spatial intelligence, vital sign monitoring, and presence detection — all without a single pixel of video.项目地址: https://gitcode.com/GitHub_Trending/wi/RuView
本文基于 ADR-172: wifi-densepose-cli + wifi-densepose-core CSI-Deserialiser Security Review 展开。RuView(π RuView)将普通 WiFi 信号转化为实时的空间智能、生命体征监测与存在感知,而这一切的数据地基是对 CSI(信道状态信息)数据包的解析——即本文所讲的"反序列化边界"。这篇技术指南还原一次真实的安全审查全过程:它验证了两种典型的不可信输入(UDP CSI 数据包、二进制 canonical 帧)在 RuView
v2/Rust 工作区中是如何被无 panic、无越界分配地处理的,并介绍了用回归测试"钉住"既有防护(regression pin)的做法。读完你将掌握:为什么wifi-densepose-core是 12 个下游 crate 的力放大点、如何用代码证据判定"NaN 状态污染"缺陷类是否根植于共享原语,以及 4 个反序列化安全防护在源码与测试中的具体落点。
1 背景:一次"超出 SOTA"安全清扫中的盲区补查
RuViewv2/工作区以 workspace 形式组织了大量 Rust crate(成员清单见 v2/Cargo.toml)。在 beyond-SOTA 安全清扫(分支feat/v2-beyond-sota-sweep)中,审查人员对每个 crate 逐一检查是否存在真实、可复现的缺陷。清扫结束时发现有两个 crate 此前没有专门的独立安全 ADR:
wifi-densepose-core—— 依赖根:所有 12 个下游 crate(wifi-densepose-calibration、-vitals、-geo、-cli等)都直接或间接依赖它提供类型、trait、错误类型与 CSI 帧原语。这里的缺陷是"力放大器":任何共享原语中的 bug 都会被每个消费者继承。wifi-densepose-cli—— 面向用户的入口点:calibrate/calibrate-serve/enroll/train-room/room-watch等命令(另有 MAT-gated 的mat export),它会解析来自网络的不可信 UDP CSI 数据包,也会处理操作员提供的路径。
换言之,这两个 crate 恰好构成 RuView 感知管线中最重要的"不可信输入边界":
| crate | 角色 | 暴露面 |
|---|---|---|
wifi-densepose-core | 库根,12 个下游 crate 依赖 | canonical 二进制帧反序列化(回放/转发边界) |
wifi-densepose-cli | 网络对外的用户入口 | UDP CSI 包解析(面向局域网任意发送方)、HTTP API(calibrate-serve) |
ADR-172 的目的不是继续打补丁,而是为已存在但"未测试"的防护补上回归测试,防止未来某次重构悄悄移除它们时 CI 没有任何反应。
2 承重问题:NaN 状态污染缺陷类是否根植于 core?
推动这次 core 审查的是一个非常具体的技术假设。在此前的三次审查中,审查人员在依赖 core 的 crate(wifi-densepose-calibration、wifi-densepose-vitals、wifi-densepose-geo)中发现了一类系统性 bug:
NaN 状态污染(NaN-state-poisoning):一个非有限(NaN/Inf)的输入被"闩锁"进持久化的滤波器/累加器状态(IIR 的
y1/y2、running mean、Welford/von-Mises 累加器、体素网格),导致静默的、永久性的特征失效。
承重问题于是变成:
这个 bug 类是起源于
wifi-densepose-core中的某个共享原语(那么正确修复应是一处根因修复),还是每个下游 crate各自独立实现了累加器(那么此前三处局部修复已经完备、不需要动 core)?
2.1 结论:NO—— NaN 类不在 core 中
审查证据表明wifi-densepose-core不暴露任何有状态累加器:没有 Welford / running-mean,没有 von-Mises / 环形均值,没有 IIR/biquad 滤波器状态,没有体素网格。
实测证据(MEASURED):对core/src进行grep,模式为welford|von_mises|biquad|y1|y2|running_mean|accumulat|voxel|self.*+=,命中的只有三类内容:
InvalidState错误枚举变体;- 与 "reset state" 有关的文档注释;
- 一处仅测试使用的 LCG(线性同余随机数生成器)。
零有状态逻辑。core 中仅有的浮点数学是构造期的投影(CsiFrame::new通过mapv计算 amplitude/phase)与纯无状态的utils函数,没有任何跨帧持久化的东西。
旁证(Corroboration):wifi-densepose-calibration::Features::from_series(源码位于 extract.rs)在实现上已经先过滤非有限样本再计算特征:
pub fn from_series(series: &[f32], fs: f32) -> Features { let clean: Vec<f32> = series.iter().copied().filter(|v| v.is_finite()).collect(); if clean.is_empty() { return Features::ZERO; // 无有限样本 → 归零,而不是传播 NaN } // ... 基于 clean 计算 mean/variance/motion/embedding ... }测试(同一文件 extract.rs)也明确验证了这一点:混入f32::NAN、f32::INFINITY的序列仍能产出有限的特征值;全 NaN/Inf 序列精确等于Features::ZERO。
这一旁证恰好说明:下游修复是各自独立实现的,从而确认了"每个 crate 自己滚动实现累加器、每个局部修复正确且完备"。由此得到两个推论:
- 在 core 里做修复会是空操作(那里没有需要修复的东西);
- NaN 状态污染这一类缺陷是下游局部模式(downstream-local pattern),而非 core 根因缺陷,共享原语中不存在隐藏的第 4 个实例。
3 发现汇总:4 条回归钉(guards 已存在,现在被测试覆盖)
审查的最终决策是"干净且有证据"(clean-with-evidence),产出了 4 条回归测试钉(regression pin)。这 4 条全部属于"防护本来就存在、只是没人测试"的情形,没有改动任何生产代码:
| # | 位置 | 防护(既有) | 回归钉测试 | 实测证据 |
|---|---|---|---|---|
| 1 | coretypes.rsfrom_canonical_bytes | 在Vec::with_capacity(rows*cols)之前做saturating_mul的形状-长度检查 | canonical_decode_oversized_shape_is_bounded_not_allocated | 移除防护后:在 types.rs:800 处capacity overflowpanic;带防护则通过 |
| 2 | coretypes.rs解码器 | 类型化CanonicalDecodeError,绝不 panic | canonical_decode_never_panics_on_arbitrary_bytes(fuzz 扫描) | 任意字节上无 panic |
| 3 | clicalibrate.rsUDP 解析器 | 在Array2::zeros(n_antennas*n_subcarriers)之前的长度检查 | test_parse_csi_packet_oversized_claim_is_rejected_not_allocated | 2 KB 数据包声称 255×65535 → 返回None,无分配 |
| 4 | clicalibrate.rsUDP 解析器 | 畸形输入一律返回None | test_parse_csi_packet_never_panics_on_arbitrary_bytes(fuzz 扫描) | 任意 UDP 字节上无 panic |
下面两节分别深入到这两条边界的源码实现。
4wifi-densepose-core:canonical 帧解码器为何"失败即关闭"
4.1 类型化错误:CanonicalDecodeError
core 的 canonical 解码器位于 types.rs。它不是一个"返回空"的宽松解码器,而是对每种畸形类别都返回类型化错误的严格解码器:
pub enum CanonicalDecodeError { Truncated { at: usize, need: usize }, // 缓冲提前结束 BadDiscriminant { field: &'static str, value: u8 }, // 未知判别字节 BadDeviceId, // device id 非 UTF-8 PayloadMismatch { rows, cols, expect, found },// 声明形状与实际负载不符 TrailingBytes(usize), // 负载后有多余字节 ReservedNotZero { field: &'static str }, // 保留区必须为全零 }其中ReservedNotZero的语义值得展开:如果允许保留区非零,就会让两个不同的字节串解码成同一帧——一旦经过"解码→重编码"的回放往返,被伪造的字节将与真实 canonical 编码不可区分。严格性保证了在可接受域上的单射性(injectivity):任何被接受的字节串重编码后都精确等于它自己。这正是"失败即关闭(fail closed)"的设计。
4.2 越界分配防护:saturating_mul先于with_capacity
解码入口是CsiFrame::from_canonical_bytes(types.rs:745)。它先用一个Cursor顺序读取帧头(UUID、时间戳、device id、频段判别、天线配置、保留区等),最后读取负载形状并分配:
let rows = c.u32()? as usize; let cols = c.u32()? as usize; // 关键:饱和乘法 —— 先算"该形状需要多少字节"再分配 let expect = rows.saturating_mul(cols).saturating_mul(16); let found = bytes.len() - c.at; if found < expect { return Err(CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found }); } let mut samples = Vec::with_capacity(rows * cols);这段代码回答了一个经典的无界内存 DoS问题:如果攻击者伪造帧头、把rows × cols声明成天文数字(例如u32::MAX × u32::MAX),而分配动作Vec::with_capacity(rows * cols)发生在长度校验之后,攻击者就能用几个字节的头部驱动一次多 GB 的分配。这里在分配前用两个saturating_mul计算"该形状按 16 字节一个复数样本所需的字节数",并与输入剩余长度比对;found < expect则直接返回类型化错误。因为saturating_mul不会溢出,expect恒大于等于实际需要的容量下限,校验一旦通过,容量就被调用者已经持有的输入长度所约束,不会 OOM。
回归钉(源码内联于同一文件,见 types.rs:1651-L1671)把这一行为固化下来:测试构造一段合法 canonical 帧,把末尾的(rows, cols)覆盖成u32::MAX × u32::MAX并截断真实负载,断言解码结果必须是Err(CanonicalDecodeError::PayloadMismatch{..})而不是 panic 或分配。
4.3 无 panic 保证:全前缀扫描 + LCG fuzz
第二条回归钉 types.rs:1677-L1703 把"panic-on-adversarial-input = 0"固化为测试:
- 全前缀扫描:对合法编码的每一个前缀
good[..n](n从 0 到全长)都调用一次解码,断言绝不 panic——任何截断点都必须落到类型化错误上; - 确定性 LCG fuzz:用固定种子的线性同余发生器生成 0~399 字节的任意字节流逐一解码,断言全程无 panic。
说明:core 测试中使用 LCG 是为了确定性,这正与上一节"core 无有状态业务逻辑"的结论呼应——该 LCG 仅是测试工具,不构成任何跨帧状态。
5wifi-densepose-cli:UDP CSI 包解析器的不可信输入边界
5.1 线格式与长度校验
CLI 的 UDP 入口是parse_csi_packet(calibrate.rs)。它解析一个 20 字节头 + IQ 采样对,线格式(文件头部注释)为:
| 偏移 | 字节数 | 字段 |
|---|---|---|
| 0 | 4 | magic(0xC511_0001LE) |
| 4 | 1 | node_id |
| 5 | 1 | n_antennas(u8) |
| 6 | 2 | n_subcarriers(LE u16——ESP32-C6 HE-SU 帧为 256,按单字节读取会把256 = 0x0100 LE误读成 0 个子载波) |
| 8 | 4 | 中心频率(MHz) |
| 12 | 4 | sequence |
| 16 | 2 | RSSI / noise floor(i8) |
| 18 | 2 | ppdu type / tier |
| 20 | 2×n_antennas×n_subcarriers | IQ 对:i_val (i8)、q_val (i8) |
解析流程的关键防护点在 calibrate.rs:276-L283:
let n_pairs = n_antennas * n_subcarriers; let iq_start = 20usize; if buf.len() < iq_start + n_pairs * 2 { return None; // ① 分配前先做长度校验 } // ② 只有校验通过后才申请 [n_antennas, n_subcarriers] 的 ndarray let mut data = Array2::<Complex64>::zeros((n_antennas.max(1), n_subcarriers.max(1)));因为默认 UDP 监听地址是0.0.0.0(calibrate.rs:56-L57 附近),局域网任意主机都能向该端口发送任意字节。若没有 ① 处的长度检查,一个 2 KB 的普通数据包只要把头部的n_antennas=255、n_subcarriers=65535填满,就能触发Array2::zeros申请 255×65535 的复数矩阵——一次内存 DoS。现在该校验在分配之前返回None。
除了恶意分配,防御还覆盖:长度小于 20 字节直接None;magic 不匹配直接None;频率转 u16 失败unwrap_or(0)兜底。整套解析器遵循**"畸形即None"**的约定(None模式贯穿调用方 calibrate.rs:152 的处理逻辑)。
两条回归钉(源码内联测试):
test_parse_csi_packet_oversized_claim_is_rejected_not_allocated(calibrate.rs:477-L492):构造 255×65535 声称的极小包,断言返回None;test_parse_csi_packet_never_panics_on_arbitrary_bytes(calibrate.rs:500-L519):多 tier 下用任意字节 fuzz 扫描,断言无 panic,且"合法 magic + 撒谎的子载波数 + 无负载"也必须优雅返回None。
5.2 NaN 处理与空帧安全
core 在数值边界上同样干净:
Confidence::new拒绝 NaN:构造置信度时先检查if !(0.0..=1.0).contains(&value)再收下数值(types.rs:265 附近),NaN 落在区间之外 ⇒Err。配套测试断言0.0/1.0/0.5合法、-0.1/1.1被拒(types.rs:1437-L1441);compute_bounding_box/to_flat_array对 NaN 容忍:f32 的 min/max 语义天然忽略 NaN;- 空帧安全:
amplitude_variance/mean_amplitude在空Array2上无 panic(ndarray 0.17 返回有限值或None)。
6calibrate-serve:HTTP 面的路径穿越与鉴权
UDP 之外,CLI 还通过calibrate-serve暴露 HTTP API。审查确认了三条既有防护(均有测试):
- 路径穿越防护
sanitize_room_id(calibrate_api.rs:107-L118):所有客户端提供的room_id/bank/baseline在进入文件名/写路径前都会被清洗——只保留 ASCII 字母数字与_、-,截断到 64 字符,空结果回退到"default"。这直接杜绝了../与绝对路径穿越; - Bearer 鉴权 + 非回环绑定警告:token 通过
--token(#[arg(long, env = "CALIBRATE_TOKEN")],见 calibrate_api.rs:100-L101)从CALIBRATE_TOKEN环境变量读取,从不嵌入代码;HTTP API 默认只绑定127.0.0.1,一旦绑定非回环地址而 token 缺失,服务端会打印警告(calibrate_api.rs:349-L353 附近); mat export例外:它写的是操作员提供的PathBuf——这是 CLI 的合理行为,不属于需要防御的不可信面。
7 验证结果
审查以测试为最终裁判(以下数据为 ADR-172 记录的实测结果):
cargo test -p wifi-densepose-core # 35 → 37 lib 通过,0 失败(+3 doctests) cargo test -p wifi-densepose-cli --no-default-features # 24 → 26 通过,0 失败 cargo test --workspace --no-default-features # exit 0,0 失败 python archive/v1/data/proof/verify.py # VERDICT: PASS其中新增的"2"(core 37 vs 35、cli 26 vs 24)正是本次加入的 4 条回归钉。proof 脚本哈希f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a保持不变——这说明 core/cli 都不在信号 proof 路径上,审查未对既有推理管线产生任何改变。
8 影响与结论
正面影响:
- 两个 CSI 反序列化器(库根与网络对外的 CLI,即不可信输入的两处边界)的 DoS 防护现在被钉死在测试里——未来任何一次去掉长度检查的重构都会让 CI 失败;
- NaN 状态污染缺陷类被定性为下游局部模式:审查者不再需要怀疑存在共享根因,此前三个 crate 的局部修复被确认完备。
负面影响:无——纯测试变更,零行为/API 变化。
中性影响:core 部分的记录同时体现在 ADR-127 §9(共享安全审查日志);本文档对应的 ADR-172 是wifi-densepose-cli审查的 canonical 记录。可交叉查阅:ADR-127(共享审查日志 §9)、ADR-136(既有的 CSI 反序列化 DoS 验收标准)、ADR-151(calibrate/calibrate-serve按房间校准能力面)。
9 可借鉴的审查方法论
把 ADR-172 还原成方法论,它提供了一套可复用的"共享原语安全归因"流程:
- 先归因再修复:遇到在多个下游 crate 中复现的系统性缺陷,先问它是否起源于共享依赖;用一次性 grep(有状态原语关键词集合)验证共享层是否存在有状态逻辑,避免在错误层级做修复;
- 区分"缺防护"与"缺测试":本审查 4 个发现全部属于后者——防护早已由 ADR-136 验收标准等落地,真正缺失的是"能防止未来重构删除防护"的回归钉。为"已存在但未测试"的守卫补测试,是成本极低、收益明确的安全投入;
- 优先选择确定性验证:fuzz 用固定种子的 LCG 而非随机,保证任意字节 panic 扫描可在任何机器上复现;
- 把 DoS 校验放在分配之前:
Vec::with_capacity/Array2::zeros之前必须有基于输入长度、用saturating_mul计算的形状校验,使"声称的大小"永远无法驱动超出输入长度的分配。
【免费下载链接】RuViewπ RuView turns commodity WiFi signals into real-time spatial intelligence, vital sign monitoring, and presence detection — all without a single pixel of video.项目地址: https://gitcode.com/GitHub_Trending/wi/RuView
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考