Trivial C# Random Exploitation
发布日期:2025年8月19日 作者:Dennis Goodlett
利用随机数生成器需要数学知识,对吗?多亏了C#的 Random 类,情况并非总是如此!我遇到了一个HTTP 2.0 Web服务,它使用 (new Random()).Next(min, max) 输出的自定义编码来生成密码重置令牌。这导致了一个严重的账户接管漏洞。
利用此漏洞不需要编写脚本、数学计算或使用库。只需要在Burp Suite中进行几次点击。虽然我拥有源代码,但我将展示一种在黑盒或漏洞悬赏风格的任务中发现并利用此漏洞的方法。该利用不涉及数学,但我个人喜欢数学。因此,文章还包含一个关于如何优化和逆向 Random 类的额外部分。
漏洞详情
我无法分享客户端的代码,但其逻辑大致如下:
|
|
这代表了一个典型的密码重置流程。令牌是使用 Random() 创建的,并且没有提供种子。这个数字随后被编码成一个字母数字令牌。令牌通过电子邮件发送给用户。用户随后可以使用他们的电子邮件和令牌登录。
这可能极易被利用。
C# PRNG 的工作原理
文档中的某个链接指向了以下参考实现。这不是真实的实现,但足够说明问题。这里不要陷入细节,显示 Random(int Seed) 只是为了上下文。
|
|
整个系统依赖于32位的种子。这通过一些复杂的数学构建了内部状态(SeedArray[55])。如果 Random 在没有参数的情况下初始化,则使用 Environment.TickCount 作为种子。伪随机数生成器的所有输出都由其种子决定。在这种情况下,种子就是 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算法
接下来是一些数学内容。不算太难,但我觉得应该提醒你一下。这是利用这个发现的“困难”方式。我写了一个可以预测 Random::Next 输出的库。它也可以逆向它,回溯到种子。或者你可以从第七个输出找到第一个输出。这些都不需要暴力破解,只需要一个模方程。代码可以在这里找到。
我本打算把它作为一个有趣的周末数学项目。但当我发现由于整数下溢导致的碰撞时,事情就变得复杂了。
种子只是一堆数学
让我们看看种子算法,但尝试概括你看到的东西。SeedArray[55] 显然是PRNG的内部状态。这是通过“数学”构建的。如果你仔细观察,几乎每次给 SeedArray[i] 赋值时,都是通过减法。之后你总会看到一个检查:减法结果是否为负数?如果是,就加上 MBIG。换句话说,所有的减法运算都是在模 MBIG 下进行的。
MBIG 的值是 Int32.MaxValue,也就是 0x7fffffff,或者 2^31 - 1。这是一个梅森素数。在一个素数下做模运算,数学家称之为伽罗瓦域。
我们这么说只是因为埃瓦里斯特·伽罗瓦太酷了。伽罗瓦域只是“我们可以使用自初中以来学到的所有常规代数技巧,尽管这不是常规数学”的一种好听说法。
所以,假设 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 第一个输出的方程:
|
|
这代表了任何种子下 Random 的第一个输出。使用 .resolve(42) 来获取 new Random(42).Next() 的输出。
|
|
或者进行逆运算,通过解析 1434747710 来找出哪个种子会使 Random 的第一个输出产生 1434747710。
|
|
这与 dotnetfiddle 上的结果一致。
Random 中的一个整数下溢
刚完成我的库,我就兴奋地把它展示给第一个愿意听我说话的人看。当然,它失败了。肯定有bug,我当然责怪了最初的实现。但由于账户接管漏洞不会在乎我的感受,我修复了代码……大部分……
简而言之,原始实现中存在一个整数下溢,这会对某些种子值扰乱数学方程。只有某些 SeedArray 元素会受到影响。例如,以下代码显示 Random 的第一个输出不需要任何调整,但第13个输出需要。
|
|
所以第13个输出将是 seed * 1476289907 + 1358625013,除非种子导致了下溢,那么它会偏差 -2。代码本身试图判断下溢是否发生。这在正向计算时效果很好,直到你进行逆向运算。
考虑一下,什么种子值会使 Random::Next 的第13个输出产生 908112456?
|
|
两个种子,619861032 和 161844078,都会在第13个输出产生 908112456(参见概念验证)。种子 619861032 是通过非调整方程的正常方式实现的。种子 161844078 则是通过下溢调整实现的。这种“碰撞”意味着恰好有两个种子产生相同的输出。这意味着 908112456 在第13个输出出现的概率是第一个输出的两倍。这也意味着没有任何种子会使 Random 的第13个输出产生 908112458。一次快速的暴力搜索产生了大约8万多个类似的“碰撞”。
附加内容总结
有时候聪明的方法反而是笨办法。一个原本有趣的数学项目最终感觉像是被千刀万剐。最好还是版本匹配、语言匹配你的利用方式,并快速让它运行起来。如果花费太长时间,就在它还在运行的时候开始优化。但在优化之前,一定要测试!测试一切!否则你可能会运行数小时的暴力破解却一无所获。为什么?也许是因为 Random(Environment.TickCount) 并不等同于 Random(),因为显式播种会导致不同的算法!
唉……我还是去审计更多的端点吧……