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
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
Тесты Rusgram: SLA (легальный флоу чекера) + регрессия по всем найденным дырам.
python3 test_rusgram.py [host]
host по умолчанию — LAN-IP этой машины (важно: проверка закрытия внутренних
портов имеет смысл только с внешнего адреса; с 127.0.0.1 они доступны намеренно).
Exit code 0 — всё зелёное. 1 — есть падения.
Против services_vulnerable/rusgram секция SECURITY обязана падать,
против services/rusgram — обязана проходить.
"""
import sys
import socket
import string
import random
import requests
TIMEOUT = 6
PROXY, STATIC, DB, LOGIC, CONTENT = 38000, 38001, 38002, 38003, 38004
_results = []
def rnd(n=10):
return "".join(random.choices(string.ascii_lowercase, k=n))
def check(section, name, passed, detail=""):
_results.append((section, name, passed))
mark = "\033[32mPASS\033[0m" if passed else "\033[31mFAIL\033[0m"
print(f" [{mark}] {name}" + (f" ({detail})" if detail else ""), flush=True)
def lan_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def port_open(host, port):
s = socket.socket()
s.settimeout(3)
try:
s.connect((host, port))
return True
except OSError:
return False
finally:
s.close()
# ---------------------------------------------------------------- SLA
def test_sla(host):
print("\n=== SLA: легальный флоу ===", flush=True)
B = f"http://{host}:{PROXY}"
s = requests.Session()
u, p = "t_" + rnd(), rnd()
flag = "".join(random.choices(string.ascii_uppercase + string.digits, k=31)) + "="
try:
r = s.post(f"{B}/api/register", json={"login": u, "password": p}, timeout=TIMEOUT)
check("SLA", "register нового юзера = 200", r.status_code == 200, r.status_code)
check("SLA", "выдана кука session", s.cookies.get("session") == f"{u}||{p}")
r = s.post(f"{B}/api/send_msg", json={"msg": flag}, timeout=TIMEOUT)
check("SLA", "send_msg = 200", r.status_code == 200, r.status_code)
r = s.post(f"{B}/api/update_personal",
json={"first_name": "Ivan", "second_name": "Ivanov", "email": "i@x.ru"},
timeout=TIMEOUT)
check("SLA", "update_personal = 200", r.status_code == 200, r.status_code)
r = s.post(f"{B}/api/post_comment", json={"img_id": 3, "msg": "nice"}, timeout=TIMEOUT)
check("SLA", "post_comment = 200", r.status_code == 200, r.status_code)
r = s.get(f"{B}/", timeout=TIMEOUT)
check("SLA", "GET / отдаёт владельцу его флаг", r.status_code == 200 and flag in r.text)
check("SLA", "GET / рендерит профиль", "Ivan" in r.text)
r = s.get(f"{B}/specific.html?img=3", timeout=TIMEOUT)
check("SLA", "specific.html?img=3 показывает комментарий",
r.status_code == 200 and "nice" in r.text, r.status_code)
s2 = requests.Session()
r = s2.post(f"{B}/api/login", json={"login": u, "password": p}, timeout=TIMEOUT)
check("SLA", "login с верными кредами = 200", r.status_code == 200, r.status_code)
check("SLA", "после login виден свой флаг", flag in s2.get(f"{B}/", timeout=TIMEOUT).text)
r = requests.post(f"{B}/api/login", json={"login": u, "password": "nope"}, timeout=TIMEOUT)
check("SLA", "login с неверными = 401", r.status_code == 401, r.status_code)
for page in ("login.html", "register.html"):
r = requests.get(f"{B}/{page}", timeout=TIMEOUT)
check("SLA", f"{page} = 200", r.status_code == 200, r.status_code)
for asset in ("css/login.css", "img/favicon.ico", "js/bootstrap.min.js"):
r = requests.get(f"{B}/{asset}", timeout=TIMEOUT)
check("SLA", f"статика {asset} = 200", r.status_code == 200, r.status_code)
except requests.RequestException as e:
check("SLA", "сервис доступен", False, str(e)[:70])
return None, None, None
return u, p, flag
# ---------------------------------------------------------------- SECURITY
def test_security(host, victim, vpass, vflag):
print("\n=== SECURITY: регрессия по уязвимостям ===", flush=True)
B = f"http://{host}:{PROXY}"
# V1 — SSTI/RCE: полная цепочка, а не только отказ в регистрации.
# Имя юзера с .html -> его файл рендерится content'ом как Jinja-шаблон.
au, ap = f"{rnd()}.html", "p"
r = requests.post(f"{B}/api/register", json={"login": au, "password": ap}, timeout=TIMEOUT)
check("SEC", "V1: регистрация имени *.html отклонена", r.status_code != 200, r.status_code)
canary = "SSTI" + rnd(6).upper()
payload = "{{ lipsum.__globals__.os.popen('echo " + canary + "').read() }}"
requests.post(f"{B}/api/update_personal",
json={"first_name": payload, "second_name": "x", "email": "x"},
cookies={"session": f"{au}||{ap}"}, timeout=TIMEOUT)
r = requests.get(f"{B}/db/users/{au}", timeout=TIMEOUT)
check("SEC", "V1: SSTI не выполняется (нет RCE)", canary not in r.text, r.status_code)
# V5 — чтение файла жертвы через render_template
r = requests.get(f"{B}/db/users/{victim}", timeout=TIMEOUT)
check("SEC", "V1/V5: render_template произвольного пути не отдаёт файл юзера",
vflag not in r.text, r.status_code)
# V2 — перерегистрация чужого аккаунта
r = requests.post(f"{B}/api/register", json={"login": victim, "password": "hacked"},
timeout=TIMEOUT)
check("SEC", "V2: перерегистрация существующего юзера отклонена",
r.status_code != 200, r.status_code)
r = requests.get(f"{B}/", cookies={"session": f"{victim}||hacked"}, timeout=TIMEOUT)
check("SEC", "V2: перезаписанный пароль не даёт доступа к флагу", vflag not in r.text)
r = requests.post(f"{B}/api/login", json={"login": victim, "password": vpass}, timeout=TIMEOUT)
check("SEC", "V2: пароль жертвы НЕ сломан (нет саботажа)", r.status_code == 200, r.status_code)
# V3 — SSRF через Origin
r = requests.post(f"{B}/api/send_msg", json={"msg": "x"},
cookies={"session": f"{victim}||WRONGPASS"},
headers={"Origin": f"http://database:5002/users/{victim}/msg?x="},
timeout=TIMEOUT)
check("SEC", "V3: SSRF через Origin не читает внутреннюю сеть", vflag not in r.text,
r.status_code)
# V6 — открытый редирект
try:
r = requests.post(f"{B}/api/send_msg", json={"msg": "x"},
cookies={"session": f"{victim}||WRONGPASS"},
headers={"Origin": "https://evil.example"},
allow_redirects=False, timeout=TIMEOUT)
loc = r.headers.get("Location", "")
check("SEC", "V6: Location не содержит внешний домен", "evil.example" not in loc,
loc or "нет Location")
except requests.RequestException as e:
check("SEC", "V6: открытый редирект", False, str(e)[:50])
# V4/V5 — публикация внутренних портов наружу
for port, name in ((STATIC, "static"), (DB, "database"), (LOGIC, "logic"), (CONTENT, "content")):
closed = not port_open(host, port)
check("SEC", f"V4/V5: порт {port} ({name}) закрыт снаружи", closed,
"закрыт" if closed else "ОТКРЫТ")
check("SEC", f"порт {PROXY} (proxy) открыт — сервис доступен", port_open(host, PROXY))
# V8 — устойчивость
cases = [
("GET / с кукой a||b||c", lambda: requests.get(f"{B}/", cookies={"session": "a||b||c"},
timeout=TIMEOUT)),
("GET / без куки", lambda: requests.get(f"{B}/", timeout=TIMEOUT)),
("send_msg без куки", lambda: requests.post(f"{B}/api/send_msg", json={"msg": "x"},
timeout=TIMEOUT)),
("send_msg с несуществующим юзером",
lambda: requests.post(f"{B}/api/send_msg", json={"msg": "x"},
cookies={"session": "nosuchuser||p"}, timeout=TIMEOUT)),
("specific.html?img=abc",
lambda: requests.get(f"{B}/specific.html?img=abc",
cookies={"session": f"{victim}||{vpass}"}, timeout=TIMEOUT)),
("specific.html?img=999",
lambda: requests.get(f"{B}/specific.html?img=999",
cookies={"session": f"{victim}||{vpass}"}, timeout=TIMEOUT)),
("post_comment с img_id='../x'",
lambda: requests.post(f"{B}/api/post_comment", json={"img_id": "../x", "msg": "y"},
cookies={"session": f"{victim}||{vpass}"}, timeout=TIMEOUT)),
("register без полей", lambda: requests.post(f"{B}/api/register", json={},
timeout=TIMEOUT)),
]
for name, fn in cases:
try:
code = fn().status_code
check("SEC", f"V8: {name} не даёт 500", code != 500, code)
except requests.RequestException as e:
check("SEC", f"V8: {name}", False, str(e)[:50])
def main():
host = sys.argv[1] if len(sys.argv) > 1 else lan_ip()
print(f"Цель: {host}", flush=True)
if host.startswith("127."):
print(" ! внутренние порты намеренно слушают 127.0.0.1 — "
"проверка их закрытия достоверна только с внешнего IP", flush=True)
victim, vpass, vflag = test_sla(host)
if victim:
test_security(host, victim, vpass, vflag)
failed = [r for r in _results if not r[2]]
print(f"\n{'=' * 52}")
for sect in ("SLA", "SEC"):
tot = [r for r in _results if r[0] == sect]
bad = [r for r in tot if not r[2]]
print(f" {sect}: {len(tot) - len(bad)}/{len(tot)} passed")
if failed:
print(f"\n\033[31mFAILED ({len(failed)}):\033[0m")
for _, name, _ in failed:
print(f" - {name}")
return 1
print("\n\033[32mВсё зелёное.\033[0m")
return 0
if __name__ == "__main__":
sys.exit(main())