随机数在信息安全中的重要性及其在密码生成与数据加密中的应用
一

研究背景
随机数在科学和工程的许多领域中发挥着重要作用,特别是在计算机科学和信息安全领域。它的重要性不容低估。在这个全球数字化时代,数据是经济和生活的核心,数据安全和保密尤为重要。我们使用密码来保护我们的银行帐户、电子邮件、社交媒体帐户,并使用加密来保证我们通信的私密性。
在这些过程中,随机数是最重要的部分。它们用于密码生成、数据加密、身份验证和网络协议安全。它们是确保电子通信安全的令牌。如果我们不能保证生成的随机数实际上是随机的,那么它们可能是被预测的,这将使我们面临安全风险。因此,在讨论随机数的同时,我们必须对随机性的安全性有更深入的了解,才能更有效地使用随机数,保护我们自己和我们的数据免受攻击。
本次分享的两个案例(CVE-2023-42820和CVE-2022-35890)都是由于随机数使用不当导致的,从而导致了更严重的安全问题。
二
随机数基础知识
根据密码学原理,随机数的随机性测试可以分为三个标准:
1. 统计伪随机性
在给定的随机比特流样本中,1 的数量大致等于 0 的数量,并且满足此要求的数字“乍一看”对人类来说似乎是随机的
2. 密码安全伪随机性
给定随机样本的一部分和随机算法,无法有效计算随机样本的剩余部分
3. 真正的随机性
随机样本不可重复
相应的,随机数也分为三类:
1. 伪随机数
满足第一个条件的随机数
2. 加密安全的伪随机数
可以使用加密安全伪随机数生成器来计算同时满足前两个条件的随机数。
3.真随机数
同时满足三个条件的随机数
加密安全伪随机数生成器 (CSPRNG)
与统计伪随机数生成器和较弱的伪随机数生成器相比,CSPRNG生成的密码安全伪随机数具有额外的伪随机属性。简单地说,CSPRNG 本质上是一种单向函数。
下面是使用python随机库生成随机数的示例
> import random> random.seed(123)> random.random()0.052363598850944326> random.random()0.08718667752263232> random.seed(123)> random.random()0.052363598850944326> random.random()0.08718667752263232> random.random()0.4072417636703983> random.seed(123)> random.random()0.052363598850944326
对于随机数的使用,一般是先播种,然后使用rand来获取随机数。如果不设置种子,将使用默认种子,并且对于不同的语言阻止版本,种子可能会有所不同。 rand 生成的这种随机数是伪随机数。只要种子固定,每次生成的随机数序列就会相同。同时,通过上面的例子,可以发现以下特点:
public class A{public Random random;public void init(){long seed = 123456L;this.random = new Random(seed);}public static void main(){this.init();int num = this.random.nextInt(100);int num2 = this.random.nextInt(100);System.out.println(num + " " + num2);}}
三
经典案例分析
01
JumpServer 任意帐户密码重置 (CVE-2023-42820)
JumpServer是国内流行的开源堡垒机,也是符合4A规范的专业运维安全审计系统。
该漏洞在于找回密码时,生成的6位验证码算法是伪随机的,可以获取伪随机种子,从而可以预测验证码,任意账户的密码最终被重置。
JumperServer找回密码的流程如下。这是从知识星球借来的流程图。
看起来流程没有问题,但是在请求验证码图片的时候直接将随机数种子展示给用户的时候就出现了问题。我们先从源码来看一下逻辑。
我们先看一下验证码生成的逻辑
def captcha_image(request, key, scale=1):if scale == 2 and not settings.CAPTCHA_2X_IMAGE:raise Http404try:store = CaptchaStore.objects.get(hashkey=key)except CaptchaStore.DoesNotExist:# HTTP 410 Gone status so that crawlers don't index these expired urls.return HttpResponse(status=410)random.seed(key) # Do not generate different images for the same key# 这里的种子是外面传进来的参数text = store.challenge...
找到这个函数被调用的地方
from django.urls import re_pathfrom captcha import viewsurlpatterns = [re_path(r"image/(?P\w+)/$" ,views.captcha_image,name="captcha-image",kwargs={"scale": 1},),re_path(r"image/(?P\w+)@2/$" ,views.captcha_image,name="captcha-image-2x",kwargs={"scale": 2},),re_path(r"audio/(?P\w+).wav$" , views.captcha_audio, name="captcha-audio"),re_path(r"refresh/$", views.captcha_refresh, name="captcha-refresh"),]
可以发现请求的URL中存在key的值,如下
这就满足了随机数种子已知的条件
我们看一下密码找回的逻辑:
这里可以发现,生成验证码也是使用了随机函数,并且没有进行补种,所以后面的随机序列完全可以计算出来,这样就可以直接计算出6位验证码了。
02
使固定
修复:修复随机错误·jumpserver/jumpserver@ce645b1·GitHub
patch就是直接重新播种None作为种子。
random.seed(a=None, version=2) 如果省略 a 或 None ,则使用当前系统时间。如果操作系统提供随机源,则使用它们而不是系统时间(有关可用性的详细信息,请参阅 os.urandom () 函数)。
查看手册并使用 None 作为种子,然后
这样就避免了生成6位验证码时种子已知,后续随机数就可以预测的情况。
03
感应点火会话劫持 (CVE-2022-35890)
Initive Automation Ignition是美国Inducing Automation公司的SCADA系统集成软件平台。该平台支持SCADA(监控和数据采集系统)、HMI(人机界面)等。
ignition是pwn2own 2022年的竞赛项目,竞赛中使用了该漏洞。
该漏洞源于Windows下用于生成会话的算法是伪随机函数,并且没有使用默认种子。还可以通过特定的方法泄露种子的大致范围,最终通过组合一定数量的爆破来劫持真实会话。
我们先看种子初始化部分。
private void initRandom() throws Exception {long seed = System.currentTimeMillis();char[] entropy = ENTROPY;for (int i = 0; i < entropy.length; ++i) {long update = (byte)entropy[i] << i % 8 * 8;seed ^= update;}this.random = new SecureRandom();this.random.setSeed(seed);this.digest = MessageDigest.getInstance("SHA-1");}
initRandom位于GatewaySessionManager刚启动时。这里初始化种子,使用的随机函数是java.security.SecureRandom()
public GWSession createSession() {GWSession session = new GWSession(this.generateSessionId());session.startup(this.context);this.sessions.put(session.getId(), session);this.log.debug((Object)("Created new session: " + session.getPublicId()));this.statusTags.refresh();return session;}
创建的session位于用户成功登录的点,然后看生成session的具体算法。
protected synchronized String generateSessionId() {byte[] random = new byte[16];String result = null;StringBuffer buffer = new StringBuffer();do {int resultLenBytes = 0;if (result != null) {buffer = new StringBuffer();++this.duplicates;}while (resultLenBytes < this.sessionIdLength) {this.random.nextBytes(random);random = this.digest.digest(random);for (int j = 0; j < random.length && resultLenBytes < this.sessionIdLength; ++resultLenBytes, ++j) {byte b1 = (byte)((random[j] & 0xF0) >> 4);byte b2 = (byte)(random[j] & 0xF);if (b1 < 10) {buffer.append((char)(48 + b1));} else {buffer.append((char)(65 + (b1 - 10)));}if (b2 < 10) {buffer.append((char)(48 + b2));continue;}buffer.append((char)(65 + (b2 - 10)));}}} while (this.sessions.get(result = buffer.toString()) != null);return result;}
这里使用this.random来生成随机数,即上面播下的以时间戳为种子的随机函数,所以可能存在被预测的风险。
查询Windows平台上java.security.SecureRandom调用的底层函数,发现堆栈溢出有类似问题。
问:我对 java.util.Random 和 java.security.SecureRandom 类感兴趣。我发现Random使用系统时钟来生成种子,SecureRandom使用/dev/randomor/dev/urandom,但是这些文件在Linux上,而在Windows上它使用一些misticCryptGenRandom。即使这是超级安全的函数,我们知道它从哪里获取值吗?生成种子的地下室是什么?
我对 java.util.Random 和 java.security.SecureRandom 类感兴趣。我发现Random使用系统时钟来生成种子,而SecureRandom使用/dev/random或/dev/urandom,但这些文件在Linux上,而在Windows上它使用一些神秘的CryptGenRandom。尽管这是一个超级安全的函数,但我们知道它从哪里获取值吗?生成种子的底层逻辑是什么?
答:在 Windows 中,SecureRandom 使用 CryptGenRandom 方法,该方法是 WinCrypt Windows 库的一部分(包含在 Windows 系统库的 Advapi32.dll 中)。
在 Windows SecureRandom 中,使用 CryptGenRandom 方法,该方法是 WinCrypt Windows 库的一部分(包含在 Windows 系统库的 Advapi32.dll 中)。
以下是来自微软官方手册对CryptGenRandom的描述(摘录)
软件随机数生成器的工作方式基本相同。他们从一个随机数(称为种子)开始,然后使用算法生成基于它的伪随机位序列。这个过程中最困难的部分是获得真正随机的种子。这通常基于用户输入延迟或来自一个或多个硬件组件的抖动。
软件随机数生成器的工作原理基本相同。它们从随机数(称为种子)开始,然后使用算法生成基于该随机数的伪随机位序列。该过程中最困难的部分是获得真正的随机种子。这通常基于用户输入延迟或来自一个或多个硬件组件的抖动。
从手册中我们知道Windows底层调用的是伪随机函数,默认使用的种子是一个很难预测的值,但是在ignition中却错误地使用了系统时间作为种子。
如何获得伪随机种子?
在ignition gateway中,有一个特殊的servlet scriptModules,用于获取第三方脚本,最后将其打包并返回一个zip。
直接走对应的逻辑
void zipThirdPartyScriptModulesAndCalcHash() {this.thirdPartyZipValid = false;Object object = this.thirdPartyZipLock;synchronized (object) {if (this.thirdPartyZipValid) {return;}try {File pylibDir = this.getThirdPartyScriptModulesDir();ZipMap zipMap = new ZipMap();this.addDirToZip(pylibDir, pylibDir, zipMap);File tempFile = new File(this.systemManager.getTempDir(), "pylib_compressed.zip");zipMap.writeToFile(tempFile);this.thirdPartyScriptModulesHash = Files.hash((File)tempFile, (HashFunction)Hashing.md5()).toString();}catch (Exception e) {this.log.error("Error calculating 3rd party script zip hash.", (Throwable)e);this.thirdPartyScriptModulesHash = null;}finally {this.thirdPartyZipValid = true;this.thirdPartyZipLock.notifyAll();}}}
每次启动点火时都会重新生成 pylib_compressed.zip。文件会有一个最后修改时间的属性,上面提到的随机数初始化时使用的时间戳会接近这个时间。
查看启动日志可以看到,点火网关是在种子生成后启动的。那么你只需要从 zip 的最后修改时间值中减去延迟即可。一般来说2s就够了。
那么爆种子的时候怎么知道当前session是否正确呢?
网关处理数据包时存在如下逻辑:
if (!versionHash.isDev() && msg.getVersion() != 0L && versionHash.getHash() != msg.getVersion()) {if (session != null) {session.setMaxInactiveInterval(10);}this.printErrorResponse((PrintWriter)out, 309, "Version mismatch", false);return;}
这里的逻辑位于会话验证之后,也就是说故意设置了错误的版本。当会话验证通过后,在返回包中可以看到309响应。
这样就完成了整个会话劫持过程。
整个流程总结如下:
通过scriptModules获取点火启动时间→使用time-delay作为初始种子→使用初始种子计算session→验证当前session是否正确→seed+1(直到正确)。
04
使固定
查看固定版本代码:
protected synchronized String generateSessionId() {String result;do {result = AuthUtil.generateRandomBase64String(32);} while(this.sessions.get(result) != null);return result;}
新版本直接删除了initRandom函数,修改了生成会话的逻辑。跟进:
public static String generateRandomBase64String(int entropyCountInBytes) {assert entropyCountInBytes > 0;byte[] bytes = new byte[entropyCountInBytes];SecureRandomProvider.get().nextBytes(bytes);return BASE64_ENCODER.encodeToString(bytes);}
继续跟进:
public void nextBytes(byte[] bytes) {LOG.tracef("nextBytes(bytes.length=%s)...", new Object[]{bytes.length});this.secureRandom.nextBytes(bytes);LOG.trace("Done.");}
看到使用的函数仍然是伪随机函数,看看种子是否可以猜测:
private SecureRandomProvider() throws NoSuchAlgorithmException {LOG.debug("Creating SecureRandom object...");this.secureRandom = SecureRandom.getInstance("SHA1PRNG");byte[] seed = new byte[128];(new Random()).nextBytes(seed);this.secureRandom.setSeed(seed);this.secureRandom.nextBytes(new byte[128]);(new Thread(new SeedGenerator(), "secure-random-seed-gen")).start();LOG.debug("... SecureRandom Created.");}
虽然这里使用了伪随机函数 random().nextbytes() 来生成种子(随机函数默认使用时间戳作为种子),但由于每次生成会话时都需要调用此过程,因此使用的种子是当前时间,所以每次生成session时的种子都无法通过之前的方法推测出来,使得session的价值无法计算,最终杜绝了session被劫持的风险。
这里还有另一个修复,即使用 java.security.SecureRandom 的默认种子,而不使用 setseed。
四
漏洞模式总结
使用不安全的随机函数→种子是已知/可预测的→随机数是可计算的→导致更严重的安全问题。
上述两种情况的修复方法都是对种子进行处理,防止种子被预测,从而修复原有的安全问题。
类似地,上述安全问题也可以通过将伪随机函数修改为安全随机函数来解决(但安全随机函数可能不如伪随机函数高效)。
语言
常见的伪随机函数
安全随机函数
斯兰德
Linux 使用 /dev/urandom
兰特
Windows 使用 CryptGenRandom 并使用默认种子
C++
mt19937
C++ 使用 std::random_device 类获取安全随机种子
默认随机引擎
Python
随机的
秘密
爪哇
java.security.SecureRandom //强伪随机函数
SecureRandom.getInstanceStrong
java.util.Random //弱伪随机数
php
mt_scrand mt_rand
随机字节
C#
随机的
系统.安全.加密.RNGCryptoServiceProvider
戈兰
数学/兰德
加密货币/兰特
如果种子是不可预测的,那么伪随机数序列就很难预测,称为强伪随机数。
如果种子是可预测的,那么随机数序列通常也是可预测的,称为弱随机数。
五
参考
Jumpserver随机数种子泄露导致账户劫持漏洞(CVE-2023-42820)
Jumpserver安全一览:Sep系列漏洞深度分析
Windows 操作系统上 java.security.SecureRandom 的种子
CryptGenRandom 函数 (wincrypt.h)