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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user