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
This commit is contained in:
2026-08-26 11:31:28 +03:00
co-authored by Claude Opus 5
parent 8041f1b90f
commit 14b5855b86
9 changed files with 1169 additions and 86 deletions
+122
View File
@@ -0,0 +1,122 @@
# Rusgram — что запатчено
Оригинал (для диффа): `services_vulnerable/rusgram`
Пропатчено: `services/rusgram`
```bash
diff -ru services_vulnerable/rusgram services/rusgram
```
Проверено на живом стенде: легальный флоу чекера проходит полностью,
`sploits/rusgram_sploit.py` против пропатченного сервиса собирает **0 флагов**
и не может даже перечислить пользователей.
---
## Изменения по файлам
### `content/content.py` — закрывает V1 (RCE) и V5
* **Белый список шаблонов.** `render_template(path)` теперь вызывается только для
`index.html`, `login.html`, `register.html`, `specific.html`. Всё остальное — 404.
Это корень SSTI: `template_folder='files'` совпадает с каталогом БД, поэтому
файл пользователя рендерился как исходник Jinja-шаблона.
* `get_login()` вынесен из тела вьюхи и больше не падает: нет куки, кука кривого
формата, юзера не существует, database недоступен — всё возвращает `None`.
* `specific.html`: `img` приводится к `int` и проверяется на диапазон 1–16.
Побочно чинит незакавыченную подстановку `{{img['id']}}` внутрь `<script>`.
### `logic/logic.py` — закрывает V2 (ATO), V3 (SSRF), V6
* **Регистрация проверяет, что юзер не существует** → `409 user_exists`.
Раньше повторный `register` перезаписывал пароль, сохраняя остальные поля,
то есть отдавал чужой аккаунт вместе с флагом.
* **`Origin` больше не попадает в `Location`.** Редирект на неавторизованный
запрос теперь фиксированный: `redirect("/login.html")`. Это убивает и открытый
редирект, и SSRF (proxy ходил по этому Location серверсайд).
* `valid_username()` — отклоняет только опасное: `||` (ломает формат куки),
`/`, `\`, `.`/`..`, имена на `.html`/`.htm` (defense in depth к V1).
Юникод и точки внутри имени **разрешены** — чтобы не поймать SLA-фейл.
* Единый `current_user()` вместо четырёх копий проверки; валидация тел запросов;
таймауты на все внутренние вызовы.
### `database/database.py` — снижает ущерб от V4
* `safe_username()` — имя юзера становится именем файла, режем разделители пути.
* Белый список полей профиля — нельзя насоздавать произвольных ключей.
* **Пароль можно только создать, но не перезаписать** (`409`). Функции смены
пароля в сервисе нет, легальный флоу не ломается, а прямой захват аккаунта
через `:38002` закрывается даже без фаервола.
* Валидация тел, ограничение длины значений (4096), корректные 400/404 вместо 500.
### `proxy/proxy.py` — defense in depth к V3
* `allow_redirects=False` на ветке `api/`. `requests` по умолчанию **ходит** по
`Location`; редирект теперь отдаётся клиенту, а не отрабатывается прокси.
* Ветка `.html` намеренно **оставлена как была** — там `Location` захардкожен
в content (`/login.html`), пользовательского ввода нет, и менять поведение
без нужды рискованно для SLA.
### `docker-compose.yml` — закрывает V4 и V5 по сети
* `image: git.itqdev.xyz/4x10m/rusgram-<node>:${IMAGE_TAG:-latest}` на **все пять**
сервисов. Без этого CI-шаг падает: он делает
`docker compose config --images | grep "^git.itqdev.xyz/4x10m/"` и `exit 1`, если пусто.
* **Внутренние узлы больше не публикуются наружу:** `38001``38004` привязаны к
`127.0.0.1`. Наружу торчит только proxy на `38000`.
* `restart: unless-stopped`.
---
## ⚠️ Единственное изменение с риском для SLA — порты
Привязка `38001``38004` к localhost сделана **отдельным шагом и легко
откатывается**. Предположение: чекер ходит только в proxy на `38000`.
Из исходников это не доказать — проверить по Pacmate/Firegex на vulnbox.
Контейнеры между собой общаются по docker-сети (`database:5002` и т.д.),
публикация портов на это не влияет — функциональность сохраняется полностью
(проверено с внешнего IP: весь флоу чекера проходит через один `38000`).
**Откат, если чекер начнёт падать:**
```yaml
ports:
- "38002:5002" # вернуть 0.0.0.0 для нужного узла
```
Локальный доступ для отладки на самом vulnbox при этом сохраняется
(`curl 127.0.0.1:38002/...`).
---
## Что осознанно НЕ менялось
* **Пароли в плейнтексте и формат куки `login||password` (V7).** Чекер почти
наверняка завязан на этот формат сессии; переделка схемы хранения на хеши
в середине A/D — это гарантированный SLA-фейл ради очков, которые не начисляют.
Добавлены только `httponly` и `samesite=Lax` на куку — это не меняет её значение.
* Логика галереи, шаблоны, вёрстка — не трогались.
---
## Как проверить после деплоя
```bash
cd services/rusgram
docker compose up -d --build
# 1) сервис жив и легальный флоу работает
curl -s -X POST http://127.0.0.1:38000/api/register \
-H 'Content-Type: application/json' -d '{"login":"t1","password":"p"}' # 200
# 2) ключевые дыры закрыты
curl -s -X POST http://127.0.0.1:38000/api/register \
-H 'Content-Type: application/json' -d '{"login":"t1","password":"x"}' # 409
curl -s -X POST http://127.0.0.1:38000/api/register \
-H 'Content-Type: application/json' -d '{"login":"a.html","password":"p"}' # 400
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:38000/db/users/t1 # 404
# 3) сплойт не собирает флаги
python3 ../../sploits/rusgram_sploit.py <внешний_ip_этой_машины> # пусто
```