Files
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

8.1 KiB
Raw Permalink Blame History

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 с комментариями

Ключевой факт: все пять контейнеров публикуют порты наружу (3800038004), хотя по замыслу «внутренними» являются четыре из них. 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:

  1. POST /api/send_msg → поле msg в файле юзера → рендерится на index.html в блоке «личное сообщение».
  2. POST /api/update_personal → поля first_name / second_name / email → рендерятся в форму профиля на index.html.

Плюс потенциально комментарии (POST /api/post_commentimages/<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. Как поднять локально

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.