Skip to content

CWE-330: Use of Insufficiently Random Values - Java

Overview

java.util.Random and Math.random() are pseudo-random number generators (PRNGs) built on a 48-bit linear congruential generator. The seed is not the interesting part - new Random() mixes System.nanoTime() with an internal uniquifier, so guessing it is awkward - and guessing it is unnecessary. The state is small enough and the algorithm linear enough that the output gives it away directly: measured on JDK 26, a single nextLong() value narrows the 48-bit state to 2^16 candidates, one brute-force loop of 65,536 iterations picks the right one, and every subsequent value is then predicted exactly.

This makes these classes unsuitable for security-sensitive values: session tokens, password reset links, API keys, OTP codes, CSRF tokens, or cryptographic nonces. For these purposes, the correct class is java.security.SecureRandom, which draws from the OS cryptographic random source through the configured JCA provider - on JDK 26 that is DRBG from the SUN provider by default, seeded from securerandom.source.

Primary Defence: Replace all new Random() and Math.random() in security contexts with SecureRandom. A single shared SecureRandom instance is thread-safe and can be reused across the application.

Common Vulnerable Patterns

Token Generation with java.util.Random

import java.util.Random;

// VULNERABLE - java.util.Random produces predictable sequences
public class TokenService {
    private static final Random RANDOM = new Random();

    public String generatePasswordResetToken() {
        long token = RANDOM.nextLong();
        return Long.toHexString(token);
    }
}

Why this is vulnerable:

  • Random uses a 48-bit linear congruential generator, and nextLong() publishes 64 bits of it in one go. One output - a reset token requested for the attacker's own account - is enough: the top 32 bits are the state's top 32 bits, the remaining 16 fall out of a 65,536-iteration loop, and every token issued after it is then computed rather than guessed. Verified against JDK 26. A shared static final Random makes this worse, because "after it" means every account, not just the attacker's.

OTP with Math.random()

// VULNERABLE - Math.random() uses a shared java.util.Random instance
public int generateOtp() {
    return (int) (Math.random() * 900_000) + 100_000;
}

Why this is vulnerable:

  • Math.random() is backed by java.util.Random. An attacker who can observe a sequence of OTP values can predict the next one, allowing them to bypass SMS-based second factors.

Session ID from Timestamp + Random

import java.util.HexFormat;
import java.util.Random;

// VULNERABLE - seeding from timestamp makes the seed guessable
public String generateSessionId() {
    Random rng = new Random(System.currentTimeMillis()); // predictable seed
    byte[] bytes = new byte[16];
    rng.nextBytes(bytes);
    return HexFormat.of().formatHex(bytes);
}

Why this is vulnerable:

  • Seeding Random from System.currentTimeMillis() makes the seed knowable to anyone who can observe the approximate time of the request. An attacker can enumerate timestamps within a small window to find the seed.

setSeed() on a Freshly Created SHA1PRNG

import java.security.SecureRandom;

// VULNERABLE - on SHA1PRNG, setSeed() before first use REPLACES the seed
public byte[] deriveTokenBytes(long tenantId) throws Exception {
    SecureRandom rng = SecureRandom.getInstance("SHA1PRNG");
    rng.setSeed(tenantId);
    byte[] bytes = new byte[16];
    rng.nextBytes(bytes);
    return bytes;
}

Why this is vulnerable:

  • setSeed() does not mean the same thing on every provider, and the difference is invisible at the call site. On the default provider it supplements the existing seeding, so the generator stays unpredictable no matter what you pass it. On SHA1PRNG it replaces the seeding entirely if the instance has not produced output yet - which it has not, one line after getInstance. Measured on JDK 26: two fresh SHA1PRNG instances given setSeed(42L) produced byte-identical streams (d50ac288b90ede2e...), while two default-provider instances given the same value did not. The class name says SecureRandom and the algorithm is not the problem; the seed is, and every tenant's token is now derived from a number the tenant knows. Name no algorithm at all - new SecureRandom() - and never call setSeed() before drawing from a generator you want to be unpredictable.

Secure Patterns

256-bit URL-Safe Token

import java.security.SecureRandom;
import java.util.Base64;

public class TokenGenerator {
    // SECURE - SecureRandom is thread-safe; reuse the instance
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    public static String generateToken() {
        byte[] bytes = new byte[32]; // 256 bits
        SECURE_RANDOM.nextBytes(bytes);
        // URL-safe Base64, no padding - safe to use in URLs and headers
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
}

Why this works:

  • SecureRandom seeds itself from the OS cryptographic random source on first use and never needs seeding by the caller. Its output cannot be predicted without the kernel's internal state, which no application-level attacker reaches. 32 bytes encode to a 43-character URL-safe string with padding stripped, so it drops into a path segment or an Authorization header unescaped.

6-Digit OTP

import java.security.SecureRandom;

public class OtpGenerator {
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    public static int generateOtp() {
        // SECURE - generates a 6-digit code in [100000, 999999]
        return SECURE_RANDOM.nextInt(900_000) + 100_000;
    }
}

Why this works:

