Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Як гонитва запитів (TOCTOU) ламає перевірки балансу, лімітів і прав?

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) або навантажувальні інструменти - звичайні послідовні тести таких помилок не бачать.

Докладніше в документації: PortSwigger: гонитва запитів

Схожі питання