Files
ALPHA-TRAIN2/services/rusgram/logic/logic.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

178 lines
6.9 KiB
Python
Executable File

#!/usr/bin/python3
from flask import Flask, make_response, request, redirect
import requests as r
app = Flask(__name__)
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 = 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"]
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 = 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"]
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:
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 = 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 = 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 = 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, data["msg"]]}, timeout=5)
except r.RequestException:
return bad_request()
return make_response({"status": "ok"}, 200)
if __name__ == '__main__':
app.run(host="0.0.0.0", port=logic_node_port)