Software Securityhome
Fix a findingSearch
Fix card · A04:2025 Cryptographic Failures

Fix insecure randomness for reset tokens in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Generate the token with java.security.SecureRandom: 32 bytes (256 bits) Base64url-encoded. Never use java.util.Random or Math.random -- a 48-bit LCG whose state is recoverable from two outputs -- and never 'fix' a weak token by hashing it. Compare the stored token in constant time with MessageDigest.isEqual, and keep it single-use with a short expiry.

Stack
  • Language Java
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Framework 7, Spring MVC)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks4 cards
Download the project ZIP, 23 KB
On this page

Steps

  1. Find every security token built from java.util.Random, Math.random() or ThreadLocalRandom: reset tokens, session IDs, API keys. Any of these is a linear congruential generator whose state leaks through its output.
  2. Replace the generator with a shared java.security.SecureRandom and read 32 bytes (256 bits); encode with Base64.getUrlEncoder().withoutPadding() for a URL-safe token.
  3. Remove any new Random(seed) that takes the clock or another guessable seed, and any step that hashes a timestamp; the hash adds no entropy.
  4. Store only a hash of the token, give it a short expiry, and mark it used on first success.
  5. Compare the presented token with MessageDigest.isEqual over fixed-length digests, never String.equals or ==.
  6. Add regression tests that recover java.util.Random's 48-bit state from an observed token and predict the next one, and that brute-force SHA-256 of the issue-time window; assert neither reproduces the victim's token.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/resets/impl/VulnerableTokens.javaJava
// A single process-wide java.util.Random. Its 48-bit seed is scrambled from the clock at
// construction, but each nextInt() leaks 32 bits of the state, and two of them pin it down
// by brute-forcing the missing 16 bits. The output only looks like 128 bits of entropy.
private static final Random RNG = new Random();

@Override
public synchronized String generate() {
    ByteBuffer buffer = ByteBuffer.allocate(16);
    for (int i = 0; i < 4; i++) {
        buffer.putInt(RNG.nextInt());
    }
    return HexFormat.of().formatHex(buffer.array());
}

With IMPL=vulnerable, 2 of 9 tests fail (2 exploit tests).

The fix: generate from a CSPRNG

Fixed…/resets/impl/FixedTokens.javaJava
// One shared SecureRandom, seeded by the OS. 32 bytes is 256 bits, well above the 128-bit
// floor for a security token. Never use java.util.Random, Math.random() or a time seed for
// tokens; only SecureRandom (or SecureRandom.getInstanceStrong()) is unpredictable.
private static final SecureRandom SECURE_RANDOM = new SecureRandom();

