☰
PHP 8.3的随机数生成器怎么用更安全
2026/10/2 9:46:59 网站建设 项目流程

前言

"随机数"在 PHP 里充满陷阱,因为PHP 同时提供了安全性完全不同的两套随机数 API,而它们长得非常像。

mt_rand()背后的算法是 Mersenne Twister(梅森旋转),统计学意义上很优秀,密码学意义上完全不合格:它的内部状态只有 624 个 32 位整数,只要拿到连续 624 个输出,就能反推出全部内部状态,进而预测之后所有的输出。用它生成密码重置令牌、会话 ID、验证码,等于把钥匙放在门口垫子下面。正确的选择random_int()/random_bytes()从 PHP 7.0 就有了。

这里需要先厘清标题里的版号:Random\Randomizer是 PHP 8.2 引入的,不是 8.3。标题里的 8.3 对应的是 8.3 这一版给 Randomizer 增加的几个新能力(getBytesFromString()、IntervalBoundary)。Randomizer 的基座是 8.2,8.3 是在它上面做加法——所以在 8.2 上也能用主体部分,只有少数方法需要 8.3。

本文按"哪些不能用 → 哪些能用 → 怎么用才对"的顺序讲清楚,并给出可以直接运行的对比代码。

一、危险名单:这些函数不能用于安全场景

先列清楚"黑名单"。下面这些函数在安全场景下一律不能用:

函数为什么不能用
rand()/mt_rand()默认引擎 Mt19937(PHP 7.1 起),拿到 624 个连续输出即可反推内部状态
srand()/mt_srand()可把种子设成固定值,输出完全可复现
shuffle()/str_shuffle()/array_rand()同样基于 Mt19937,洗牌、抽取的结果都可预测
uniqid()它不是随机数,是"微秒时间戳 + 进程内自增序号",可精确预测
lcg_value()线性同余,只适合数值模拟
microtime()拼哈希、crc32()/md5()当随机前者无随机性,后者是确定性函数——输入确定则输出确定

两个最容易被忽视的点。一是uniqid()。很多教程写md5(uniqid())生成令牌,但uniqid()的全部输入都是可观测或可枚举的,攻击者知道你生成令牌的大致时间就能把搜索空间压到很小;md5(uniqid())和uniqid()的安全性完全一样——哈希不增加熵。二是str_shuffle()生成的"随机密码"可以还原,因为它用的是 Mt19937,从打乱后的字符串反推原始顺序只是个可暴力搜索的排列问题(8 位密码的排列空间只有 40320),字符集已知时枚举种子即可匹配。

另外还有一个隐蔽的坑:mt_rand()的状态是进程级的全局状态。任何一处代码调用mt_srand(12345),整个进程后续的mt_rand()输出都变成确定的;而在 FPM 模式下它只影响当前 worker 进程,其他请求用的是另外的状态——同一个"随机"函数在不同请求里行为不同,排查起来极其困难。

二、安全名单:三个层级的正确选择

2.1 第一层:过程式函数(PHP 7.0+)

<?php // 运行环境:PHP 7.0+ // 生成随机字节(二进制) $bytes = random_bytes(32); // 32 字节 = 256 位 // 生成指定范围内的随机整数 $num = random_int(1, 100); // 闭区间 [1, 100] // 常用组合:生成 URL 安全的令牌 $token = bin2hex(random_bytes(32)); // 64 个十六进制字符 $tokenUrlSafe = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');

这两个函数底层是操作系统的 CSPRNG(Cryptographically Secure Pseudo-Random Number Generator,密码学安全伪随机数生成器):Linux 上是getrandom(2)系统调用(早期内核回退到/dev/urandom),Windows 上是CryptGenRandom/BCryptGenRandom。它们不可能被设置种子——这是设计使然,能设种子就存在"种子被猜到"的风险,所以找不到random_bytes对应的srand()是正确的,不是缺功能。

异常类型也要注意:PHP 8.2 之前失败时抛Exception,8.2 起改为抛\Random\RandomException。不要写死更具体的类型,否则跨版本会失效。

2.2 第二层:面向对象 API(PHP 8.2+)

Random\Randomizer是 PHP 8.2 引入的。它把"随机源(Engine)"和"随机操作(Randomizer)"分离开,这是它的核心设计价值。

可用的引擎:

