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
This commit is contained in:
@@ -1,47 +1,90 @@
|
||||
#!/usr/bin/python3
|
||||
from flask import Flask, render_template, request, redirect
|
||||
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):
|
||||
def get_login():
|
||||
if "session" not in request.cookies:
|
||||
return None
|
||||
username, password = request.cookies["session"].split("||")
|
||||
correct_password = r.get(f"http://{database_node}/users/{username}/password").json()["data"]
|
||||
if correct_password != password:
|
||||
return None
|
||||
return username
|
||||
if path == "index.html":
|
||||
if (username := get_login()) is None:
|
||||
return redirect("/login.html", code=302)
|
||||
imgs = list()
|
||||
for i in range(1, 17):
|
||||
imgs.append(r.get(f"http://{database_node}/images/{i}").json()["data"])
|
||||
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()
|
||||
first_name_resp = r.get(f"http://{database_node}/users/{username}/first_name")
|
||||
user["first_name"] = first_name_resp.json()["data"] if first_name_resp.status_code == 200 else ""
|
||||
second_name_resp = r.get(f"http://{database_node}/users/{username}/second_name")
|
||||
user["second_name"] = second_name_resp.json()["data"] if second_name_resp.status_code == 200 else ""
|
||||
email_resp = r.get(f"http://{database_node}/users/{username}/email")
|
||||
user["email"] = email_resp.json()["data"] if email_resp.status_code == 200 else ""
|
||||
msg_resp = r.get(f"http://{database_node}/users/{username}/msg")
|
||||
user["msg"] = msg_resp.json()["data"] if msg_resp.status_code == 200 else ""
|
||||
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)
|
||||
img_id = request.args.get('img')
|
||||
img = r.get(f"http://{database_node}/images/{img_id}").json()["data"]
|
||||
img["id"] = img_id
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user