Skip to content

CWE-338: Use of Cryptographically Weak PRNG - C# / .NET

Overview

System.Random is a statistical generator, not a cryptographic one. On .NET 6+ the parameterless new Random() and Random.Shared use xoshiro256**, seeded from the OS cryptographic provider - measured on .NET 10, two instances constructed within the same tick produced unrelated sequences, and neither matched new Random(Environment.TickCount). The often-quoted "brute-force the tick count" attack therefore does not apply on a current runtime, and a fix aimed at the seed fixes nothing. What does apply is that xoshiro256** offers no resistance to an adversary who watches its output: the whole state is 256 bits and is an invertible function of a handful of consecutive draws, so once recovered, every past and future value follows. new Random(seed) is a separate case - it drops to the .NET 5 compatibility generator (Knuth's subtractive method over a 56-word table) so that seeded sequences stay reproducible - and is equally unsuitable. Neither form should generate security tokens, session identifiers, OTP codes, cryptographic nonces, or any other value that must be unpredictable.

CWE-338 targets the use of a weak PRNG in a security context, while CWE-330 is the broader class of insufficient randomness. In .NET both manifest as using System.Random where System.Security.Cryptography.RandomNumberGenerator is required, and both take the same fix: replace the weak PRNG with the OS-backed CSPRNG.

Primary Defence: Use System.Security.Cryptography.RandomNumberGenerator static methods (GetBytes, GetInt32, GetHexString) for all security-sensitive random values. Keep System.Random only for non-security uses (simulations, shuffling non-sensitive lists, game logic).

Common Vulnerable Patterns

Session Token from System.Random

// VULNERABLE - System.Random is a statistical generator, whatever seeds it
public string GenerateSessionToken()
{
    var random = new Random();
    var bytes = new byte[16];
    random.NextBytes(bytes);
    return Convert.ToHexString(bytes).ToLowerInvariant();
}

Why this is vulnerable:

  • The seed is not the weakness here, which is what makes this one survive review: on .NET 6+ new Random() is seeded from the OS cryptographic provider, so there is no tick count to guess. NextBytes fills the buffer straight from xoshiro256**, whose entire state is 256 bits. An attacker who collects a few tokens - their own sessions will do - solves for that state and then reproduces every token the process has issued and will issue. Sixteen bytes of xoshiro output is 128 bits of sequence, not 128 bits of entropy.

Predictable CSRF Token

// VULNERABLE - shared Random instance with System.Random
private static readonly Random _rng = new Random();

public string GenerateCsrfToken()
{
    // Two consecutive calls produce correlated values
    return $"{_rng.Next():x8}{_rng.Next():x8}";
}

Why this is vulnerable:

  • Each Next() publishes 31 bits of the generator's output stream, so one token hands over 62 of them and a handful of tokens supply more than enough to solve for the 256-bit state. An attacker who can load a few of their own forms recovers the state and computes the token the endpoint will hand the next visitor, which defeats CSRF protection entirely. Nothing about the token's length is the problem - it is that the values are drawn from a sequence anyone who has seen part of it can continue.

Password Reset Code from Random.Shared

// VULNERABLE - Random.Shared is thread-safe but still a weak PRNG
public string GeneratePasswordResetCode()
{
    return Random.Shared.Next(100_000, 1_000_000).ToString("D6");
}

Why this is vulnerable:

  • Random.Shared is a thread-safe wrapper over the same non-cryptographic xoshiro256** algorithm. Thread safety is the only thing it adds. Measured on .NET 10 it is a System.Random+ThreadSafeRandom holding a [ThreadStatic] xoshiro256** instance, so the state is per-thread rather than one process-wide stream - which does not help here, because a server hands each request whichever pooled thread is free, so an attacker's requests and a victim's routinely draw from the same state. An attacker who can request reset codes for their own account collects consecutive outputs, solves for that state, and computes the code the next caller served by that thread receives.

Secure Patterns

Cryptographically Secure Token (.NET 6+)

using System.Security.Cryptography;

// SECURE - OS-backed CSPRNG
public static string GenerateToken(int byteLength = 32)
{
    byte[] bytes = RandomNumberGenerator.GetBytes(byteLength);
    // URL-safe Base64, no padding
    return Convert.ToBase64String(bytes)
        .TrimEnd('=')
        .Replace('+', '-')
        .Replace('/', '_');
}

Why this works:

  • RandomNumberGenerator.GetBytes() calls the Windows CNG or Linux /dev/urandom provider. The output is statistically indistinguishable from true random data, making prediction computationally infeasible.

Secure OTP / PIN

using System.Security.Cryptography;

// SECURE - unbiased integer in [fromInclusive, toExclusive)
public static int GenerateOtp() =>
    RandomNumberGenerator.GetInt32(100_000, 1_000_000);

public static string GeneratePin(int digits = 6) =>
    RandomNumberGenerator.GetInt32((int)Math.Pow(10, digits - 1), (int)Math.Pow(10, digits))
        .ToString();

Why this works:

  • RandomNumberGenerator.GetInt32 uses rejection sampling to generate an unbiased result. There is no modulo bias (a subtle weakness in naive rand() % n patterns).

Hex Token and API Key (.NET 6+)

using System.Security.Cryptography;

// SECURE - 64-character hex string (256 bits of entropy)
public static string GenerateApiKey()
{
    byte[] bytes = RandomNumberGenerator.GetBytes(32);
    return Convert.ToHexString(bytes).ToLowerInvariant();
}

// .NET 8+ shorthand. The argument is a character count, not a byte count:
// 64 hex characters is 32 bytes.
public static string GenerateHexToken(int length = 64)
    => RandomNumberGenerator.GetHexString(length, lowercase: true);

Why this works:

  • Hex encoding is unambiguous and safe in any storage or transport medium without further encoding. At 32 bytes (64 hex chars) the token provides 256 bits of entropy. The static RandomNumberGenerator.GetBytes(int) overload needs .NET 6 and GetHexString needs .NET 8; on anything older, use the Create() form below.

Legacy .NET Framework / .NET 5 Fallback

using System.Security.Cryptography;

public static string GenerateTokenLegacy()
{
    using var rng = RandomNumberGenerator.Create();
    byte[] buffer = new byte[32];
    rng.GetBytes(buffer);
    return Convert.ToBase64String(buffer)
        .TrimEnd('=')
        .Replace('+', '-')
        .Replace('/', '_');
}

Why this works:

  • RandomNumberGenerator.Create() returns the platform CSPRNG implementation and is available on all .NET versions. The using statement disposes the instance after use.

Testing

  • Normal input: exercise each security-sensitive random value flow and confirm tokens, OTPs, nonces, and keys still validate.
  • Boundary input: test short configured lengths, high-volume generation, and concurrent requests for duplicate or malformed values.
  • Malicious input: collect a run of consecutive values and attempt to predict or replay the next one; confirm all weak PRNG calls were removed.

Common Pitfalls

  • Swapping new Random() for Random.Shared: Random.Shared (.NET 6+) fixes the thread-safety bug of sharing one Random instance across requests, but it's still backed by the same non-cryptographic algorithm, so the predictability is untouched.
  • Reducing RandomNumberGenerator output with % instead of GetInt32: Taking secure random bytes and applying % range to fit an OTP or code into a smaller number space introduces modulo bias - some values become measurably more likely than others. RandomNumberGenerator.GetInt32(min, max) uses rejection sampling specifically to avoid this.
  • Migrating the login flow but leaving a legacy path on System.Random: A "resend code" helper or an old admin/debug endpoint can still call the original weak-PRNG method because it wasn't part of the reviewed change, leaving the weak PRNG reachable from a less obvious entry point.
  • Treating Guid.NewGuid() as a security token: On current .NET it is CSPRNG-backed, so the objection is not a weak generator - it is that a v4 GUID is fixed at 122 random bits, the remaining 6 being version and variant markers. Comfortable for an identifier, and short of what key material wants: use RandomNumberGenerator.GetBytes(32) where you need 256 bits.
  • Reaching for Guid.CreateVersion7() because it is newer: the .NET 9 v7 GUID spends its leading 48 bits on a Unix millisecond timestamp and keeps only 74 random. Values minted close together therefore share a visible prefix - measured on .NET 10, two consecutive calls returned 01a02197-25a8-7661-... and 01a02197-25a8-7e1e-.... The sortability that makes it a good clustered database key is exactly what disqualifies it as an unguessable token, and it publishes the creation time as a side effect.

Additional Resources