Generate the token with RandomNumberGenerator.GetBytes(32) (256 bits), Base64Url-encoded. Never use System.Random -- new Random(seed) is fully determined by the seed, and a clock-derived seed is tiny -- and never 'fix' a weak token by hashing it. Compare the stored token in constant time with CryptographicOperations.FixedTimeEquals, and keep it single-use with a short expiry.
- Find every security token built from System.Random or Random.Shared: reset tokens, session IDs, API keys. In .NET Core the parameterless new Random() is seeded from a good source, but new Random(seed) is fully determined by its int seed.
- Replace the generator with RandomNumberGenerator.GetBytes(32) (256 bits) and encode with Base64Url.EncodeToString for a URL-safe token.
- Delete any new Random(seed) whose seed comes from the clock (DateTime.Now.Millisecond, Environment.TickCount, ticks) and any step that hashes a timestamp.
- Store only a hash of the token, give it a short expiry, and mark it used on first success.
- Compare the presented token with CryptographicOperations.FixedTimeEquals over fixed-length hashes, never == or string.Equals.
- Add regression tests that enumerate the small seed space of new Random(clock) and brute-force SHA-256 of the issue-time window, and assert neither reproduces the issued token.
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
// DateTime.UtcNow.Millisecond is in 0..999, so the whole token is one of only 1000
// possibilities. Even a full tick or Environment.TickCount seed would be recoverable from
// the issue time; System.Random is a predictable PRNG whatever the seed.
public string Generate()
{
var rng = new Random(DateTime.UtcNow.Millisecond);
var bytes = new byte[16];
rng.NextBytes(bytes);
return Convert.ToHexStringLower(bytes);
}With IMPL=vulnerable, 2 of 9 tests fail (2 exploit tests).
The fix: generate from a CSPRNG
// RandomNumberGenerator is the cryptographic RNG; never System.Random, which is a
// predictable PRNG. 32 bytes is 256 bits, well above the 128-bit floor for a token.
// Base64Url keeps it URL-safe for a reset link.
public string Generate() => Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));With IMPL=fixed, 9 of 9 tests pass.
The fix: constant-time compare
// 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 and never short-circuits.
public bool Compare(string presented, string stored) =>
CryptographicOperations.FixedTimeEquals(
SHA256.HashData(Encoding.UTF8.GetBytes(presented)),
SHA256.HashData(Encoding.UTF8.GetBytes(stored)));With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
public string Generate()
{
var material = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture);
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material)));
}With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).
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.
/// <summary>Exploit: an attacker who can trigger the victim's reset tries to predict the token.</summary>
public sealed class ExploitTests : ResetsApiTests
{
public ExploitTests(ResetsApiFactory factory) : base(factory) { }
// new Random(DateTime.UtcNow.Millisecond) has only these many seeds.
private const int SeedSpace = 1000;
private const int PadMs = 50;
private static string PrngTokenForSeed(int seed)
{
var rng = new Random(seed);
var bytes = new byte[16];
rng.NextBytes(bytes);
return Convert.ToHexStringLower(bytes);
}
private static string HashedClock(long ms) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(ms.ToString(CultureInfo.InvariantCulture))));
[Fact]
public async Task TheVictimTokenCannotBeReproducedFromASmallSeedSpace()
{
var (_, token) = await Request(Victim);
var predicted = new HashSet<string>();
for (var seed = 0; seed < SeedSpace; seed++)
{
predicted.Add(PrngTokenForSeed(seed));
}
Assert.DoesNotContain(token, predicted);
}
[Fact]
public async Task TheVictimTokenCannotBeRecoveredByHashingTheIssueTime()
{
var before = ResetStore.Now();
var (_, token) = await Request(Victim);
var after = ResetStore.Now();
var candidates = new HashSet<string>();
for (var ms = before - PadMs; ms <= after + PadMs; ms++)
{
candidates.Add(HashedClock(ms));
}
Assert.DoesNotContain(token, candidates);
}
[Fact]
public async Task TheVictimAccountCannotBeTakenOverWithAForgedToken()
{
var before = ResetStore.Now();
await Request(Victim); // triggered by the attacker; its token is never seen directly
var after = ResetStore.Now();
var forged = new List<string>();
// The attacker triggered the reset, so they know the issue-time window. Both
// techniques use it: for System.Random(DateTime.UtcNow.Millisecond) the seed is
// ms % 1000, so the window names a handful of candidate seeds.
var seeds = new HashSet<int>();
for (var ms = before - PadMs; ms <= after + PadMs; ms++)
{
seeds.Add((int)(((ms % 1000) + 1000) % 1000));
}
foreach (var seed in seeds) // technique 1: reproduce the PRNG from the issue-time seed
{
forged.Add(PrngTokenForSeed(seed));
}
for (var ms = before - PadMs; ms <= after + PadMs; ms++) // technique 2: hash the issue time
{
forged.Add(HashedClock(ms));
}
var tookOver = false;
foreach (var token in forged)
{
if (await Confirm(Victim, token) == HttpStatusCode.OK)
{
tookOver = true;
break;
}
}
Assert.False(tookOver);
}
}- The Microsoft docs themselves suggest new Random((int)DateTime.Now.Ticks & 0xFFFF) as a way to get a 'unique' seed; that is only 65536 seeds, and new Random(DateTime.Now.Millisecond) only 1000. A predictable seed makes System.Random trivially reproducible.
- Since .NET 6 the parameterless new Random() is seeded from a thread-static source, so it is not clock-predictable, but it is still not a CSPRNG and its subtractive-generator state can be reconstructed from enough outputs. Use RandomNumberGenerator for anything secret.
- RandomNumberGenerator.GetBytes is the modern API; the older RNGCryptoServiceProvider is obsolete. Both are CSPRNGs, unlike System.Random.
- CryptographicOperations.FixedTimeEquals needs equal-length spans; hash both sides to 32 bytes (as impl/FixedTokens.cs does) so a wrong-length token neither throws nor leaks its length.
- Guid.NewGuid() is a v4 UUID: unpredictable on modern .NET but only 122 bits, below ASVS 11.5.1's 128, so use RandomNumberGenerator bytes for tokens.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs insecure-randomness/csharp-aspnetcore does this for you).
- The exploit enumerates all 1000 seeds of new Random(DateTime.UtcNow.Millisecond), reproduces the token, and takes over the account; against RandomNumberGenerator the same sweep finds nothing.
- CA5394 flags the vulnerable new Random use but is silent on the hashed-timestamp wrong impl, and Semgrep's default pack found nothing. The regression tests are the control.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/default | No findings on any implementation. Semgrep CE's default pack has no rule for System.Random used as a security RNG; the .NET analyzer CA5394 is the tool that covers this and is recorded under 'other' below.Checked: Semgrep 1.180.0 (CE) |
| Other | CA5394 | Warns on impl/VulnerableTokens.cs line 20 (the new Random(...) / NextBytes use): 'Random is an insecure random number generator. Use cryptographically secure random number generators when randomness is required for security.' No warning on FixedTokens.cs (RandomNumberGenerator) or WrongTokens.cs (SHA-256). CA5394 flags any System.Random use but cannot tell that WrongTokens' hashed timestamp is equally guessable. It is off by default; this run enabled it with AnalysisModeSecurity=All.Checked: .NET SDK 10.0.107 (dotnet build -p:AnalysisModeSecurity=All) |
| Component | Version |
|---|---|
| Language | C# |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API |
| System.Security.Cryptography.RandomNumberGenerator (shared framework) | 10.0.12 |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.2 (Microsoft Testing Platform v2) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the insecure-randomness/csharp-aspnetcorefolder.
dotnet restore tests/Resets.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Resets.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Resets.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Resets.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs insecure-randomness/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- Apple M5 Max, macOS 27.0
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- aspnetcoreRuntime 10.0.12
- 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
- Microsoft.AspNetCore.Mvc.Testing 10.0.12 and xunit.v3 4.0.2 are the current releases; both projects set RestorePackagesWithLockFile and install with --locked-mode. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
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
- System.Security.Cryptography.RandomNumberGenerator
- System.Random constructors (default seed vs predictable seed)
- CryptographicOperations.FixedTimeEquals
- .NET CA5394: Do not use insecure randomness
- OWASP ASVS 5.0.0 V11.5 Random Values (11.5.1)
- CWE-338: Use of Cryptographically Weak PRNG
- CAPEC-59: Session Credential Falsification through Prediction
Attack patterns: