Skip to content

CWE-330: Use of Insufficiently Random Values - C# / .NET

Overview

System.Random is a seeded pseudo-random number generator (PRNG) that produces a deterministic sequence of values. Given knowledge of the seed - or a few observed outputs - the entire sequence can be reconstructed. This makes it unsuitable for any security-sensitive purpose such as session tokens, password reset tokens, API keys, OTP codes, or cryptographic nonces.

Which weakness you have depends on how the instance was created, and the answer changed in .NET 6:

  • new Random(seed) keeps the legacy algorithm for compatibility. Anyone who can guess the seed - a timestamp, a user ID, Environment.TickCount - reproduces the whole sequence.
  • new Random() and Random.Shared use xoshiro256**, seeded from the OS cryptographic provider. The old brute-force-the-tick-count attack does not apply: measured on .NET 10, three instances constructed in the same millisecond produced three unrelated sequences, and none matched new Random(Environment.TickCount). What does apply is that xoshiro256** is a statistical generator with no resistance to an adversary who watches its output - its 256-bit state is a published, invertible function of a handful of consecutive values, and once recovered, every past and future draw follows. An unguessable seed does not help, because the attacker never needs the seed.

On .NET Framework and .NET Core 2.x the parameterless constructor did seed from the tick count, so both weaknesses are live in code that still targets those.

Primary Defence: Replace all System.Random usage in security contexts with System.Security.Cryptography.RandomNumberGenerator, which sources entropy from the operating system's cryptographic provider (CNG on Windows, /dev/urandom on Linux/macOS).

Common Vulnerable Patterns

Token Generation with System.Random

// VULNERABLE - System.Random produces predictable values
private static readonly Random _random = new Random();

public string GeneratePasswordResetToken()
{
    const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    return new string(Enumerable.Repeat(chars, 32)
        .Select(s => s[_random.Next(s.Length)])
        .ToArray());
}

Why this is vulnerable:

  • Every character comes from one generator, so the 32-character token carries no more unpredictability than that generator's state. An attacker who requests a reset for their own account gets a run of consecutive outputs to work from, recovers the state, and computes the token issued to the next account - without needing the seed, the server start time, or any access to the machine.

OTP Generation with Random.Shared

// VULNERABLE - shared Random instance still uses the same weak algorithm
public int GenerateOtp()
{
    return Random.Shared.Next(100_000, 1_000_000); // 6-digit OTP
}

Why this is vulnerable:

  • Random.Shared is a static convenience property that hands out a thread-safe System.Random. Thread safety is the only thing it adds: the algorithm underneath is the same non-cryptographic xoshiro256**. 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 codes of their own collects consecutive outputs, solves for that state, and computes the code the next caller served by that thread receives. The 6-digit range is a second, independent problem - see the note on output size in Considerations.

Time-Ordered Guid as Security Token

// VULNERABLE - a version 7 Guid puts the creation time in its leading bits
public string GenerateSessionId()
{
    return Guid.CreateVersion7().ToString("N"); // e.g., "01a0219b673d7012927628d03648dc72"
}

Why this is vulnerable:

  • Guid.CreateVersion7() (.NET 9+) is designed to be database-friendly, not secret: the leading 48 bits are the Unix timestamp in milliseconds and the version and variant nibbles are fixed, leaving 74 random bits. Measured on .NET 10, six identifiers created 2 ms apart sorted into creation order as plain strings, and the timestamp read straight back out of the first six bytes. An attacker who holds one session identifier therefore knows the millisecond it was minted and needs to search only the random remainder to reach identifiers issued alongside it - and the value itself discloses when each account signed in. The same applies to any sequential-GUID scheme: SQL Server's NEWSEQUENTIALID() and EF Core's SequentialGuidValueGenerator are both fine as clustered keys and wrong as tokens.
  • Guid.NewGuid() is a different case and is not this weakness - see Considerations.

Secure Patterns

256-bit URL-Safe Token (.NET 6+)

using System.Security.Cryptography;

public static string GenerateToken()
{
    // SECURE - 32 bytes = 256 bits of OS-sourced entropy
    byte[] bytes = RandomNumberGenerator.GetBytes(32);
    // URL-safe Base64 encoding (no padding)
    return Convert.ToBase64String(bytes)
        .TrimEnd('=')
        .Replace('+', '-')
        .Replace('/', '_');
}

Why this works:

  • RandomNumberGenerator.GetBytes() calls the OS cryptographic provider, which uses hardware entropy and a CSPRNG. The output is not predictable regardless of when it was generated.

Hex Token (.NET 6+)

using System.Security.Cryptography;

public static string GenerateHexToken(int byteLength = 32)
{
    // SECURE - hex encoding of 32 cryptographically random bytes = 64-char hex string
    byte[] bytes = RandomNumberGenerator.GetBytes(byteLength);
    return Convert.ToHexString(bytes).ToLowerInvariant();
}

Why this works:

  • Hex tokens are easier to store in databases (no special character handling). 32 bytes provides 256 bits of entropy, far exceeding the 128 bits OWASP's Session Management Cheat Sheet gives as the floor for a session identifier. The static RandomNumberGenerator.GetBytes(int) overload was added in .NET 6 (Convert.ToHexString in .NET 5); on earlier targets use the RandomNumberGenerator.Create() form below.

6-Digit OTP (.NET 6+)

using System.Security.Cryptography;

public static int GenerateOtp()
{
    // SECURE - cryptographically random integer in [100000, 999999]
    return RandomNumberGenerator.GetInt32(100_000, 1_000_000);
}

Why this works:

  • RandomNumberGenerator.GetInt32(fromInclusive, toExclusive) uses rejection sampling to produce an unbiased random integer within the specified range, sourced from the OS CSPRNG.

Legacy .NET Framework / .NET 5 Fallback

using System.Security.Cryptography;

public static string GenerateTokenLegacy()
{
    // SECURE - works on .NET Framework and .NET 5
    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's CSPRNG implementation. This pattern is compatible with older .NET targets where the static helper methods are unavailable.

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.

Guid.NewGuid() is CSPRNG-backed but fixed at 122 bits. That is reasonable for a session identifier and short of what you want for key material, and the constraint is the UUID format rather than the generator - no care elsewhere raises it. A scanner rule that flags every Guid as a weak random source is usually a false positive on NewGuid() and usually right about CreateVersion7(), so read which factory the finding names before deciding.

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. Hex encoding doubles the character count, which is where half the intended entropy usually goes missing.

Testing

  • Normal input: generate session tokens, reset tokens, OTPs, and API keys through the application paths that use the new generator.
  • Boundary input: request minimum and maximum supported token lengths and confirm encoding, storage, and URL handling still work.
  • Malicious input: attempt to predict or replay several generated values; verify old deterministic seeds and System.Random paths are gone.

Common Pitfalls

  • "More random" seeding of System.Random: Replacing new Random() with new Random(Guid.NewGuid().GetHashCode()), thinking a GUID-derived seed fixes the weakness - it's still System.Random, GetHashCode() collapses the seed into the 32-bit int range, and the algorithm remains deterministic and brute-forceable.
  • Modulo bias after correct generation: Calling RandomNumberGenerator.GetBytes() correctly, then converting to a numeric OTP with BitConverter.ToInt32(bytes) % 1_000_000 - a signed-int modulo can produce negative values and skews the digit distribution. Use RandomNumberGenerator.GetInt32(min, max), which applies rejection sampling instead.

Additional Resources