Files
ALPHA-TRAIN2/services/rusgram/content/content.py
T
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

93 lines
3.8 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/python3
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):
if path == "index.html":
if (username := get_login()) is None:
return redirect("/login.html", code=302)
imgs = list()
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()
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)
# 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)
if __name__ == '__main__':
app.run(host="0.0.0.0", port=content_node_port)