Разбор сервиса и все 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
229 lines
11 KiB
Python
229 lines
11 KiB
Python
#!/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())
|