Skip to content

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:

  • secrets module uses OS CSPRNG: It calls os.urandom() internally rather than the Mersenne Twister behind random, whose state is recoverable from 624 outputs
  • OS entropy sources: platform cryptographic RNG APIs such as getrandom()//dev/urandom on 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 calls os.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() uses os.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; string module constants provide standard sets (filter ambiguous chars like O, 0, l, 1 for 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 with int.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. Use secrets.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. Prefer secrets.token_urlsafe()/secrets.token_hex() for session tokens, reset tokens, and API keys.

Additional Resources