Чи траплялося вам бачити, як в інтернет-магазині ціна на товар раптово падає до нуля, або як хтось встигає 'купити' п'ять квитків за ціною одного? Це не магія і не випадковий збій. Це — результат експлуатації однієї з найцікавіших, але водночас складних вразливостей: 'Race Condition' або 'стан перегонів'. У цій статті ми розберемося, як хакери використовують паралельні запити, щоб обдурити логіку веб-додатків, і як інструменти на кшталт Burp Suite допомагають перетворити мілісекунди на реальну вигоду.

Цікава функція Repeater у Burp Suite

Всі, хто працює з Burp Suite, знають про зручність групування запитів у Repeater. Це дуже корисна функція. Можна розбити запити по папках і спокійно тестувати, не забуваючи, що цікавого побачив.

Групування запитів у Burp Suite Repeater
Burp Suite Repeater: Групування запитів

Але, як виявилося, є дещо цікаве в цих групах, про що я навіть не замислювався. Це різні методи відправки запитів через маніпуляції з TCP-пакетами. А ось це те, що може вам дуже знадобитися в подальших тестуваннях, і не тільки на 'стан перегонів'.

Методи відправки запитів у Burp Suite
Методи відправки груп запитів

Single connection (Одне з'єднання)

Тут все просто. Запити відправляються по черзі через одне TCP-з'єднання. Звичайна відправка групи запитів.

bash→ Відправляється Запит 1
← Отримано Відповідь 1

→ Відправляється Запит 2
← Отримано Відповідь 2

→ Відправляється Запит 3
← Отримано Відповідь 3

Separate connection (Окремі з'єднання)

Запити відправляються по черзі, але через різні TCP-з'єднання. Найбільш повільний метод, якщо потрібно швидко отримати відповіді.

bash→ [open TCP conn A]
→ Надіслано запит 1
← Отримано відповідь 1
→ [close TCP conn A]

→ [open TCP conn B]
→ Надіслано запит 2
← Отримано відповідь 2
→ [close TCP conn B]

→ [open TCP conn C]
→ Надіслано запит 3
← Отримано відповідь 3
→ [close TCP conn C]

Single packet attack (Атака одним пакетом та HTTP/2)

Ось тут найцікавіше. Якщо на цільовому сайті підтримується HTTP/2 (а на момент 2025 року майже всі сайти його підтримують), стає доступною функція мультиплексування. При стандартній відправці (HTTP/1.1) одне TCP-з'єднання може обробляти тільки 1 запит одночасно:

bashЗапит 1 → Відповідь 1
Запит 2 → Відповідь 2

Але в HTTP/2 з мультиплексуванням все відбувається в одному TCP-з'єднанні паралельно:

bashЗапит 1 (частина)
Запит 2 (частина)
Запит 1 (частина)
Запит 3 (частина)
→ Все відбувається в одному TCP-з'єднанні паралельно.
Схема мультиплексування HTTP/2
Мультиплексування в HTTP/2

Тобто сервер обробляє кілька запитів одночасно і паралельно, повертаючи відповіді в тому ж з'єднанні. Ключове слово — паралельно, що для нас найголовніше.

Що ж таке цей ваш 'Стан Перегонів'?

Стан перегонів (Race Condition) — це ситуація, при якій кілька потоків або процесів намагаються одночасно взаємодіяти зі спільним ресурсом (наприклад, балансом гаманця або кількістю промокодів). В результаті цього два паралельних запити потрапляють у коротке 'вікно' (race window) і викликають непередбачувану поведінку. Зараз ви, напевно, нічого не зрозуміли, але далі зрозумієте.

Приклад №1: Базовий злам промокоду

Коли мова заходить про 'стан перегонів', усі наводять цей приклад. Він хоч і найпопулярніший, але для новачка ідеальний, щоб набратися базового розуміння. Є веб-додаток з таким функціоналом:

Поле для введення промокоду на сайті
Функціонал активації промокоду

Тобто вводимо в поле промокод, отримуємо 20% знижки від суми нашого кошика. Багато разів активувати цей промокод не вийде. Але що, якщо відправити, наприклад, 20 паралельних запитів на активацію? Чи можна буде 'збити' ціну? Якщо додаток вразливий до 'стану перегонів' — можливо.

Додамо 17 однакових запитів на активацію промокоду в одну групу Burp Repeater.

17 однакових запитів у Burp Repeater
Підготовка запитів у Burp Suite

І надішлемо всі ці запити паралельно, щоб створити 'race window'.

Результати паралельної відправки запитів
Атака 'Single packet attack'

І ми вибили настільки велику знижку, що тепер можемо купити цей дизайнерський піджак всього лише за 30 доларів.

Фінальна ціна товару зі знижкою 99%
Результат експлуатації вразливості

Що щойно сталося?

Для наочності подивимося на (спрощену) схему функціоналу активації та перевірки купонів.

Схема роботи промокоду
Логіка сервера при обробці купона

Якщо до цього промокод ми не активували і спробуємо його активувати через два паралельних запити, то вони можуть 'застрягти' у віконці (race window) між перевіркою 'Чи використаний купон?' та кроком 'Позначити купон як використаний'.

Схема вразливості Race Condition
Експлуатація Race Window

Тобто, оскільки запити відправлені паралельно, сервер для Запиту 1 бачить, що промокод не був активований, і пропускає його до кроку 'Застосувати знижку'. Але поки він не дійшов до кроку 'Позначити купон як використаний', Запит 2 також проходить перевірку і сервер бачить, що купон *все ще* не активований. Таким чином, знижка застосовується двічі (або, в нашому випадку, 17 разів) до того, як система встигає оновити статус промокоду.

Висновок: Як захиститися?

Вразливість 'стан перегонів' — це не проблема конкретного інструменту, а фундаментальна помилка в логіці програми, коли кілька процесів одночасно отримують доступ до спільного ресурсу. Як ми бачили, це може призвести до катастрофічних фінансових втрат. Головний принцип захисту — забезпечення атомарності: перевірка стану (наприклад, 'чи використаний промокод?') і зміна стану ( 'позначити промокод як використаний') повинні відбуватися як одна неподільна операція.

Уявімо типовий вразливий код на сервері (наприклад, Node.js), який обробляє активацію промокоду:

javascript// ПОГАНИЙ КОД (ВРАЗЛИВИЙ)

async function applyPromoCode(userId, promoCode) {
  const code = await db.promoCodes.find(promoCode);

  // 1. ПЕРЕВІРКА
  if (code.isUsed) {
    throw new Error('Промокод вже використано');
  }

  // --- RACE WINDOW --- 
  // (Сюди можуть 'втиснутися' 10 інших запитів, поки цей спить)
  // Імітація затримки мережі або іншої складної логіки
  await new Promise(resolve => setTimeout(resolve, 50)); 
  // --- RACE WINDOW --- 

  // 2. ДІЯ
  code.isUsed = true;
  await code.save();

  await applyDiscountToUser(userId, code.discount);
  return 'Знижку застосовано';
}

Щоб захистити цей код, ми повинні заблокувати промокод на час операції. Це можна зробити за допомогою mutex або простого механізму locking на рівні додатка. Це гарантує, що тільки один процес може одночасно працювати з конкретним промокодом.

javascript// ХОРОШИЙ КОД (ЗАХИЩЕНИЙ ЗА ДОПОМОГОЮ LOCKING)

// Глобальний об'єкт (або Set) для відстеження операцій, що тривають
const processingCodes = new Set();

async function applyPromoCodeSecure(userId, promoCode) {
  
  // 1. БЛОКУВАННЯ
  // Перевіряємо, чи цей промокод вже обробляється
  if (processingCodes.has(promoCode)) {
    throw new Error('Операція вже виконується, спробуйте пізніше');
  }
  // Блокуємо промокод
  processingCodes.add(promoCode);

  try {
    // Цей блок коду тепер 'атомарний' для кожного promoCode
    const code = await db.promoCodes.find(promoCode);

    // 2. ПЕРЕВІРКА (вже всередині 'замка')
    if (code.isUsed) {
      throw new Error('Промокод вже використано');
    }

    // 3. ДІЯ
    code.isUsed = true;
    await code.save();

    await applyDiscountToUser(userId, code.discount);
    return 'Знижку застосовано';

  } catch (error) {
    // Обробляємо будь-які помилки
    throw error;
  } finally {
    // 4. РОЗБЛОКУВАННЯ (Найважливіший крок!)
    // Завжди знімаємо блок, навіть якщо сталася помилка.
    processingCodes.delete(promoCode);
  }
}

Цей підхід (відомий як м'ютекс або семафор) гарантує, що тільки один запит може одночасно змінювати стан конкретного промокоду. Альтернативою, особливо в складних системах, є використання транзакцій на рівні бази даних, які блокують рядок для оновлення.