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>
12 KiB
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)
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
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:
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
(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.pyheadless wymaga PyGhidry (CPython), której ten obraz nie inicjalizuje, i dostanieszGhidra was not started with PyGhidra. Python is not available(analiza przejdzie, ale post-skrypt nie wyemituje snapshotu). DomyślnyGHIDRA_URLceluje w 11.2.1 (z Jythonem). Chcesz zostać na 12.x? Trzeba doinstalowaćpyghidrai odpalać headless przez PyGhidrę — sam skrypt jest CPython-kompatybilny, więc zadziała, gdy interpreter wstanie (patrz dokumentacja PyGhidra w danej wersji Ghidry).
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):
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) ─────────────┘
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):
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.
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)
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.
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.