Разбор сервиса и все 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
178 lines
6.9 KiB
Python
Executable File
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)
|