一个共享随机流如何把钱包变成一本公开账本2026 年 10 月 17 日攻击者在不到两小时内清空了 1,404 个 Qubic 钱包卷走约 2.94 万亿 QU。Qubic 协议本身没有被攻破问题全部出在钱包软件里而且归根到底是一个设计决策同一个非密码学安全的随机数流既用来生成账户种子又用来产生明文写入网络包头部的值。这不是一个漏了输入校验、或者忘了权限检查的故事。这是两个相隔很远的代码路径悄悄共享了同一份可预测状态合起来把攻击门槛从“在受害者浏览器里执行代码”直接拉低到“在网络上盯几秒钟”。钱包到底拿随机数做了什么账户种子生成器长这样privateseedGen():string{constlettersabcdefghijklmnopqrstuvwxyz;constletterSizeletters.length;letseed;for(leti0;i55;i){seedletters[Math.floor(Math.random()*letterSize)];}returnseed;}55 个小写字母名义空间是 26⁵⁵大约 258 比特。这个数字在这里毫无意义因为Math.random()不是 CSPRNG。在 V8 引擎里它是 xorshift128从一个 64 位操作系统熵值播种。整条流不管调用位置在哪最多只有 2⁶⁴ 种可能结果。后面用 K12 哈希种子并不会增加熵。第二条代码路径不在钱包源码里而在一个依赖库中randomizeDejaVu(){this.dejaVuMath.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{constlettersabcdefghijklmnopqrstuvwxyz;constletterSizeletters.length;constrandomBytesnewUint8Array(55);crypto.getRandomValues(randomBytes);letseed;for(leti0;i55;i){seedletters[randomBytes[i]%letterSize];}returnseed;}这样种子有了自己的熵源和写进包头部的任何东西无关。% letterSize带来的模偏差在这个字母表规模下可以忽略但如果要严格均匀正确做法是换成拒绝采样。头部字段的修法不只是把现有函数里的Math.random()换成crypto.getRandomValues()。更深层的修正是架构层面的协议头部随机化值应该从一个独立的 CSPRNG 上下文生成或者把这个字段当成与安全无关的标识符直接移出威胁模型。任何在密钥材料和网络可见输出之间共享生成器状态的做法都是必须消失的模式。总结这次事件不需要复杂的内存破坏利用也不需要 JavaScript 引擎的零日。它只需要有人注意到两个看起来无害的函数在从同一口可预测的井里打水而其中一口井正往网络里明文倒水。逐个函数检查的安全评审会一直漏掉这类 bug。