На маркетплейсе у покупателя 100 бонусов. Он открывает оформление заказа, копирует запрос «списать 80 бонусов» и отправляет его 20 раз одновременно. Каждый запрос по отдельности честно проверяет «хватает ли бонусов», каждый видит 100, и списаний проходит больше одного. Это гонка (race condition): результат зависит от того, в каком порядке выполнились параллельные запросы, а код писали так, будто порядок всегда последовательный.
Как устроена атака и чем грозитспросят на собеседовании
В CWE это два связанных класса. CWE-362 «Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')» - общий: общий ресурс без правильной синхронизации. CWE-367 «Time-of-check Time-of-use (TOCTOU) Race Condition» - частный: «The product checks the state of a resource before using that resource, but the resource's state can change between the check and the use».
Для атаки не нужно ничего, кроме возможности слать запросы параллельно. Где это бьёт на маркетплейсе:
- Промокод «один на покупателя» применяется дважды: оба запроса проверили «ещё не использован», оба записали использование.
- Бонусный баланс уходит в минус или списывается дважды при одной покупке.
- Одноразовая ссылка сброса пароля срабатывает два раза: два запроса нашли токен «не использован» до того, как любой из них его погасил.
- Остаток на складе: последний товар покупают трое, потому что все трое увидели
qty = 1. - Файл: «если файла нет, создать» и между проверкой и созданием кто-то подложил свой файл или символическую ссылку.
Ущерб прямой и денежный: бонусы, скидки, проданный несуществующий товар. Обычные проверки доступа здесь ни при чём: каждый запрос легален, незаконна только их сумма.
Почему «проверил, потом списал» не работаетспросят на собеседовании
Типичный код: прочитать баланс, сравнить, списать. Ниже он сведён к счёту в памяти, а гонка сделана детерминированной: запросы ждут друг друга сразу после проверки, как это бывает под нагрузкой. Само списание атомарное (в Python даже под блокировкой), и это не спасает: атомарна каждая операция, но не пара «проверка плюс списание».
live example
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
public class BonusWithdrawRace {
static final AtomicLong balance = new AtomicLong(100);
static final CountDownLatch bothChecked = new CountDownLatch(2);
static boolean withdraw(long amount) throws InterruptedException {
if (balance.get() < amount) {
return false;
}
bothChecked.countDown();
bothChecked.await(); // оба запроса уже прошли проверку
balance.addAndGet(-amount);
return true;
}
public static void main(String[] args) throws Exception {
boolean[] approved = new boolean[2];
Thread[] requests = new Thread[2];
for (int i = 0; i < 2; i++) {
int n = i;
requests[i] = new Thread(() -> {
try { approved[n] = withdraw(80); } catch (InterruptedException e) { throw new RuntimeException(e); }
});
requests[i].start();
}
for (Thread t : requests) t.join();
int passed = (approved[0] ? 1 : 0) + (approved[1] ? 1 : 0);
System.out.println("одобрено списаний: " + passed + " из 2");
System.out.println("баланс бонусов: " + balance.get());
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var balance atomic.Int64
func withdraw(amount int64, bothChecked *sync.WaitGroup) bool {
if balance.Load() < amount {
bothChecked.Done()
return false
}
bothChecked.Done()
bothChecked.Wait() // оба запроса уже прошли проверку
balance.Add(-amount)
return true
}
func main() {
balance.Store(100)
var requests, bothChecked sync.WaitGroup
bothChecked.Add(2)
approved := make([]bool, 2)
for i := range approved {
requests.Add(1)
go func() {
defer requests.Done()
approved[i] = withdraw(80, &bothChecked)
}()
}
requests.Wait()
passed := 0
for _, ok := range approved {
if ok {
passed++
}
}
fmt.Println("одобрено списаний:", passed, "из 2")
fmt.Println("баланс бонусов:", balance.Load())
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const account = { balance: 100 };
const loadFraudScore = () => new Promise((resolve) => setImmediate(resolve));
async function withdraw(amount) {
const current = account.balance;
if (current < amount) return false;
await loadFraudScore(); // здесь цикл событий пускает второй запрос
account.balance = current - amount;
return true;
}
(async () => {
const approved = await Promise.all([withdraw(80), withdraw(80)]);
console.log(`одобрено списаний: ${approved.filter(Boolean).length} из 2`);
console.log(`выдано бонусов: ${80 * approved.filter(Boolean).length}, баланс: ${account.balance}`);
})();
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
import threading
balance = 100
write_lock = threading.Lock()
both_checked = threading.Barrier(2)
def withdraw(amount: int) -> bool:
global balance
if balance < amount:
return False
both_checked.wait() # оба запроса уже прошли проверку
with write_lock: # блокировка только на запись не спасает
balance -= amount
return True
approved = [False, False]
def request(n: int) -> None:
approved[n] = withdraw(80)
threads = [threading.Thread(target=request, args=(n,)) for n in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print("одобрено списаний:", sum(approved), "из 2")
print("баланс бонусов:", balance)
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Вывод: оба списания по 80 одобрены, баланс -60. В Node нет потоков, но та же беда возникает на любом await между чтением и записью: второй запрос проходит проверку, пока первый ждёт скоринга, и оба записывают 100 - 80. Выдано 160 бонусов, а баланс показывает 20: это потерянное обновление (lost update), и оно хуже минуса, потому что его не видно в данных.
С базой то же самое. В PostgreSQL уровень по умолчанию READ COMMITTED (документация: «Read Committed is the default isolation level in PostgreSQL»), и пара «SELECT balance в приложение, UPDATE ... SET balance = :новое» в нём теряет обновления точно так же, как код выше. Транзакция вокруг этой пары ничего не меняет: она про атомарность отката, а не про то, что никто другой не прочитает строку между вашими двумя командами. Подробно аномалии разобраны в статье Уровни изоляции транзакций.
Как чинить: проверка и действие одной операцией
Принцип один: проверка и действие должны быть неделимы. CWE-362 формулирует его так: «Use atomic operations on shared variables. Be wary of innocent-looking constructs such as "x++"» - чтение, вычисление и запись это три шага. В памяти это compare-and-set, мьютекс вокруг проверки и списания или очередь, через которую проходят все операции одного счёта:
live example
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
public class BonusWithdrawCas {
static final AtomicLong balance = new AtomicLong(100);
static final CountDownLatch bothRead = new CountDownLatch(2);
static boolean withdraw(long amount) throws InterruptedException {
long seen = balance.get();
bothRead.countDown();
bothRead.await(); // та же гонка: оба прочитали 100
while (seen >= amount) {
if (balance.compareAndSet(seen, seen - amount)) {
return true;
}
seen = balance.get(); // баланс изменили, проверяем заново
}
return false;
}
public static void main(String[] args) throws Exception {
boolean[] approved = new boolean[2];
Thread[] requests = new Thread[2];
for (int i = 0; i < 2; i++) {
int n = i;
requests[i] = new Thread(() -> {
try { approved[n] = withdraw(80); } catch (InterruptedException e) { throw new RuntimeException(e); }
});
requests[i].start();
}
for (Thread t : requests) t.join();
int passed = (approved[0] ? 1 : 0) + (approved[1] ? 1 : 0);
System.out.println("одобрено списаний: " + passed + " из 2");
System.out.println("баланс бонусов: " + balance.get());
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import (
"fmt"
"sync"
)
type Account struct {
mu sync.Mutex
balance int64
}
func (a *Account) Withdraw(amount int64) bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
return false
}
a.balance -= amount // проверка и списание под одной блокировкой
return true
}
func main() {
acc := &Account{balance: 100}
start := make(chan struct{})
var requests sync.WaitGroup
approved := make([]bool, 2)
for i := range approved {
requests.Add(1)
go func() {
defer requests.Done()
<-start
approved[i] = acc.Withdraw(80)
}()
}
close(start) // оба запроса стартуют одновременно
requests.Wait()
passed := 0
for _, ok := range approved {
if ok {
passed++
}
}
fmt.Println("одобрено списаний:", passed, "из 2")
fmt.Println("баланс бонусов:", acc.balance)
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const account = { balance: 100, tail: Promise.resolve() };
const loadFraudScore = () => new Promise((resolve) => setImmediate(resolve));
function serialized(acc, operation) {
const result = acc.tail.then(operation);
acc.tail = result.catch(() => {});
return result;
}
function withdraw(amount) {
return serialized(account, async () => {
const current = account.balance;
if (current < amount) return false;
await loadFraudScore(); // второй запрос ждёт в очереди счёта
account.balance = current - amount;
return true;
});
}
(async () => {
const approved = await Promise.all([withdraw(80), withdraw(80)]);
console.log(`одобрено списаний: ${approved.filter(Boolean).length} из 2`);
console.log(`выдано бонусов: ${80 * approved.filter(Boolean).length}, баланс: ${account.balance}`);
})();
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
import threading
balance = 100
account_lock = threading.Lock()
both_started = threading.Barrier(2)
def withdraw(amount: int) -> bool:
global balance
both_started.wait() # оба запроса стартуют одновременно
with account_lock: # проверка и списание под одной блокировкой
if balance < amount:
return False
balance -= amount
return True
approved = [False, False]
def request(n: int) -> None:
approved[n] = withdraw(80)
threads = [threading.Thread(target=request, args=(n,)) for n in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print("одобрено списаний:", sum(approved), "из 2")
print("баланс бонусов:", balance)
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Тот же запуск даёт одно одобренное списание и баланс 20. Java-вариант специально сохраняет гонку (оба читают 100), но compareAndSet пропускает только того, кто первым поменял баланс; второй перечитывает 20 и получает отказ.
Но в бэкенде счёт живёт в базе, а экземпляров сервиса несколько, поэтому мьютекс в памяти защищает только один процесс. Дальше способы для базы, от самого простого.
Атомарный условный UPDATEспросят на собеседовании
Проверку переносят в WHERE, а вычитание делает сама база:
UPDATE bonus_account
SET balance = balance - :amount
WHERE customer_id = :customerId
AND balance >= :amount;
Если обновлено 0 строк, бонусов не хватило, и это отказ. Работает это и в READ COMMITTED: второй UPDATE ждёт, пока первый зафиксируется, а затем, по документации PostgreSQL, «The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition». На строке с балансом 20 условие balance >= 80 уже ложно. Главное в коде - не забыть посмотреть на число строк:
int updated = jdbc.update(
"UPDATE bonus_account SET balance = balance - ? WHERE customer_id = ? AND balance >= ?",
amount, customerId, amount);
if (updated == 0) {
throw new InsufficientBonusException(customerId);
}
tag, err := pool.Exec(ctx,
"UPDATE bonus_account SET balance = balance - $1 WHERE customer_id = $2 AND balance >= $1",
amount, customerID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrInsufficientBonus
}
const result = await pool.query(
'UPDATE bonus_account SET balance = balance - $1 WHERE customer_id = $2 AND balance >= $1',
[amount, customerId],
);
if (result.rowCount === 0) {
throw new InsufficientBonusError(customerId);
}
cur = conn.execute(
"UPDATE bonus_account SET balance = balance - %s WHERE customer_id = %s AND balance >= %s",
(amount, customer_id, amount),
)
if cur.rowcount == 0:
raise InsufficientBonusError(customer_id)
Так же закрываются склад (SET qty = qty - :n WHERE sku = :sku AND qty >= :n) и ссылка сброса пароля: токен гасится одной командой, и пароль меняется, только если она вернула строку.
UPDATE password_reset_token
SET used_at = now()
WHERE token_hash = :hash AND used_at IS NULL AND expires_at > now()
RETURNING customer_id;
Страховка на уровне схемы: CHECK (balance >= 0) на столбце. Если где-то остался старый код «прочитал-записал», база вернёт ошибку 23514 вместо отрицательного баланса.
Блокировка строки и версияспросят на собеседовании
Когда решение сложнее одного выражения (лимит в сутки, скоринг, несколько таблиц), строку блокируют на время транзакции:
BEGIN;
SELECT balance FROM bonus_account WHERE customer_id = :customerId FOR UPDATE;
-- проверки в приложении; второй такой же SELECT ждёт здесь
UPDATE bonus_account SET balance = :newBalance WHERE customer_id = :customerId;
COMMIT;
Без FOR UPDATE это тот самый уязвимый код. Альтернатива без ожидания - оптимистическая блокировка: в строке хранится version, и запись проходит, только если версия не изменилась с момента чтения.
UPDATE bonus_account
SET balance = :newBalance, version = version + 1
WHERE customer_id = :customerId AND version = :readVersion;
0 строк значит «кто-то успел раньше»: перечитать и повторить или вернуть клиенту 409. Что выбрать и чем грозят взаимные блокировки, разобрано в статье Блокировки в PostgreSQL.
Уникальный индекс и ключ идемпотентностиспросят на собеседовании
Для «один раз на покупателя» лучшая защита та, которую нельзя забыть в коде: ограничение в схеме.
CREATE TABLE promo_code_usage (
customer_id bigint NOT NULL,
code text NOT NULL,
order_id bigint NOT NULL,
used_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (customer_id, code)
);
Вставка использования идёт в той же транзакции, что и применение скидки. Второй параллельный запрос получит 23505 unique_violation, транзакция откатится вместе со скидкой. Проверка «уже использован?» в коде остаётся ради понятного сообщения, но решает индекс.
Тот же приём против повторов, а не атак: клиент повторил POST по таймауту, администратор нажал «вернуть деньги» дважды. Ключ идемпотентности хранится в таблице с уникальным индексом в той же транзакции, что и операция; как отвечать на повтор (409 пока в работе, 422 на другое тело), разобрано в статье Аудит административных команд, схема таблицы - в Блокировках.
Как проверить
- Тест на параллельность. Запустите N одинаковых запросов через общий старт (латч, барьер, закрытый канал) и проверьте инварианты: одно применение промокода, баланс не ниже нуля, сумма списаний равна изменению баланса. Без принудительного старта тест будет зелёным по везению.
- Детектор гонок не поможет.
go run -raceна уязвимом Go-примере выше молчит: там нет гонки данных, все операции атомарные, гонка в логике. - Ручная проверка. В Burp Repeater запросы собирают в группу и отправляют «in parallel (all at once)» (документация); цели - эндпоинты с промокодами, бонусами, сбросом пароля, лимитами.
- Ревью. Ищите
SELECT, за которым следуетifиUPDATEс вычисленным в приложении значением;exists()передinsert;UPDATEбез проверки числа строк; «одноразовое», у которого нет уникального ограничения в схеме.
Типичные ошибки
- Транзакция вокруг «прочитал-проверил-записал» считается защитой. В READ COMMITTED нет.
- Атомарный
UPDATEесть, а число обновлённых строк не проверяется: отказ молча превращается в успех. - Мьютекс в памяти при нескольких экземплярах сервиса.
- Защита через кэш («положим флаг в Redis на 5 секунд») без уникального ограничения в базе: флаг истёк или кэш недоступен, и двойное списание вернулось.
- Ограничение частоты запросов как средство от гонки. Rate limiting сокращает число попыток, но два запроса в одну миллисекунду он пропускает.
Глубже: SERIALIZABLE, очередь и advisory lockрасширенное
SERIALIZABLE закрывает гонки целиком, включая write skew, который не ловит условие по одной строке. Цена в повторах: «applications using this level must be prepared to retry transactions due to serialization failures» (документация), ошибка 40001. Без цикла повторов в коде это превращается в случайные 500.
Очередь по счёту. Все операции одного счёта идут через одного обработчика: ключ сообщения customer_id, и партицию читает один потребитель по порядку. Это Node-вариант исправления, вынесенный на уровень системы. Подходит, когда ответ можно отдать асинхронно.
Advisory lock - блокировка на произвольный ключ, например pg_advisory_xact_lock(:customerId) в начале транзакции. PostgreSQL предупреждает: «the system does not enforce their use - it is up to the application to use them correctly». Один путь в код без блокировки, и защиты нет.
Глубже: TOCTOU в файловой системерасширенноеспросят на собеседовании
Классический TOCTOU: «если файла нет - создать». Между проверкой и созданием другой процесс может создать файл или подложить символическую ссылку на чужой. Документация Node говорит об этом прямо: проверка access() перед open() «introduces a race condition, since other processes may change the file's state between the two calls» (fs). Решение то же, что в базе: одна атомарная операция «создать, если нет», которая падает, если файл есть.
try (var out = Files.newOutputStream(path, StandardOpenOption.CREATE_NEW)) {
out.write(invoicePdf);
} catch (FileAlreadyExistsException e) {
throw new InvoiceAlreadyExistsException(orderId);
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, fs.ErrExist) {
return ErrInvoiceAlreadyExists
}
if err != nil {
return err
}
defer f.Close()
try {
await fsPromises.writeFile(path, invoicePdf, { flag: 'wx', mode: 0o600 });
} catch (err) {
if (err.code === 'EEXIST') throw new InvoiceAlreadyExistsError(orderId);
throw err;
}
try:
with open(path, "xb") as out:
out.write(invoice_pdf)
except FileExistsError:
raise InvoiceAlreadyExistsError(order_id)
Флаги из документации: Java CREATE_NEW - «Create a new file, failing if the file already exists» (StandardOpenOption); Go O_EXCL - «used with O_CREATE, file must not exist» (os); Node 'wx' - «Like 'w' but fails if the path exists»; Python режим 'x' - «exclusive creation» (open).
Коротко
- Гонка (CWE-362, CWE-367): проверка и действие разнесены во времени, и параллельные запросы проходят проверку все сразу.
- На маркетплейсе это двойной промокод, бонусы в минус, повторный сброс пароля, продажа сверх остатка.
- Транзакция в READ COMMITTED не спасает «прочитал-проверил-записал»: теряются обновления.
- Чинить: условный
UPDATE ... WHERE balance >= :amountс проверкой числа строк;SELECT ... FOR UPDATE; версия строки;UNIQUEиCHECKв схеме как последний рубеж. - Повторы закрывает ключ идемпотентности с уникальным индексом в той же транзакции.
- SERIALIZABLE требует цикла повторов на
40001; advisory lock работает, только если его берут везде. - Файлы создают атомарно:
CREATE_NEW,O_EXCL,'wx','x'. - Тестируют параллельным стартом через барьер и проверкой инвариантов, а не одним запросом.
Что почитать дальше
- Уровни изоляции транзакций - потерянное обновление, READ COMMITTED, SERIALIZABLE и повтор на
40001. - Блокировки в PostgreSQL -
FOR UPDATE, optimistic против pessimistic, advisory locks, ключ идемпотентности. - Аудит административных команд - идемпотентность денежных команд и правило четырёх глаз.
- Перебор и ограничение частоты - чем rate limiting помогает и где не помогает.