/Разборы задач
1 ноября 2026
Райтап на задачу «Вектор атаки»
Используем внешнюю XML-сущность в SVG-аватарке, чтобы прочитать файл с флагом, и разбираем причину XXE.11
43

Web
Задача «OnlyBugs Video»
Из описания таска мы узнаём, что Пикси запустила приватный стрим на OnlyBugs и показывает там свой флаг. Открываем сайт, и нас встречает страница с формой входа и регистрации.
Регистрируемся, входим и попадаем в список эфиров.
Видим две трансляции от Пикси: публичную «Пикси приподнимает майку» (chat12), куда можно зайти кнопкой «Войти», и приватную «Пикси показывает флаг» (chat93) с пометкой private и кнопкой «Закрыто» — именно она нам и нужна.
Внизу сайдбара есть форма «Новый эфир», где любой пользователь может создать собственную трансляцию, в том числе приватную.
Из описания мы знаем, что флаг находится в самом приватном эфире chat93. Значит, наша задача будет получить доступ к нему.
Сложив всё, что у нас имеется, мы можем сформулировать итоговый результат как получение доступа к данным (в нашем случае — трансляции), к которым мы не должны иметь доступ. Попробуем узнать, есть ли какая-то известная уязвимость, которой будет соответствовать такое описание.
Существует целый ряд уязвимостей, позволяющих получить несанкционированный доступ, но если мы вспомним, что ранее видели у каждой трансляции уникальный номер чата, то можем понять, что в данном случае нас будет интересовать уязвимость типа IDOR.
Разберёмся, как работает эта уязвимость и как нам её проэксплуатировать.
Теперь мы знаем, что нам надо каким-то образом подменить идентификатор объекта (номер чата в нашем случае) на интересующий нас. Но для этого разберёмся, как вообще устроен доступ к видеопотоку. Зайдём в доступную нам комнату и откроем вкладку «Сеть» в DevTools.
Среди запросов можем сразу заметить те, которые как раз будут возвращать нам медиа. Попробуем перейти по любому из них.
https://onlybugs-video.t2.bug-makers.ru/api/media/12?callToken=eyJwcm92a...В запросе напрямую передаётся ID комнаты, значит, мы можем попробовать аналогичным образом обратиться к интересующей нас 93.
Но по итогу получим ошибку с сообщением о том, что наш токен не будет подходить: сервер всё же проверяет доступ к чужим данным по токену. Получается, что нам надо подделать валидный для данной комнаты токен. Посмотрим, как вообще сайт создаёт их.
Интересовать нас будет app.js, так как именно в нём будет лежать вся клиентская логика. Для удобства можем выкачать его себе и разобрать подробно:
Весь доступ к трансляции состоит из трёх запросов:
entergetCallToken/api/call/joinconst gate = await api(`/api/streams/${roomId}/enter`, { method: "POST" });
rememberCallNonce(gate.callNonce);Заходим в комнату. Если мы её участник, сервер в ответе присылает нам callNonce — одноразовый ключ, который понадобится дальше для приватного эфира. Клиент его запоминает, причём раскладывает по номеру чата:
function rememberCallNonce(callNonce) {
if (!callNonce?.value || !callNonce.issuedForChatId) return;
state.callNonces[String(callNonce.issuedForChatId)] = callNonce;
}Тут видно три разных случая: публичный эфир или наш собственный — токен дают просто по chatId. А вот для чужого приватного нужно приложить callNonce и подпись X-Call-Trace. Смотрим, как эта подпись считается:
async function makeCallTrace(callNonce, chatId) {
return sha256Hex(
`${callNonce}.${chatId}.${state.user.id}.${state.user.username}.pixie-live-web`,
);
}Получается, что это просто SHA-256 от следующей склейки: наш callNonce, номер чата, наш ID, наш username и зашитая в код строка pixie-live-web. Все эти значения нам известны, поэтому мы можем посчитать её для любой комнаты сами. Но прежде чем его получить, посмотрим, как конкретно мы получаем видео.
const session = await api("/api/call/join", { method:"POST", body:{ callToken: tokenData.callToken } });
player.src = session.mediaUrl; // /api/media/:id?callToken=...По токену join возвращает ссылку вида /api/media/93?callToken=…, которую плеер и проигрывает, а сам эндпоинт media отдаёт видео и проверяет, что chatId внутри токена совпадает с номером в URL.
Получается, чтобы забрать приватное видео chat93, нам нужен токен, выписанный именно на chat93. А чтобы его получить, нужны валидный callNonce и подпись, которую мы посчитаем сами. Nonce приходит только тому, кого пригласили в приватный эфир, а Пикси нас в свой эфир не зовёт. Посмотрим, что сервер на самом деле проверяет у этого nonce и действительно ли он сверяет, что nonce выдан именно для той комнаты, токен для которой мы просим.
Увидеть серверную проверку мы не можем, поэтому просто проверим её на практике.
Для этого регистрируем второй аккаунт, а из-под первого создаём свою приватную комнату и приглашаем в неё второго.
Заходим со второго аккаунта и в ответе на запрос enter можем увидеть наш callNonce.value.
Теперь берём этот callNonce, выданный для нашей комнаты chat123, и просим токен для чужого chat93, а X-Call-Trace считаем уже для chatId 93 и своей личности. Если сервер проверяет только валидность nonce и сходимость подписи, он выпишет токен. А если бы он сверял, что nonce выдан именно для 93?..
Сервер отвечает 200 и выписывает токен прямо на chat93, а это уже значит, что привязки nonce к комнате нет. Подставляем этот токен в такой же урл, как в начале, /api/media/93?callToken=…, и чужой приватный эфир отдаёт нам видео с флагом.
Получилось так, что getCallToken не привязывает callNonce к конкретной комнате, поэтому nonce от любой комнаты (в том числе нашей собственной) принимается при выдаче токена на чужой chatId. X-Call-Trace это не исправляет и считается из полностью подконтрольных нам данных. По итогу мы получаем возможность чтения чужого приватного потока без приглашения.
Достаточно в getCallToken сверять, что nonce выдан именно для запрошенной комнаты и принадлежит текущему пользователю.
A01:2025 — Broken Access Control: пользователь получает доступ к приватному ресурсу, к которому не был допущен.
По CWE это классический CWE-639 (Authorization Bypass Through User-Controlled Key) — IDOR: доступ к чужой комнате по подконтрольному chatId.
Также можно выделить CWE-284 (Improper Access Control), так как callNonce не связан с конкретной комнатой.