Разбор сервиса и все 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
8.1 KiB
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> (имя файла = логин):
{"password": "plaintext", "first_name": "...", "second_name": "...",
"email": "...", "msg": "..."}
Картинка — website/db/images/01..16:
{"name": "Coffee", "description": "...", "comments": [["author", "text"], ...]}
Пароли лежат в открытом виде. Хеширования нет нигде.
3. Аутентификация
Сессия — это незашифрованная, неподписанная кука:
session = "<username>||<password>"
Проверка сессии (одинаково скопирована в logic.py 4 раза и в content.py):
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:
POST /api/send_msg→ полеmsgв файле юзера → рендерится наindex.htmlв блоке «личное сообщение».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)
databaseне имеет аутентификации и опубликован на 38002. Это key-value store, гдеGET /users/<кто угодно>/passwordвозвращает пароль, аPOSTперезаписывает любое поле любого юзера.contentрендеритrender_template(path)с путём из URL, а егоtemplate_folder— это тот же каталог, где лежит БД. Значит файл юзера можно отрендерить как Jinja-шаблон.registerне проверяет, существует ли юзер.database.pyна POST просто делаетtouch+ перезапись поля.proxyходит в бэкенды черезrequestsсallow_redirects=True— то есть 302 от logic заставляет proxy сходить по чужому URL серверсайд.- Пароли в плейнтексте, сессия без подписи,
split("||")падает на логине с||.
7. Как поднять локально
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.