Райтап на задачу «YOFA 2.0»

velvetway

Автор:

velvetway

29 октября 2026
YOFA 2.0
Категория:

Web

Задача «YOFA 2.0»

tears

Автор:

tears

Первая кровь:
bot
Сложность:
hard
Дата публикации:
14.02.2026
Решений:
39
Ссылка:
Решать

Из описания таска мы узнаем, что YOFA — это новый сервис, который обещает хранить секреты и пароли в надёжном зашифрованном месте. Открываем созданный инстанс задания. Нас встречает минималистичная страница входа в стиле терминала.

Стартовая страница

Стартовая страница

Регистрируемся и входим под созданным пользователем и попадаем в профиль.

Профиль обычного пользователя

Профиль обычного пользователя

После логина сервер устанавливает cookie token с JWT. Внутри токена лежит роль:

{
  "id": 4,
  "role": "user",
  "iat": 1793098609,
  "exp": 1793102209
}

Изучим созданный профиль: в нём есть обновление описания и ссылка на Secrets. Сам функционал не даёт нам подсказок, где искать флаг и что делать дальше, поэтому обратимся к исходникам приложения.

Роуты приложения в server.js

Роуты приложения в server.js

В server.js видим подключение уже пройденных страниц регистрации и логина, а также ряд роутов: /auth, /user, /admin, /msg, /log, /secrets и /report.

Изучим каждый подробнее, чтобы понять, куда двигаться дальше:

Роуты /register и /auth

Роуты /register и /auth

  • /register — страница регистрации, /auth — её API: регистрация и логин, ничего интересного для нас.

Роут /user

Роут /user

  • /user — просмотр профиля по id и обновление описания, здесь проверяется, что пользователь смотрит свой профиль или является администратором.

Роут /admin

Роут /admin

  • /admin — админский слой: сообщения, список пользователей, выдача роли admin и нужная нам страница с флагом.

Недолго думая, попробуем получить флаг, перейдя по этому роуту.

Переход на /admin/flag

Переход на /admin/flag

Получим ошибку об отсутствии доступа. Теперь мы точно определили вектор решения задачи: мы знаем, где расположен флаг и что нам нужно получить привилегии админа, чтобы его прочитать. Для этого изучим оставшиеся роуты.

Роут /msg

Роут /msg

  • /msg — отправка сообщений, которые затем видит администратор.

Роут /log

Роут /log

  • /log — запись действий пользователя, вызывается со страницы профиля.

Роут /secrets

Роут /secrets

  • /secrets — добавление и просмотр секретов, доступ проверяется по владельцу.

Роут /report

Роут /report

  • /report. В отличие от всех других роутов, он содержит уже более внушительный код, поэтому изучим его подробнее.

В нём находится обработчик, который запускает Puppeteer (библиотека Node.js) и открывает переданный URL от имени администратора.

try {
  const admin = await User.findById(1);
  if (!admin) throw new Error("Admin not found");

  const token = jwt.sign({ id: admin.id, role: admin.role }, JWT_SECRET, { expiresIn: "1h" });

  // Launch Puppeteer
  const browser = await puppeteer.launch({
    headless: true,
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  });

Бот подписывает JWT администратора, ставит его в cookie

// Set admin token cookie
await page.setCookie({
  name: "token",
  value: token,
  domain: "localhost",
  path: "/",
});

и открывает переданный URL.

    // Visit the reported URL
    await page.goto(url, { waitUntil: "networkidle2" });

    await browser.close();

    res.status(200).send("Thanks for your report");
  } catch (error) {
    console.error(error);
    res.status(200).send("Thanks for your report");
  }
});

Значит, через /report можно заставить администратора открыть произвольную страницу.

Казалось бы, проще подписать себе админский JWT напрямую, но JWT_SECRET берётся из окружения (process.env.JWT_SECRET), и на инстансе он задан не дефолтным — подделать токен не выйдет, поэтому остаётся путь через бота.

Теперь вернёмся к профилю. Бот может открыть любую страницу на этом же инстансе с админским JWT, значит, нам нужно найти действие, которое страница выполнит автоматически. Посмотрим на controllers/userController.js:

controllers/userController.js

controllers/userController.js

В getProfile сервер преобразует параметр id в число через parseInt(req.query.id), находит соответствующего пользователя и передаёт его в шаблон профиля. Администратору разрешено просматривать чужие профили.

Ниже находится addAdmin. Этот обработчик получает userId из тела POST-запроса и назначает указанному пользователю роль администратора, если отправитель запроса уже имеет роль admin.

Теперь посмотрим, какие запросы выполняет страница профиля при открытии. Для этого откроем её шаблон и клиентский JavaScript.

Клиентский скрипт profile.ejs

Клиентский скрипт profile.ejs

Разберём этот скрипт подробно. Сначала он читает параметры из адресной строки и достаёт из них id:

const urlParams = new URLSearchParams(window.location.search);
const profileIds = urlParams.getAll("id");
const profileId = profileIds[profileIds.length - 1];

getAll("id") возвращает все значения параметра, и клиент берёт из них последнее — в отличие от сервера, который через parseInt берёт первое.

Затем страница сама, при загрузке, отправляет запрос:

fetch("/log/" + profileId, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  credentials: "include",
  body: JSON.stringify({
    userId: "<%= user.id %>",
    action: "Visited user profile with id=" + profileId,
    _csrf: csrfToken
  })
});

Тут есть два важных момента.

Во-первых, fetch("/log/"+profileId) вызывается сразу при загрузке страницы. Он написан прямо в скрипте, а не внутри обработчика клика. Значит, запрос уходит автоматически, как только кто-то открывает профиль. Жать ничего не нужно, и именно поэтому атака и работает через бота. Достаточно, чтобы он просто открыл нашу ссылку.

