TOCTOU (time-of-check to time-of-use) - між перевіркою умови й використанням її результату стан змінюється. У вебзастосунку це паралельні запити, кожен з яких проходить перевірку до того, як інший запише зміни.
// вразливо
public function withdraw(Request $request)
{
$account = $request->user()->account;
if ($account->balance >= $request->amount) { // перевірка
$account->decrement('balance', $request->amount); // використання
Payout::create([...]);
}
}
Нападник відправляє 20 однакових запитів одночасно. Усі читають баланс 1000, усі проходять перевірку, і рахунок отримує 20 виплат. Інструменти на кшталт Burp Suite відправляють такі «пачки» з точністю до мілісекунд, тож це не теоретична атака.
Де це трапляється:
- баланс, бонуси, подарункові картки, виведення коштів;
- одноразові купони й промокоди («використати один раз»);
- ліміти («до 3 безкоштовних проєктів», «один відгук на замовлення»);
- голосування й лайки;
- запрошення й одноразові посилання;
- перевірка прав, після якої права відкликано, а дія вже виконується.
Як захищатися:
1. Атомарна умова в самому запиті до бази:
$updated = Account::whereKey($account->id)
->where('balance', '>=', $amount)
->decrement('balance', $amount);
if ($updated === 0) {
throw new InsufficientFunds;
}
Перевірка й зміна - один оператор UPDATE ... WHERE balance >= ?. База гарантує, що паралельні запити не пройдуть обидва.
2. Блокування рядка в транзакції:
DB::transaction(function () use ($accountId, $amount) {
$account = Account::lockForUpdate()->findOrFail($accountId);
// поки транзакція не завершилась, інші lockForUpdate чекають
});
3. Унікальні обмеження - найнадійніше для «один раз»: унікальний індекс на (coupon_id, user_id) - другий запис просто не вставиться, хоч скільки паралельних запитів.
4. Атомарні блокування кешу - Cache::lock("payout:{$userId}", 10)->block(5, ...) для операцій, що зачіпають кілька систем.
5. Ключі ідемпотентності для операцій з грошима - повтор того самого запиту не створює другу операцію.
Чого недостатньо:
- перевірка в застосунку без блокування - вікно гонитви лишається;
- блокування в пам'яті процесу - на кількох серверах чи воркерах не працює;
- «малоймовірно» - нападник спеціально робить це ймовірним.
Тестування: паралельні запити в тестах (Http::pool, Concurrency::run) або навантажувальні інструменти - звичайні послідовні тести таких помилок не бачать.