Ghidra 11.4+/12.x dropped the bundled Jython, so the .py extractor fails headless with "Ghidra was not started with PyGhidra. Python is not available" — analysis succeeds but the post-script never runs, so no snapshot is produced. Default GHIDRA_URL now points at 11.2.1 (Jython); README documents the constraint and the PyGhidra path for staying on 12.x. Keeps the local Dockerfile fixes (pip upgrade, non-editable install). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
12 KiB
Markdown
220 lines
12 KiB
Markdown
# aidem_media_playground
|
||
|
||
Narzędzie do **analizy różnicowej** silników gier Aidem Media (Piklib / BlooMoo).
|
||
Cel: katalog gier (z ISO/ZIP), wersje silnika z hashami, oraz ekstrakcja i porównywanie
|
||
"powierzchni silnika" — typów, metod, eventów i pól klas `CMC_*` — między wersjami.
|
||
|
||
## Status
|
||
|
||
Pełny łańcuch działa: **binarka → (Ghidra worker) → `snapshot.json` → katalog (DB) → diff → UI**.
|
||
Ekstraktor pokrywa 5 osi (typy / metody / eventy / pola skryptowe / ciała metod) + bonus layout C++,
|
||
zwalidowany na golden pair PIKLIB8 (MSVC6) ↔ bloomoo (MSVC8). Nad tym: FastAPI + katalog (SQLite/Postgres),
|
||
diff z normalizacją ciał i miarą „podobnych wersji", Command Center UI, pipeline akwizycji ISO/ZIP
|
||
i pełny stack `docker compose` z workerem Ghidry. Zobacz **Quickstart** niżej.
|
||
|
||
## Architektura (docelowa)
|
||
|
||
Modularny monolit + worker. Backend Python/FastAPI tylko zleca, wersjonuje i diffuje.
|
||
Cała ekstrakcja żyje w **workerze = Ghidra headless + ten skrypt**, bo wymaga dostępu
|
||
do call-grafu, referencji i vtable. Worker emituje `snapshot.json`, monolit go konsumuje.
|
||
|
||
```
|
||
Front (centrum dowodzenia) ─ FastAPI (katalog/hashe/diff) ─ PostgreSQL
|
||
│ kolejka
|
||
Worker: Ghidra headless + extract_engine_surface.py
|
||
```
|
||
|
||
## Quickstart
|
||
|
||
### 1. Lokalnie, bez Dockera (działa od ręki — golden snapshoty są w repo)
|
||
|
||
```bash
|
||
python -m venv .venv && source .venv/bin/activate
|
||
pip install -e ".[api,dev]"
|
||
|
||
# zasiej katalog dwoma wersjami z golden pair
|
||
python -m ams.api.importer --game "Reksio i UFO" snapshots/PIKLIB8.dll.snapshot.json
|
||
python -m ams.api.importer --game "Reksio i Kapitan Nemo" snapshots/bloomoodll.dll.snapshot.json
|
||
|
||
uvicorn ams.api.app:create_app --factory # → http://127.0.0.1:8000/
|
||
```
|
||
|
||
Diff też z CLI: `python -m ams OLD.json NEW.json --owner CMC_Animo --only dispatch`.
|
||
|
||
### 2. Pełny stack w Dockerze (Ghidra w workerze) — upload ISO/ZIP → snapshot
|
||
|
||
```bash
|
||
docker compose up --build # db(Postgres) + redis + api + worker(Ghidra)
|
||
```
|
||
|
||
Pierwszy build workera **ściąga Ghidrę (~1 GB) + JDK 21** (wolny, ale cache'owany). Potem wgraj grę
|
||
przyciskiem **+ wgraj** w UI (http://localhost:8000) albo z CLI:
|
||
|
||
```bash
|
||
curl -F file=@"/sciezka/do/gra.iso" -F game="Reksio i Czarodzieje" http://localhost:8000/jobs
|
||
curl http://localhost:8000/jobs/1 # status: queued → started → finished
|
||
```
|
||
|
||
Worker rozpakowuje (bsdtar: ISO/ZIP), content-based znajduje DLL silnika, hashuje, odpala
|
||
Ghidra headless + `extract_engine_surface.py` → snapshot → import do Postgresa → UI.
|
||
|
||
### 3. Ghidra w obrazie — wersja / troubleshooting
|
||
|
||
Worker (`docker/worker.Dockerfile`, `eclipse-temurin:21-jdk`) pobiera Ghidrę i ustawia
|
||
`GHIDRA_HOME=/opt/ghidra`. Wersja jest przypięta w `ARG GHIDRA_URL`. Jeśli build padnie na pobieraniu,
|
||
nadpisz URL realnym wydaniem z [releases NSA](https://github.com/NationalSecurityAgency/ghidra/releases)
|
||
(nazwa pliku: `ghidra_<wer>_PUBLIC_<data>.zip`).
|
||
|
||
> **Musi to być Ghidra ≤ 11.3.x.** Ekstraktor to skrypt **Pythona (`.py`)**, który Ghidra w trybie
|
||
> headless uruchamia przez wbudowanego **Jythona**. Ghidra **11.4+ / 12.x usunęły Jythona** — tam
|
||
> `.py` headless wymaga **PyGhidry** (CPython), której ten obraz nie inicjalizuje, i dostaniesz
|
||
> `Ghidra was not started with PyGhidra. Python is not available` (analiza przejdzie, ale post-skrypt
|
||
> nie wyemituje snapshotu). Domyślny `GHIDRA_URL` celuje w 11.2.1 (z Jythonem). Chcesz zostać na 12.x?
|
||
> Trzeba doinstalować `pyghidra` i odpalać headless przez PyGhidrę — sam skrypt jest CPython-kompatybilny,
|
||
> więc zadziała, gdy interpreter wstanie (patrz dokumentacja PyGhidra w danej wersji Ghidry).
|
||
|
||
```bash
|
||
docker compose build worker \
|
||
--build-arg GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.2.1_build/ghidra_11.2.1_PUBLIC_20241105.zip
|
||
docker compose up
|
||
```
|
||
|
||
### 4. Ekstrakcja ręcznie w GUI Ghidry (alternatywa, bez Dockera)
|
||
|
||
*Script Manager → Manage Script Directories* → wskaż `ghidra_scripts/`, otwórz program (DLL),
|
||
uruchom `extract_engine_surface.py`. Snapshot ląduje w `snapshots/<nazwa>.snapshot.json`,
|
||
potem zaimportuj go przez `python -m ams.api.importer …`.
|
||
|
||
## Zasada ekstrakcji
|
||
|
||
Ekstrakcja stoi na **kotwicach semantycznych** (cele wywołań, referowane literały
|
||
stringów, immediaty `PUSH`), a **nie** na tekście dekompilatu. Dzięki temu jeden skrypt
|
||
działa na MSVC6 (Piklib) i MSVC8 (BlooMoo) mimo różnego kodu wynikowego.
|
||
|
||
| Co | Kotwica w Ghidrze | Status |
|
||
|----|-------------------|--------|
|
||
| Typy | `CMC_ObjectsContainer::resolve`: `operator==("NAME")` → `operator_new(SIZE)` → `CMC_X::CMC_X` | ✅ (+ `dispatch_addr`, `via_module_iface`) |
|
||
| Metody | `CMC_*_Runner::prepareMthHashSet`: `CInteger(id)` + `CStringHashCode("NAME")` + `CHashtable::put` | ✅ (+ `method_inheritance`) |
|
||
| Eventy | `CMC_*::getBehavioursList`: lista literałów `CXString` | ✅ (lista per klasa, bez dziedziczenia) |
|
||
| Pola (skryptowe) | ctory `CMC_*`: literały czytane przez `CMElement::getProperty<T>Value` → nazwa + typ pola (FPS, PRELOAD, VISIBLE…) | ✅ (+ `field_inheritance`) |
|
||
| Layout C++ (bonus) | ctory `CMC_*`: store'y `this+offset` przez P-code (rozmyte, `is_vtable`) | ✅ pod `struct_layout` |
|
||
| Ciała metod | `CMC_*_Runner::run`: `switch(id)` (vtable slot 17) → per case kotwice CALL (`Klasa::metoda` / `vtbl+0xNN`), rozwinięcie wrapperów | ✅ pod `method_dispatch` (id→`impl_addr`+`calls`) |
|
||
|
||
## Uruchomienie ekstraktora
|
||
|
||
**W GUI Ghidry** (najszybciej do walidacji): skopiuj `ghidra_scripts/extract_engine_surface.py`
|
||
do swojego katalogu skryptów (Script Manager → *Manage Script Directories*), otwórz program,
|
||
uruchom skrypt. Wynik trafi do `<NazwaProgramu>.snapshot.json` w katalogu roboczym Ghidry.
|
||
|
||
**Headless** (tryb docelowy):
|
||
```bash
|
||
analyzeHeadless <projDir> <projName> -process PIKLIB8.dll \
|
||
-postScript extract_engine_surface.py "$(pwd)/snapshots/PIKLIB8.snapshot.json"
|
||
```
|
||
|
||
## Uruchomienie w kontenerach (docker compose)
|
||
|
||
Pełny stack — modularny monolit + **wydzielony worker Ghidry** — czterema usługami:
|
||
|
||
```
|
||
db (Postgres) ── api (FastAPI + UI) ──┐
|
||
├── redis (kolejka)
|
||
worker (Ghidra headless) ─────────────┘
|
||
```
|
||
|
||
```bash
|
||
docker compose up --build # api na http://localhost:8000
|
||
```
|
||
|
||
`api` i `worker` współdzielą wolumen `uploads`: API streamuje wgrane archiwum na dysk,
|
||
worker czyta je po ścieżce (przez Redisa leci tylko ścieżka, nie bajty). Obraz workera
|
||
**pobiera Ghidrę (~1 GB) przy pierwszym buildzie** — wersję nadpiszesz przez
|
||
`--build-arg GHIDRA_URL=…`. Postgres trzyma katalog trwale (wolumen `pgdata`).
|
||
|
||
Asynchroniczna akwizycja przez API (zlecenie → kolejka → worker → snapshot w bazie):
|
||
|
||
```bash
|
||
curl -F file=@game.iso -F game="Reksio i UFO" http://localhost:8000/jobs # → 202 {id, status:queued}
|
||
curl http://localhost:8000/jobs/1 # poll: queued→started→finished
|
||
```
|
||
Endpointy joba: `POST /jobs` (upload+enqueue), `GET /jobs`, `GET /jobs/{id}` (status, `snapshot_id`, `error`).
|
||
|
||
## Akwizycja — ISO/ZIP → katalog
|
||
|
||
Worker, który domyka łańcuch od *pliku gry* do wpisu w katalogu: rozpakowuje archiwum,
|
||
**sam znajduje DLL silnika** (po markerach w binarce — `CMC_ObjectsContainer` w RTTI —
|
||
więc działa nawet po zmianie nazwy pliku), liczy hashe (sha256 + md5 + opcjonalnie ssdeep),
|
||
odpala Ghidrę headless z ekstraktorem i ląduje snapshotem w bazie.
|
||
|
||
```bash
|
||
pip install -e ".[api,acquire]" # acquire = ppdeep (fuzzy hash, opcjonalny)
|
||
export GHIDRA_HEADLESS=/path/to/ghidra/support/analyzeHeadless # albo GHIDRA_HOME
|
||
|
||
python -m ams.acquire game.iso --game "Reksio i UFO" # ISO/ZIP/katalog/luźny DLL
|
||
python -m ams.acquire dump_dir --game "Reksio i UFO" --sink http --post http://127.0.0.1:8000
|
||
python -m ams.acquire PIKLIB8.dll --identify-only # tylko unpack+identify+hash, bez Ghidry
|
||
```
|
||
|
||
`--sink db` (domyślnie) importuje wprost do bazy, `--sink http` POST-uje na `/snapshots`,
|
||
`--sink none` zostawia sam snapshot. `--identify-only` to suchy bieg do walidacji bez Ghidry.
|
||
Rozpakowywanie stoi na `bsdtar` (libarchive — czyta i ISO9660, i ZIP); ZIP ma fallback na
|
||
czysty Python. Snapshot dostaje doklejony blok `binary.acquisition` (źródło, nazwa DLL) oraz
|
||
`binary.fuzzy/md5/size`.
|
||
|
||
## Diff engine (CLI)
|
||
|
||
```bash
|
||
python -m ams OLD.snapshot.json NEW.snapshot.json [--owner CMC_Animo] \
|
||
[--only types,methods,events,fields,layout,dispatch] [--json]
|
||
```
|
||
Porównuje dwa snapshoty po osiach (added/removed/changed) + wykrywa metody przeniesione
|
||
w hierarchii. Oś `dispatch` (ciała metod, klucz `owner`+`id`) diffuje fingerprint wywołań
|
||
każdej metody — wykrywa **zmiany ciała** między wersjami; jak `struct_layout`, najczystsza
|
||
między wersjami tego samego kompilatora (cross-compiler proste metody i tak się zgadzają,
|
||
np. Animo `SHOW`→`vtbl+0xa0` na MSVC6 i MSVC8).
|
||
|
||
**Normalizacja ciał** (`ams.normalize`): każda zmiana w osi `dispatch` niesie wynik
|
||
`body = {similarity, added, removed}` — podobieństwo 0–100% sekwencji liści (`difflib`,
|
||
po zwinięciu sąsiednich duplikatów = artefaktów codegenu) oraz *które* wywołania doszły/zniknęły.
|
||
Blok dostaje też `summary` (wspólne / identyczne / zmienione / średnie podobieństwo). Na golden
|
||
pair (cross-compiler): 470 wspólnych ciał, 131 identycznych, średnio 66% — a `SHOW/HIDE/PAUSE/
|
||
RESUME` Animo wychodzą 100% mimo MSVC6↔MSVC8. To jest miara „na ile się zmieniło" na poziomie kodu.
|
||
|
||
## Backend (FastAPI + katalog)
|
||
|
||
Modularny monolit nad SQLAlchemy — domyślnie SQLite (zero setupu), gotowy pod Postgres
|
||
przez `DATABASE_URL`. Pełny snapshot trzymany jest w bazie verbatim; diff czyta go z powrotem
|
||
przez `ams.diff`.
|
||
|
||
```bash
|
||
pip install -e ".[api,dev]" # zależności
|
||
python -m ams.api.importer --game "Reksio i UFO" snapshots/PIKLIB8.dll.snapshot.json
|
||
uvicorn ams.api.app:create_app --factory --reload # serwer
|
||
```
|
||
Endpointy: `POST/GET /games`, `POST/GET /snapshots` (import deduplikowany po sha256),
|
||
`GET /diff?old=&new=[&owner=]`, `GET /snapshots/{id}/similar`, `POST/GET /jobs`, `GET /health`.
|
||
Testy: `pytest` (28, w tym integracyjne na golden pair).
|
||
|
||
### Podobne wersje
|
||
|
||
`GET /snapshots/{id}/similar[?min=N]` rankuje pozostałe wersje w katalogu po **overlapie
|
||
powierzchni** — Jaccard zbiorów tożsamości (te same klucze co diff) per oś, plus pula `overall`.
|
||
Miara jest *cross-compiler*: golden pair PIKLIB8 (MSVC6) ↔ bloomoodll (MSVC8) wychodzi **85%**
|
||
(types 95% / methods 87% / events 77% / fields 90%), tam gdzie fuzzy-hash binarki daje 0.
|
||
Fuzzy (ssdeep) leci jako sygnał poboczny „prawie ten sam plik", gdy snapshot ma `binary.fuzzy`.
|
||
|
||
## Front — Command Center
|
||
|
||
Po starcie serwera otwórz **http://127.0.0.1:8000/** (`/` → `/ui/`). Statyczny UI bez build-stepu
|
||
(czysty HTML/CSS/JS w `ams/api/static/`, serwowany przez FastAPI): lista gier/wersji, wybór dwóch
|
||
wersji (A/B), wizualny diff po 4 osiach z filtrem klasy i przeglądarka pojedynczej powierzchni.
|
||
Przycisk **+ wgraj** w panelu gier otwiera upload ISO/ZIP/DLL → `POST /jobs`; status zadania
|
||
(queued→started→finished/failed) jest odpytywany na żywo, a po zakończeniu lista wersji odświeża się sama.
|
||
W przeglądarce pojedynczej wersji widać panel **Podobne wersje** (pasek overlapu + `⇄ diff` ustawiający
|
||
A/B i odpalający porównanie).
|
||
|
||
## Format snapshotu
|
||
|
||
`schema_version`, `binary{name,sha256,engine,compiler,factory_addr}`, oraz listy
|
||
`types` / `methods` / `events` / `fields`. Diff = operacje na zbiorach dwóch snapshotów.
|