Generate the token with crypto/rand: rand.Read into 32 bytes, encoded with base64.RawURLEncoding, is 256 bits from the OS CSPRNG (rand.Text() gives at least 128). Never use math/rand or math/rand/v2 for secrets, never seed with the clock, and never fix a weak token by hashing it. Compare with subtle.ConstantTimeCompare; keep tokens single-use and short-lived.
- Find every security token built from math/rand, math/rand/v2 or the clock: reset tokens, session IDs, API keys, invite links. rand.New(rand.NewSource(time.Now().UnixNano())), rand.Intn, a PCG or a ChaCha8 seeded from the time all count, and so does a token hashed from a timestamp.
- Replace the generator with crypto/rand: rand.Read into a 32-byte slice and encode it with base64.RawURLEncoding, or call rand.Text() (Go 1.24+) for base32 text with at least 128 bits. Since Go 1.24, crypto/rand.Read never returns an error and always fills the slice.
- Delete any rand.Seed call (a no-op since Go 1.24) and any step that hashes a timestamp, counter or PRNG output into a token.
- Store only a hash of the token, give it a short expiry, and mark it used on first success.
- Compare SHA-256 digests of the presented and stored tokens with subtle.ConstantTimeCompare (or hmac.Equal), never with ==.
- Add regression tests that replay the math/rand seed over the issue-time window and brute-force SHA-256 of the clock, and assert that 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
func (Vulnerable) Generate() string {
// A clock seed gives only as much entropy as the uncertainty in the issue time, not the
// 128 bits the 16-byte length suggests. math/rand's documentation says its outputs
// "might be easily predictable regardless of how it's seeded".
rng := rand.New(rand.NewSource(time.Now().UnixMilli()))
token := make([]byte, 16)
for i := 0; i < len(token); i += 4 {
binary.LittleEndian.PutUint32(token[i:], rng.Uint32())
}
return hex.EncodeToString(token)
}With IMPL=vulnerable, 2 of 9 tests fail (2 exploit tests).
The fix: generate from crypto/rand
func (Fixed) Generate() string {
// crypto/rand.Read fills the slice from the OS CSPRNG; since Go 1.24 it never returns an
// error. 32 bytes is 256 bits, well above the 128-bit floor for a security token, and
// base64url keeps it safe in a reset link. (rand.Text() is a Go 1.24 shortcut: at least
// 128 bits as base32 text.)
token := make([]byte, 32)
rand.Read(token)
return base64.RawURLEncoding.EncodeToString(token)
}With IMPL=fixed, 9 of 9 tests pass.
The fix: constant-time compare
func (Fixed) Compare(presented, stored string) bool {
// Constant-time comparison (CWE-208). ConstantTimeCompare returns at once when the
// lengths differ, so hash both sides to a fixed 32 bytes first: a token of the wrong
// length then takes the same time and does not reveal the stored length.
a := sha256.Sum256([]byte(presented))
b := sha256.Sum256([]byte(stored))
return subtle.ConstantTimeCompare(a[:], b[:]) == 1
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
func (Wrong) Generate() string {
sum := sha256.Sum256([]byte(strconv.FormatInt(time.Now().UnixMilli(), 10)))
return hex.EncodeToString(sum[:])
}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.
// Exploit: an attacker who can trigger the victim's reset tries to predict the token.
func TestExploitTheVictimTokenCannotBeReproducedByReplayingAPRNG(t *testing.T) {
_, srv := newServer(t)
from := time.Now().UnixMilli()
_, token := requestReset(t, srv, victim)
to := time.Now().UnixMilli()
if slices.Contains(prngTokensForWindow(from, to), token) {
t.Error("the token was reproduced from the clock alone")
}
}
func TestExploitTheVictimTokenCannotBeRecoveredByHashingTheIssueTime(t *testing.T) {
_, srv := newServer(t)
from := time.Now().UnixMilli()
_, token := requestReset(t, srv, victim)
to := time.Now().UnixMilli()
if slices.Contains(hashedClockTokensForWindow(from, to), token) {
t.Error("the token was SHA-256 of the clock")
}
}
func TestExploitTheVictimAccountCannotBeTakenOverWithAForgedToken(t *testing.T) {
_, srv := newServer(t)
from := time.Now().UnixMilli()
requestReset(t, srv, victim) // the attacker triggers the victim's reset and notes the time
to := time.Now().UnixMilli()
forged := append(prngTokensForWindow(from, to), hashedClockTokensForWindow(from, to)...)
for _, token := range forged {
if confirmReset(t, srv, victim, token) == http.StatusOK {
t.Fatal("a forged token reset the victim account")
}
}
}- Both packages are called rand, so rand.Read looks the same either way: check the import, not the call. goimports can pick math/rand when crypto/rand is not already imported.
- Since Go 1.22, math/rand's top-level functions use the ChaCha8Rand generator unless Seed was called, so an accidental rand.Intn is no longer predictable. rand.New(rand.NewSource(seed)) still gives the Go 1 generator, whose whole stream follows from one 63-bit seed, and the package documentation still says its outputs "might be easily predictable regardless of how it's seeded".
- math/rand/v2's ChaCha8 is a cryptographically strong generator, but it is only as unpredictable as its seed; seeded from the clock it is as guessable as the clock. Its PCG source is "unfit for security-relevant purposes", and the package sends security-sensitive work to crypto/rand.
- subtle.ConstantTimeCompare returns 0 at once when the lengths differ. Hash both sides to 32 bytes first, as impl/fixed.go does, so a wrong-length token neither short-circuits nor reveals the stored length.
- A UUID is an identifier, not a token: Go 1.27's uuid.New() and github.com/google/uuid's NewRandom use a CSPRNG, but a version 4 UUID carries only 122 random bits, below ASVS 11.5.1's 128.
- base64.StdEncoding uses + and /, which change meaning in URLs and forms; use base64.RawURLEncoding for a token that travels in a link. Do not log the token or let it reach Referer headers and access logs.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs insecure-randomness/go-nethttp does this for you, in Docker if you do not have Go).
- Run gosec ./... and semgrep --config r/go.lang.security.audit.crypto.math_random.math-random-used .; both flag math/rand in impl/vulnerable.go and say nothing about impl/wrong.go's hashed timestamp. The regression tests are the control.
- Against your own app, request a reset for an account you control and check that the token is 43 base64url characters (32 bytes) with no structure across requests, and that the lookup compares digests with subtle.ConstantTimeCompare.
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 | go.lang.security.audit.crypto.math_random.math-random-used | One finding: the math/rand import in impl/vulnerable.go. Nothing in impl/fixed.go or impl/wrong.go: the rule matches an import of math/rand or math/rand/v2 that is used, so it cannot see that wrong.go's SHA-256 of the clock is just as guessable. p/golang reported nothing else.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> . and --config p/golang |
| CodeQL | go/insecure-randomness | No results. The query's sources are calls into math/rand, but its sinks are arguments to crypto/* functions other than hashes (keys and nonces) and code in functions named like generatePassword or makeSalt. A reset token that goes from math/rand into hex.EncodeToString and an HTTP response reaches neither.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12 (query source read at github/codeql b5b8165) |
| gosec | G404Insecure random number source (rand) | One finding: rand.New(rand.NewSource(...)) in impl/vulnerable.go (HIGH severity, MEDIUM confidence). Nothing in impl/wrong.go, whose token is SHA-256 of the clock and uses no PRNG at all.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./... |
| Component | Version |
|---|---|
| Language | Go |
| Runtime | Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9 |
| Framework | Go standard library 1.27.2 (net/http, crypto/rand) |
| crypto/rand, crypto/sha256, crypto/subtle (standard library) | Go 1.27.2 |
| gotest.tools/gotestsum (JUnit report, a go.mod tool) | 1.13.0 |
| golang.org/x/text (raised for gotestsum) | 0.42.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the insecure-randomness/go-nethttpfolder.
go mod download
go build ./... tool# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go mod download && go build ./... tool'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=fixed \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=vulnerable \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=wrong \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs insecure-randomness/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
- Toolchain
- go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
- gotestsum 1.13.0
- semgrep 1.180.0
- gosec 2.29.0
- codeql CLI 2.27.1, codeql/go-queries 1.6.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
- fixed 9/9 pass; vulnerable 2/9 fail (2 exploit tests: the replayed PRNG and the takeover); wrong 2/9 fail (2 exploit tests: the hashed clock and the takeover), with --network=none after install; the same on Go 1.26.9
- Dependency freshness
- The card needs only the standard library. gotestsum 1.13.0 was published on 2025-09-11; go.mod raises its golang.org/x/text from 0.17.0 to 0.42.0 (2026-09-08) for GO-2026-5970. Go 1.27.2 (2026-10-08) is under seven days old but is a security release.
- Notes
- Nine tests, like the Java and C# cards: the Node.js and Python cards add a tenth that spies on the constant-time primitive, and Go cannot replace a standard library function at run time. The compare region shows subtle.ConstantTimeCompare instead.
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
- Go standard library: crypto/rand (Read, Text)
- Go standard library: math/rand (not for security-sensitive work; Seed a no-op since Go 1.24)
- Go standard library: math/rand/v2 (ChaCha8 and PCG sources)
- Go standard library: crypto/subtle ConstantTimeCompare
- The Go Blog: Secure Randomness in Go 1.22 (ChaCha8Rand in math/rand)
- Go 1.24 release notes (crypto/rand.Read cannot fail, rand.Text, math/rand.Seed no-op)
- OWASP ASVS 5.0.0 V11.5 Random Values (11.5.1)
- CWE-338: Use of Cryptographically Weak PRNG
- gosec 2.29.0 rules (G404)
Attack patterns: