☰
Java随机数安全:从Random到SecureRandom实战指南
2026/10/17 14:27:03 网站建设 项目流程

有段时间没写 Java 安全相关的话题了。前阵子帮一个团队做代码走查,发现线上生成优惠券兑换码的地方还在用new Random().nextLong()。我当时就说,这个代码不出事是运气好,出事的概率其实一直挂在审计面前。方案改为SecureRandom后,除了首次启动时的熵收集过程,后面几乎感受不到性能差异。为什么这一字之差会影响安全级别?因为Random是线性同余发生器,它有规律可循。这篇文章我就把SecureRandom的正确打开方式从头到尾拆一遍,顺带把我在生产环境里踩过的坑、用过的配置、写过的样板代码都给你摆出来。

1. 为什么 Random 会成为安全隐患

1.1 可预测的数学本质

java.util.Random的随机性来自一个 48 位长度的种子。每次生成下一个伪随机数时,它内部执行一次取模和乘法运算,也就是线性同余发生器(LCG)。公式是:

seed = (seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1)

这个公式意味着,只要攻击者拿到了连续两次的输出值,理论上就可以反推出绝大部分中间状态,甚至推算出整个种子序列。是的,LCG 的周期很长,但它的输出顺序完全由种子唯一决定。更麻烦的是,在很多实际攻击场景里,攻击者不需要完整推算种子,只需要根据其中一次输出猜测下一次输出即可。

关于这一点,我见过最形象的类比是:你家里的保险柜密码锁每个数字都在 0 到 9 之间跳动,但每次跳动的序列是提前印在纸上的。别人只要看到了序列中的几项,就可以推导出这张纸上的全部内容,那锁还有意义吗?

1.2 攻击者怎么利用它

如果一段业务代码用Random生成会话 ID、密码重置 token、优惠券码,攻击者的攻击链路大致是:

  1. 注册或者触发一次业务请求,拿到生成出来的一个“随机数”。
  2. 把连续两个输出值放进逆向算法(这类脚本在安全社区里很常见,只要几十行代码)。
  3. 推算出当前内部种子,预测后续所有“随机”值。
  4. 直接构造出别人的重置密码链接,或者批量生成别人可用的优惠码。

这不只是理论。我在一次内部安全演练中,给某个模拟项目写了一个Random的会话令牌预测脚本,从前两次输出开始,很快就预测出了后续令牌。整个过程只消耗了几秒钟。

1.3 判定是否需要换掉的几条标准

碰到下面任何一条,都应该立刻把Random换掉:

  • 生成结果用于鉴权、授权、防重放,例如 session id、csrf token。
  • 生成结果用于密码、密钥、盐值。
  • 生成结果可以被当作一种“不可猜测的凭证”,例如邀请链接、重置码。
  • 生成结果会公开暴露在日志、响应体或者数据库记录里。

上面这些场景,唯一的要求不是“看起来随机”,而是“不可预测”。SecureRandom的出现正是为了解决这个问题:它从操作系统的熵源收集真随机数,再通过密码学安全的伪随机数生成器,把随机性扩散成任意长度的不可预测序列。

2. 先理解 SecureRandom,再谈“正确打开”

2.1 第一层理解:SecureRandom 不是简单的“加了盐的 Random”

很多人以为SecureRandom就是给Random套了一个更长的种子,或者多了一层运算。实际上它们在设计目标上就完全不一样。

Random的目标是速度和可复现性,适合模拟、洗牌、非安全的随机抽样。你给它一个相同的种子,每次得到的序列都完全相同,这在单元测试里是个优势。

SecureRandom的目标是密码学安全。它必须有足够好的熵来源,并且生成的输出不能被攻击者在现实时间内反推出内部状态。所以它混合了两个部分:

  • 不预测性的种子来源。
  • 一个密码学安全的伪随机数生成器(CSPRNG)。

在 Linux 上,最底层通常是/dev/urandom或者系统调用getrandom()。在 Windows 上则是 CNG(Cryptography API)。你在代码里很少直接跟这些打交道,但要知道,你初始化出来的对象背后,拉的是一整套操作系统的安全内核能力。

2.2 第二层理解:三种初始化方式,应该怎么选

SecureRandom有三种常见的初始化方式。我好几次看到团队里有人一股脑全用getInstanceStrong(),或者全用默认构造,这个选择其实要结合场景。

初始化方式含义推荐场景
new SecureRandom()使用系统默认的 CSPRNG,通常即可满足安全需求大多数业务场景,API token、临时密码、盐值
SecureRandom.getInstanceStrong()强制使用平台强度最高、阻塞性可能更强的算法密钥生成、长期证书生成等高安全场景
SecureRandom.getInstance("DRBG")使用标准 NIST SP 800-90A DRBG,可选参数更多有合规要求、需要指定生成算法的场景

