尧图精选

PHP 8.3的随机数生成器怎么用更安全

🕒 发布时间:2026/10/2 9:47:02 📁 来源:尧图网络
前言随机数在 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.28.3 是在它上面做加法——所以在 8.2 上也能用主体部分只有少数方法需要 8.3。本文按哪些不能用 → 哪些能用 → 怎么用才对的顺序讲清楚并给出可以直接运行的对比代码。一、危险名单这些函数不能用于安全场景先列清楚黑名单。下面这些函数在安全场景下一律不能用函数为什么不能用rand()/mt_rand()默认引擎 Mt19937PHP 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)), /, -_), );这两个函数底层是操作系统的 CSPRNGCryptographically Secure Pseudo-Random Number Generator密码学安全伪随机数生成器Linux 上是getrandom(2)系统调用早期内核回退到/dev/urandomWindows 上是CryptGenRandom/BCryptGenRandom。它们不可能被设置种子——这是设计使然能设种子就存在种子被猜到的风险所以找不到random_bytes对应的srand()是正确的不是缺功能。异常类型也要注意PHP 8.2 之前失败时抛Exception8.2 起改为抛\Random\RandomException。不要写死更具体的类型否则跨版本会失效。2.2 第二层面向对象 APIPHP 8.2Random\Randomizer是 PHP 8.2 引入的。它把随机源Engine和随机操作Randomizer分离开这是它的核心设计价值。可用的引擎引擎类是否安全用途Random\Engine\Secure是默认安全场景的唯一选择Random\Engine\Mt19937否需要可复现结果的测试、模拟Random\Engine\PcgOneseq128XslRr64/Random\Engine\Xoshiro256StarStar否统计模拟?php declare(strict_types1); // 运行环境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 加了两样东西1getBytesFromString()——从指定字符集里生成随机字符串。这个方法是生成人类可读随机码最正确的工具比bin2hex(random_bytes())更适合做验证码、邀请码、短链接。?php declare(strict_types1); // 运行环境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_types1); // 运行环境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还有一个反直觉的点重复字符会制造偏斜分布。字符集写成aaaaabcdefa被选中的概率就是其他字符的数倍——需要加权随机字符时很好用但如果不小心把某个字符写重了会得到一个悄无声息的概率偏差显著降低实际的密钥空间。2getFloat()的IntervalBoundary参数。?php declare(strict_types1); // 运行环境PHP 8.3IntervalBoundary 枚举是 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是一个枚举enumPHP 8.1 引入四个 case 分别是ClosedOpen、ClosedClosed、OpenClosed、OpenOpen。三、实战生成各类安全令牌下面是一个完整可运行的脚本覆盖了实际项目里最常需要的几种随机值。?php declare(strict_types1); /** * 安全随机数工具集 * 运行环境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_types1); /** 随机源健康检查运行环境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.2IntervalBoundary需 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\RandomizerSecure 引擎。rand()和mt_rand()唯一合理的用途是结果需要能被复现的场景——比如测试用例、程序化生成的游戏地图、monte carlo 模拟——那时它们的可播种性mt_srand反而是优点。最后一个提醒随机数只是安全链条上的一环。熵再高也救不了被日志打出来的令牌、被写进 URL query 的会话 ID、或者没有有效期的重置链接。把随机源换对是必要条件不是充分条件。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →