适合人群:会写一点 Go,听说过
math/rand但没听说过crypto/rand。学完你能做到:知道在什么场景下绝不能用
math/rand,并且能写出工业级的验证码 / 密码生成器。
一、5 秒钟搞清楚最重要的一件事
凡是给人用的"随机"东西------验证码、临时密码、Token、Session ID、CSRF Token、密钥------都用
crypto/rand,永远不要用math/rand。
二、math/rand 到底"不安全"在哪儿
很多 Go 教程会教你这样写:
go
import "math/rand"
rand.Seed(time.Now().UnixNano())
code := fmt.Sprintf("%06d", rand.Intn(1000000))
这段代码看着挺像那么回事,但对攻击者几乎透明。
2.1 伪随机的本质
math/rand 是伪随机数生成器 (PRNG):用一个"种子"喂进固定算法,吐出一长串看似随机的数字。
只要种子相同,输出序列就完全相同。
2.2 实际攻击是怎么做的
攻击者知道你大概什么时候启动了服务(比如看 HTTP 响应头里的时间戳),他就能:
- 把那个时间前后 1 分钟的所有
UnixNano()值列出来(也就 60 亿个候选) - 每个值都喂进
math/rand跑一遍 - 对照实际看到的几个验证码,反推出种子
- 之后所有未来的验证码都能预测出来
这不是理论------是早被复现过无数次的真实攻击。
顺嘴说一句,Go 1.20+ 默认全局源已经自动 seed,不用你手动调用
rand.Seed了。但算法本质 还是伪随机,永远不要用于安全场景。
三、crypto/rand 是怎么解决问题的
crypto/rand 不自己造随机数,它直接从操作系统拿------而操作系统的随机源是密码学安全的:
| 系统 | 随机源 |
|---|---|
| Linux | /dev/urandom 或 getrandom(2) |
| macOS | /dev/urandom |
| Windows | BCryptGenRandom |
这些都是内核维护的"熵池",会从硬件中断、磁盘 IO、键盘鼠标时序等不可预测的物理事件里收集真实噪声。攻击者无法预测。
代价:比 math/rand 慢一点(一次系统调用 vs 一次内存运算),但在生成验证码这种偶尔做一次的场景里完全可以忽略。
四、实战 1:6 位数字验证码
最常见的需求。两种写法都对,看你喜欢哪个:
4.1 写法 A:逐位生成
go
package pkg
import (
"crypto/rand"
"math/big"
"strings"
)
func GenerateVerifyCode(length int) (string, error) {
if length <= 0 {
length = 6
}
var sb strings.Builder
sb.Grow(length)
for i := 0; i < length; i++ {
// rand.Int(reader, max) 返回 [0, max) 里均匀分布的随机数
n, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", err
}
sb.WriteString(n.String())
}
return sb.String(), nil
}
关键点:
rand.Reader是全局随机源,类型是io.Readerrand.Int(reader, max)返回[0, max)区间均匀分布 的*big.Int- 用
big.NewInt(10)限制每位只取 0~9 strings.Builder+Grow()一次分配避免重复拷贝
4.2 写法 B:一次取整再 zero-pad
go
func GenerateVerifyCode6() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
性能略好(一次系统调用 vs 6 次),但只生成 6 位定长。两种都安全。
4.3 千万别这样写
go
// 错误示范 1:用 math/rand
n := rand.Intn(1000000) // ← 可被预测,永远别用
// 错误示范 2:自己 % 取模
b := make([]byte, 4)
rand.Read(b)
n := binary.BigEndian.Uint32(b) % 1000000 // ← 模偏差,分布不均匀
第二种写法虽然用了 crypto/rand,但取模会引入偏差 :因为 2^32 不能被 1000000 整除,靠近 0 的数字概率比靠近 999999 的略高。rand.Int(reader, max) 内部会用拒绝采样保证均匀分布,这就是它存在的意义。
五、实战 2:随机密码(重置密码用)
字符集要够大,长度要够长:
go
const charset = "abcdefghijklmnopqrstuvwxyz" +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"0123456789" +
"!@#$%^&*"
func GeneratePassword(length int) (string, error) {
var sb strings.Builder
sb.Grow(length)
max := big.NewInt(int64(len(charset)))
for i := 0; i < length; i++ {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
sb.WriteByte(charset[n.Int64()])
}
return sb.String(), nil
}
调用:
go
pwd, _ := GeneratePassword(16) // 类似 "aB3$xK9!mPq2zL@n"
额外注意:
- 长度建议 >= 12 才有抗暴力破解的强度
- 包含大小写 + 数字 + 特殊符号
- 永远只发一次(短信/邮件给用户),数据库里只存 hash(用 bcrypt / argon2)
- 别记日志!别记日志!别记日志!
六、一句话总结
凡是给人用的"随机"东西,都用
crypto/rand。6 位数字本身不安全,短过期 + 限错次数 + 冷却这三件套才让它安全。