Разбор сервиса и все 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
126 lines
4.9 KiB
Python
Executable File
126 lines
4.9 KiB
Python
Executable File
#!/usr/bin/python3
|
|
from flask import Flask, make_response, request
|
|
import os
|
|
import json
|
|
from pathlib import Path
|
|
app = Flask(__name__)
|
|
database_node_port = 5002
|
|
basic_path = "./files/db"
|
|
|
|
IMG_MIN, IMG_MAX = 1, 16
|
|
# Поля профиля, которые вообще могут существовать
|
|
ALLOWED_FIELDS = {"password", "first_name", "second_name", "email", "msg"}
|
|
|
|
|
|
def safe_username(username):
|
|
"""Имя юзера становится именем файла в db/users/ — режем всё, что уводит
|
|
запись/чтение за пределы каталога."""
|
|
if not username or len(username) > 64:
|
|
return False
|
|
if "/" in username or "\\" in username or username in (".", ".."):
|
|
return False
|
|
if "\x00" in username:
|
|
return False
|
|
return True
|
|
|
|
|
|
def user_path(username):
|
|
return os.path.join(basic_path, "users", username)
|
|
|
|
|
|
@app.route('/users/<string:username>/<string:field>', methods=["GET", "POST"])
|
|
def users(username, field):
|
|
if not safe_username(username) or field not in ALLOWED_FIELDS:
|
|
return make_response({"status": "bad_request"}, 400)
|
|
path = user_path(username)
|
|
|
|
if request.method == 'GET':
|
|
if not os.path.exists(path):
|
|
return make_response({"status": "user_not_found"}, 404)
|
|
try:
|
|
with open(path, "r") as f:
|
|
data = json.loads(f.read())
|
|
except (OSError, json.JSONDecodeError):
|
|
return make_response({"status": "error"}, 500)
|
|
if not isinstance(data, dict) or field not in data:
|
|
return make_response({"status": "field_not_found"}, 404)
|
|
return make_response({"status": "ok", "data": data[field]}, 200)
|
|
|
|
elif request.method == "POST":
|
|
body = request.get_json(silent=True)
|
|
if not isinstance(body, dict) or "value" not in body:
|
|
return make_response({"status": "bad_request"}, 400)
|
|
value = body["value"]
|
|
if not isinstance(value, str) or len(value) > 4096:
|
|
return make_response({"status": "bad_request"}, 400)
|
|
|
|
existed = os.path.exists(path)
|
|
if not existed:
|
|
Path(path).touch()
|
|
try:
|
|
with open(path, "r") as f:
|
|
data = json.loads(f.read())
|
|
except (OSError, json.JSONDecodeError):
|
|
data = {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
|
|
# PATCH V2 (defense in depth): пароль можно только СОЗДАТЬ при регистрации,
|
|
# но не перезаписать. Функции смены пароля в сервисе нет, поэтому легальный
|
|
# флоу это не ломает, а прямой захват аккаунта через :38002 — закрывает.
|
|
if field == "password" and "password" in data:
|
|
return make_response({"status": "user_exists"}, 409)
|
|
|
|
data[field] = value
|
|
try:
|
|
with open(path, "w") as f:
|
|
f.write(json.dumps(data))
|
|
except OSError:
|
|
return make_response({"status": "error"}, 500)
|
|
return make_response({"status": "ok"}, 200)
|
|
|
|
|
|
@app.route('/images/<string:id>', methods=["GET", "POST"])
|
|
def images(id):
|
|
# PATCH V8: раньше любой нечисловой/несуществующий id давал 500
|
|
try:
|
|
img_id = int(id)
|
|
except (TypeError, ValueError):
|
|
return make_response({"status": "bad_request"}, 400)
|
|
if not (IMG_MIN <= img_id <= IMG_MAX):
|
|
return make_response({"status": "not_found"}, 404)
|
|
path = os.path.join(basic_path, "images", str(img_id).zfill(2))
|
|
|
|
if request.method == 'GET':
|
|
try:
|
|
with open(path, "r") as f:
|
|
data = json.loads(f.read())
|
|
except (OSError, json.JSONDecodeError):
|
|
return make_response({"status": "error"}, 500)
|
|
return make_response({"status": "ok", "data": data}, 200)
|
|
|
|
elif request.method == "POST":
|
|
body = request.get_json(silent=True)
|
|
if not isinstance(body, dict) or "value" not in body:
|
|
return make_response({"status": "bad_request"}, 400)
|
|
value = body["value"]
|
|
if (not isinstance(value, list) or len(value) != 2
|
|
or not all(isinstance(x, str) for x in value)
|
|
or len(value[1]) > 4096):
|
|
return make_response({"status": "bad_request"}, 400)
|
|
try:
|
|
with open(path, "r") as f:
|
|
data = json.loads(f.read())
|
|
if not isinstance(data, dict) or not isinstance(data.get("comments"), list):
|
|
return make_response({"status": "error"}, 500)
|
|
data["comments"].append(value)
|
|
with open(path, "w") as f:
|
|
f.write(json.dumps(data))
|
|
except (OSError, json.JSONDecodeError):
|
|
return make_response({"status": "error"}, 500)
|
|
return make_response({"status": "ok"}, 200)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host="0.0.0.0", port=database_node_port)
|