CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG) - Go
Overview
Weak PRNG vulnerabilities in Go occur when developers use the math/rand package for security-sensitive operations instead of the cryptographically secure crypto/rand package. Go provides two families of random number generation API with very different security characteristics:
math/rand (and math/rand/v2) - Built for simulation, sampling and games. Its own package documentation says the output "might be easily predictable regardless of how it's seeded". What it runs underneath is not one thing, and the difference matters when you are triaging a finding:
- A source you construct yourself -
rand.New(rand.NewSource(n))in v1,rand.New(rand.NewPCG(a, b))in v2 - is fully deterministic. Give it the same seed and you get the same sequence, on every run and every machine. This is the case a scanner finding usually points at, and it is the one that is still straightforwardly attackable. - The top-level convenience functions (
rand.Intn,rand.Int63,rand.Float64, and their v2 equivalents) do not use that generator. Since Go 1.22 they draw from the runtime's ChaCha8 generator, so they are neither seedable nor reproducible -GODEBUG=randautoseed=0restores the old behaviour, and Go 1.21 and earlier did not have this. Guidance that treats these calls as trivially predictable is describing an older toolchain; guidance that treats them as safe is relying on an implementation detail the package explicitly declines to promise.
crypto/rand - The cryptographically secure generator, backed by the operating system: getrandom(2) on Linux, FreeBSD, Dragonfly and Solaris, arc4random_buf(3) on macOS and OpenBSD, ProcessPrng on Windows, and /dev/urandom only on Linux older than 3.17. It is the only source in the standard library that makes an unpredictability guarantee, and it is what every security-sensitive value should come from.
The distinction that matters is what is promised, not what happens to be true this release. crypto/rand is documented to be unpredictable; math/rand is documented not to be, whichever generator it currently uses. Using math/rand for session tokens, CSRF tokens, encryption keys or password reset tokens leaves you depending on a guarantee that was never made, and on older toolchains it leaves values an attacker can reproduce outright.
Primary Defence: Always use crypto/rand for security-sensitive operations. Reserve math/rand strictly for non-security use cases like shuffle algorithms, test data generation, or game mechanics.
Common Vulnerable Patterns
Session Token Generation with math/rand
// VULNERABLE - Predictable session tokens
package main
import (
"fmt"
"math/rand"
"net/http"
"time"
)
func generateSessionToken() string {
// DANGEROUS: a locally constructed source is reproducible from its seed,
// and this seed is the wall clock at the moment of the request
r := rand.New(rand.NewSource(time.Now().UnixNano()))
// VULNERABLE - math/rand produces a deterministic sequence
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
token := make([]byte, 32)
for i := range token {
token[i] = charset[r.Intn(len(charset))]
}
return string(token)
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
// Generate session token
sessionToken := generateSessionToken()
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionToken,
Path: "/",
MaxAge: 3600,
HttpOnly: true,
})
fmt.Fprintf(w, "Session: %s", sessionToken)
}
// ATTACK: Attacker observes several tokens, determines seed,
// predicts future tokens to hijack sessions
Why this is vulnerable: the whole token is a function of one int64 seed, and that seed is a clock reading the attacker can bound. Set-Cookie arrives alongside a Date header, so the login is placed to the second; the remaining candidates are the nanoseconds inside it, and testing one is a matter of replaying the loop above and comparing 32 bytes. Clock resolution makes it far cheaper than the nanosecond unit suggests, and by more than most people expect: the value has nanosecond units, not nanosecond resolution. Measured on Go 1.25 under Windows, the smallest non-zero step between consecutive time.Now().UnixNano() readings is about 0.5 ms - 200,000 back-to-back calls land on only a handful of distinct values, because the loop finishes inside a few ticks. That is roughly two thousand candidate seeds inside a known second, not a billion. The granularity is platform-specific, so measure it on the target rather than assuming - a finer clock raises the candidate count without changing the shape of the attack. Nothing here is fixed by picking a better seed: as long as the sequence is generated from a seed, recovering it recovers every token that source will ever produce, and observing enough output of a math/rand source recovers the state without the seed at all.
This example seeds its own source deliberately. Writing the same thing with the package-level rand.Intn would not demonstrate the attack on a current toolchain - see Common Pitfalls.
CSRF Token with Predictable randomness
// VULNERABLE - Weak CSRF token generation
import (
"fmt"
"math/rand/v2"
"net/http"
"time"
)
// VULNERABLE - math/rand/v2 is the newer API, not a stronger generator.
// PCG is a small, fully invertible statistical generator.
var tokenSource = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0))
func generateCSRFToken() string {
// VULNERABLE - 63 bits drawn from a reproducible sequence
token := fmt.Sprintf("%016x", tokenSource.Int64())
return token
}
func formHandler(w http.ResponseWriter, r *http.Request) {
csrfToken := generateCSRFToken()
// Store in session and render form
w.Write([]byte(fmt.Sprintf(`
<form method="POST">
<input type="hidden" name="csrf_token" value="%s">
<button>Submit</button>
</form>
`, csrfToken)))
}
// ATTACK: Attacker predicts CSRF tokens, bypasses protection
Why this is vulnerable: the /v2 import path is what makes this one survive review - it reads as the modern, corrected package, and the API genuinely is better. The generator is not. NewPCG builds a 128-bit permuted congruential generator whose state, like any statistical PRNG, follows from a short run of its output; and because the second word is a hard-coded 0, the whole state here comes from one clock reading. An attacker fetches a few forms of their own, recovers the sequence, and computes the token this handler will render for the next visitor - CSRF protection that validates a value the attacker can produce is not protection. The %016x also promises more than it delivers: Int64 returns a non-negative value, so the leading bit is always zero and the field carries 63 bits, not 64.
Encryption Key Derivation
// VULNERABLE - Using math/rand for key generation
import (
"crypto/aes"
"crypto/cipher"
"math/rand"
"time"
)
// DANGEROUS: one seed decides every key and nonce this process ever produces
var weak = rand.New(rand.NewSource(time.Now().UnixNano()))
func generateEncryptionKey() []byte {
// DANGEROUS: Encryption keys must be cryptographically random
key := make([]byte, 32) // 256-bit key
for i := range key {
key[i] = byte(weak.Intn(256))
}
return key
}
func encryptData(plaintext []byte) ([]byte, error) {
key := generateEncryptionKey()
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Create GCM cipher
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Generate nonce with math/rand - ALSO VULNERABLE!
nonce := make([]byte, gcm.NonceSize())
for i := range nonce {
nonce[i] = byte(weak.Intn(256))
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
return ciphertext, nil
}
// ATTACK: Attacker can predict key or nonce, decrypt data
Why this is vulnerable: the key is 32 bytes but not 256 bits of key. Every byte comes out of one 64-bit seed, so the search space is the seed, not the key - and because the same source feeds both the key and the nonce, an attacker who recovers it gets the nonce stream as well. GCM is unforgiving here in a second way: it requires that a nonce never repeat under one key, and a deterministic source restarted with the same seed - two replicas launched together, a container restored from a checkpoint, a process that crashes and comes back inside the same clock tick - replays the identical nonce sequence. Nonce reuse under AES-GCM does not merely weaken confidentiality; it leaks the XOR of the two plaintexts and exposes the GHASH authentication key, after which the attacker can forge tags for messages that were never sent.
Password Reset Token
// VULNERABLE - Predictable password reset tokens
import (
"fmt"
"math/rand"
"time"
)
func generateResetToken(email string) string {
// VULNERABLE - reseeding per request makes the token a function of the clock
r := rand.New(rand.NewSource(time.Now().UnixNano()))
token := fmt.Sprintf("%s_%d", email, r.Int63())
return token
}
func requestPasswordReset(email string) {
resetToken := generateResetToken(email)
// Store in database
// Send email with reset link
fmt.Printf("Reset link: /reset?token=%s\n", resetToken)
}
// ATTACK: Attacker requests password reset for their account,
// observes token, predicts tokens for victim accounts
Why this is vulnerable: reseeding on every call looks like extra caution and is the opposite. It ties each token to the instant its request was handled, so an attacker who triggers a reset for the victim and one for themselves a moment later has bracketed the victim's seed between two known clock readings; every candidate in that window produces a token they can test against the reset endpoint. The email prefix makes it worse rather than better - it is attacker-supplied and public, so the only unknown in the whole token is the single Int63 value, and the token's length is doing none of the work its appearance suggests.
Secure Patterns
Secure Session Token Generation
// SECURE - Cryptographically strong session tokens
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"net/http"
)
func generateSecureToken(length int) (string, error) {
// SECURE - crypto/rand provides cryptographic randomness
bytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
return "", fmt.Errorf("failed to generate token: %w", err)
}
// Encode as URL-safe base64
token := base64.URLEncoding.EncodeToString(bytes)
return token, nil
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
// Generate 32-byte (256-bit) session token
sessionToken, err := generateSecureToken(32)
if err != nil {
http.Error(w, "Internal server error", 500)
return
}
// Set secure cookie
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionToken,
Path: "/",
MaxAge: 3600,
HttpOnly: true,
Secure: true, // HTTPS only
SameSite: http.SameSiteStrictMode,
})
// Store session in database/cache
// ...
w.WriteHeader(http.StatusOK)
}
Why this works: crypto/rand.Reader reads from the operating system's CSPRNG - getrandom(2) on Linux, ProcessPrng on Windows - which is designed to resist state recovery no matter how much output an attacker collects. The 32-byte token provides 256 bits of entropy, making brute-force infeasible, and base64.URLEncoding keeps it safe in URLs and cookie values.
Be clear about what the error path is doing, because it is easy to over-read: since Go 1.24 the standard Reader does not fail. crypto/rand.Read is documented to never return an error and to crash the program irrecoverably rather than hand back short or predictable data, and the default Reader.Read always returns (len(b), nil). There is no "entropy pool ran dry" condition to handle on any supported platform. Keep the err check anyway - Reader is a package variable that tests and FIPS builds replace - but do not present it as the control that stops a weak token being issued. Nothing reaches the encoder unless the read succeeded.
CSRF Token Generation
// SECURE - Cryptographically secure CSRF tokens
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
)
func generateCSRFToken() (string, error) {
// SECURE - 32 bytes = 256 bits of entropy
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("crypto/rand failed: %w", err)
}
// Hex encoding for HTML form compatibility
token := hex.EncodeToString(bytes)
return token, nil
}
func formHandler(w http.ResponseWriter, r *http.Request) {
csrfToken, err := generateCSRFToken()
if err != nil {
http.Error(w, "Internal error", 500)
return
}
// Store in session (e.g., in session store or encrypted cookie)
// ... sessionStore.Set(session.ID, csrfToken)
// Render form with token
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `
<form method="POST" action="/submit">
<input type="hidden" name="csrf_token" value="%s">
<input type="text" name="data">
<button type="submit">Submit</button>
</form>
`, csrfToken)
}
func submitHandler(w http.ResponseWriter, r *http.Request) {
// Verify CSRF token
submittedToken := r.FormValue("csrf_token")
// storedToken := sessionStore.Get(session.ID)
// Constant-time comparison to prevent timing attacks
// if subtle.ConstantTimeCompare([]byte(submittedToken), []byte(storedToken)) != 1 {
// http.Error(w, "Invalid CSRF token", 403)
// return
// }
// Process form...
}
Why this works: crypto/rand.Read() fills the byte slice with cryptographically secure random data. Each CSRF token carries 256 bits of entropy, and an attacker holding other tokens learns nothing about the next one. Hex encoding produces tokens safe for HTML forms. The token is verified server-side using constant-time comparison to prevent timing attacks that could leak information about the correct token.
Secure Encryption Key and Nonce
// SECURE - Cryptographic key and nonce generation
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"io"
)
func generateKey() ([]byte, error) {
// SECURE - 32 bytes for AES-256
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
return key, nil
}
func encryptData(plaintext []byte, key []byte) ([]byte, error) {
if len(key) != 32 {
return nil, errors.New("key must be 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// SECURE - Cryptographically random nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
// Encrypt and authenticate
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
func decryptData(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
Why this works: Both the AES-256 key and the GCM nonce are generated using crypto/rand, so neither is predictable, and 32 bytes is the key size AES-256 takes. The nonce is prepended to the ciphertext, allowing decryption without storing it separately. Each encryption draws a fresh random nonce, which is what keeps GCM's no-repeat requirement satisfied: a repeated nonce under one key leaks the XOR of the two plaintexts and the GHASH authentication key. io.ReadFull ensures the entire byte slice is filled with random data.
Secure API Key Generation
// SECURE - API key generation for authentication
import (
"crypto/rand"
"encoding/base32"
"strings"
)
func generateAPIKey() (string, error) {
// SECURE - 20 bytes = 160 bits (similar to UUID v4)
bytes := make([]byte, 20)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Base32 encoding (uppercase, no padding) for readability.
// 20 bytes -> 32 characters, over the RFC 4648 alphabet A-Z and 2-7.
key := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes)
// Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XX
// Makes keys easier to read and verify
key = formatAPIKey(key)
return key, nil
}
func formatAPIKey(key string) string {
// Insert dashes every 5 characters for readability
var formatted strings.Builder
for i, char := range key {
if i > 0 && i%5 == 0 {
formatted.WriteRune('-')
}
formatted.WriteRune(char)
}
return formatted.String()
}
// Example usage:
// apiKey, err := generateAPIKey()
// // Result: "PO3BY-FZAVS-ZYME7-ERKFR-NAR7Y-UG5VP-TP"
Why this works: 160 bits is well past the point where brute force is worth considering, and it keeps the key shorter than a 256-bit session token, which matters for a value people paste by hand. RFC 4648 base32 helps that too: its alphabet is A-Z and 2-7 only, so 0 and 1 - the digits people transcribe as O, I and l - are not in it at all, and being uppercase-only there is no case to get wrong either. crypto/rand is what makes each key unpredictable rather than merely unique.
Check the arithmetic when you change the byte count, because the grouping is not free-standing: 20 bytes encode to exactly 32 base32 characters, which formatAPIKey splits into six groups of five and a trailing pair. Ask for 25 bytes and you get 40 characters and eight clean groups; ask for 16 and you get 26, with a single character stranded after the last dash.
Password Reset Token with Timeout
// SECURE - Time-limited password reset tokens
import (
"crypto/rand"
"encoding/base64"
"fmt"
"time"
)
type ResetToken struct {
Token string
Email string
ExpiresAt time.Time
}
func generateResetToken(email string) (*ResetToken, error) {
// SECURE - 32-byte cryptographically random token
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return nil, err
}
token := base64.URLEncoding.EncodeToString(bytes)
return &ResetToken{
Token: token,
Email: email,
ExpiresAt: time.Now().Add(1 * time.Hour), // 1-hour expiry
}, nil
}
func requestPasswordReset(email string) error {
resetToken, err := generateResetToken(email)
if err != nil {
return fmt.Errorf("token generation failed: %w", err)
}
// Store in database with expiry
// db.SaveResetToken(resetToken)
// Send email
// sendEmail(email, fmt.Sprintf("/reset?token=%s", resetToken.Token))
return nil
}
func validateResetToken(token string, email string) (bool, error) {
// Retrieve from database
// storedToken := db.GetResetToken(token)
// Check expiration
// if time.Now().After(storedToken.ExpiresAt) {
// return false, errors.New("token expired")
// }
// Verify email match
// if storedToken.Email != email {
// return false, errors.New("email mismatch")
// }
// Delete token after use (one-time use)
// db.DeleteResetToken(token)
return true, nil
}
Why this works: The reset token is generated with crypto/rand, so its 256 bits of unpredictable entropy put guessing out of reach. The one-hour expiry narrows the window in which a leaked token is still usable. Tokens are single-use - deleted after consumption - preventing replay. Email verification ensures tokens can only be used for the intended account.
Framework-Specific Guidance
Gin with Secure Session Management
// SECURE - Gin with secure session tokens
package main
import (
"crypto/rand"
"encoding/base64"
"io"
"net/http"
"github.com/gin-gonic/gin"
)
func generateSessionID() (string, error) {
b := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func main() {
r := gin.Default()
r.POST("/login", loginHandler)
r.GET("/dashboard", authMiddleware(), dashboardHandler)
r.Run(":8080")
}
func loginHandler(c *gin.Context) {
// Authenticate user (check username/password)
username := c.PostForm("username")
password := c.PostForm("password")
// ... authenticate(username, password) ...
sessionID, err := generateSessionID()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
return
}
// Store session in Redis/database
// sessionStore.Set(sessionID, username, 3600)
c.SetCookie(
"session_id",
sessionID,
3600,
"/",
"",
true, // Secure (HTTPS only)
true, // HttpOnly
)
c.JSON(http.StatusOK, gin.H{"status": "logged in"})
}
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
// Validate session
// username := sessionStore.Get(sessionID)
// if username == "" {
// c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid session"})
// return
// }
// c.Set("username", username)
c.Next()
}
}
func dashboardHandler(c *gin.Context) {
// username := c.GetString("username")
c.JSON(http.StatusOK, gin.H{"message": "Welcome to dashboard"})
}
Why this works: Session IDs are generated with crypto/rand, so an attacker cannot hijack a session by predicting its ID. Gin's cookie functions set secure flags (HttpOnly, Secure) preventing JavaScript access and ensuring HTTPS-only transmission. Sessions are stored server-side, allowing revocation. The authentication middleware validates sessions on every request.
Echo with CSRF Middleware
// SECURE - Echo with built-in CSRF protection
package main
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
// SECURE - Echo's CSRF middleware uses crypto/rand
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLength: 32, // 32 CHARACTERS, not bytes - see below
TokenLookup: "form:csrf_token", // Look in form field
CookieName: "_csrf",
CookieSecure: true,
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
}))
e.GET("/form", showFormHandler)
e.POST("/submit", submitHandler)
e.Start(":8080")
}
func showFormHandler(c echo.Context) error {
// Get CSRF token from context (auto-generated by middleware)
csrfToken := c.Get("csrf").(string)
html := `
<form method="POST" action="/submit">
<input type="hidden" name="csrf_token" value="` + csrfToken + `">
<input type="text" name="data">
<button type="submit">Submit</button>
</form>
`
return c.HTML(http.StatusOK, html)
}
func submitHandler(c echo.Context) error {
// CSRF middleware automatically validates token
data := c.FormValue("data")
return c.JSON(http.StatusOK, map[string]string{
"status": "received",
"data": data,
})
}
Why this works: Echo's CSRF middleware draws from crypto/rand.Reader internally and uses rejection sampling rather than a modulo, so the token is both unpredictable and uniform over its alphabet. The middleware validates automatically on state-changing requests (POST, PUT, DELETE), and the cookie carries HttpOnly, Secure and SameSite=Strict.
TokenLength is the one thing here worth reading twice. It counts characters, not bytes, and the middleware draws them from a 52-character alphabet (A-Z, a-z). Measured on Echo v4.15.4, TokenLength: 32 emits a 32-character token carrying about 182 bits - comfortably enough for CSRF, and not the 256 the number invites you to assume. If you are budgeting entropy, do it in characters times log2(52), and raise TokenLength rather than reasoning from the byte count of something else.
Common Pitfalls
- Reading a
rand.Seed()call as evidence of the bug, or as the fix: it is neither any more. Go 1.20 deprecated it and auto-seeded the global source at startup; Go 1.24 made it a no-op outright (GODEBUG=randseednop=0restores the old behaviour). Measured on Go 1.25,rand.Seed(12345)followed byrand.Intn(1000)gives a different sequence on every run. So a finding whose evidence is arand.Seed(time.Now().UnixNano())line is describing something the toolchain has already neutralised, and deleting that line changes nothing either. Look at where the values come from instead: arand.New(rand.NewSource(...))orrand.NewPCG(...)you constructed is still fully reproducible. - Assuming the package-level functions are the predictable ones: they are the case where the usual attack does not work. Since Go 1.22
rand.Intn,rand.Int63and theirmath/rand/v2equivalents draw from the runtime's ChaCha8 generator, which is not seedable and whose state does not fall out of its output. That is worth knowing so you can tell a live finding from a stale one - it is not a licence to use them. The package documents no unpredictability guarantee,GODEBUG=randautoseed=0reverts it, Go 1.21 and earlier did not have it, and a future release is free to change it again. Move the value tocrypto/rand; do not build on an implementation detail. - Assuming
math/rand/v2is stronger because it is newer: it is a better API - realUint64, no globalSeed, generators you choose explicitly - carrying the same warning in its documentation.NewPCGandNewChaCha8are both reachable from it, and only the second is built to resist an adversary. Neither iscrypto/rand. - Partial migration leaving a helper on
math/rand: Switching the token or session-ID generator tocrypto/randbut leaving a "shuffle" or "sample" utility - written before the audit - still importingmath/randand reused later for something security-relevant, like selecting a verification question or partitioning access to a feature flag. - Reducing
crypto/randbytes with%instead ofrand.Int(): Reading secure random bytes and usingbinary.BigEndian.Uint32(b) % nto fit an OTP into a smaller range introduces modulo bias.crypto/rand.Int(rand.Reader, big.NewInt(n))performs rejection sampling and avoids it.