Race condition - PHP

Race condition - PHP

Need

Prevention of race conditions in the system

Context

  • Usage of PHP 7.0 for server-side scripting and web development
  • Usage of a Controller for handling and managing application logic and data flow
  • Usage of Request for making HTTP requests
  • Usage of Auth for user authentication and authorization
  • Usage of Payment for handling payment transactions

Description

Non compliant code

        all();

        // Update user balance
        $user->balance -= $data['amount'];
        $user->save();

        // Process payment
        $payment = new Payment();
        $payment->user_id = $user->id;
        $payment->amount = $data['amount'];
        $payment->save();
    }
}
        
        

The above code is an example of a race condition vulnerability.

This code represents a part of a backend application that uses the Laravel framework. The processRequest function is supposed to deduct an amount from the user's balance and then process a payment of the same amount.

However, because the code first deducts the amount from the user's balance and then creates a payment, if two requests arrive almost simultaneously, they could both pass the balance check before either of them has a chance to update the balance. This could result in the user's balance going into the negative.

For example, if a user has a balance of $100 and two requests to deduct $70 arrive at the same time, both requests could pass the balance check before either has a chance to update the balance. As a result, the balance would be updated twice, resulting in a balance of -$40, even though the user only had $100 to start with.

This is a classic example of a race condition, where the system's behavior is dependent on the sequence or timing of uncontrollable events.

Steps

  • Use database transactions to ensure that the operations are atomic. This means that either all operations are executed or none are, preventing inconsistent states.
  • Lock the user record for the duration of the transaction. This prevents other processes from reading or writing to the record until the transaction is complete.
  • Check the user's balance before and after deducting the payment amount. If the balance is insufficient at any point, roll back the transaction.
  • Ensure that the payment processing and balance update are part of the same transaction. If the payment fails for any reason, the balance update should also be rolled back.
  • Handle exceptions properly. If an exception occurs during the transaction, roll back the changes and handle the error appropriately.

Compliant code

        lockForUpdate();

            $data = $request->all();

            // Check if user has enough balance
            if ($user->balance < $data['amount']) {
                throw new Exception('Insufficient balance');
            }

            // Update user balance
            $user->balance -= $data['amount'];
            $user->save();

            // Process payment
            $payment = new Payment();
            $payment->user_id = $user->id;
            $payment->amount = $data['amount'];
            $payment->save();

            DB::commit();
        } catch (Exception $e) {
            DB::rollBack();
            // Handle exception
            return response()->json(['error' => $e->getMessage()], 400);
        }
    }
}
        
        

The updated code now uses Laravel's database transactions to ensure that the operations are atomic. This means that either all operations are executed or none are, preventing inconsistent states.

At the start of the process, we begin a new transaction with DB::beginTransaction(). This means that all database operations that follow will be part of this transaction.

We then lock the user record for the duration of the transaction using lockForUpdate(). This prevents other processes from reading or writing to the record until the transaction is complete.

Before updating the user's balance, we check if the user has enough balance to cover the payment. If not, we throw an exception, which will be caught in the catch block and cause the transaction to be rolled back.

The payment processing and balance update are part of the same transaction. If the payment fails for any reason, the balance update will also be rolled back.

If an exception occurs during the transaction, we roll back the changes with DB::rollBack(). We then handle the exception by returning a JSON response with the error message and a 400 status code. This ensures that the client is informed of the error in a proper way.

References