CWE-338: Use of Cryptographically Weak PRNG - PHP
Overview
Use of Cryptographically Weak PRNG in PHP applications occurs when developers use non-cryptographic random number generators like rand(), mt_rand(), or uniqid() for security-sensitive operations. These functions are not cryptographically secure and should never be used for generating tokens, keys, passwords, or other security-critical values. Attackers can predict the values they produce and forge the tokens built from them.
Primary Defence: Use random_bytes() or random_int() (PHP 7.0+) for all security-sensitive random value generation including session tokens, CSRF tokens, API keys, and password reset tokens.
Common Vulnerable Patterns
Using rand() or mt_rand() for Security
<?php
// INSECURE: Using weak PRNG for session token
session_id(md5(rand())); // Predictable session ID
// INSECURE: Using mt_rand for CSRF token
$csrf_token = md5(mt_rand()); // Predictable token
// INSECURE: Using rand for password reset token
$reset_token = bin2hex(pack('N', rand())); // Easily guessable
// INSECURE: Using mt_rand for API key generation
$api_key = '';
for ($i = 0; $i < 32; $i++) {
$api_key .= chr(mt_rand(33, 126)); // Predictable API key
}
Why this is vulnerable:
Historically, rand() varied by platform and implementation and was not cryptographically secure; since PHP 7.1 it is an alias of mt_rand(), which uses the MT19937 algorithm and is still not cryptographically secure. Even with MD5 hashing, if the input space is small (32-bit rand() output), attackers can brute-force or rainbow-table the hash. For session IDs, this enables session hijacking; for CSRF tokens, it allows cross-site request forgery; for password reset tokens, account takeover. PHP versions before 7.1 used an even weaker linear congruential generator for rand(). An attacker who observes a few outputs from your application can recover the internal state and generate valid tokens.
Using uniqid() for Security Tokens
<?php
// INSECURE: Using uniqid for session token
$session_token = uniqid('sess_', true); // Based on time, predictable
// INSECURE: Using uniqid for password reset
$reset_token = uniqid(mt_rand(), true); // Mersenne Twister prefix on a clock
// INSECURE: Using uniqid with more_entropy still predictable
$api_key = uniqid('', true); // Still based on microsecond time
Why this is vulnerable:
uniqid() is a clock reading in hex - seconds and microseconds - with an optional prefix in front and, when more_entropy is set, a php_combined_lcg() value on the end. That last part is the trap: the flag is named for entropy and supplies a linear congruential generator, which is a formatting change rather than a security one. The microseconds inside a known second are about 20 bits, and an attacker rarely has to guess even the second, because the response that delivered the token usually carried a Date header. Prefixing with mt_rand(), as above, adds a Mersenne Twister draw that is itself recoverable, so the whole token stays inside a space small enough to enumerate against the reset endpoint - which is account takeover, since a reset token is the credential.
Be precise about what the objection is when you triage this, because uniqid() has one form that is not this weakness. uniqid(bin2hex(random_bytes(8)), true) carries 64 bits of genuine CSPRNG output in its prefix and is not guessable - what it still does is publish the generation time in every token, which is an information leak (CWE-200) rather than a weak generator. Recording that as a different finding is more useful than forcing it into this one. uniqid() is fine for a temporary filename or a DOM id; it should never be the whole of an authentication token or a key.
Helpers that draw on the weak engine without saying so
<?php
// INSECURE: str_shuffle() is Mersenne Twister, and permuting a fixed alphabet
// can never repeat a character
$temp_password = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 12);
// INSECURE: shuffle() and array_rand() use the same engine
shuffle($security_questions);
$question = $security_questions[0];
$winner = $entrants[array_rand($entrants)];
// INSECURE: lcg_value() is a linear congruential generator,
// and deprecated since PHP 8.4
$jitter_token = md5((string) lcg_value());
Why this is vulnerable: none of these reads as a random number generator, so they survive review and scanner rules written around rand( and mt_rand(. All of them are the Mt19937 engine underneath, and the same mt_srand() seed reproduces every one of them - measured on PHP 8.5.8, mt_srand(4242) before str_shuffle() and before array_rand() returns identical results on each run. The str_shuffle() password has a second, independent problem that survives even swapping in a secure engine: a shuffle permutes, so no character can appear twice, and 12 characters drawn without replacement from 36 is 36P12, about 6.0x10^17, rather than the 4.7x10^18 of 36^12 that the alphabet suggests - an eightfold reduction handed over for free. Draw characters independently instead of permuting a fixed string.
Secure Patterns
Using random_bytes() for Raw Binary Randomness
<?php
// SECURE - Generate cryptographically secure random bytes
$key = random_bytes(32); // 256-bit encryption key
$iv = random_bytes(16); // 128-bit initialization vector
// SECURE - Generate secure session token
$session_token = bin2hex(random_bytes(32)); // 64-character hex token
// SECURE - Generate secure password reset token
$reset_token = base64_encode(random_bytes(32)); // Base64-encoded token
// SECURE - Generate secure CSRF token
$csrf_token = bin2hex(random_bytes(24)); // 48-character hex token
// SECURE - Generate secure API key
$api_key = base64_encode(random_bytes(32));
$api_key = rtrim(strtr($api_key, '+/', '-_'), '='); // URL-safe base64
Why this works:
random_bytes()uses OS CSPRNG: platform cryptographic random APIs such asgetrandom()//dev/urandomon Unix-like systems and CNG on Windows- Fail-safe behavior: Throws exception if secure randomness unavailable, never silently falls back to weak sources
- 256 bits prevents brute-force:
random_bytes(32)draws from 2^256 possible values - Hex/base64 encoding for compatibility: Suitable for URLs, databases, HTTP headers
- Prevents prediction attacks: Unlike weak PRNGs where state can be recovered from outputs
Using random_int() for Random Integers
<?php
// SECURE - Generate secure random integer in range
$otp_code = random_int(100000, 999999); // 6-digit OTP
$verification_code = random_int(1000, 9999); // 4-digit code
// SECURE - Generate random array index securely
$items = ['apple', 'banana', 'cherry', 'date'];
$random_index = random_int(0, count($items) - 1);
$random_item = $items[$random_index];
// SECURE - Generate random delay for rate limiting (milliseconds)
$delay = random_int(100, 500);
usleep($delay * 1000);
// SECURE - Shuffle array securely on PHP 8.1 (see Randomizer below for 8.2+)
function secure_shuffle(array &$array): void {
$count = count($array);
for ($i = $count - 1; $i > 0; $i--) {
$j = random_int(0, $i);
$temp = $array[$i];
$array[$i] = $array[$j];
$array[$j] = $temp;
}
}
Why this works:
random_int()uses same CSPRNG asrandom_bytes(): Outputs cannot be predicted from earlier ones, unlikemt_rand()- Unbiased uniform distribution: Each value in the range has exactly the same chance, unlike
mt_rand() % rangewhich introduces statistical bias - Explicit failure: Throws exception if range invalid or secure randomness unavailable
- Secure array shuffling:
random_int()in place ofshuffle(), which is Mt19937 underneath, so the permutation cannot be reproduced from a known seed
Using Random\Randomizer with the Secure engine (PHP 8.2+)
<?php
// SECURE - the engine is chosen explicitly, and Secure is the CSPRNG one
$rng = new \Random\Randomizer(new \Random\Engine\Secure());
$otp = $rng->getInt(100000, 999999); // unbiased, like random_int()
$token = bin2hex($rng->getBytes(32)); // like bin2hex(random_bytes(32))
// The replacements for the weak helpers, without hand-rolling a shuffle
$questions = $rng->shuffleArray($security_questions);
$scrambled = $rng->shuffleBytes('abcdef');
// Drawing WITH replacement - the fix for the str_shuffle password above
$temp_password = $rng->getBytesFromString(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
16
);
Why this works: PHP 8.2 separated the algorithm from the operation, which is what the old API got wrong. shuffle() and str_shuffle() bake Mt19937 in and give you no way to ask for anything else; Randomizer takes the engine as a constructor argument, so Random\Engine\Secure routes the same shuffle, the same range selection and the same string draw through the CSPRNG that backs random_bytes(). getBytesFromString() is the piece with no pre-8.2 equivalent: it picks each character independently, so unlike a shuffle it can repeat one, and a 16-character password really is 62^16 rather than a permutation of a fixed alphabet. Keep random_bytes() and random_int() where they already do the job - Randomizer earns its place for the shuffle and selection operations, and on PHP 8.1 the manual Fisher-Yates above is still the answer.
Framework-Specific Examples
Laravel - Secure Token Generation
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
// SECURE - Laravel's Str::random uses random_bytes
public function generateApiToken()
{
$api_token = Str::random(64); // 64-character secure token
// Store hashed version in database
$hashed_token = hash('sha256', $api_token);
return response()->json([
'token' => $api_token,
'expires_at' => now()->addYear()
]);
}
// SECURE - Generate password reset token
public function generatePasswordResetToken($user)
{
$token = Str::random(60);
// Store token with expiration
// `password_reset_tokens` is the table the current skeleton's
// create_users_table migration builds; older apps still say
// `password_resets`
DB::table('password_reset_tokens')->insert([
'email' => $user->email,
'token' => Hash::make($token),
'created_at' => now()
]);
return $token;
}
// SECURE - Generate CSRF token (Laravel does this automatically)
public function getCsrfToken()
{
return csrf_token(); // Uses Str::random internally
}
// SECURE - Generate secure session ID
public function regenerateSession()
{
request()->session()->regenerate(); // Uses random_bytes
return response()->json(['status' => 'session_regenerated']);
}
}
Why this works:
- Laravel's
Str::random(): Cryptographically secure by default, backed byrandom_bytes() - Token hashing before storage: Prevents leakage if database compromised
- Built-in CSRF protection: Automatically generates and validates tokens using secure randomness
- Session regeneration: Uses PHP's native secure session mechanisms
- Defense-in-depth: Secure generation stops a token being guessed; hashing at rest and an expiry limit what a leaked one is worth
Symfony - Secure Random Generation
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
class SecurityController extends AbstractController
{
private CsrfTokenManagerInterface $csrfTokenManager;
public function __construct(CsrfTokenManagerInterface $csrfTokenManager)
{
$this->csrfTokenManager = $csrfTokenManager;
}
// SECURE - Generate CSRF token
public function getCsrfToken(): Response
{
$token = $this->csrfTokenManager->getToken('form_intent');
return $this->json([
'csrf_token' => $token->getValue() // Uses random_bytes
]);
}
// SECURE - Validate CSRF token
public function validateCsrfToken(string $tokenValue): bool
{
$token = new CsrfToken('form_intent', $tokenValue);
if (!$this->csrfTokenManager->isTokenValid($token)) {
throw new InvalidCsrfTokenException('Invalid CSRF token');
}
return true;
}
// SECURE - Generate API key
public function generateApiKey(): Response
{
$apiKey = bin2hex(random_bytes(32));
// Store hashed version
$hashedKey = password_hash($apiKey, PASSWORD_ARGON2ID);
return $this->json([
'api_key' => $apiKey,
'store_hash' => $hashedKey
]);
}
// SECURE - Generate verification token
public function generateVerificationToken(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
}
Why this works:
- Symfony's CSRF manager: Cryptographically secure token generation, backed by
random_bytes() - Built-in CSRF protection: Framework security component handles generation and validation
- Argon2id password hashing: Resists GPU-based cracking attacks
- URL-safe base64 encoding: Tokens work correctly in URLs and HTTP headers
- Dependency injection: The token manager is injected, so every controller gets the same secure implementation
Migration Guide
Migrating from Weak PRNG to Secure PRNG
<?php
// BEFORE (PHP < 7.0 or insecure code):
$token = md5(uniqid(mt_rand(), true));
$session_id = md5(rand());
$api_key = substr(md5(microtime()), 0, 32);
// AFTER (PHP 7.0+):
$token = bin2hex(random_bytes(32));
$session_id = bin2hex(random_bytes(32));
$api_key = bin2hex(random_bytes(32));
// Regenerate all existing tokens after migration
function regenerate_all_tokens() {
// Invalidate old session tokens
DB::table('sessions')->truncate();
// Regenerate API keys (notify users)
$users = DB::table('users')->get();
foreach ($users as $user) {
$new_api_key = bin2hex(random_bytes(32));
DB::table('users')
->where('id', $user->id)
->update(['api_key' => hash('sha256', $new_api_key)]);
// Email user with new API key
send_api_key_email($user->email, $new_api_key);
}
}
Common Pitfalls
- Explicit seeding with
mt_srand(): Callingmt_srand()with a "more unpredictable" seed likemicrotime()orgetmypid()before generating a token.mt_rand()is still the Mersenne Twister algorithm underneath - an explicit, derivable seed makes prediction easier, and skipping the manual seed doesn't fix anything either, since the algorithm itself isn't suitable for security regardless of how it's seeded. - Reaching for
shuffle()orarray_rand()as a "safer" alternative: Both are documented as using the same non-cryptographic RNG asrand()/mt_rand(), so swapping directmt_rand()calls for one of these helpers to pick a security question, sample a subset, or choose a token index doesn't add any security. - Ignoring the
$crypto_strongoutput ofopenssl_random_pseudo_bytes(): This function can silently produce non-cryptographically-strong output on some platforms or OpenSSL builds; the third, by-reference parameter reports whether that happened. Code that calls it and uses the bytes without checking that flag can end up using weak randomness with no visible failure. Preferrandom_bytes(), which always throws rather than degrading silently.