起因是海平问的一个问题:随机生成的 UUID,重复的概率是多少。算完之后他接着问,那 BIP-39 的 12 个助记词呢。
这里有个容易踩的坑:12 个词不是「2048 选 12」。BIP-39 是 128 位熵加 4 位校验和,凑成 132 位再按 11 位切成 12 段,所以有效空间是 2^128 而不是 2048^12 —— 后者里有 15/16 是校验和不合法的废组合。按生日悖论算,要撞一次需要 2.17×10^19 个助记词;全世界钱包的助记词总量乐观估计在 10^9 量级,碰撞概率 1.5×10^-21,比中一次双色球头奖还小 2.6×10^14 倍。
如果你担心的只是随机撞车,这件事在数学上根本不存在。但现实里真的出过事,而且不是因为运气差。
2023 年 8 月公开的 CVE-2023-39910 就是这类事故里最典型的一个,名字叫 Milk Sad —— 来自种子为 0 时生成的那个助记词,开头两个词正好是 milk sad。受影响的是 Libbitcoin Explorer 3.0.0 到 3.6.0 的 bx seed,一个被无数教程和脚本用来「生成安全助记词」的命令。Distrust 的分析指出,漏洞公开之前,6 月到 7 月已经有人在链上利用它扫钱包。
源码里的三件事
根因集中在一个函数里:
const auto get_clock_seed = []() NOEXCEPT
{
const auto now = high_resolution_clock::now();
return static_cast<uint32_t>(now.time_since_epoch().count());
};
twister.reset(new std::mt19937(get_clock_seed()));
一是截断。 high_resolution_clock 的周期是纳秒,count() 返回 64 位的纳秒计数,但 static_cast<uint32_t> 把它砍成了 32 位。在纳秒周期下 2^32 纳秒约等于 4.29 秒 —— 种子不仅被封顶在 43 亿,还会每四秒多绕回一圈。
二是种子空间。 std::mt19937 的构造函数只接受一个 uint32_t。Mersenne Twister 内部那 19937 位的状态听起来很大,但整个状态是由这 32 位确定性地铺开的。状态大不等于种子空间大。
三是 PRNG 选型。 MT19937 是 GF(2) 上的线性递推,拿到 624 个连续的 32 位输出就能用线性代数反推全部内部状态。它的周期长到 2^19937−1,这个天文数字在安全上一点用都没有。
熵不会因为被展开而增加
bx seed -b 256 能输出 256 位,看起来信息量十足。但输出的熵不可能超过输入的熵 —— 这 256 位是由一个 32 位种子展开出来的,可能的结果只有 4,294,967,296 种。
打个比方:一张写满一百万位数字的纸,如果是用一个 10 位密码生成的,那它的信息量就是 10 位。攻击者不用读那张纸,只要把那 10 位密码试一遍。输出的长度是幻觉。
时间戳能不能当熵源
能,Linux 内核天天这么干。区别在于用时间戳的哪一个维度。
bx seed 用的是时钟读数的绝对值,而且是被截断过的绝对值。如前面所说,低 32 位在时间轴上以 4.29 秒为周期反复出现。这意味着攻击者甚至不需要知道钱包是什么时候生成的:把 2^32 个种子全部跑一遍,就能覆盖这个工具能产出的每一把密钥。公开分析的说法是,这个工作量「不到一天」。
这一点比「秒级时间戳」更隐蔽。如果种子是完整的秒级时间戳,攻击者至少还得猜个大致年份;而截断之后,时间信息本身就被丢掉了,剩下的只是一个四秒一循环的计数器。
操作系统的熵池用的是中断到达时刻的纳秒级抖动。单个抖动贡献的熵不多,但它不可观测、不可复现,每次事件都在往里累积,攒够之后再做哈希白化。
一个是可枚举的绝对量,一个是不可复现的相对偏差。都叫「用时间」,其实完全是两回事。
为什么它一直没被发现
MT19937 的输出通过了所有统计随机性测试。分布均匀、周期极长、看不出规律,任何「随机数质量检测」工具都会给它绿灯。但密码学要的不是统计上均匀,是不可预测:
- 统计随机性问的是「输出分布均匀吗」,MT19937 通过。
- 密码学不可预测性问的是「攻击者能枚举出状态吗」,MT19937 不通过 —— 而且这一条没法靠测试发现,只能去审熵源。
所以那 12 个词看起来完美无瑕:合法的 BIP-39 词、校验和对、和任何一个正常钱包的助记词没有外观差别。弱点在生成它的那一刻就写死了,之后肉眼看不出,工具也测不出。
落到代码上
生成密钥材料只有一条路:操作系统的密码学安全随机源。Python 用 secrets 而不是 random,Node 用 crypto.randomBytes() 或 crypto.randomUUID(),C/C++ 用 getrandom() 或 /dev/urandom,Java 用 SecureRandom。std::mt19937、rand()、srand(time(NULL))、Math.random(),以及任何拿时间当种子自己拼的方案,都不能碰密钥。
判断一个随机数能不能用于密钥,只需要问一个问题:攻击者能不能把那个种子枚举出来。能,就是 Milk Sad;不能,才叫 CSPRNG。