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
+152
View File
@@ -0,0 +1,152 @@
# Rusgram — врайтап по сервису
Python-сервис A/D. Инстаграм-клон: галерея из 16 картинок, комментарии, профиль
пользователя. Написан как **пять отдельных Flask-приложений**, общающихся по HTTP
внутри docker-сети.
---
## 1. Архитектура
```
┌──────────────────────────────────────────┐
игрок/чекер ──────► │ proxy :5000 (публикуется на :38000) │
└───┬──────────────┬───────────────┬───────┘
*.html │ *.css │ api/* │
▼ *.jpg ▼ ▼
┌─────────────┐ ┌────────────┐ ┌─────────────┐
│ content │ │ static │ │ logic │
│ :5004 │ │ :5001 │ │ :5003 │
│ (:38004) │ │ (:38001) │ │ (:38003) │
└──────┬──────┘ └─────┬──────┘ └──────┬──────┘
│ │ │
└───────────────┼────────────────┘
┌─────────────────┐
│ database │
│ :5002 │
│ (:38002) │
└────────┬────────┘
./website/db/ (том)
users/<username> ← JSON-файл на юзера
images/01..16 ← JSON с комментариями
```
**Ключевой факт: все пять контейнеров публикуют порты наружу** (3800038004),
хотя по замыслу «внутренними» являются четыре из них. `docker-compose.yml`
мапит `38002:5002` для database, `38003:5003` для logic и т.д.
### Роли узлов
| Узел | Порт | Что делает |
|---|---|---|
| **proxy** | 38000 | Единственная «легальная» точка входа. Роутит по расширению пути: `.html` → content, статика → static, `api/*` → logic, остальное → редирект на `/` |
| **content** | 38004 | Рендерит Jinja-шаблоны. `template_folder='files'`**тот же том, где лежит БД** |
| **static** | 38001 | Отдаёт `send_file` из `files/static/{img,css,fonts,js}` |
| **logic** | 38003 | Вся бизнес-логика: `/login`, `/register`, `/update_personal`, `/send_msg`, `/post_comment` |
| **database** | 38002 | Плоское key-value хранилище поверх файлов. **Никакой аутентификации вообще** |
---
## 2. Модель данных
Пользователь — это один JSON-файл `website/db/users/<username>` (имя файла = логин):
```json
{"password": "plaintext", "first_name": "...", "second_name": "...",
"email": "...", "msg": "..."}
```
Картинка — `website/db/images/01`..`16`:
```json
{"name": "Coffee", "description": "...", "comments": [["author", "text"], ...]}
```
Пароли лежат **в открытом виде**. Хеширования нет нигде.
---
## 3. Аутентификация
Сессия — это незашифрованная, неподписанная кука:
```
session = "<username>||<password>"
```
Проверка сессии (одинаково скопирована в `logic.py` 4 раза и в `content.py`):
```python
username, password = request.cookies["session"].split("||")
correct_password = r.get(f"http://{database_node}/users/{username}/password").json()["data"]
if correct_password != password:
return redirect(...)
```
То есть **пароль в открытом виде ездит в куке в каждом запросе**, а «сессия» —
это просто пара логин/пароль. Ни срока жизни, ни подписи, ни HttpOnly/Secure.
---
## 4. Где живут флаги
Чекер кладёт флаги в **два** места, оба через публичный API:
1. **`POST /api/send_msg`** → поле `msg` в файле юзера → рендерится на `index.html`
в блоке «личное сообщение».
2. **`POST /api/update_personal`** → поля `first_name` / `second_name` / `email`
→ рендерятся в форму профиля на `index.html`.
Плюс потенциально комментарии (`POST /api/post_comment``images/<id>.comments`),
но там автор виден всем залогиненным — это скорее не flag store.
**Вывод для атаки:** флаг = содержимое `website/db/users/<victim>`. Всё, что даёт
чтение этого файла (или выполнение кода в контейнере, где он смонтирован), даёт флаг.
**Вывод для защиты:** флаг должен читаться **только** владельцем аккаунта после
проверки пароля. Любой путь, обходящий эту проверку, — дыра.
---
## 5. Легальный флоу
```
POST /api/register {"login","password"} → 200 + Set-Cookie: session=login||password
POST /api/login {"login","password"} → 200 + Set-Cookie
POST /api/send_msg {"msg"} → пишет msg
POST /api/update_personal {...} → пишет first_name/second_name/email
GET / → index.html со своим профилем и галереей
GET /specific.html?img=N → картинка + комментарии
POST /api/post_comment {"img_id","msg"} → комментарий
```
---
## 6. Что бросается в глаза сразу (детали в `VULNS.md`)
1. **`database` не имеет аутентификации и опубликован на 38002.** Это key-value
store, где `GET /users/<кто угодно>/password` возвращает пароль, а `POST`
перезаписывает любое поле любого юзера.
2. **`content` рендерит `render_template(path)` с путём из URL**, а его
`template_folder` — это тот же каталог, где лежит БД. Значит файл юзера можно
отрендерить *как Jinja-шаблон*.
3. **`register` не проверяет, существует ли юзер.** `database.py` на POST просто
делает `touch` + перезапись поля.
4. **`proxy` ходит в бэкенды через `requests` с `allow_redirects=True`** — то есть
302 от logic заставляет proxy сходить по чужому URL серверсайд.
5. Пароли в плейнтексте, сессия без подписи, `split("||")` падает на логине с `||`.
---
## 7. Как поднять локально
```bash
cd services/rusgram
docker compose up -d --build
curl http://127.0.0.1:38000/login.html
```
Состояние сервиса — в `website/db/`. Чтобы сбросить: удалить файлы из
`website/db/users/` (кроме `.gitkeep`) и вернуть `website/db/images/*` из git.