CSRF: JSON API без кастомного заголовка (PHP)
JSON-endpoint /api/profile принимает state-changing запрос без проверки CSRF-токена и кастомного заголовка X-Requested-With. Простой fetch с Content-Type:text/plain даёт CSRF.
mediumphpPro
Задача
# CSRF: атака на JSON API
## Сценарий
Перед вами интернет-магазин **SecureShop**. Команда разработки недавно мигрировала страницу настроек профиля с классических HTML-форм на современный JSON API: фронтенд отправляет POST-запросы с JSON-телом на `/api/profile`, бэкенд возвращает JSON-ответ. Разработчик уверен, что новый API **автоматически защищён от CSRF**, потому что HTML-формы физически не могут отправить `Content-Type: application/json` — а для произвольных типов содержимого браузер обязан выполнить CORS preflight, на который сервер атакующему не ответит разрешением. Это распространённое заблуждение, и ваша задача — продемонстрировать, в чём оно ошибочно.
В системе есть административная учётная запись с CTF-флагом. Используя CSRF-уязвимость в JSON API смены пароля, заставьте браузер админа сменить пароль на известное вам значение — и войдите в админ-панель.
## Теория
**CSRF (Cross-Site Request Forgery)** — атака, при которой злоумышленник заставляет браузер жертвы выполнить запрос на целевой сайт от её имени. Браузер автоматически прикрепляет сессионную cookie к любому исходящему запросу на тот же домен, **даже если запрос инициирован сторонней страницей**. Если сервер не требует никакого дополнительного «доказательства намерения» — CSRF-токена, проверки `Origin`/`Referer`, обязательного кастомного заголовка — любая открытая в браузере жертвы страница в интернете может выполнить от её имени state-changing операцию.
Заблуждение разработчиков о «JSON-иммунитете» опирается на правила CORS. Действительно: для запроса с `Content-Type: application/json` браузер обязан выполнить preflight (`OPTIONS`), и без явного разрешения сервером cross-origin-вызов не пойдёт. Но это верно **только для `application/json`** в категории «non-simple». На практике обходится несколькими способами:
* **HTML-форма с `enctype="text/plain"`** — собирает тело как `name=value` без URL-кодирования. При правильном подборе имени поля получается валидный JSON. Type `text/plain` входит в категорию «simple», preflight не запрашивается.
* **`fetch(..., {mode: 'no-cors'})` с `Content-Type: text/plain`** — не вызывает CORS preflight, потому что считается «простым» запросом.
* **`navigator.sendBeacon()` с `Blob`** — позволяет указать произвольный `Content-Type` (включая `text/plain`), при этом браузер прикрепляет cookies жертвы.
Любой современный backend парсит тело как JSON независимо от заявленного `Content-Type`, если код вызывает `json_decode(file_get_contents('php://input'))` напрямую — что в этой лабе и происходит.
**Уязвимый паттерн:**
```php
function apiUpdateProfile(PDO $db): void
{
$user = getUser($db);
if (!$user) { /* 401 */ return; }
// VULN: нет проверки CSRF-токена, кастомного заголовка, Origin/Referer
$raw = file_get_contents('php://input');
$data = json_decode($raw, true); // парсит JSON независимо от Content-Type
if (!empty($data['password'])) {
$hash = password_hash((string)$data['password'], PASSWORD_BCRYPT);
$stmt = $db->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
$stmt->execute([$hash, $user['id']]);
}
}
```
## Цели
1. Авторизуйтесь как `demo` / `demo` и изучите страницу настроек профиля. Откройте DevTools, вкладка «Сеть» — посмотрите, как фронтенд общается с серверным API при сохранении изменений.
2. Найдите способ выполнить тот же запрос **с другого домена** — в обход «защиты», на которую полагается разработчик.
3. Доставьте подготовленную страницу-эксплойт администратору через предусмотренный для этого механизм отчётов.
4. После того как пароль администратора изменится, войдите от его имени и заберите CTF-флаг из админ-панели.
## Точка входа атаки
| Параметр | Значение |
|----------|----------|
| Ваш аккаунт | `demo` / `demo` |
| Уязвимый эндпоинт | `POST /api/profile` (JSON-тело) |
| Поле атаки | `password` в JSON-теле |
| Доставка боту | `POST /report` (бот залогинен как admin) |
| Где появится флаг | `GET /admin` после входа под `admin` |
| Exploit Server | внешний хостинг для HTML-payload (см. панель лабы) |