CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition') - PHP
Overview
PHP's typical process-per-request model (PHP-FPM, mod_php) means requests do not share PHP-level memory directly, so races appear at the resources requests do share: database rows, files, sessions, and caches such as APCu or Redis. The primary fix is to push the read-modify-write into a single atomic operation at that shared resource: a database transaction with SELECT ... FOR UPDATE or a conditional atomic UPDATE, flock() for shared files, or apcu_inc()/apcu_cas() for a counter shared across PHP-FPM workers on the same host. PHP's default session handler (files) already serializes concurrent requests for the same session ID by locking the session file for the request's duration - do not defeat this by calling session_write_close() early when later code in the same request still touches $_SESSION.
Common Vulnerable Patterns
Separate SELECT Then UPDATE
<?php
// VULNERABLE - check and update are two separate, unsynchronized statements
function withdraw(PDO $pdo, int $accountId, int $amount): void
{
$stmt = $pdo->prepare('SELECT balance FROM accounts WHERE id = :id');
$stmt->execute(['id' => $accountId]);
$balance = $stmt->fetchColumn();
if ($balance < $amount) {
throw new RuntimeException('insufficient funds');
}
// RACE WINDOW: another request's SELECT/UPDATE pair can run here
$update = $pdo->prepare('UPDATE accounts SET balance = balance - :amount WHERE id = :id');
$update->execute(['amount' => $amount, 'id' => $accountId]);
}
// Attack: two concurrent withdraw($pdo, $accountId, 100) requests when the
// balance is 100. Both SELECTs see balance = 100, both UPDATEs subtract 100.
// Result: the balance ends at -100 instead of the second request being rejected.
Why this is vulnerable: The SELECT and UPDATE run as two independent statements with no lock or transaction tying them together, so a concurrent request's SELECT can read the same pre-deduction balance before this request's UPDATE commits.
APCu fetch() Then store() (Non-Atomic Counter)
<?php
// VULNERABLE - fetch-then-store is a check-then-act race across PHP-FPM workers
function incrementRequestCount(): int
{
$count = apcu_fetch('request_count');
$count = ($count === false) ? 1 : $count + 1;
apcu_store('request_count', $count);
return $count;
}
// Attack: 1,000 concurrent requests each call incrementRequestCount() once.
// Result: apcu_fetch('request_count') often ends up less than 1,000 -
// increments overlap across worker processes sharing the same APCu segment.
Why this is vulnerable: apcu_fetch() and apcu_store() are each individually atomic, but the pair together is not: two PHP-FPM worker processes can both fetch the same starting value before either stores the incremented result.
File Read-Modify-Write Without flock()
<?php
// VULNERABLE - no lock around the read-modify-write on the file
function appendToLog(string $path, string $line): void
{
$contents = file_get_contents($path);
$contents .= $line . "\n";
// RACE WINDOW: another request can read/write the same file here
file_put_contents($path, $contents);
}
// Attack: two concurrent appendToLog() calls on the same file.
// Both read the same starting content before either writes.
// Result: one of the two log lines is silently lost.
Why this is vulnerable: file_get_contents() and file_put_contents() are separate system calls with no lock between them, so a concurrent request's read can happen before this request's write, and whichever write happens last wins, discarding the other request's change.
Secure Patterns
SELECT ... FOR UPDATE Inside a Transaction
<?php
// SECURE - the row lock is held from the SELECT through the UPDATE, inside
// one transaction, so no other transaction can read a stale balance
function withdraw(PDO $pdo, int $accountId, int $amount): void
{
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT balance FROM accounts WHERE id = :id FOR UPDATE');
$stmt->execute(['id' => $accountId]);
$balance = $stmt->fetchColumn();
if ($balance === false) {
throw new RuntimeException('account not found');
}
if ($balance < $amount) {
throw new RuntimeException('insufficient funds');
}
$update = $pdo->prepare('UPDATE accounts SET balance = balance - :amount WHERE id = :id');
$update->execute(['amount' => $amount, 'id' => $accountId]);
$pdo->commit();
} catch (Throwable $e) {
// Guard the rollback: if commit() itself was what threw, or the
// connection dropped, there is no transaction left to roll back and
// rollBack() raises PDOException("There is no active transaction"),
// replacing the real error with a misleading one
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
Why this works: FOR UPDATE takes a row-level lock at the SELECT, and the transaction holds that lock until commit() or rollBack(). Any other transaction's SELECT ... FOR UPDATE on the same row blocks until this one finishes, so the balance this code checked cannot change out from under it before the UPDATE runs, regardless of which PHP-FPM worker or server handles the concurrent request.
The catch is what makes the lock safe to take: without it, an exception between beginTransaction() and commit() leaves the row locked until the connection is closed or the server's lock timeout fires, and every concurrent request for that row blocks behind it. Verified on PHP 8.5.8 that the guard is not redundant - calling rollBack() with no active transaction throws PDOException: There is no active transaction, so an unguarded rollback in the error path converts a real failure into a confusing one.
Conditional Atomic UPDATE (No Row Lock Needed)
<?php
// SECURE - the precondition and the write happen in one atomic statement
function withdrawAtomic(PDO $pdo, int $accountId, int $amount): bool
{
$stmt = $pdo->prepare(
'UPDATE accounts SET balance = balance - :amount
WHERE id = :id AND balance >= :amount'
);
$stmt->execute(['amount' => $amount, 'id' => $accountId]);
return $stmt->rowCount() === 1;
}
// usage
if (!withdrawAtomic($pdo, $accountId, $amount)) {
throw new RuntimeException('insufficient funds or account not found');
}
Why this works: There is no separate read at all - the database evaluates balance >= :amount and performs the subtraction as part of the same atomic statement. rowCount() === 1 separates "the precondition failed" from "the write succeeded" here, without needing an explicit transaction or lock for this simple case. One caveat if the database is MySQL: PDO reports rows changed rather than matched unless the connection sets PDO::MYSQL_ATTR_FOUND_ROWS, so an UPDATE that writes a row the value it already holds reports 0. A withdrawal of a non-zero amount always changes the balance, so this pattern is unaffected - but a conditional update that can legitimately be a no-op needs that attribute, or a different signal.
apcu_inc() / apcu_cas() for a Shared Counter
<?php
// SECURE - apcu_inc() is an atomic increment shared across PHP-FPM workers
function incrementRequestCount(): int
{
// creates the key at 0 first if it does not exist, then increments atomically
apcu_add('request_count', 0);
return apcu_inc('request_count');
}
// SECURE - apcu_cas() (compare-and-swap) for a check-then-act on a cached value
function tryReserveStock(string $sku, int $quantity, int $maxAttempts = 10): bool
{
$key = "stock:$sku";
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
// Re-read inside the loop: a retry that reuses the value from the
// previous attempt is racing on exactly the stale data the CAS rejected
$current = apcu_fetch($key, $found);
if (!$found || $current < $quantity) {
return false;
}
// cas() only succeeds if the value is still $current
if (apcu_cas($key, $current, $current - $quantity)) {
return true;
}
usleep(random_int(100, 1000)); // back off before trying again
}
// Contention did not settle within the cap. Reporting that is a legitimate
// answer; looping forever is not.
throw new RuntimeException("could not reserve $sku, please retry");
}
Why this works: apcu_inc() performs the read-modify-write as a single atomic operation inside the APCu shared memory segment - no PHP-level check-then-act is involved. apcu_cas() provides compare-and-swap: the update only applies if the stored value still matches $current, so if another worker changed it in between, the CAS fails and the loop re-reads and decides again against the fresh value instead of overwriting a concurrent change.
The bound and the backoff are part of the pattern, not defensive padding. An unbounded do/while around a CAS is a livelock waiting for a busy key: every worker retries immediately, each one's write invalidates the others, and a request thread spins until the FPM timeout. The randomised usleep staggers the retries so one caller wins per round. Note also that apcu_cas() only operates on integers - a value stored as a string, or as an array, always fails the compare and this loop exhausts its attempts on a key that never changed.
flock() Around the Full Read-Modify-Write
<?php
// SECURE - flock() serializes the entire read-modify-write on the file
function appendToLog(string $path, string $line): void
{
$handle = fopen($path, 'c+');
if ($handle === false) {
throw new RuntimeException('unable to open log file');
}
try {
if (!flock($handle, LOCK_EX)) {
throw new RuntimeException('unable to lock log file');
}
$contents = stream_get_contents($handle);
$contents .= $line . "\n";
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, $contents);
fflush($handle);
flock($handle, LOCK_UN);
} finally {
fclose($handle);
}
}
Why this works: flock($handle, LOCK_EX) acquires an exclusive lock before the read, and the lock is held through the read, the modification, and the write, so a concurrent call blocks until this one releases it with LOCK_UN. The full sequence - read, append, truncate, write - happens under the same lock, closing the gap the earlier file_get_contents()/file_put_contents() pair left open. The 'c+' mode matters: it opens for reading and writing and creates the file if absent without truncating, so the lock is taken before any content is at risk - 'w+' truncates at fopen(), before flock() has been called, and loses the file to whichever process opens it during another's critical section.
Where the operation really is a pure append rather than a read-modify-write, the whole function collapses to one call: file_put_contents($path, $line . "\n", FILE_APPEND | LOCK_EX) takes the exclusive lock, appends and releases, with no window to get wrong. Verified on PHP 8.5.8, as was the flock version above. Reach for the explicit handle only when the new content genuinely depends on the old.
Framework-Specific Guidance
Laravel
<?php
// SECURE - Eloquent's lockForUpdate() issues SELECT ... FOR UPDATE inside a
// DB transaction
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($accountId, $amount) {
$account = Account::where('id', $accountId)->lockForUpdate()->firstOrFail();
if ($account->balance < $amount) {
throw new InsufficientFundsException();
}
$account->decrement('balance', $amount);
});
Why this works: DB::transaction() wraps the closure in a database transaction with automatic commit/rollback, and lockForUpdate() adds FOR UPDATE to the query so the row is locked for the duration of the transaction. decrement() also has an atomic single-statement form (Account::where('id', $accountId)->decrement('balance', $amount)) that skips the explicit lock entirely for simple numeric updates, similar to the conditional UPDATE pattern above.
Symfony (Doctrine ORM)
<?php
// SECURE - Doctrine optimistic locking with a #[Version] column
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Account
{
#[ORM\Version]
#[ORM\Column(type: 'integer')]
private int $version;
// balance, id, etc.
}
// the EntityManager throws OptimisticLockException if the row's version
// changed since it was read, instead of silently overwriting the other write
try {
$entityManager->flush();
} catch (\Doctrine\ORM\OptimisticLockException $e) {
// reload the entity and retry, or surface the conflict to the caller
}
Why this works: #[ORM\Version] marks the column as a concurrency token; Doctrine includes it in the UPDATE ... WHERE id = ? AND version = ? and increments it on every successful write. If a concurrent request already updated the row, the WHERE clause matches zero rows and Doctrine raises OptimisticLockException rather than silently applying a stale write.
Considerations
A lock is only as wide as the thing holding it. An in-process lock serialises the threads inside one instance and does nothing about a second instance, so a fix that works on a developer machine can fail the moment the service is scaled out or restarted behind a load balancer. Decide first where the shared state actually lives. If it is a database row, the serialisation has to happen in the database - a row lock, a conditional update, or a version column. If it is genuinely in-process and stays that way, an in-process lock is correct and cheaper.
Not every race is worth fixing. Two requests overwriting a display preference, or a page-view counter losing an increment, is a race with no security consequence and often no user-visible one. The ones that matter change a decision: a balance check, a quota, a one-time token being redeemed, a permission being evaluated. Fixing a benign race costs throughput and adds a failure mode, so say which category the finding is in before reaching for a lock.
In-process locking rarely applies here. PHP's shared-nothing model gives each request its own memory, so the state two requests contend over is almost always in the database, in APCu or Redis, in a session store, or on disk. That means the serialisation belongs to whichever of those the value lives in - a row lock, an atomic cache operation, or an advisory file lock - and a mutex in PHP code is usually solving a problem the runtime does not have.
Optimistic and pessimistic locking fail in opposite directions. A pessimistic lock makes every caller wait, so it is predictable but caps throughput and can deadlock if two paths take locks in different orders. Optimistic concurrency lets callers proceed and rejects the loser, which is faster when conflicts are rare and degenerates into wasted work and retries when they are common. Pick by how often the same row is genuinely contended, not by which is easier to write.
Retries need a bound and a backoff. A conflict-and-retry loop with neither turns a contended row into a livelock under load - every caller retrying immediately, none making progress. Cap the attempts, back off between them, and decide what the caller sees when the cap is reached. "Try again" is a legitimate answer; silently returning stale data is not.
Testing
A re-scan cannot confirm this fix. The tool sees FOR UPDATE or a conditional
WHERE where there was none and reports the finding closed; it cannot tell
whether the lock spans the whole decision, and it cannot tell a working
transaction from one that refuses every legitimate request.
PHP's shared-nothing model decides the shape of the test. There is no in-process interleaving to pin with a barrier, because the two racing callers are two processes - so the test has to be two real requests, and the thing to assert on is the persisted state, not the response codes. A count of HTTP 200s tells you how many callers believed they succeeded, which is precisely the number a lost update gets wrong.
<?php
use PHPUnit\Framework\TestCase;
// Fire concurrent HTTP requests at the same endpoint using curl_multi, so the
// race runs between real PHP-FPM workers rather than inside one process
function concurrentWithdrawals(string $url, int $callers): array
{
$multi = curl_multi_init();
$handles = [];
for ($i = 0; $i < $callers; $i++) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($multi, $ch);
$handles[] = $ch;
}
$running = null;
do {
curl_multi_exec($multi, $running);
curl_multi_select($multi); // block instead of spinning on the CPU
} while ($running > 0);
$statuses = [];
foreach ($handles as $ch) {
$statuses[] = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_multi_remove_handle($multi, $ch);
// no curl_close(): a no-op since PHP 8.0 and deprecated in 8.5 - the
// handle is freed when the last reference to it goes away
}
curl_multi_close($multi);
return $statuses;
}
class WithdrawalRaceTest extends TestCase
{
public function testConcurrentWithdrawalsNeverOverdraft(): void
{
$this->seedBalance(100);
$statuses = concurrentWithdrawals('https://app.test/withdraw?amount=30', 5);
$succeeded = count(array_filter($statuses, fn ($s) => $s === 200));
// The balance is the assertion that matters: it is written by the code
// under test, where the status codes are written by the caller's view of it
$this->assertSame(10, $this->currentBalance());
$this->assertSame(3, $succeeded);
$this->assertSame(2, count(array_filter($statuses, fn ($s) => $s === 409)));
}
}
Use a real assertion library rather than assert(). zend.assertions is 1 in
php.ini-development and -1 in php.ini-production, where the calls are
compiled out entirely - so a test suite that asserts with assert() passes
unconditionally in exactly the configuration most CI images ship.
Assert these, with the result each should produce:
- Accept, single call. One request for 30 against a balance of 100 returns 200 and the stored balance is exactly 70. A transaction that rolls back everything satisfies "no customer was overdrawn" while failing this - which is why the accept cases are on the list at all.
- Accept, at the boundary. A request for exactly the remaining balance returns 200 and leaves 0.
- Reject, past the boundary. One more than the balance returns 409 (not 500) and leaves the balance unchanged - a 500 here usually means the
RuntimeExceptionreached the framework's error handler instead of a rejection path. - Concurrent. The test above: stored balance 10, three 200s, two 409s. Assert all three; a lost update can produce the right number of 200s and the wrong balance.
- The row lock is released on the rejection path. After a refused withdrawal, a later legitimate request against the same row completes rather than timing out. A
throwbetweenbeginTransaction()and arollBack()that is not in afinally(or not guarded byinTransaction()) leaves the row locked until the connection is reaped. - Every path uses the same protection. Grep for every statement that writes the balance - the refund path, the admin adjustment, the batch job - and check each is inside a transaction with
FOR UPDATEor carries its precondition in its ownWHERE. A race fixed on the withdrawal path and left on the refund path is still exploitable, and the concurrency test above only drives one endpoint.
Re-run the concurrent test twenty times, not once. Unlike the in-process races in other languages, this one depends on two workers overlapping at the database, which the first run may not achieve.
Common Pitfalls
- Assuming PHP's process-per-request model removes the race: "PHP is single-threaded" is true within one request, but concurrent requests are still concurrent processes acting on the same external resource (database row, file, cache key); the race exists at that shared resource regardless of PHP's own threading model.
- Calling
session_write_close()early "for performance" while later code still reads or writes$_SESSION: This releases the default session file lock before the request is done using session data, defeating PHP's built-in per-session serialization and reintroducing a race on session-stored state. - Wrapping a query in a transaction without an explicit lock or conditional
WHERE:beginTransaction()/commit()alone does not add row-level locking - a plainSELECTfollowed byUPDATEinside a transaction can still lose an update under common isolation levels unless the read usesFOR UPDATEor the update includes the precondition in itsWHEREclause. - Using
apcu_fetch()/apcu_store()because "it's just a local cache, not a real race": APCu is shared across every PHP-FPM worker process on the host, so a fetch-then-store pair races exactly like a database read-then-write does; useapcu_inc()/apcu_cas()for compound updates.
Dependencies and Installation
No additional package is required for flock(), PDO transactions, or SELECT ... FOR UPDATE - all are part of PHP's standard library and any configured PDO driver. APCu requires the apcu PECL extension (pecl install apcu), commonly already installed for opcode/user caching. Laravel's lockForUpdate() and Doctrine's #[ORM\Version] require no extra package beyond the framework's own ORM, already a dependency for a Laravel or Symfony project.
Additional Resources
- CWE-362 Details
- PHP Manual: flock()
- PHP Manual: APCu Functions
- PHP Manual: PDO Transactions
- PHP Manual: PDOStatement::rowCount() - on MySQL this counts rows changed, not matched, unless the connection sets
PDO::MYSQL_ATTR_FOUND_ROWS - Laravel Documentation: Database Transactions and Locking