探秘C#伪随机数生成器的安全漏洞与破解之道
Trivial C# Random Exploitation
2025年8月19日 - 作者:Dennis Goodlett
利用随机数生成器需要数学知识,对吗?得益于C#的Random类,情况未必如此!我遇到了一个HTTP 2.0 Web服务,它使用(new Random()).Next(min, max)输出的自定义编码来生成密码重置令牌。
这导致了严重的账户接管风险。利用此漏洞不需要编写脚本、数学知识或使用库。仅仅在Burp Suite中点击几次即可。虽然我拥有源代码,但我将展示一种在"黑盒"或漏洞赏金式测试中发现并利用此漏洞的方法。该利用不涉及数学计算,但我个人喜欢数学。因此,有一个关于如何优化和逆向Random类的附加章节。
漏洞详情
我无法分享客户端的代码,但大致如下:
var num = new Random().Next(min, max);
var token = make_password_reset_token(num);
save_reset_token_to_db(user, token);
这代表了一个典型的密码重置流程。令牌是使用Random()创建的,并且没有设置种子。然后该数字被编码为字母数字令牌。令牌通过电子邮件发送给用户。用户随后可以使用其电子邮件和令牌登录。
这可能是极易被利用的。
C# PRNG的工作原理
文档在某种程度上将我引向以下参考实现。这不是真正的实现,但足够好。不要在这里深入细节,显示Random(int Seed)只是为了上下文。
Git链接
public Random()
: this(Environment.TickCount) {
}
public Random(int Seed) {
int ii;
int mj, mk;
//初始化我们的种子数组。
//此算法来自《C语言数值计算(第二版)》
int subtraction = (Seed == Int32.MinValue) ? Int32.MaxValue : Math.Abs(Seed);
mj = MSEED - subtraction;
SeedArray[55]=mj; // [2]
mk=1;
for (int i=1; i<55; i++) { //显然范围[1..55]是特殊的(Knuth),所以我们浪费了第0个位置。
ii = (21*i)%55;
SeedArray[ii]=mk;
mk = mj - mk;
if (mk<0) mk+=MBIG;
mj=SeedArray[ii];
}
for (int k=1; k<5; k++) {
for (int i=1; i<56; i++) {
SeedArray[i] -= SeedArray[1+(i+30)%55];
if (SeedArray[i]<0) SeedArray[i]+=MBIG;
}
}
inext=0;
inextp = 21;
Seed = 1;
}
整个系统依赖于32位种子。这通过一些复杂的数学运算构建了内部状态(SeedArray[55])。如果Random在没有参数的情况下初始化,则使用Environment.TickCount作为种子。PRNG的所有输出都由其种子决定。在这种情况下,它就是TickCount——本质上就是时间。所以你可以把这个算法想象成通过电子邮件发送时间给你,只是使用了一种非常奇怪的编码方式。
在某种意义上,你甚至可以提交一个时间进行编码。你不是通过URL参数做到这一点,而是通过等待。等待正确的时间,你就能得到想要的编码。我们应该等待什么时间或事件呢?
利用方法
文档说得最好。
在.NET Framework中,默认种子值派生自系统时钟,其分辨率有限。因此,通过调用无参数构造函数在短时间内连续创建的不同Random对象具有相同的默认种子值,因此会产生相同的随机数集合。
如果我们在同一个1毫秒窗口内提交两个请求,我们会得到相同的种子,相同的种子产生相同的输出,相同的重置令牌会发送到两个电子邮件地址。当然,其中一个电子邮件地址是我们拥有的,另一个属于管理员。
我们如何命中1毫秒的窗口?我们使用单数据包攻击。
但这真的有效吗?
黑盒方法论
在验证漏洞之前,你肯定不想向管理员发送大量的重置电子邮件。所以,在目标网站上创建两个你可以控制的账户。虽然你可以用一个账户进行攻击,但这容易出现误报。你正在快速连续发送两个账户重置请求。第二个请求可能会在电子邮件服务读取第一个之前向数据库写入不同的重置令牌,从而导致误报。
使用Burp的Repeater组来执行单数据包攻击,以重置两个账户。检查你的电子邮件是否有重复的令牌。如果失败了,继续测试其他内容,直到锁定窗口结束。然后只需再次点击发送,很可能你不需要担心保持会话令牌的存活。
注意:Burp在Repeater的右下角显示往返时间。
密切关注那个数字。每个请求都有自己的时间。对我来说,大约需要10个请求才能获得重复的令牌。这仅在往返时间差为1毫秒或更少时发生。
在发起实际攻击时,检查你的令牌是否与受害者账户匹配的唯一方法是尝试登录。登录请求通常受到速率限制和保护。所以首先用测试账户验证。利用这个来获得一个有效的时间差窗口。然后,在实际发起攻击时,只有当时间差在你的测试边界内时才尝试登录。
啊……我想减去两个时间也算是数学。利用PRNG总是需要数学。
总结
这种攻击并非完全新颖。我在CTF比赛中见过类似的攻击。这是一个关于时间的好教训。我们通过等待或不等待来控制时间。如果一个秘密令牌只是编码的时间,你可以通过复制时间来复制它们。
如果你深入研究.NET运行时,你可能会说服自己这种攻击不会成功。Random有不止一种实现,我的客户应该使用的那个并不是用时间作为种子的。
我甚至可以用dotnetfiddle证明这一点。
这就像是安全版的“在我电脑上能用”。这就是为什么我们要测试“安全”代码,以及为什么我们要用随机输入进行模糊测试。所以,下次看到安全令牌时,试试这个利用方法。
这不仅适用于C#的Random。考虑一下Python的uuid?文档警告说,根据“底层平台”的不同,由于缺乏“同步”,可能存在潜在的冲突,除非使用了safeUUID。我想知道攻击在那里是否有效?只有一种方法可以找到答案。
修复弱PRNG漏洞的方法总是查阅文档。
在这种情况下,你必须点击“备注”部分中的“Random的补充API备注。”才能看到安全信息,其中写道:
要生成加密安全的随机数,例如适合创建随机密码的数字,请使用
System.Security.Cryptography.RandomNumberGenerator类中的静态方法之一。
所以,C#应使用RandomNumberGenerator而不是Random。
附加章节:破解C#旧版随机算法
接下来是一些数学知识。不算太难,但我觉得应该提醒你。这是利用这个发现的“困难”方法。我写了一个可以预测Random::Next输出的库。它也可以逆向它,回到种子。或者你可以从第七个输出找到第一个输出。所有这些都不需要暴力破解,只需要一个模方程。代码可以在这里找到。
我本打算这是一个有趣的周末数学项目。当我发现由于整数下溢导致的冲突时,事情变得混乱了。
种子生成全是数学
让我们看看种子生成算法,但尝试概括你所看到的内容。SeedArray[55]显然是PRNG的内部状态。这是用“数学”构建的。如果你仔细观察,几乎每次SeedArray[i]被赋值时,都是通过减法。之后你总是看到一个检查:减法结果是否为负数?如果是,则加上MBIG。换句话说,所有的减法都是在模MBIG下进行的。
MBIG的值是Int32.MaxValue,也就是0x7fffffff,或者2^31 - 1。这是一个梅森素数。对素数进行取模运算,数学家称之为伽罗华域。
我们这样说只是因为Évariste Galois太酷了。伽罗华域只是一种很好的说法,表示“我们可以使用自中学以来学到的所有常规代数技巧,即使这不是常规的数学”。
所以,假设SeedArray[i]是某个a*Seed + b mod MBIG。它在循环中通过减去另一个c*Seed + d mod MBIG来改变。我们不需要那个循环——代数告诉我们直接得到(a+c)*Seed + (b+d) Mod MBIG。通过循环遍历并进行代数运算,你可以得到SeedArray的每个元素,形式为a*Seed + b mod MBIG。
每次采样PRNG时,都会调用Random::InternalSample。这只是另一个减法。结果既被返回,又被用来设置SeedArray的某个元素。这又是一些方程。它仍然在伽罗华域中,仍然只是代数,你可以反转所有这些方程。给定Random::Next的一个输出,我们可以反转相应的方程并得到原始种子。
但是,我们还可以做更多!
csharp_rand.py 库
我创建的库从这些方程构建SeedArray。它将用这些方程的形式输出。让我们得到表示Random任何种子的第一个输出的方程:
>>> from csharp_rand import csharp_rand
>>> cs = csharp_rand()
>>> first = cs.sample_equation(0)
>>> print(first)
rand = seed * 1121899819 + 1559595546 mod 2147483647
这代表了任何种子的Random的第一个输出。使用.resolve(42)来获取new Random(42).Next()的输出。
>>> first.resolve(42)
1434747710
或者反转并解析1434747710,以找出哪个种子将在Random的第一个输出中产生1434747710。
>>> first.invert().resolve(1434747710)
42
这与dotnetfiddle的结果一致。
Random中的整数下溢
刚完成我的库,我就兴奋地把它展示给第一个愿意听我说话的人看。当然,它失败了。肯定有bug,我当然责怪原始实现。但由于账户接管漏洞不在乎我的感受,我修复了代码……大部分……
简而言之,原始实现存在整数下溢,这会影响某些种子值的数学方程。只有某些SeedArray元素会受到影响。例如,以下显示Random的第一个输出不需要任何调整,但第13个输出需要。
>>> print(cs.sample_equation(0))
rand = seed * 1121899819 + 1559595546 mod 2147483647
>>> print(cs.sample_equation(12))
rand = seed * 1476289907 + 1358625013 mod 2147483647 underflow adjustment: -2
所以第13个输出将是seed * 1476289907 + 1358625013,除非种子导致下溢,那么它将偏离-2。代码试图自己判断是否发生溢出。这在反转之前效果很好。
考虑一下,什么种子值将在Random::Next的第13个输出中产生908112456?
>>> cs.sample_equation(12).invert().resolve2(908112456)
(619861032, 161844078)
两个种子,619861032和161844078,都将在第13个输出中产生908112456(poc)。种子619861032是通过非调整方程以适当的方式做到的。种子619861032则是通过下溢达到的。这种“碰撞”意味着恰好有2个种子产生相同的输出。这意味着908112456在第13个输出上出现的可能性是第一输出的两倍。这也意味着没有种子会在Random的第13个输出上产生908112458。一个快速的暴力破解产生了大约80K+个类似的“碰撞”。
附加章节结论
有时聪明的方法很愚蠢。一个有趣的数学项目最终感觉像是被千刀万剐。最好让你的利用方法版本匹配、语言匹配,并快速启动它。如果花费很长时间,在它还在运行时就开始优化。但在优化之前,先测试!测试一切!否则,你会运行几个小时的暴力破解却一无所获。为什么?好吧,也许Random(Environment.TickCount)不是Random(),因为显式播种会导致不同的算法!
唉……我要去审计更多的端点了……
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)
公众号二维码

公众号二维码


浙公网安备 33010602011771号