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:
@@ -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 с комментариями
|
||||
```
|
||||
|
||||
**Ключевой факт: все пять контейнеров публикуют порты наружу** (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.
|
||||
Reference in New Issue
Block a user