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:
+140
-37
@@ -6,67 +6,170 @@ 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 = request.get_json()
|
||||
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"]
|
||||
correct_password = r.get(f"http://{database_node}/users/{username}/password").json()["data"]
|
||||
if correct_password == password:
|
||||
resp = make_response({"status": "ok"}, 200)
|
||||
resp.set_cookie("session", f"{username}||{password}")
|
||||
return resp
|
||||
else:
|
||||
return make_response({"status": "wrong_creds"}, 401)
|
||||
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 = request.get_json()
|
||||
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"]
|
||||
resp = r.post(f"http://{database_node}/users/{username}/password", json={"value": 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:
|
||||
resp = make_response({"status": "ok"}, 200)
|
||||
resp.set_cookie("session", f"{username}||{password}")
|
||||
return resp
|
||||
else:
|
||||
return make_response({"status": "error"}, resp.status_code)
|
||||
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, password = request.cookies["session"].split("||")
|
||||
correct_password = r.get(f"http://{database_node}/users/{username}/password").json()["data"]
|
||||
if correct_password != password:
|
||||
return redirect(request.headers.get("Origin", "") + "/login.html", code=302)
|
||||
data = request.get_json()
|
||||
name, sname, email = data["first_name"], data["second_name"], data["email"]
|
||||
r.post(f"http://{database_node}/users/{username}/first_name", json={"value": name})
|
||||
r.post(f"http://{database_node}/users/{username}/second_name", json={"value": sname})
|
||||
r.post(f"http://{database_node}/users/{username}/email", json={"value": email})
|
||||
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, password = request.cookies["session"].split("||")
|
||||
correct_password = r.get(f"http://{database_node}/users/{username}/password").json()["data"]
|
||||
if correct_password != password:
|
||||
return redirect(request.headers.get("Origin", "") + "/login.html", code=302)
|
||||
r.post(f"http://{database_node}/users/{username}/msg", json={"value": request.get_json()["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, password = request.cookies["session"].split("||")
|
||||
correct_password = r.get(f"http://{database_node}/users/{username}/password").json()["data"]
|
||||
if correct_password != password:
|
||||
return redirect(request.headers.get("Origin", "") + "/login.html", code=302)
|
||||
data = request.get_json()
|
||||
img_id, msg = data["img_id"], data["msg"]
|
||||
r.post(f"http://{database_node}/images/{img_id}",
|
||||
json={"value": [username, 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()
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user