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:
2026-08-26 11:31:28 +03:00
co-authored by Claude Opus 5
parent 8041f1b90f
commit 14b5855b86
9 changed files with 1169 additions and 86 deletions
+97 -25
View File
@@ -7,45 +7,117 @@ 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):
path = basic_path + "/users/" + username
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)
with open(path, "r") as f:
data = json.loads(f.read())
if 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":
if not os.path.exists(path):
Path(path).touch()
with open(path, "r+") as f:
try:
try:
with open(path, "r") as f:
data = json.loads(f.read())
except json.JSONDecodeError:
data = {}
data[field] = request.json["value"]
with open(path, "w") as f:
f.write(json.dumps(data))
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):
path = basic_path + "/images/" + id.zfill(2)
# 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':
with open(path, "r") as f:
data = json.loads(f.read())
return make_response({"status": "ok", "data": data}, 200)
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":
with open(path, "r+") as f:
data = json.loads(f.read())
data["comments"].append(request.json["value"])
with open(path, "w") as f:
f.write(json.dumps(data))
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)