  • SecureRandom.nextInt(bound) uses the CSPRNG to generate an unbiased value in [0, bound). Adding 100_000 shifts the range to [100000, 999999] for a 6-digit OTP.

API Key Generation

import java.security.SecureRandom;
import java.util.HexFormat;

public class ApiKeyGenerator {
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    public static String generateApiKey() {
        byte[] keyBytes = new byte[32]; // 256-bit key
        SECURE_RANDOM.nextBytes(keyBytes);
        return HexFormat.of().formatHex(keyBytes); // 64-char hex string
    }

    // Alternative: the algorithm named by the securerandom.strongAlgorithms
    // security property, for deployments with a policy about which one to use
    public static String generateMasterKey() throws java.security.NoSuchAlgorithmException {
        SecureRandom strongRng = SecureRandom.getInstanceStrong();
        byte[] keyBytes = new byte[32];
        strongRng.nextBytes(keyBytes);
        return HexFormat.of().formatHex(keyBytes);
    }
}

Why this works:

  • HexFormat.of().formatHex() (Java 17+) is the standard library's hex encoder, 64 characters for a 32-byte key.
  • new SecureRandom() is already sufficient for keys. getInstanceStrong() does not return a "more random" generator - it returns whichever algorithm the securerandom.strongAlgorithms security property names first, which is a deployment policy setting, not a strength ranking. Both draw from the same kernel CSPRNG. Measured on JDK 26/Windows, the default resolved to DRBG from SUN and getInstanceStrong() to Windows-PRNG from SunMSCAPI, and the "strong" one was consistently the faster of the two over 1,000 32-byte draws - by about twice once the JIT had fully warmed both up, and by more than an order of magnitude on the first thousand draws. Which one wins is a property of the platform's providers, not of how strong they are; do not reach for getInstanceStrong() believing the default is weaker, and use it where a policy requires a named algorithm.

Considerations

Ask what guessing the value would get someone. Randomness has non-security uses everywhere - sampling, shuffling, jitter, cache-busting, test fixtures - and none of them need a CSPRNG. The finding is material when the value is a session identifier, a token, a key, an OTP, a salt, an IV, or anything else whose unpredictability is what makes it work. If it is not, the general-purpose generator is the correct choice and the finding should be closed with the reason recorded.

Do not blanket-replace. A cryptographic generator draws on the OS entropy pool and is meaningfully slower than a PRNG. That cost is irrelevant for a handful of tokens per request and very relevant in a simulation or a rendering loop generating millions of values. Replacing every call site to make a scanner quiet trades real throughput for no security benefit, and it makes the genuine findings harder to see.

UUID.randomUUID() is CSPRNG-backed but fixed at 122 bits. Reasonable for a session identifier, short of what you want for key material, and the constraint is the UUID format rather than the generator.

A shared SecureRandom instance is fine. It is thread-safe, and creating one per request adds seeding cost for no benefit - a private static final field is the usual shape.

Anything derived from a weak value stays weak. Hashing it, base64-encoding it, concatenating a timestamp, or truncating it changes how the output looks without adding entropy - the result is still fully determined by the predictable input. There is no post-processing that fixes the source; only replacing the generator does.

Check the length once the generator is right. This CWE is about the unpredictability of the value, which depends on both the source and how much of it you take. Four bytes from a cryptographic generator is still only 32 bits. Use at least 16 bytes for tokens and 32 for key material, and remember hex encoding doubles the character count, which is where half the intended entropy usually goes missing.

Testing

  • Normal input: exercise token, OTP, API-key, and session creation flows that now call SecureRandom.
  • Boundary input: test minimum and maximum token lengths, encoding into URLs or headers, and concurrent generation under load.
  • Malicious input: generate many values around the same timestamp and confirm there is no deterministic sequence or repeated seed behavior.

Common Pitfalls

  • Falling back to ThreadLocalRandom under load: Constructing a fresh SecureRandom per request pays the provider lookup and self-seeding cost every time, so a hot token-generation path gets "optimized" with ThreadLocalRandom.current() - which is a java.util.Random subclass with a name that reads like a threading fix, and whose per-thread generator is as non-cryptographic as the superclass. Reuse a single static final SecureRandom instead; it is thread-safe, and reusing it removes the cost that prompted the change.
  • Believing entropy runs out: The advice to prefer /dev/random, NativePRNGBlocking or getInstanceStrong() because the ordinary source "might exhaust the entropy pool" is pre-2020 folklore. Since Linux 5.6 getrandom() blocks only until the CRNG is first initialised at boot, /dev/urandom never blocks, and /dev/random no longer blocks on an entropy estimate either. Drawing bytes does not deplete anything. A generator that blocks in production is a container or VM starting before the host kernel's CSPRNG has been seeded, which is an infrastructure problem - give the guest a virtual RNG device or an entropy daemon. Choosing a different JCA algorithm does not fix it and changing to a blocking one causes it.
  • Treating any UUID as a secure token: UUID.randomUUID() does use SecureRandom internally in modern JDKs, but only 122 of its 128 bits are random (6 are fixed for version/variant). Some codebases also swap in UUID.nameUUIDFromBytes() (version 3, MD5-based and fully deterministic from its input) as though it were a random generator.

Additional Resources