# A Race to the Bottom - Database Transactions Undermining Your AppSec

Read Committed isolation leaves SELECT unlocked, so concurrent /transfer requests all pass the balance check and overdraw the account, minting money.

- Payout: Research
- Severity: high
- Program: Doyensec sample transfer app
- Write-up by: Doyensec
- Disclosed: 2024-07-10
- Write-up quality: 8/10
- Original: https://blog.doyensec.com/2024/07/11/database-race-conditions.html
- Library page: https://bugbountylibrary.com/r/blog%3Ablog-doyensec-com-2024-07-11-database-race-conditions-html
- Tags: area:web domain:web impact:funds-loss impact:integrity kind:research platform:research sev:high tech:go tech:nodejs technique:race-window vuln:business-logic vuln:race-condition

**Target:** Go/Node transfer endpoint backed by Postgres/MySQL/MariaDB
**Root cause:** Default Read Committed isolation places no lock on the SELECT, so the balance check and UPDATE form a TOCTOU window; concurrent transactions read the same balance and all commit.
**Steps:**
1. Send `POST /transfer` with `{"source":1,"destination":2,"amount":50}` to confirm normal behavior.
2. Fire 10 concurrent identical requests via goroutines (or Burp Intruder/Turbo Intruder).
3. Observe nearly all requests accepted instead of only two.
4. Check `/dump`: source balance is negative.
**How found:** Reviewed a common transfer code pattern, noticed SELECT occurs before any lock under Read Committed, then enabled DB logging to see interleaved BEGIN/SELECT/UPDATE/COMMIT across concurrent transactions.
**Exploitation:** Attacker sends many simultaneous transfer requests using last-byte sync (HTTP/1.1) or single-packet attack (HTTP/2) to widen the race window; each transaction reads the same pre-update balance, passes validation, and commits, overdrawing the source account or duplicating transfers to multiple destinations.
**Escalation:** Pattern #2 (stale-value calculation) lets attacker call downstream services multiple times or fan out transfers to many destination accounts, corrupting state beyond simple overdraw.
**Insight:** When auditing financial logic, check the isolation level and whether the read is locked (FOR UPDATE) — a plain SELECT inside a transaction under Read Committed is a TOCTOU; confirm by enabling DB query logs and looking for interleaved operations.

## Proof of concept (copied exactly from the original report)

**Vulnerable Transfer function with TOCTOU race**

```
func (db *Db) Transfer(source int, destination int, amount int) error {
 ctx := context.Background()

 conn, err := pgx.Connect(ctx, db.databaseUrl)
 defer conn.Close(ctx)

 // (1)
 tx, err := conn.BeginTx(ctx)

 var user User
 // (2)
 err = conn.
 QueryRow(ctx, "SELECT id, name, balance FROM users WHERE id = $1", source).
 Scan(&user.Id, &user.Name, &user.Balance)

 // (3)
 if amount <= 0 || amount > user.Balance {
 tx.Rollback(ctx)
 return fmt.Errorf("invalid transfer")
 }

 // (4)
 _, err = conn.Exec(ctx, "UPDATE users SET balance = balance - $2 WHERE id = $1", source, amount)
 _, err = conn.Exec(ctx, "UPDATE users SET balance = balance + $2 WHERE id = $1", destination, amount)

 // (5)
 err = tx.Commit(ctx)
 return nil
}
```

**Stale-value transfer variant with app-layer calculation**

```
newSrcBalance := userSrc.Balance - amount
 newDestBalance := userDest.Balance + amount

 _, err = conn.Exec(ctx, "UPDATE users SET balance = $2 WHERE id = $1", source, newSrcBalance)
 _, err = conn.Exec(ctx, "UPDATE users SET balance = $2 WHERE id = $1", destination, newDestBalance)
```

