In a recent incident that shook the financial technology sector in Nigeria, HabariPay, a prominent payment institution, faced a significant challenge in their payment processing system. This event highlighted the critical importance of implementing robust safeguards in financial transactions, especially in high-volume, time-sensitive environments.
HabariPay, a subsidiary of Guaranty Trust Holding Company (GTCO), is one of Nigeria's leading payment service providers. The incident resulted in unauthorized transactions totaling approximately 1.1 billion Naira (about $1.1 million USD).
The Incident
According to TechCabal, "One person with direct knowledge of the situation said hackers accessed the fintech’s website using a strategy called race conditioning, which allowed them to trigger simultaneous transactions."
This incident underscores the importance of building secure, resilient payment processing systems to safeguard against sophisticated attacks.
Understanding Race Conditions
Race conditions occur when the behavior of a system depends on the sequence or timing of uncontrollable events. In payment systems, this could happen when multiple transactions are processed simultaneously without proper safeguards. For instance, if two transactions attempt to withdraw funds from the same account at the exact same time, a race condition could lead to both transactions succeeding even if there were only enough funds for one.
Example: Suppose you have an account (A1) with a balance of ₦200,000. Transaction 1 (T1): Withdraw ₦100,000
Transaction 2 (T2) - Another transaction (e.g., an automatic bill payment) wants to withdraw ₦80,000.
Here's what might happen:
T1 requests the balance of A1, ₦200,000.
T2 requests the balance of A1, ₦200,000 (before T1's withdrawal is processed).
T1 processing: makes a withdrawal of ₦100,000 from ₦200,000; balance = ₦100,000.
T2 processing: makes a withdrawal of ₦80,000 from ₦200,000 (using the outdated balance); balance = ₦120,000.
Result: Account A1's actual balance should be ₦20,000 (₦200,000 - ₦100,000 - ₦80,000). However, due to the race condition, the balance is incorrectly updated to ₦120,000.
This example illustrates how a race condition can lead to inconsistent and incorrect outcomes in payment systems.
Preventing against Race Conditions in Django Applications
Let's explore some practical ways to prevent race conditions in a Django applications:
1. Pessimistic Concurrency Control (Database-Level Locking)
Django select_for_update
ensures that rows retrieved by a query are locked until the end of the current database transaction. This prevents other transactions from modifying or even reading the locked rows,
Imagine you're updating a bank account balance. To prevent others from changing it at the same time, you "lock" the account record.
How Django's select_for_update()
Works:
When you use
select_for_update()
, Django locks the rows you are updating.These rows stay locked until your transaction is complete (committed).
While locked, other transactions can't modify these rows, preventing data conflicts.
from django.db import transaction
def process_payment(account_id, amount):
account = Account.objects.select_for_update().get(id=account_id)
if account.balance >= amount:
account.balance -= amount
account.save()
return
raise InsufficientFund
Suppose you have an account (A1) with a balance of ₦200,000.
Transaction 1 (T1): Withdraw ₦100,000.
Transaction 2 (T2): An automatic bill payment wants to withdraw ₦80,000.
With
select_for_update()
T1 starts processing and locks the row for account A1 using
select_for_update()
.T1 requests the balance of A1: ₦200,000 and locks it.
T2 attempts to access A1, but since T1 has locked the row, T2 must wait.
T1 processes: Makes a withdrawal of ₦100,000 from ₦200,000; new balance = ₦100,000.
T1 commits the transaction, releasing the lock.
T2 can now access A1: The balance has already been updated to ₦100,000.
T2 processes: Attempts to withdraw ₦80,000 from ₦100,000; this succeeds, leaving a new balance of ₦20,000.
Result: The account's balance is accurately updated to ₦20,000 after both transactions are processed correctly, preventing any inconsistencies due to race conditions.
2. Implement Atomic Transactions
Wrap critical operations in transaction.atomic()
to ensure all-or-nothing execution:
from django.db import transaction
from django.db.models import F
@transaction.atomic
def transfer_funds(from_account_id, to_account_id, amount):
from_account = Account.objects.select_for_update().get(id=from_account_id)
to_account = Account.objects.select_for_update().get(id=to_account_id)
if from_account.balance >= amount:
from_account.balance = F('balance') - amount
to_account.balance = F('balance') + amount
from_account.save()
to_account.save()
return
return InsufficientFund
3. Use Optimistic Concurrency Control
For scenarios where locking might impact performance, consider optimistic concurrency control: Optimistic concurrency control assumes that conflicts are rare and doesn't acquire locks upfront. Instead, it checks for conflicts at the end of the transaction. If a conflict is detected, the transaction is retried or aborted.
from django.db import transaction
from django.db.models import F
def update_balance(account_id, amount):
account = Account.objects.get(id=account_id)
original_balance = account.balance
# Attempt to update
rows_updated = Account.objects.filter(
id=account_id,
balance=original_balance
).update(balance=F('balance') + amount)
if rows_updated == 0:
# Someone else modified the balance, retry or handle accordingly
raise ConcurrencyException("Balance was modified concurrently")
Suppose two transactions (T1 and T2) attempt to update the same account balance: T1: Read balance = 100
T2: Read balance = 100
T1: Update balance = 100 + 50 = 150
T2: Update balance = 100 + 20 = 120 ( fails because balance is no longer 100 )
Using Optimistic concurrency control, T2's update will fail, and rows_updated will be 0, since there had been an earlier update to the Account
instance, thereby triggering the concurrency exception.
To handle retries for optimistic concurrency, you can implement a retry mechanism as follows:
import time
from django.db import transaction
from django.db.models import F
MAX_RETRIES = 3
RETRY_DELAY = 0.1
def update_balance(account_id, amount):
retries = 0
while retries < MAX_RETRIES:
try:
with transaction.atomic():
account = Account.objects.get(id=account_id)
original_balance = account.balance
rows_updated = Account.objects.filter(
id=account_id,
balance=original_balance
).update(balance=F('balance') + amount)
if rows_updated == 0:
raise ConcurrencyException("Balance was modified concurrently")
return
except ConcurrencyException:
retries += 1
time.sleep(RETRY_DELAY)
raise ConcurrencyException("Failed after {} retries".format(MAX_RETRIES))
4. Test Thoroughly
No solution is complete without comprehensive testing. Ensure your test cases cover race conditions, concurrency handling, and edge cases. Simulating high-traffic scenarios during testing will likely expose potential vulnerabilities.
Conclusion
The HabariPay incident serves as a crucial reminder of the complexities involved in building robust payment systems. As the fintech industry continues to evolve, it's imperative that we remain vigilant and continuously improve our systems to ensure the highest levels of reliability and security in financial transactions.
While these measures are essential, they are not the only mechanisms for preventing such incidents. Other important security considerations include regular audits, penetration testing, and continuous monitoring.
By prioritizing payment processing security, we can protect users' financial information and maintain trust in our applications.
Share your thoughts: What other security measures do you use to protect your Django applications? Let us know in the comments.