From f100a81e325ece21dd6b48e4ae5155eaff15164c Mon Sep 17 00:00:00 2001 From: Patrick Nappa Date: Sat, 22 Nov 2025 10:16:36 +1100 Subject: [PATCH] [LOW][Security] Fix biased random number generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The random number generation logic results in a bias leading to some characters being more frequent than others, due to incorrect processing of random bytes. This can result in more easily guessable IDs, enabling some enumeration of resources. The crux of the vulnerability is that the RNG (as defined in `detectPRNG`), generates a float by generating a random byte and dividing by 255 (0xff). So, if the random byte is 0x00, it returns 0. If the byte is 0x01, it returns ~0.0039, and so on, until if the random byte is 0xff, it returns 1. Thus, the float returned could be [0, 1], rather than [0, 1) (as some comments expect). For reference, The whole list of possible outputs of the rng function returned by `detectPRNG` can be obtained by the following snippet: ``` const rngValues = Array.from(new Array(256), (_, idx) => (idx)/0xff); > [0, 0.0039, …, 1] ``` As a result, the `randomChar` function (as used by `encodeRandom`) will generate a bias, as it wraps around if the value is 1. All possible randomPosition values are thus (where 32 is from `ENCODING_LEN`): ``` const randomPositions = rngValues.map((v) => Math.floor(v * 32) % 32) > [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, …, 31, 0] ``` If you take a look at the values of this array, you’ll notice that the distribution isn’t equal: ``` randomPositions.reduce((acc, v) => { if (!acc.has(v)) { acc.set(v, 0); } acc.set(v, acc.get(v)+1); return acc; }, new Map()); > { 0 => 9, 1 => 8, 2 => 8, …, 30 => 8, 31 => 7 } ``` Therefore, it’s more likely to generate a ‘0’ character, than a ‘Z’ character, and thus will lead to bias, thus a lower entropy than expected. --- source/ulid.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/ulid.ts b/source/ulid.ts index 24ebb28..a270724 100644 --- a/source/ulid.ts +++ b/source/ulid.ts @@ -52,12 +52,12 @@ export function detectPRNG(root?: any): PRNG { return () => { const buffer = new Uint8Array(1); globalCrypto.getRandomValues(buffer); - return buffer[0] / 0xff; + return buffer[0] / 256; }; } else if (typeof globalCrypto?.randomBytes === "function") { - return () => globalCrypto.randomBytes(1).readUInt8() / 0xff; + return () => globalCrypto.randomBytes(1).readUInt8() / 256; } else if (crypto?.randomBytes) { - return () => crypto.randomBytes(1).readUInt8() / 0xff; + return () => crypto.randomBytes(1).readUInt8() / 256; } throw new ULIDError(ULIDErrorCode.PRNGDetectFailure, "Failed to find a reliable PRNG"); }