你可能会问,为什么不用getInstanceStrong()一把梭?因为它背后的实现可能依赖/dev/random,在 Linux 上熵不足时会阻塞。有一次在容器里,我看到整个应用启动时因为强随机数初始化阻塞了几十秒。如果你的应用只是生成临时验证码,这完全不值得。

但如果你的应用要生成长期有效的密钥,比如 RSA 密钥对,那就要认真考虑getInstanceStrong()。密钥一旦生成,泄露的代价极高,多付一点启动等待时间换取更强的随机性,完全值得。

2.3 第三层理解:一个对象始终是一个对象

另外一个重要认知是:SecureRandom对象是线程安全的,而且通常也是无状态的。意思是你在应用启动阶段创建一个单例实例,之后所有线程共用它是正常的。

为什么我强调这一点?因为我在不少代码里见过“每次需要随机数时临时 new 一个 SecureRandom”。

public String newToken() { SecureRandom sr = new SecureRandom(); // 这是坏味道 return createTokenWith(sr); }

每一次实例化,都可能触发一次熵源的重新初始化。虽然现代 JDK 做了很多优化,不会每次都去读/dev/random,但频繁对象创建会带来不必要的性能损耗,而且让代码看上去非常不专业。

正确的姿势是把它作为一个容器级单例长期持有:

@Component public class SecureTokenService { private final SecureRandom secureRandom = new SecureRandom(); // ... }

2.4 不要亲手给 SecureRandom “播种”

这是一个非常反直觉的注意点。Random支持传入种子来复现序列,SecureRandom也继承了setSeed方法。但这个方法的语义和Random完全不同,它把种子作为“额外随机源”混合进当前状态,而不是替代当前状态。从安全角度看,你手动传入的任何种子,都可能让攻击者获得额外的猜测维度。

所以我的建议非常明确:绝对不要调用setSeed,也不要用new SecureRandom(byte[])去构造。把它交给底层系统,让它自己去收集熵。唯一的例外是测试环境模拟固定序列时。但即便是测试,我也更建议使用专门的测试构造器,而不要在生产代码里污染这个习惯。

3. 实战:几类高频场景的代码正确写法

3.1 API Token / Session ID 生成

业界比较通用的令牌格式是 256 位随机字节再加 Base64 URL 编码。256 位是 32 个字节,这是一个安全性的基本基准线。太少容易碰撞,够了才不容易被暴力枚举。

import java.security.SecureRandom; import java.util.Base64; public class TokenGenerator { private final SecureRandom secureRandom = new SecureRandom(); public String generateUrlSafeToken() { byte[] bytes = new byte[32]; secureRandom.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } }

这里重点解释一下:Base64.getUrlEncoder()会把+/换成-_,withoutPadding()去掉了末尾的=。这样可以保证令牌放进 URL 查询参数、放在Authorization请求头里都不需要额外转义。

32 字节 = 256 位,经过 Base64 URL 编码后大约 43 个字符。这样长度的 token,攻击者即使每秒尝试十亿次,也需要极长时间才能猜中。

3.2 临时密码与重置码

临时密码的生成有个容易忽略的点:字符集越乱越好,但也不能乱到用户输错。我建议去掉容易混淆的字符,比如大写的I、小写的l、数字1、大写O、数字0。这样打印在邮件里,用户不会瞪着屏幕反复分辨。

import java.security.SecureRandom; public class TempPasswordGenerator { private static final char[] PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789".toCharArray(); private final SecureRandom secureRandom = new SecureRandom(); public String generate(int length) { StringBuilder sb = new StringBuilder(length); for (int i = 0; i < length; i++) { sb.append(PASSWORD_CHARS[secureRandom.nextInt(PASSWORD_CHARS.length)]); } return sb.toString(); } }

要注意的一个细节:我使用secureRandom.nextInt(chars.length)而不是secureRandom.nextInt() % chars.length。后者会造成模运算偏差,会让某些字符的出现概率比另一些略高。虽然偏差不至于让攻击者在单次猜测中明显占优,但安全代码应该从原则上去除偏差。

如果是验证码或 6 位数字重置码,我更建议把随机数格式化和限流结合在一起。比如:

int code = 100000 + secureRandom.nextInt(900000);

这一步之后,请在服务端限制单用户每 5 分钟最多发送 3 次,并设置 10 分钟过期。随机数再安全,也扛不住无限重试,限流是必需品。

3.3 对称密钥生成

生成 AES 密钥也基本是同样套路:

import javax.crypto.spec.SecretKeySpec; import java.security.SecureRandom; public class KeyService { private final SecureRandom secureRandom = new SecureRandom(); public SecretKeySpec generateAesKey(int keySizeBits) { byte[] keyBytes = new byte[keySizeBits / 8]; secureRandom.nextBytes(keyBytes); return new SecretKeySpec(keyBytes, "AES"); } }

使用 128、192 或 256 位密钥时,分别传入 16、24、32 字节长度即可。

在我做安全合规咨询时,经常看到有人在 Key 生成过程中使用Random,理由是“密钥只在内存里存在,不在网络上传输”。实际上Random的输出规律在内存中也可能被侧信道攻击或者日志截获。别给自己的代码留这种侥幸空间。

3.4 安全随机 UUID:不要只依赖标准库的 randomUUID

严格来说,标准库的UUID.randomUUID()在大多数 JDK 实现里已经使用了SecureRandom,这一点在常规场景够用。但因为它是通过全局静态实例完成的,而且在某些 JVM 版本上会使用内部的Holder单例,这种实现的封装方式让你无法控制随机源。

如果你对随机源有更强的控制需求,或者希望一次生成多个 UUID,可以自己基于SecureRandom构建:

import java.util.UUID; import java.security.SecureRandom; public class SecureUuidGenerator { private final SecureRandom secureRandom = new SecureRandom(); public UUID randomUuid() { long msb = secureRandom.nextLong(); long lsb = secureRandom.nextLong(); msb = (msb & 0xffffffffffff0fffL) | 0x0000000000004000L; // version 4 lsb = (lsb & 0x3fffffffffffffffL) | 0x8000000000000000L; // variant return new UUID(msb, lsb); } }

这段代码把最高 4 位设置为版本 4,并把变体位设置为标准值。它和标准库输出格式完全一致,唯一区别是你自己管理随机源,适合在需要统一随机源或批量生成的场景下使用。

另外一个和生产相关的提示:如果拿 UUID 做数据库主键,建议不要用数据库生成的随机 UUID,而是在应用层生成,并考虑使用有序 UUID 来减少索引碎片。这不是随机安全的问题,但和 UUID 业务强相关,顺手一提。

4. 性能与线程模型:什么时候用,什么时候换

4.1 SecureRandom 的线程安全与性能真相

SecureRandom是线程安全的,多线程并发调用不会出问题。事实上,JDK 内部的 DRBG 实现通常会做并发优化,比如在单线程内生成批量随机字节,然后拆给多个调用方。

但性能上,它无法和ThreadLocalRandom相比。因为 CSPRNG 每生成一定量的数据后,需要更新内部状态,这个状态更新过程有密码学运算,成本比普通的乘加运算高得多。

从经验来看,如果你每秒只生成几万个短随机数,SecureRandom完全不是瓶颈。真正拖垮应用的,往往是数据库查询、远程调用、日志追加。所以“性能差”这个帽子更多是从理论层面扣上去的。业务侧不要为了省那几微秒去赌随机安全性。

SecureRandom还有一个隐蔽的性能坑:某些实现会在初始阶段收集熵,如果应用启动时大量线程同时触发初始化,可能造成短暂阻塞。解决办法很简单,在启动阶段预初始化单例,让它有一个“热身期”。比如在 Spring 的@PostConstruct或 main 函数里先调用一次secureRandom.nextInt(),把可能发生的阻塞提前消耗掉。

4.2 ThreadLocalRandom 的边界在哪里

ThreadLocalRandom是 JDK 7 之后新增的轻量随机数生成器,它本质上是对Random的算法改进,每个线程维护独立的种子变量,消除了竞争,性能非常出色。

但它依然不是密码学安全的。它跟Random一样使用 LCG 思想,输出可预测。

给你一个比较清晰的分界线:

场景推荐方案原因
非安全随机:模拟、性能测试、随机抽样ThreadLocalRandom快,无竞争
业务单号非安全标识ThreadLocalRandom不需要不可预测
会话 ID、密钥、盐、重置码SecureRandom必须不可预测
分布式 ID(雪花算法)专门算法需要趋势递增和有序性,随机生成器不是主角

我记得有一次优化一个模拟压测工具,把里面所有SecureRandom调换成了ThreadLocalRandom,压测吞吐提升了接近三倍。这说明性能差异是真实存在的。但同样的压测工具如果换成生成真实 token 的业务,性能差异完全可以忽略。选型的关键是看数据的安全属性,而不是一味追求快。

4.3 容器环境下的熵配置

在 Docker / Kubernetes 环境中跑 Java 应用,可能会遇到一个经典问题:应用启动时卡在SecureRandom初始化上。很多资料会让你加一行参数:

-Djava.security.egd=file:/dev/urandom

这个参数曾经是解决启动阻塞的银弹。它的作用是指定SecureRandom从/dev/urandom读取熵,从而避免和/dev/random的阻塞问题。在现代 JDK 版本里,java.security配置已经默认使用了一个叫NativePRNG的实现,它内部使用getrandom(),这个系统调用在不要求强安全时会优先使用 urandom 源,所以上述参数已不再是必需品。

如果你还在维护铁杆的老 JDK 环境,我的建议是:

  1. 先在测试环境复现问题,确认是不是初始化阻塞。
  2. 优先升级 JDK,很多老问题在新版本中都解决了。
  3. 确实无法升级时,再用-Djava.security.egd=file:/dev/urandom做兜底。

还要注意一个反向风险:如果你强行把SecureRandom切到 urandom 源,在某些高安全敏感场景下,可能减弱了熵保障。/dev/urandom在现代 Linux 上并不比/dev/random差太多,但从安全规范角度看,你仍然要明白自己在改什么。

5. 排查与经验:我踩过的坑

5.1 坑一:循环里疯狂创建 SecureRandom

这个我之前提过,算是最常见的问题。还有一种变体是把SecureRandom放在方法级私有对象里,每次调用都 new 一次。在并发高的时候,不仅增加 GC 压力,而且可能导致系统调用频繁进入熵源。

服务端代码写得好不好,看随机数对象的生命周期就能看出一些端倪。SecureRandom属于重量级安全组件,应该和数据库连接池、HTTP 客户端一样,拥有单例生命周期。

5.2 坑二:为了“固定测试数据”给 SecureRandom setSeed

测试想要固定数据是合理的,但直接在业务代码里写:

secureRandom.setSeed(12345L);

这会带来非常危险的副作用:如果这个分支在生产环境被触发,等于主动削弱了随机性。更好的方案是把随机数生成抽象成一个接口,生产环境用SecureRandom,测试环境用固定实现的MockRandom。

public interface TokenGenerator { String generateToken(); }

这样你在测试环境注入一个返回固定 token 的伪造实现,而不是去操纵SecureRandom的种子。安全代码和测试代码的边界就清晰了。

5.3 坑三:把随机字节直接当字符串存储

生成 token 后,很多人会习惯性把它转成纯十六进制字符串:

byte[] bytes = new byte[32]; secureRandom.nextBytes(bytes); String hex = DatatypeConverter.printHexBinary(bytes); // 长度暴涨,还不好读

十六进制编码不是不行,但它只使用了 16 个字符,而且没有 Base64 URL 紧凑。同样的 256 位随机性,Base64 URL 只需要 43 个字符,十六进制要用 64 个字符。更重要的是,十六进制字符串大小写不敏感,有些场景会把它和用户输入混淆,带来后续规范化问题。

我项目组里统一的约定是:凡是安全令牌一律用 Base64 URL 编码;凡是数字验证码一律格式化后存储哈希值。不要把明文验证码存数据库,存是不可能避免的,但可以存加盐哈希。这个教训来自一次某后台数据库泄露导致的验证码批量使用事件。

5.4 一个快速自查清单

检查点推荐做法坚决避免
随机数对象生命周期应用启动时创建单例每次调用都 new
种子管理完全交给系统熵源手动 setSeed 或 new SecureRandom(byte[])
编码方式Base64 URL 编码普通 Hex 加大冗余
线程安全SecureRandom 线程安全可直接复用引入不必要的 ThreadLocal 包装
容器配置现代 JDK 无需额外配置盲目设置 urandom 参数
测试替身接口抽象 + 测试实现在生产分支改种子

这张表是整个文章的核心浓缩,我用它来做代码评审时的 checklist。

6. 一点拓展心得

写到这里,我特别想说说我心里反复出现的一个原则:不要用通用随机数去处理安全敏感信息,更不要因为一段随机数“看起来乱”,就默认它是安全的。

我在实际项目中见过很多次这样的场景:业务侧为了性能指标,坚持在生成优惠券码时用Random。他们觉得“用户又不知道算法,猜不出来”。但安全不是靠算法保密来保证的。攻击者不需要知道你的业务名称,不需要知道你的具体实现,只要知道样品输出,加上行业通用知识,就有相当大的概率推测出模式。真正安全的设计,必须假设攻击者知道你的所有代码,只差一个随机源。

所以每当我写一行随机数相关代码时,都会自问:如果这一点东西泄露了,对整个系统的影响是什么?如果答案是“不算大事”,那用Random也说得过去。如果答案是“可能被人盗号”,那就必须SecureRandom。

最后再分享一个使用技巧:如果你在 JDK 17 上开发,建议看一下SecureRandom.getInstance("DRBG")的用法。JDK 9 开始支持标准 DRBG 机制,JDK 17 默认构造函数在很多平台上已经可以直接生成强随机数。在合规审计时,这种标准化生成器的可解释性更强,文档上也更容易说话。但万变不离其宗:单例持有、尊重熵源、不要手动播种、做好编码格式。这几条做到位,随机数这块的安全底线就有了。

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

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

立即咨询