CWE-330: Use of Insufficiently Random Values - Python
Overview
Weak random number generation in Python occurs when developers use the random module for security-sensitive operations like generating session tokens, password reset tokens, API keys, CSRF tokens, or cryptographic keys. The random module uses the Mersenne Twister algorithm, which is deterministic and predictable if an attacker can observe outputs or guess the seed. For security purposes, Python provides the secrets module (Python 3.6+) and os.urandom(), which use cryptographically secure random number generators (CSRNGs).
Primary Defence: Use secrets module (Python 3.6+) for all security-sensitive random value generation including tokens and keys.
Common Vulnerable Patterns
random.randint() for Session IDs
import random
# VULNERABLE - Predictable session ID
def generate_session_id():
session_id = random.randint(1000000, 9999999)
return session_id
# Attacker can predict: If they see a few session IDs (e.g., 3045213, 8765443),
# they can infer the PRNG state and predict future values
Why this is vulnerable: random is not cryptographically secure. Mersenne Twister state can be recovered from 624 consecutive outputs.
random.choice() for Password Reset Tokens
import random
import string
# VULNERABLE - Predictable reset token
def generate_reset_token():
chars = string.ascii_letters + string.digits
token = ''.join(random.choice(chars) for _ in range(32))
return token
# Token looks random: "a7B3xQ9..." but is predictable
# Attacker can brute force or predict based on seed
Why this is vulnerable: random.choice() uses Mersenne Twister. If attacker knows seed (often time-based), they can generate same tokens.
Time-Based Seed
import random
import time
# VULNERABLE - Predictable seed
random.seed(int(time.time())) # Seed with current timestamp
token = random.randint(0, 999999)
# Attacker knowing approximate time can brute force seed
# Time has limited entropy (~32 bits for timestamp)
Why this is vulnerable: Time is predictable. Attacker can try all possible timestamps within a time window and reproduce tokens.
Custom UUID Polyfills
import uuid
# SECURE in supported Python: uuid4 uses os.urandom()
user_id = str(uuid.uuid4())
# VULNERABLE pattern to avoid: custom UUIDs from random.getrandbits()
Why this matters: Modern Python's uuid.uuid4() uses os.urandom() and is suitable for random UUID generation. The vulnerable pattern is a custom UUID implementation built from random or another non-CSPRNG source.
random.shuffle() for Security
import random
# VULNERABLE - Shuffling for security purposes
def generate_verification_code(user_id):
digits = list("0123456789" * 10)
random.shuffle(digits)
code = ''.join(digits[:6])
return code
# Appears random but is predictable with seed knowledge
Why this is vulnerable: random.shuffle() uses Mersenne Twister. Patterns are reproducible.
Predictable Encryption Key
import random
from cryptography.fernet import Fernet
# VULNERABLE - Key derived from weak random
def generate_encryption_key():
key_bytes = bytes([random.randint(0, 255) for _ in range(32)])
# This is NOT how you generate Fernet keys, but illustrates the point
return key_bytes
# Attacker can predict key if they know seed
Why this is vulnerable: Encryption keys must have full entropy. Predictable random = predictable keys = broken encryption.
Random Salt for Passwords
import random
import hashlib
# VULNERABLE - Weak salt
def hash_password(password):
salt = str(random.randint(0, 999999)) # Predictable salt
salted = salt + password
hashed = hashlib.sha256(salted.encode()).hexdigest()
return hashed, salt
# Salt should be unique and generated with a CSPRNG
Why this is vulnerable: A small, predictable salt space increases collision and precomputation risk. Password salts do not need to be secret, but they should be unique per password and generated with enough entropy by a password-hashing library or CSPRNG.
Random Nonce/IV
import random
from Crypto.Cipher import AES
# VULNERABLE - Predictable nonce/IV
def encrypt_data(data, key):
nonce = bytes([random.randint(0, 255) for _ in range(16)]) # WEAK
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(data)
return nonce, ciphertext, tag
# Nonce reuse with the same key breaks AES-GCM security
Why this is vulnerable: AES-GCM nonces must be unique for each encryption under the same key. Weak random generation increases the risk of nonce reuse; reuse is catastrophic because it can expose plaintext relationships and enable authentication forgeries.
Secure Patterns
secrets.token_hex() for Session IDs
import secrets
# SECURE - Cryptographically strong session ID
def generate_session_id():
# 32 hex chars = 128 bits of entropy
session_id = secrets.token_hex(16)
return session_id
# Example: "3a7b9c8e4f1d2a5b6c7e8f9a0b1c2d3e"
# Unpredictable even with knowledge of previous tokens
Why this works:
secretsmodule uses OS CSPRNG: It callsos.urandom()internally rather than the Mersenne Twister behindrandom, whose state is recoverable from 624 outputs- OS entropy sources: platform cryptographic RNG APIs such as getrandom()/
/dev/urandomon Linux/Unix and CNG on Windows - 128 bits makes collisions negligible: 16 bytes = 32 hex chars. Note that 2^-128 is the chance two particular IDs match; the figure that matters is the birthday bound, roughly n^2/2^129 for n IDs, which stays negligible until around 2^64 of them. Collision resistance is half the bit length - the same reasoning the AES-GCM nonce guidance below applies when it puts a 96-bit nonce's limit at ~2^48
- Unpredictable output: Observing any number of IDs provides no prediction capability
- Prevents session guessing: Cryptographic randomness stops an attacker guessing or brute-forcing a live session ID. It does not address session fixation, where the attacker supplies an ID the application then keeps - that one is closed by regenerating the ID when privilege changes
secrets.token_urlsafe() for Reset Tokens
import secrets
# SECURE - URL-safe reset token
def generate_reset_token():
# 32 bytes = 256 bits of entropy, base64-encoded (URL-safe)
token = secrets.token_urlsafe(32)
return token
# Example: "A3b7K9xQmZpLr4tYwFj2nVc8hG1sE6uD..."
# Can be safely used in URLs, emails
Why this works:
- 256 bits prevents brute-force: Testing 1 trillion tokens/second takes ~10^57 years to exhaust half the space
- base64url encoding is URL-safe: Replaces
+with-,/with_, omits=for safe transmission in URLs/emails - Independent token generation: Observing millions of tokens provides no prediction capability
- Not derived from time or a counter: Timestamp and sequential tokens can be predicted or enumerated
- Best practices: Single-use, 1-24 hour expiration, rate limiting to prevent brute-force
os.urandom() for Encryption Keys
import os
from cryptography.fernet import Fernet
# SECURE - Generate Fernet key properly
def generate_encryption_key():
# Fernet.generate_key() uses os.urandom() internally
key = Fernet.generate_key()
return key
# Or directly use os.urandom for custom needs
def generate_custom_key():
# 32 bytes = 256 bits
key = os.urandom(32)
return key
Why this works:
- OS entropy, not a userspace PRNG:
os.urandom()draws from the OS cryptographic source, which combines hardware entropy (CPU jitter, interrupt timing, hardware RNG) with cryptographic algorithms - 256-bit security: Provides sufficient margin against brute-force; even with quantum computers using Grover's algorithm (quadratic speedup), 256-bit keys retain 128 bits post-quantum security
- Fernet integration:
Fernet.generate_key()internally callsos.urandom(32)for 32-byte key (AES-128-CBC + HMAC-SHA256 authenticated encryption) - Direct usage:
os.urandom()appropriate for raw random bytes, custom cryptographic constructions, specific key sizes - What it replaces: Keys derived from passwords without a KDF, or drawn from predictable, reproducible sources such as
random.random()
secrets.choice() for Random Selection
import secrets
import string
# SECURE - Random password generation
def generate_password(length=16):
chars = string.ascii_letters + string.digits + string.punctuation
password = ''.join(secrets.choice(chars) for _ in range(length))
return password
# Example: "x7!aB9#qZ3$mK2&p"
# Each character independently random
Why this works:
- Cryptographic randomness:
secrets.choice()usesos.urandom()for independent, uniformly distributed character selection across the character set - High entropy: 94-character set (uppercase, lowercase, digits, punctuation) with 16 characters = 94^16 ≈ 3.7x10^31 possibilities (≈105 bits entropy)
- Independence: Knowing any number of generated passwords provides no advantage in predicting future passwords (unlike
random.choice()where Mersenne Twister state can be reconstructed) - Uniform distribution critical: Unbiased selection maximizes entropy per character;
stringmodule constants provide standard sets (filter ambiguous chars likeO,0,l,1for usability)
secrets.randbelow() for Random Integers
import secrets
CODE_SPACE = 1_000_000 # 6-digit decimal codes
def generate_verification_code():
code = secrets.randbelow(CODE_SPACE)
return f"{code:06d}"
Why this works:
- Avoids modulo bias: Naively reducing random_bytes with % n introduces bias when the RNG's range is not an exact multiple of n (Example: 0..255 % 10 gives 0-5 occurring 26 times each, 6-9 occurring 25 times each → biased)
- Rejection sampling:
secrets.randbelow()internally uses rejection sampling to discard out-of-range values - Equal probability: For 6-digit codes (0-999999), each code has identical generation probability with ~20 bits entropy (log2(1000000) ≈ 19.93)
- Short-lived use cases: Suitable for email confirmation/2FA when combined with rate limiting, expiration (5-15 min), account lockout
- Unpredictable: Cryptographic randomness prevents prediction even with knowledge of previous codes
UUID4 with CSPRNG
import uuid
# SECURE - UUID4 uses CSPRNG in supported Python
def generate_user_id():
# uuid4() uses os.urandom()
user_id = uuid.uuid4()
return str(user_id)
# Example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
Why this works:
- 122 bits randomness (RFC 4122 v4): 6 bits reserved for version/variant identifiers
- Uses
os.urandom(): Supported Python versions generate UUID4 random bits from the OS CSPRNG - Avoid custom UUID polyfills: Do not build UUIDs from
random.getrandbits()or other non-CSPRNG sources - Negligible collision probability: at 1 billion UUIDs/second a 50% chance of a single collision arrives after about 86 years - the 2.7 x 10^18 UUIDs that takes is 2.7 x 10^9 seconds, not years
- Ideal for distributed systems: No coordination needed; prevents information leakage vs sequential IDs
Password Hashing with Cryptographic Salt
import hashlib
import os
# SECURE - Proper salting with os.urandom
def hash_password(password):
salt = os.urandom(16) # 128 bits of random salt
# pbkdf2_hmac takes the salt as its own argument - do not also
# prepend it to the password, which changes the derived key for no gain
hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600000)
return hashed.hex(), salt.hex()
# Better: Use argon2 or bcrypt library
from argon2 import PasswordHasher
def hash_password_argon2(password):
ph = PasswordHasher()
# Argon2 handles salt generation internally with CSPRNG
hashed = ph.hash(password)
return hashed
Why this works:
- Cryptographic salt prevents rainbow tables:
os.urandom(16)generates 128 bits random salt ensuring unique hashes even for identical passwords - Salt doesn't need secrecy: Stored with hash; it must be unique and large enough to prevent reusable precomputed tables. CSPRNG-generated salts are the usual safe default.
- PBKDF2 with 600,000 iterations: Makes brute-force expensive - 600,000 hash operations per guess (OWASP 2023 recommendation)
- Modern best practice: Argon2/bcrypt: Memory-hard algorithms resist GPU/ASIC attacks; Argon2 won 2015 Password Hashing Competition
- Libraries auto-generate salts: Argon2/bcrypt use CSPRNG internally, eliminate manual salt handling, include constant-time comparison for timing attack protection
Cryptographic Nonce/IV Generation
import os
from Crypto.Cipher import AES
# SECURE - Proper nonce generation
def encrypt_data(data, key):
# AES-GCM nonce should be 12 bytes (96 bits) for best performance
nonce = os.urandom(12)
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(data)
return nonce, ciphertext, tag
# Nonce is unpredictable and unique with overwhelming probability
Why this works:
- Nonce uniqueness critical: For AES-GCM, the nonce must be unique per encryption under a given key - reuse breaks authentication, allowing forgery and key recovery
- Optimal size: 12-byte (96-bit) nonce is optimal for AES-GCM performance, avoiding internal GHASH operations needed for other sizes
- Statistical uniqueness:
os.urandom(12)provides statistical uniqueness - with 96 bits, collision probability negligible until ~2^48 nonces (~281 trillion) - CBC vs GCM: AES-CBC requires 16-byte IV that's unpredictable (not just unique) to prevent chosen-plaintext attacks; GCM only requires uniqueness
- Storage: Nonce doesn't need secrecy; returned alongside ciphertext and auth tag for decryption
Key Security Functions
Token Generation Helper
import secrets
def generate_token(purpose, nbytes=32):
"""
Generate secure tokens for various purposes
Args:
purpose: 'session', 'reset', 'api', 'csrf'
nbytes: Number of random bytes (default 32 = 256 bits)
Returns:
Secure random token as hex string
"""
token = secrets.token_hex(nbytes)
# Optionally prefix with purpose for identification
return f"{purpose}_{token}"
# Usage
session_token = generate_token('session') # "session_a7b3c9..."
reset_token = generate_token('reset', 48) # Longer for password reset
Secure Random String Generator
import secrets
import string
def generate_secure_string(length, charset='alphanumeric'):
"""
Generate cryptographically secure random string
Args:
length: Desired string length
charset: 'alphanumeric', 'hex', 'base64', 'ascii', 'digits'
Returns:
Random string
"""
charsets = {
'alphanumeric': string.ascii_letters + string.digits,
# NOT string.hexdigits.lower(): that constant is '0123456789abcdefABCDEF',
# so lowercasing it yields a-f twice and biases them 2:1 against the digits
'hex': string.digits + 'abcdef',
'ascii': string.ascii_letters + string.digits + string.punctuation,
'digits': string.digits,
'base64': string.ascii_letters + string.digits + '+/'
}
chars = charsets.get(charset, charsets['alphanumeric'])
if len(set(chars)) != len(chars):
raise ValueError(f"charset {charset!r} contains duplicates: output would be biased")
return ''.join(secrets.choice(chars) for _ in range(length))
# Usage
api_key = generate_secure_string(32, 'alphanumeric')
pin = generate_secure_string(6, 'digits')
secrets.choice() picks uniformly from the sequence it is given, so a duplicate
in the alphabet is a bias the CSPRNG cannot correct - it is drawing fairly from
an unfair list. string.hexdigits is the trap here, because it is
'0123456789abcdefABCDEF' and reads as "the hex alphabet": lowercasing it
leaves 22 characters of which a-f appear twice, so those come up twice as
often as the digits. Measured over 200,000 draws: 18,100 for each letter against
9,100 for each digit, and 3.91 bits per character instead of 4. The guard above
turns that class of mistake into an exception at the call site rather than a
token that looks fine.
Entropy Checker
import secrets
def entropy_bits(random_byte_count):
"""
Entropy of a token, in bits.
Count the BYTES DRAWN, never the characters printed: encoding
rearranges entropy, it never adds any.
Args:
random_byte_count: Bytes taken from the CSPRNG
Returns:
Entropy in bits
"""
return random_byte_count * 8
# Usage
nbytes = 32
token = secrets.token_hex(nbytes)
print(len(token)) # 64 characters
print(entropy_bits(nbytes)) # 256 bits - the correct answer
# Minimum entropy recommendations:
# - Session tokens: 128 bits
# - Password reset: 128-256 bits
# - API keys: 128-256 bits
# - Encryption keys: 128-256 bits (AES-128/AES-256)
The obvious version of this helper takes the finished string and returns
len(value) * math.log2(alphabet_size). For hex that is right - 64 characters
times 4 is 256 - and for base64 it is not. secrets.token_urlsafe(32) is 43
characters, and 43 x 6 = 258, two bits more than the generator produced,
because the last character carries only the leftover bits rather than a full
six. secrets.token_bytes(32) run through base64.b64encode is worse: 44
characters, 264 bits claimed against 256 real, since the = padding is not from
the alphabet at all. An entropy check that overstates entropy is worse than none,
because the value it gets run on is the borderline one. Count the bytes you
asked the CSPRNG for.
Analysis Steps
Locate the weak random usage
# Line 45 in auth/tokens.py
import random
import string
def generate_api_key():
chars = string.ascii_letters + string.digits
token = ''.join(random.choice(chars) for _ in range(32)) # VULNERABLE
return token
Identify the purpose
- API key generation (security-critical)
- Requires unpredictability
- Used for authentication
Assess the risk
- API keys grant access to protected resources
- Predictable keys = unauthorized access
- Impact: High (authentication bypass)
Determine required entropy
- API keys should have 128-256 bits entropy
- Current: 32 chars from 62-char alphabet = ~190 bits (if truly random)
- Problem: Not truly random - predictable with seed knowledge
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.
secrets cannot be seeded, and that is the point. If a test needs
reproducible values, inject a generator the test can substitute rather than
keeping random on the production path. Reproducibility and unpredictability
are the same property seen from opposite sides.
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, and remember hex encoding doubles the character count, which is where half the intended entropy usually goes missing.
Common Pitfalls
- Modulo-reducing a secure token into a short OTP: Generating bytes with
secrets.token_bytes()correctly, then reducing them withint.from_bytes(token_bytes, 'big') % 1_000_000- the modulo introduces slight bias, and if too few bytes were requested for the reduction (e.g., 3 bytes for a 6-digit code), the OTP can end up with less entropy than intended. Usesecrets.randbelow()for bounded integers; it applies rejection sampling internally. - Reaching for
uuid.uuid4()as a security token: It is CSPRNG-backed in CPython, but it's designed as a unique identifier, not a secret - 6 of its 128 bits are fixed by the UUID version/variant, and the dashed hex format wastes representational space compared to a purpose-built token. Prefersecrets.token_urlsafe()/secrets.token_hex()for session tokens, reset tokens, and API keys.
Additional Resources
- Bandit Security Scanner
- NIST Randomness Recommendations
- os.urandom() documentation
- OWASP Cryptographic Storage Cheat Sheet
- OWASP Password Storage Cheat Sheet - the source for the PBKDF2 iteration count, bcrypt work factor, and Argon2 parameters used here
- Python secrets module
- Secure Random Values (OWASP)