引擎类是否安全用途
Random\Engine\Secure是(默认)安全场景的唯一选择
Random\Engine\Mt19937否需要可复现结果的测试、模拟
Random\Engine\PcgOneseq128XslRr64/Random\Engine\Xoshiro256StarStar否统计模拟
<?php declare(strict_types=1); // 运行环境:PHP 8.2+ $r = new Random\Randomizer(); // 默认引擎就是 Secure,不用传参数 $dice = $r->getInt(1, 6); // [1, 6] 闭区间 $raw = $r->getBytes(16); // 16 字节随机数据 $shuffled = $r->shuffleArray([1, 2, 3, 4, 5]); // 返回新数组,不修改原数组 $bytes = $r->shuffleBytes('abcdefghij'); // 打乱字节串 $f = $r->getFloat(0.0, 1.0); // [0.0, 1.0)

getInt()的区间是闭区间(两端都包含),这点和 Python 的random.randint一致,和 C 的rand() % n语义不同。shuffleArray()返回新数组,原数组不变——这一点和过程式的shuffle(&$array)完全相反(后者按引用修改)。从shuffle()迁移过来时,忘接返回值是最常见的错误,代码不报错但洗牌根本没生效。

2.3 第三层:PHP 8.3 的增量

PHP 8.3 给 Randomizer 加了两样东西:

(1)getBytesFromString()——从指定字符集里生成随机字符串。

这个方法是生成"人类可读随机码"最正确的工具,比bin2hex(random_bytes())更适合做验证码、邀请码、短链接。

<?php declare(strict_types=1); // 运行环境:PHP 8.3+ $r = new Random\Randomizer(); // 10 位纯数字随机码(短信验证码场景) $code = $r->getBytesFromString('0123456789', 10); // 16 位邀请码,字符集里去掉了易混淆的 I O 0 1 $charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $invite = $r->getBytesFromString($charset, 16);

方法的签名是getBytesFromString(string $string, int $length): string。两个参数都以"字节"为单位,这一点必须记清楚:


  • $length是返回字符串的字节长度,不是字符数;取值必须 ≥ 1,否则抛ValueError;

  • $string为空字符串时同样抛ValueError;

  • 取值规则是"按字节等概率",被选中的概率与该字节在$string中出现的次数成正比。


因此这个方法是给单字节字符集用的——数字、ASCII 字母、Base64 字符表都没问题,但不能用来生成中文、emoji 这类多字节字符组成的随机串,那样会切出半个字符,产生非法 UTF-8 序列。需要随机中文时就改用数组 +getInt():

<?php declare(strict_types=1); // 运行环境:PHP 8.2+ $r = new Random\Randomizer(); $chars = preg_split('//u', '甲乙丙丁戊己庚辛壬癸', -1, PREG_SPLIT_NO_EMPTY) ?: []; $picked = ''; for ($i = 0; $i < 4; $i++) { $picked .= $chars[$r->getInt(0, count($chars) - 1)]; // getInt 是闭区间 } var_dump(mb_strlen($picked, 'UTF-8')); // 4

还有一个反直觉的点:重复字符会制造偏斜分布。字符集写成'aaaaabcdef',a被选中的概率就是其他字符的数倍——需要"加权随机字符"时很好用,但如果不小心把某个字符写重了,会得到一个悄无声息的概率偏差,显著降低实际的密钥空间。

(2)getFloat()的IntervalBoundary参数。

<?php declare(strict_types=1); // 运行环境:PHP 8.3+(IntervalBoundary 枚举是 8.3 引入的) $r = new Random\Randomizer(); $f1 = $r->getFloat(0.0, 1.0); // 默认 ClosedOpen,即 [0, 1) $f2 = $r->getFloat(0.0, 1.0, Random\IntervalBoundary::ClosedClosed); // [0, 1] $f3 = $r->getFloat(0.0, 1.0, Random\IntervalBoundary::OpenOpen); // (0, 1)

IntervalBoundary是一个枚举(enum,PHP 8.1 引入),四个 case 分别是ClosedOpen、ClosedClosed、OpenClosed、OpenOpen。

三、实战:生成各类安全令牌

下面是一个完整可运行的脚本,覆盖了实际项目里最常需要的几种随机值。