Во-вторых, credentials: "include" заставляет браузер приложить к запросу куки того, кто открыл страницу, включая token с JWT. То есть запрос уходит от имени текущего пользователя, а если страницу открыл админ-бот, то от имени администратора. Благодаря этому addAdmin позже и видит админскую роль.

Здесь появляется рассинхрон. Сервер берёт из req.query.id первый id через parseInt, а клиент через getAll("id") — последний. Если передать ?id=4&id=..%2Fadmin%2FaddAdmin, Express вернёт массив ["4","../admin/addAdmin"]. parseInt("4,../admin/addAdmin") = 4, поэтому сервер отрендерит наш профиль и подставит в тело userId=4.

Первым мы ставим именно свой id (4): сервер рисует профиль пользователя 4, его user.id уходит в тело запроса, и в итоге addAdmin повышает именно нас. А соглашается отрисовать профиль сервер потому, что запрос приходит от админ-бота — условие req.user.id !== userId && req.user.role !== "admin" для админа ложно, и 403 не срабатывает. А клиент возьмёт последний id и сделает fetch("/log/" + "../admin/addAdmin"). Браузер нормализует путь, и запрос уйдёт на POST /admin/addAdmin.

При этом в теле уже будет валидный CSRF-токен, который сервер сам вставил в страницу. CSRF здесь сделан как double-submit на куках (csrf({ cookie: true })), поэтому, когда админ-бот открывает профиль, сервер выдаёт ему и csrf-куку, и совпадающий с ней токен прямо в странице. Автоматический fetch кладёт этот токен в тело (_csrf), кука совпадает — проверка проходит. То есть токен не универсальный, а самосогласованный в контексте бота. Нам остаётся заставить админ-бота открыть этот URL.

Профиль открывается по ссылке с двумя id – сервер видит первый id

Профиль открывается по ссылке с двумя id – сервер видит первый id

Проверим, какой запрос автоматически отправляет страница. В DevTools видно:

POST /admin/addAdmin HTTP/1.1
Host: bee6e527bfca4b74a59d3335.t2.bug-makers.ru
Content-Type: application/json
Cookie: token=<JWT администратора>; _csrf=<...>

{
  "userId": "4",
  "action": "Visited user profile with id=../admin/addAdmin",
  "_csrf": "..."
}

HTTP/1.1 200 OK
Content-Type: application/json

{"message":"Role updated","user":{"id":4,"username":"yofa_demo","description":"","role":"admin"}}

Отправляем ссылку через /report:

https://bee6e527bfca4b74a59d3335.t2.bug-makers.ru/user/profile/?id=4&id=..%2Fadmin%2FaddAdmin
Страница отправки ссылки администратору

Страница отправки ссылки администратору

Бот проверяет страницу.

Бот проверяет страницу

Бот проверяет страницу

Отчёт принят.

Отчёт принят

Отчёт принят

В этот момент браузер бота открывает профиль уже с админским JWT. Страница выполняет тот же fetch, но теперь запрос идёт от администратора. Контроллер addAdmin проверяет req.user.role === "admin", видит админскую роль и выполняет User.updateRole(4, "admin"). Наша роль в БД меняется на admin.

После повторного входа в JWT уже role: admin

После повторного входа в JWT уже role: admin

Пользователь yofa_demo теперь администратор.

Список пользователей

Список пользователей

Остаётся открыть /admin/flag.

В чём уязвимость?

Здесь сразу несколько ошибок, которые складываются в цепочку повышения привилегий:

  1. Parameter pollution на сервере: parseInt(req.query.id) на массиве берёт первый элемент, а клиент через getAll("id") — последний. Один и тот же параметр сервер и клиент понимают по-разному.

  2. Подмена пути в клиентском запросе: profileId из URL без валидации подставляется в fetch("/log/" + profileId). Последовательность ../ нормализуется браузером, и безобидный POST /log/... превращается в POST /admin/addAdmin

  3. Админская cookie и валидный CSRF-токен оказываются в одном запросе: страница сама вставляет _csrf в тело, а бот приносит админский JWT. В итоге состояние меняется от имени администратора.

  4. Слабая проверка прав: /admin/addAdmin доверяет роли из JWT и позволяет менять роль произвольному userId, если запрос пришёл от админа.

Как закрыть уязвимость

Ключевым действием будет убрать рассинхрон и не выполнять привилегированные операции по запросу со страницы пользователя. Для этого нужно валидировать id как целое число и отклонять массивы, не строить путь для fetch из пользовательского ввода и проверять права на /admin/addAdmin по актуальным данным в БД, а не только по роли из JWT, и не разрешать менять роль произвольному пользователю.

OWASP Top 10

В OWASP Top 10 этой уязвимости соответствует A01:2025 — Broken Access Control: пользователь получает доступ к административной функции и меняет свою роль.

CWE

По CWE это CWE-352 (CSRF) — запрос на смену роли выполняется в контексте администратора без его ведома, а в итоге CWE-269 (Improper Privilege Management) — повышение привилегий обычного пользователя до админа.

Если вы нашли опечатку или ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter

Также по теме:

Райтап на задачу «Шакализатор картинок»

/Разборы задач

Райтап на задачу «Шакализатор картинок»

Разбор задачи «Шакализатор картинок»: Path Traversal, анализ nginx и PHP, обход проверки пути и чтение файла .env.
Прочитать

87

Райтап на задачу «Просмотр файлов»

/Разборы задач

Райтап на задачу «Просмотр файлов»

Райтап feadal на задачу «Просмотр файлов»: проверка пути через startswith, поведение os.path.normpath и обход запрета на чтение файла.
Прочитать

65