背景
10 月 18 日,Qubic 官方公告披露,攻击者利用部分钱包 Seed 随机性不足的问题,访问并转走 1404 个 Qubic 钱包中的约 2.94 万亿枚 QU。慢雾安全团队在对 Qubic Wallet 的分析中发现,钱包内置的账户 seed 生成器使用非密码学安全的 Math.random(),而同一条随机流的部分输出会经协议包头(dejaVu 字段)以明文发送给网络对端。这两处相距甚远的代码路径被同一条伪随机流串在一起,构成了从少量网络观测值恢复账户私钥的完整链路。
漏洞概览
字段 | 详情 |
漏洞类型 | 弱随机数(非 CSPRNG)密钥生成 + 伪随机输出经网络包头外泄 |
受影响组件 | Qubic Wallet 桌面钱包(seed 生成器)与 `@qubic-lib/qubic-ts-library`(`randomizeDejaVu()`) |
漏洞根因 | `Math.random()`(V8 xorshift128+)同时用于 seed 生成与 dejaVu 包头随机化 |
影响范围 | 使用内置随机生成器创建账户的钱包用户 |
危害等级 | Critical(可在特定信任边界下远程恢复私钥) |
所在链 | Qubic(K12 派生私钥 / SchnorrQ 签名) |
披露状态 | 已修复(改用 `crypto.getRandomValues()`) |
漏洞根因
seedGen():用非 CSPRNG 生成账户私钥的唯一种子
钱包的「创建金库」与「新增账户」两个入口使用了同一套 seed 生成逻辑:
// ⚠️ 漏洞点:账户 seed 由非密码学安全的 Math.random() 逐字符生成private seedGen(): string {const letters = 'abcdefghijklmnopqrstuvwxyz';const letterSize = letters.length;let seed = '';for (let i = 0; i < 55; i++) {seed += letters[Math.floor(Math.random() * letterSize)];}return seed;}
这 55 个小写字母即账户 seed,随后经 seedToBytes()(字母映射为 0–25)与 privateKey(seed, 0, K12) 确定性派生 32 字节私钥,再由 SchnorrQ 生成公钥。该派生路径没有混入用户密码、设备密钥或任何第二随机源,因此 seed 一旦被预测,私钥与签名权限即被完全掌握。
从熵的角度看,55 个小写字母的名义空间为 26^55 ≈ 258.5 bit,但 Math.random() 并非 CSPRNG:在钱包锁定的 V8 实现中,它是 xorshift128+,128 bit 状态仅由一个 64 bit 的 OS 熵值经 MurmurHash3(seed) / MurmurHash3(~seed) 派生而来。给定固定调用位置,整条随机流的可能性不超过 2^64,后续的 K12 哈希也无法扩大这份熵。
randomizeDejaVu():把同一条随机流的输出写入网络包头
仅发现弱随机还不足以让外部攻击者盗币,关键在依赖库中的这一段:
// ⚠️ 漏洞点:与 seedGen 共享同一条 Math.random() 流,并把截断输出写入请求包头randomizeDejaVu() {this.dejaVu = Math.floor(Math.random() * 2147483647);}
dejaVu 是 Qubic 请求包头中的一个字段,以 4 字节小端序写入 8 字节包头:
bytes[offset++] = this.dejaVu;bytes[offset++] = (this.dejaVu >> 8);bytes[offset++] = (this.dejaVu >> 16);bytes[offset++] = (this.dejaVu >> 24);
钱包通过 QubicConnector 连接默认 bridge wss://webbridge.qubic.li,bridge 终止 TLS 后以明文 TCP(端口 21841)将原始 Qubic 包转发给 peer。tick 请求每 2 秒、balance 请求每 30 秒各调用一次 randomizeDejaVu()。因此 bridge、peer 以及 bridge→peer 明文链路上的观察者都能持续读到这条随机流的截断样本;而 balance 请求(REQUEST_ENTITY)还携带用户公钥,可作为离线校验候选 seed 的 oracle。
私钥还原算法
记原始 Math.random() 小数输出为 r(携带 N = 52/53 bit),网络上观测到的 dejaVu 为 v,则每个观测值把 r 约束在一个区间:
v = floor(r × (2^31 − 1))v / (2^31 − 1) ≤ r < (v + 1) / (2^31 − 1)
我们通过 PoC 实例验证,将上述区间约束连同 V8 xorshift128+ 的状态转移方程一并编码进 Z3 求解器。实测结果:4、5 个 dejaVu 值仍存在多个满足约束的状态模型,6 个连续值即可唯一恢复 128 bit 状态,进而按钱包相同的 Math.floor(random × 26) 规则重建 55 位 seed:
drop=11 dejaVu= 6: unique=True match=Truerecovered private key: 045913ec…actual private key : 045913ec…private keys match : true
若 seed 在观测之前生成,则利用 xorshift 状态转移的可逆性反向回推并枚举调用偏移;桥接节点在 balance 请求中已见到用户公钥,可离线核对候选 seed 是否命中,使整条攻击链可完全自动化。
攻击流程推测
第一阶段:持续采集同源随机输出
用户打开钱包并连接 bridge 后,钱包按固定节奏发送 tick / balance 请求,每次调用 randomizeDejaVu() 产生一个约 31 bit 的 Math.random() 截断值写入包头。处于 bridge、peer 或明文链路观察位置的攻击者连续采集这些包头值。
第二阶段:由区间约束恢复 PRNG 状态
攻击者把 6 个连续 dejaVu 观测值代入区间约束,结合 V8 xorshift128+ 状态转移模型交给 Z3 求解,唯一恢复出 128 bit 内部状态(求解约 15 ms,不含采样与偏移处理)。
第三阶段:重建 seed 并校验候选
用户随后在「创建金库」或「新增账户」入口生成账户时,seedGen() 从同一条随机流连续消费 55 次。攻击者以恢复出的状态向前预测(或反向回推历史 seed),并用 balance 请求中已暴露的公钥离线校验候选 seed 是否正确。
第四阶段:派生私钥并转移资产
命中后,攻击者按 seed → seedToBytes → K12 派生 32 字节私钥,伪造 SchnorrQ 签名,即可转走钱包内全部 Qubic 代币及链上资产。
需要说明的是,上述链路由本地 PoC 复现,验证的是「截断观测 → 状态恢复 → seed 重建 → 私钥一致」的信息论可行性。实际利用的成功率取决于 bridge/peer 的信任边界、dejaVu 调用的连续性与对齐情况,以及请求之间是否夹有其它 Math.random() 消费者。
总结
本次风险的核心教训在于:把密钥生成与任何会落到网络上的随机输出放进同一条非密码学安全随机流,就会把「需要同 realm 代码执行」的攻击条件降级为「网络对端可观测」。慢雾安全团队建议项目方在部署前进行完整的外部安全审计,并对密钥生成、随机数来源与协议包头字段的信任边界做系统性收敛。
往期回顾
慢雾导航
慢雾科技官网
https://www.slowmist.com/
慢雾区官网
https://slowmist.io/
慢雾 GitHub
https://github.com/slowmist
Telegram
https://t.me/slowmistteam
https://twitter.com/@slowmist_team
Medium
https://medium.com/@slowmist
知识星球
https://t.zsxq.com/Q3zNvvF