<?php declare(strict_types=1); /** * 安全随机数工具集 * 运行环境:PHP 8.3+ */ final class SecureToken { private readonly Random\Randomizer $random; public function __construct() { // 不传引擎 = 使用 Random\Engine\Secure $this->random = new Random\Randomizer(); } /** * 会话令牌 / API Key:使用 Base64 URL 安全变体 * 256 位熵,暴力破解在物理上不可行 */ public function apiKey(int $bytes = 32): string { $raw = $this->random->getBytes($bytes); // Base64 的 + / = 在 URL 和 Header 里需要转义,换成 URL 安全字符并去掉填充 return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); } /** * 短信/邮箱验证码:纯数字,位数可控 * 用 getBytesFromString 而不是 random_int 循环, * 因为每个字符的取值独立且等概率,不受取模偏差影响 */ public function numericCode(int $length = 6): string { return $this->random->getBytesFromString('0123456789', $length); } /** * 邀请码 / 短链码:去掉易混淆字符 */ public function inviteCode(int $length = 8): string { $charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; return $this->random->getBytesFromString($charset, $length); } /** * 一次性密码重置令牌:随机体 + 过期时间戳 + HMAC 签名, * 服务端只需存密钥即可做无状态校验 */ public function passwordResetToken(int $ttlSeconds = 1800): string { $body = base64_encode($this->random->getBytes(24)) . '.' . (time() + $ttlSeconds); $key = getenv('APP_SECRET'); // 真实项目从环境变量读,绝不能硬编码 if ($key === false || strlen($key) < 32) { throw new RuntimeException('APP_SECRET 未配置或长度不足 32 字节'); } $sig = base64_encode(hash_hmac('sha256', $body, $key, true)); return rtrim(strtr(base64_encode($body . '.' . $sig), '+/', '-_'), '='); } /** 恒定时间比较:防止通过响应时间差逐字节猜出令牌 */ public function verify(string $known, string $userInput): bool { return hash_equals($known, $userInput); } } // ------------------------------------------------------------------ $t = new SecureToken(); echo "API Key : ", $t->apiKey(), PHP_EOL; echo "验证码 : ", $t->numericCode(6), PHP_EOL; echo "邀请码 : ", $t->inviteCode(8), PHP_EOL; echo "重置令牌 : ", $t->passwordResetToken(1800), PHP_EOL;

关于hash_equals():它是恒定时间(constant-time)比较函数。普通的==在比较字符串时会在第一个不同的字节处提前返回,攻击者可以通过测量响应时间一个字节一个字节地把正确令牌猜出来。这不是理论攻击,在局域网环境下完全可行。所有涉及令牌、签名、密码哈希的比对都必须用hash_equals()。

四、验证与测试:怎么证明你的随机源是安全的

安全的东西最难验证,因为你不能用"结果看起来乱"来判断。两个可操作的检查方法:

<?php declare(strict_types=1); /** 随机源健康检查,运行环境:PHP 8.2+ */ // 1) 确认用的是哪个引擎,期望输出 Random\Engine\Secure echo (new Random\Randomizer())->getEngine()::class, PHP_EOL; // 2) 反面教材:证明 mt_rand 是可预测的 mt_srand(12345); $a = [mt_rand(), mt_rand(), mt_rand()]; mt_srand(12345); $b = [mt_rand(), mt_rand(), mt_rand()]; var_export($a === $b); // true —— 这就是它不安全的含义

第 2 项是理解"为什么 Mt19937 不安全"最直观的方式:只要种子相同(或种子被推出来),输出就 100% 相同。而random_bytes()永远做不到这一点——它连让你设置种子的接口都不提供。

如果需要更强的自证,可以做卡方均匀性检验:getInt(1, 6)采样 6 万次,按χ² = Σ(观测值 - 期望值)² / 期望值算出卡方值,在自由度 5、显著性 0.05 下临界值是 11.07,超过就说明存在显著偏差。但要注意:这只是"发现明显 bug"的手段,不是"证明安全"的手段——一个通过卡方检验的伪随机数生成器依然可能完全可预测。

常见坑点

1. 用uniqid()或md5(uniqid())生成令牌

❌$token = md5(uniqid());—— 熵来自微秒时间戳,攻击者知道大致时间就能把搜索空间压到几百万以内,md5完全无法弥补(哈希不增加熵)。 ✅bin2hex(random_bytes(32))。

2. 用mt_rand()/rand()生成安全敏感值

❌str_pad((string) mt_rand(0, 999999), 6, '0', STR_PAD_LEFT);—— 拿到 624 个连续输出即可反推种子;验证码存在 session 里时,攻击者快速请求就能刷出大量输出。 ✅(new Random\Randomizer())->getBytesFromString('0123456789', 6),低版本上用random_int()循环拼装。

3. 用%取模把随机字节映射到范围内

❌ord(random_bytes(1)) % 10;—— 256 不是 10 的整数倍,余数 0~5 比 6~9 各多出现一次,产生取模偏差(modulo bias);范围越大偏差越明显(% 200时 0~55 的出现概率是 56~199 的两倍)。 ✅ 直接用random_int(0, 9)或getInt(0, 9),它们内部用拒绝采样(rejection sampling)消除偏差。

4.shuffle()和shuffleArray()混用,忘了接返回值

❌$r->shuffleArray($list);之后foreach ($list ...)——shuffleArray()按值返回新数组,原数组不变,代码不报错,只是"洗完还是原来的顺序"。 ✅$list = $r->shuffleArray($list);。过程式的shuffle(&$array)按引用改,两者语义相反,迁移时特别容易出错。

5. 用==比较令牌

❌if ($storedToken == $userToken)—— 普通比较会短路,响应时间随匹配前缀长度变化,可被时间侧信道(timing side channel)逐字节攻破。 ✅hash_equals($storedToken, $userToken)。

6. 把random_bytes()的输出直接当字符串用;或用getBytesFromString()处理多字节字符集

❌echo random_bytes(16);—— 输出原始二进制,写进 HTML/JSON/日志会破坏编码,json_encode甚至直接返回false(是失败返回,不是抛异常,这是它最讨厌的地方)。 ❌$r->getBytesFromString('甲乙丙丁', 4);—— 该方法按字节取值,多字节字符集会被切成半个字符,产生非法 UTF-8。 ✅ 二进制结果用bin2hex()或base64_encode()转成可打印字符;需要随机中文先把字符集preg_split('//u', ...)成数组,再用getInt(0, count-1)取下标。

7. 自己实现"随机"逻辑

❌(int)(microtime(true) * 10000) % 100000;—— 完全由时间决定,可预测;srand()系列同理。 ✅ "我需要一个随机值"的念头出现时,先问这个值被猜到的后果。后果严重必须用random_int()/Randomizer;后果不严重也建议直接用,因为改起来成本几乎为零,用错了的返工成本极高。

8. 以为"熵越多越好",给所有场景都上 256 位

❌ 6 位数字验证码也非要random_bytes(32)再转数字——转换过程反而引入偏差,而且依然只有 100 万种可能。 ✅熵的总量取决于搜索空间大小,而搜索空间由业务决定。6 位数字验证码就是 100 万,用多强的随机源都一样。真正要配合的是限制尝试次数和有效期,而不是无限加大比特数。

总结

需求正确做法最低版本
随机字节random_bytes(int $length)PHP 7.0
随机整数(有界)random_int(int $min, int $max)PHP 7.0
随机整数(面向对象)(new Random\Randomizer())->getInt($min, $max)PHP 8.2
洗牌$r->shuffleArray($arr)PHP 8.2
随机浮点$r->getFloat($min, $max, $boundary)PHP 8.2(IntervalBoundary需 8.3)
自定义字符集随机串$r->getBytesFromString($charset, $n)PHP 8.3
恒定时间比较hash_equals($known, $user)PHP 5.6
密码存储password_hash()/password_verify()PHP 5.5
不安全的替代品randmt_randuniqidshufflestr_shufflearray_randlcg_value——

选随机数 API 只需要一条判据:如果这个值被攻击者预测出来会造成任何损失,就必须用random_*或Random\Randomizer(Secure 引擎)。rand()和mt_rand()唯一合理的用途是"结果需要能被复现"的场景——比如测试用例、程序化生成的游戏地图、monte carlo 模拟——那时它们的可播种性(mt_srand)反而是优点。

最后一个提醒:随机数只是安全链条上的一环。熵再高也救不了被日志打出来的令牌、被写进 URL query 的会话 ID、或者没有有效期的重置链接。把随机源换对是必要条件,不是充分条件。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询