/Разборы задач
27 октября 2026
Райтап на задачу «Шакализатор картинок»
Разбор задачи «Шакализатор картинок»: Path Traversal, анализ nginx и PHP, обход проверки пути и чтение файла .env.87
10

Web
Задача «YOFA 2.0»
Из описания таска мы узнаем, что YOFA — это новый сервис, который обещает хранить секреты и пароли в надёжном зашифрованном месте. Открываем созданный инстанс задания. Нас встречает минималистичная страница входа в стиле терминала.
Регистрируемся и входим под созданным пользователем и попадаем в профиль.
После логина сервер устанавливает cookie token с JWT. Внутри токена лежит роль:
{
"id": 4,
"role": "user",
"iat": 1793098609,
"exp": 1793102209
}Изучим созданный профиль: в нём есть обновление описания и ссылка на Secrets. Сам функционал не даёт нам подсказок, где искать флаг и что делать дальше, поэтому обратимся к исходникам приложения.
В server.js видим подключение уже пройденных страниц регистрации и логина, а также ряд роутов: /auth, /user, /admin, /msg, /log, /secrets и /report.
Изучим каждый подробнее, чтобы понять, куда двигаться дальше:
/register — страница регистрации, /auth — её API: регистрация и логин, ничего интересного для нас.
/user — просмотр профиля по id и обновление описания, здесь проверяется, что пользователь смотрит свой профиль или является администратором.
/admin — админский слой: сообщения, список пользователей, выдача роли admin и нужная нам страница с флагом.
Недолго думая, попробуем получить флаг, перейдя по этому роуту.
Получим ошибку об отсутствии доступа. Теперь мы точно определили вектор решения задачи: мы знаем, где расположен флаг и что нам нужно получить привилегии админа, чтобы его прочитать. Для этого изучим оставшиеся роуты.
/msg — отправка сообщений, которые затем видит администратор.
/log — запись действий пользователя, вызывается со страницы профиля.
/secrets — добавление и просмотр секретов, доступ проверяется по владельцу.
/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:
В getProfile сервер преобразует параметр id в число через parseInt(req.query.id), находит соответствующего пользователя и передаёт его в шаблон профиля. Администратору разрешено просматривать чужие профили.
Ниже находится addAdmin. Этот обработчик получает userId из тела POST-запроса и назначает указанному пользователю роль администратора, если отправитель запроса уже имеет роль admin.
Теперь посмотрим, какие запросы выполняет страница профиля при открытии. Для этого откроем её шаблон и клиентский JavaScript.
Разберём этот скрипт подробно. Сначала он читает параметры из адресной строки и достаёт из них 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.
Проверим, какой запрос автоматически отправляет страница. В 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.
Пользователь yofa_demo теперь администратор.
Остаётся открыть /admin/flag.
Здесь сразу несколько ошибок, которые складываются в цепочку повышения привилегий:
Parameter pollution на сервере: parseInt(req.query.id) на массиве берёт первый элемент, а клиент через getAll("id") — последний. Один и тот же параметр сервер и клиент понимают по-разному.
Подмена пути в клиентском запросе: profileId из URL без валидации подставляется в fetch("/log/" + profileId). Последовательность ../ нормализуется браузером, и безобидный POST /log/... превращается в POST /admin/addAdmin
Админская cookie и валидный CSRF-токен оказываются в одном запросе: страница сама вставляет _csrf в тело, а бот приносит админский JWT. В итоге состояние меняется от имени администратора.
Слабая проверка прав: /admin/addAdmin доверяет роли из JWT и позволяет менять роль произвольному userId, если запрос пришёл от админа.
Ключевым действием будет убрать рассинхрон и не выполнять привилегированные операции по запросу со страницы пользователя. Для этого нужно валидировать id как целое число и отклонять массивы, не строить путь для fetch из пользовательского ввода и проверять права на /admin/addAdmin по актуальным данным в БД, а не только по роли из JWT, и не разрешать менять роль произвольному пользователю.
В OWASP Top 10 этой уязвимости соответствует A01:2025 — Broken Access Control: пользователь получает доступ к административной функции и меняет свою роль.
По CWE это CWE-352 (CSRF) — запрос на смену роли выполняется в контексте администратора без его ведома, а в итоге CWE-269 (Improper Privilege Management) — повышение привилегий обычного пользователя до админа.