@Override
public String generate() {
    byte[] bytes = new byte[32];
    SECURE_RANDOM.nextBytes(bytes);
    return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

With IMPL=fixed, 9 of 9 tests pass.

The fix: constant-time compare

Fixed…/resets/impl/FixedTokens.javaJava
// Constant-time comparison (CWE-208). Hashing both sides to a fixed 32 bytes first means a
// token of the wrong length still takes the same time; MessageDigest.isEqual then compares
// without short-circuiting.
@Override
public boolean compare(String presented, String stored) {
    return MessageDigest.isEqual(sha256(presented), sha256(stored));
}

private static byte[] sha256(String value) {
    try {
        return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException(e);
    }
}

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fix…/resets/impl/WrongTokens.javaJava
@Override
public String generate() {
    String material = String.valueOf(System.currentTimeMillis());
    return HexFormat.of().formatHex(sha256(material));
}

With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Test…/example/resets/ResetApiTest.javaJava
@Test
void exploitTheVictimTokenCannotBePredictedByRecoveringPrngState() throws Exception {
    // The attacker requests a token for an account they control, then the victim's is the
    // very next draw from the shared generator.
    String sample = requestReset(ATTACKER);
    String victim = requestReset(VICTIM);
    assertThat(victim).isNotEqualTo(predictNextToken(sample));
}

@Test
void exploitTheVictimTokenCannotBeRecoveredByHashingTheIssueTime() throws Exception {
    long before = System.currentTimeMillis();
    String victim = requestReset(VICTIM);
    long after = System.currentTimeMillis();
    List<String> candidates = new ArrayList<>();
    for (long ms = before - PAD_MS; ms <= after + PAD_MS; ms++) {
        candidates.add(sha256Hex(String.valueOf(ms)));
    }
    assertThat(candidates).doesNotContain(victim);
}

@Test
void exploitTheVictimAccountCannotBeTakenOverWithAForgedToken() throws Exception {
    long before = System.currentTimeMillis();
    String sample = requestReset(ATTACKER); // an account the attacker controls
    requestReset(VICTIM); // triggered by the attacker; its token is never seen directly
    long after = System.currentTimeMillis();

    List<String> forged = new ArrayList<>();
    String predicted = predictNextToken(sample); // technique 1: recover the PRNG state
    if (predicted != null) {
        forged.add(predicted);
    }
    for (long ms = before - PAD_MS; ms <= after + PAD_MS; ms++) {
        forged.add(sha256Hex(String.valueOf(ms))); // technique 2: hash the issue time
    }

    boolean tookOver = false;
    for (String token : forged) {
        if (confirmReset(VICTIM, token) == 200) {
            tookOver = true;
            break;
        }
    }
    assertThat(tookOver).isFalse();
}

Pitfalls

  • new SecureRandom() is fine and self-seeding; SecureRandom.getInstanceStrong() can block on a depleted entropy source on some Linux configurations, so prefer the default for request-path tokens (ASVS 11.5.2 asks the RNG to work under load).
  • java.util.Random's state is only 48 bits and each nextInt() leaks 32 of them, so two consecutive outputs pin the generator down by brute-forcing 2^16; ThreadLocalRandom and Math.random() are the same algorithm family and no safer.
  • new SecureRandom(seed) seeds *in addition* to the system source and is safe, but new Random(seed) is fully determined by the seed; never pattern the two the same way.
  • MessageDigest.isEqual has been constant-time since Java 6u17, but a plain String.equals or Arrays.equals is not; compare digests, and hash both sides to a fixed length so a wrong-length token does not reveal its length.
  • A UUID from java.util.UUID.randomUUID() is CSPRNG-backed but only 122 bits; ASVS 11.5.1 asks for 128, so use SecureRandom bytes for tokens.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs insecure-randomness/java-spring does this for you).
  2. The headline exploit recovers java.util.Random's 48-bit state from a single token an attacker minted for their own account and predicts the victim's next token exactly; against SecureRandom the same code predicts nothing.
  3. Semgrep's weak-random rule misses a stored Random instance (it matches only new Random().method()); CodeQL's java/insecure-randomness is the query that would catch it. The regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix insecure randomness for reset tokens in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.lang.security.audit.crypto.weak-random.weak-randomNo findings on any implementation, including the vulnerable one. The rule's pattern is new java.util.Random(...).$FUNC(...) -- a method called directly on a freshly constructed Random -- so it misses this card's idiom of a stored private static final Random RNG = new Random() whose RNG.nextInt() is called later. Its own metadata maps CWE-330 and OWASP A04:2025, confirming the category.Checked: Semgrep 1.180.0 (CE)
CodeQLjava/insecure-randomnessA path-problem query that reports a flow from a java.util.Random (or Math.random) source to a sink that uses the value for a security decision. It can recognise a stored Random instance that the Semgrep rule misses, provided the token flows to a sink its model treats as security-sensitive.Checked: github/codeql main; read from source, not executed
CodeQLjava/predictable-seedReports a SecureRandom (or Random) seeded with a predictable value such as System.currentTimeMillis(). It targets an explicit predictable seed; this card's vulnerable code uses the no-argument new Random(), so predictable-seed would not fire while insecure-randomness is the relevant query.Checked: github/codeql main; read from source, not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7, Spring MVC)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-test4.1.1
java.security.SecureRandom (JDK)OpenJDK 21.0.12.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the insecure-randomness/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs insecure-randomness/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 23 KB

Verified

Machine
Apple M5 Max, macOS 27.0
Toolchain
  • java OpenJDK 21.0.12.1
  • maven 3.9.16
Matrix
fixed: 9 of 9 tests pass; vulnerable: 2 of 9 tests fail (2 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
Test results
Offline run of run-all.mjs (outbound network blocked): fixed 9/9 pass; vulnerable 2/9 fail (2 exploit); wrong 2/9 fail (2 exploit)
Dependency freshness
Spring Boot 4.1.1 is the current GA release. The card overrides Boot's managed Tomcat (11.0.24 to 11.0.26) and Jackson (3.1.5 to 3.1.7) to pick up their security releases, matching the other Java cards. No randomness library is pinned; SecureRandom and java.util.Random are JDK built-ins.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. java.security.SecureRandom (JDK 21)
  2. java.util.Random (JDK 21): a linear congruential generator, not cryptographically secure
  3. java.security.MessageDigest.isEqual (constant-time comparison)
  4. CodeQL java/insecure-randomness
  5. OWASP ASVS 5.0.0 V11.5 Random Values (11.5.1)
  6. CWE-338: Use of Cryptographically Weak PRNG
  7. CAPEC-59: Session Credential Falsification through Prediction

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.