Разбор сервиса и все 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
153 lines
8.1 KiB
Markdown
153 lines
8.1 KiB
Markdown
# 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 с комментариями
|
||
```
|
||
|
||
**Ключевой факт: все пять контейнеров публикуют порты наружу** (38000–38004),
|
||
хотя по замыслу «внутренними» являются четыре из них. `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.
|