[事故分析]一个共享随机流,如何把钱包变成一本公开账本

发布时间:2026/10/30 6:47:30
[事故分析]一个共享随机流,如何把钱包变成一本公开账本

一个共享随机流,如何把钱包变成一本公开账本

2026 年 10 月 17 日,攻击者在不到两小时内清空了 1,404 个 Qubic 钱包,卷走约 2.94 万亿 QU。Qubic 协议本身没有被攻破,问题全部出在钱包软件里,而且归根到底是一个设计决策:同一个非密码学安全的随机数流,既用来生成账户种子,又用来产生明文写入网络包头部的值。

这不是一个漏了输入校验、或者忘了权限检查的故事。这是两个相隔很远的代码路径,悄悄共享了同一份可预测状态,合起来把攻击门槛从“在受害者浏览器里执行代码”直接拉低到“在网络上盯几秒钟”。

钱包到底拿随机数做了什么

账户种子生成器长这样:

privateseedGen():string{constletters='abcdefghijklmnopqrstuvwxyz';constletterSize=letters.length;letseed='';for(leti=0;i<55;i++){seed+=letters[Math.floor(Math.random()*letterSize)];}returnseed;}

55 个小写字母,名义空间是 26⁵⁵,大约 258 比特。这个数字在这里毫无意义,因为Math.random()不是 CSPRNG。在 V8 引擎里它是 xorshift128+,从一个 64 位操作系统熵值播种。整条流不管调用位置在哪,最多只有 2⁶⁴ 种可能结果。后面用 K12 哈希种子并不会增加熵。

第二条代码路径不在钱包源码里,而在一个依赖库中:

randomizeDejaVu(){this.dejaVu=Math.floor(Math.random()*2147483647);}

dejaVu会以小端 4 字节字段写进 Qubic 请求头。钱包每 2 秒调用一次randomizeDejaVu()发 tick 请求,每 30 秒调用一次发余额请求。这些包从钱包到桥接走 TLS,但桥接随后会把原始 Qubic 包通过 21841 端口的明文 TCP 转发给对等节点。任何坐在桥接上、坐在对等节点上、或者在那条明文链路上的人,都能读到流过的截断版Math.random()输出。

六次观测如何变成一把私钥

  • 从网络抓连续六个dejaVu值。每个都是Math.random()调用的 31 比特截断样本。
  • 把每个观测转成对底层浮点输出的区间约束。值v意味着真实随机数r满足v / (2³¹ − 1) ≤ r < (v + 1) / (2³¹ − 1)。
  • 把这些约束加上 V8 xorshift128+ 的状态转移方程,一起喂给 Z3 求解器。四五个观测会留下多个满足条件的状态,六个连续值就能唯一恢复 128 比特内部状态,通常耗时约 15 毫秒。
  • 状态到手后,向前或向后跑生成器,重建 55 字符种子。余额请求里暴露的公钥(REQUEST_ENTITY)可以当离线 oracle 验证候选种子,不用再碰网络。
  • 通过seed → seedToBytes → K12推导 32 字节私钥,伪造 SchnorrQ 签名,转走资产。

用户的密码、设备密钥、任何第二随机源,都不在这条推导路径上。控制了种子就等于控制了签名私钥。

核心教训:共享状态把攻击面降级了

种子的熵弱不是唯一的问题。熵还和某个可观测的东西共享了。

如果seedGen()只是孤立地用了一个弱 PRNG,攻击者仍然需要猜或者暴力破解种子,没有可靠 oracle。但把同一个 PRNG 状态的输出写进对等节点可读的头部字段,就等于把网络本身变成了侧信道。真正要问的安全边界不是“Math.random()拿来做 nonce 够不够好”,而是“这个生成器状态的任何输出,会不会到对手手里”。

4.26.4 补丁修了什么,又留下了什么

Qubic 的 4.26.4 版本在Create Vault和Add Account的种子生成路径里,把Math.random()换成了crypto.getRandomValues()。这切断了新种子的共享流链接:种子生成器不再从产生dejaVu的同一状态里取数。

但这个版本的依赖锁文件仍然把@qubic-lib/qubic-ts-library钉在 0.1.5,它的头部生成器继续用Math.random()。补丁改的是新签名密钥的来源,没有移除可预测的头部输出,也没有替换之前生成的种子。补丁之前通过弱路径生成的钱包,只要种子是那么来的,依然暴露。

正确的代码应该怎么写

种子生成器根本不该碰Math.random()。用有足够熵的 CSPRNG 覆盖整个种子空间,并且绝不从同一流里推导任何外部可观测的值。

privateseedGen():string{constletters='abcdefghijklmnopqrstuvwxyz';constletterSize=letters.length;constrandomBytes=newUint8Array(55);crypto.getRandomValues(randomBytes);letseed='';for(leti=0;i<55;i++){seed+=letters[randomBytes[i]%letterSize];}returnseed;}

这样种子有了自己的熵源,和写进包头部的任何东西无关。% letterSize带来的模偏差在这个字母表规模下可以忽略,但如果要严格均匀,正确做法是换成拒绝采样。

头部字段的修法不只是把现有函数里的Math.random()换成crypto.getRandomValues()。更深层的修正是架构层面的:协议头部随机化值应该从一个独立的 CSPRNG 上下文生成,或者把这个字段当成与安全无关的标识符,直接移出威胁模型。任何在密钥材料和网络可见输出之间共享生成器状态的做法,都是必须消失的模式。

总结

这次事件不需要复杂的内存破坏利用,也不需要 JavaScript 引擎的零日。它只需要有人注意到,两个看起来无害的函数在从同一口可预测的井里打水,而其中一口井正往网络里明文倒水。逐个函数检查的安全评审会一直漏掉这类 bug。