Initial commit: noxis JVM bytecode obfuscator

Workspace of 9 crates (noxis, noxis-core, noxis-classfile, noxis-analysis,
noxis-passes, noxis-runtime, noxis-jar, noxis-verify, noxis-structure-check).
AGPL-3.0-only license, Rust 2024 edition, unsafe_code denied.
This commit is contained in:
Loki 2026-10-08 22:01:03 +02:00
commit a641e69455
140 changed files with 17197 additions and 0 deletions

68
docs/README.md Normal file
View file

@ -0,0 +1,68 @@
# Документация noxis
Обфускатор JVM/Java-байткода на Rust. Этот файл — индекс.
## Что где
| файл | что внутри |
|---|---|
| [`architecture.md`](architecture.md) | слои, крейты, собственный IR, конвейер пассов, структурные правила |
| [`techniques.md`](techniques.md) | как работает каждая техника + плюсы/минусы/цена |
| [`stack.md`](stack.md) | почему свой IR, какой backend, `StackMapTable`, PRNG, генерация рантайма |
| [`learnings/lessons.md`](learnings/lessons.md) | уроки из разбора EvoVis: что защита сделала плохо и чего мы не делаем |
| [`learnings/reference.md`](learnings/reference.md) | разбор референсного обфускатора (Java/ASM) — что берём, что нет |
| [`learnings/priorities.md`](learnings/priorities.md) | что улучшим: стратегия и приоритеты |
План выполнения — [`../todo.md`](../todo.md). Исходный материал разбора —
[`../info/`](../info/README.md).
## Структурные правила проекта
Правила действуют на **весь наш код и документацию**: `crates/**` и `docs/**`.
Не учитываются: `Cargo.lock`, `.gitignore`, `info/`, корень крейта
(в нём всегда лежат `Cargo.toml` + `build.rs`).
1. **Не больше 250 строк** в одном `.rs` или `.md` файле.
2. **Не больше 4 файлов** в одной директории. Подпапки не ограничены:
`src/` может содержать `lib.rs` и три модуля-директории, каждая из которых
в свою очередь содержит не больше четырёх файлов.
Проверяются: `crates/*/src`, `crates/*/tests`, `crates/*/benches` и `docs/`.
`tests/` и `benches/` не исключены намеренно — иначе правило
«250 строк» обходится выносом кода в тесты.
Правило 2 — это не косметика: файл из тысячи строк означает, что в нём
смешаны разные причины к изменению (нарушение KISS/DRY/SOLID), а команда из
восьми файлов в одной папке — что модуль перестал быть модулем.
### Как проверяется
Правила гоняются **при компиляции**. Каждый крейт имеет `build.rs`:
```rust
fn main() {
noxis_structure_check::check();
}
```
`noxis-structure-check` находит корень воркспейста, обходит деревья `src/`,
`tests/` и `benches/` всех крейтов плюс `docs/`, и **падает со стопкой ошибок**,
если лимит нарушен. Поэтому `cargo build` не соберёт нарушение — специально
запускать проверку не нужно. Деревья регистрируются через
`cargo:rerun-if-changed`, так что правка `docs/` тоже перезапускает гейт.
Остальное, что не привязано к компиляции, собирается одним скриптом:
```sh
./scripts/check.sh # structure → fmt → clippy -D warnings → test
```
### Что делать при нарушении
- файл разрослся → разрезать по одной ответственности на файл:
`mod.rs` = публичный контракт модуля, соседние файлы — внутренности;
- папка переполнилась → завести подпапку и вынести подфункцию целиком,
а не «чтобы влезло».
Каждый модуль получает `mod.rs` с `//!`-документацией — это точка входа,
читающаяся вместо пяти файлов.

128
docs/architecture.md Normal file
View file

@ -0,0 +1,128 @@
# Архитектура
## Слои
```text
┌──────────────────────────┐
CLI (noxis) │ clap → конфиг → отчёт │
└────────────┬─────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ noxis-jar │ │noxis-passes │ │noxis-verify │
│ ZIP, ресурсы│ │ трансформации│ │ red-паттерны │
└──────┬──────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ ┌──────┴───────┐ │
│ │ noxis-core │ │
│ │ конфиг, seed │ │
│ │ маппинги, Pass│ │
│ └──────┬───────┘ │
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────┐
│ noxis-classfile (IR) │
│ Program → ClassFile → Code → Insn (метки, не смещения)│
│ model/ format/ insn error │
└────────────────────────────────────────────────────────┘
▲
┌──────┴──────┐ ┌───────────────┐ ┌──────────────────┐
│noxis- │ │noxis-runtime │ │noxis-structure- │
│analysis │ │ шифры, ABI │ │check (build-time)│
└─────────────┘ └───────────────┘ └──────────────────┘
```
Зависимости направлены только вниз. Циклов нет: сборка конвейера живёт в
`noxis-passes`, потому что `noxis-core` знает только контракт `Pass`.
## Крейты
| крейт | ответственность |
|---|---|
| `noxis-classfile` | собственный IR классовых файлов, чтение/запись, `StackMapTable` |
| `noxis-core` | конфигурация, детерминированный seed, маппинги, контекст и контракт `Pass` |
| `noxis-analysis` | иерархия наследования, entrypoint'ы, рефлексия по имени, keep-правила |
| `noxis-passes` | сами трансформации + сборка стандартного конвейера |
| `noxis-runtime` | схемы шифрования строк и ABI класса-декодера |
| `noxis-jar` | ZIP: чтение, перепаковка, вырезание подписей, патч ресурсов |
| `noxis-verify` | red-паттерны, `java -Xverify:all`, метрики |
| `noxis` | CLI |
| `noxis-structure-check` | автогейт структурных правил (см. [`README.md`](README.md)) |
## Собственный IR
Пассы не работают с API стороннего парсера — они работают с моделью:
```text
Program все классы + ресурсы архива
├─ ClassFile версия, флаги, this/super, поля, методы, атрибуты
│ ├─ Field
│ └─ Method
│ └─ Code max_stack/max_locals, insns, exception_table, frames
└─ Resource путь + байты внутри архива
```
Ключевое решение — **`Insn::Mark(Label)` вместо байтовых смещений**:
- любой пасс, меняющий порядок или длину инструкций, не пересчитывает
jump-офсеты;
- пересчёт делается ровно один раз — в writer'е;
- в IR нет состояния, в котором «смещение уехало, а метка осталась».
Атрибуты, которые мы не понимаем, хранятся как `Attribute::Raw { name, data }`
и возвращаются на место без изменений — round-trip без потерь.
## Контракт пасса
```rust
pub trait Pass: Send + Sync {
fn id(&self) -> PassId;
fn apply(&self, ctx: &mut Context, program: &mut Program)
-> Result<PassReport, CoreError>;
}
```
- `Context` — всё состояние прогона: конфиг, мастер-сид, маппинги, заметки.
Из него берётся RNG: `ctx.rng("rename/classes")` — доменное разделение,
чтобы случайности разных пассов не были статистически коррелированы.
- `PassReport` — что трансформировано, что сознательно пропущено и почему.
- `Report` — собирает отчёты всех пассов + счётчики, сериализуется в JSON.
Детерминированность — свойство, а не пожелание: одинаковый seed на одинаковой
версии даёт байт-в-байт одинаковый выход. Это проверяется тестами.
## Конвейер
Порядок пассов — не деталь:
```text
Rename → Resources → Strings → Constants → Flow → Noise → DebugInfo
```
почему именно так:
1. **Rename первым** — все остальные техники порождают новые ссылки
(`LDC`-шум, вызовы декодера, junk-классы), и они уже должны быть в
обфусцированных именах.
2. **Resources сразу после Rename** — патч `fabric.mod.json`, `*.mixins.json`,
refmap и access widener должен видеть *итоговые* имена классов.
3. **Strings и Constants** — работают по телам методов, ещё не искажённым
control-flow'ем.
4. **Flow** — искажает CFG. Если поставить его раньше, Strings/Constants
перестанут находить свои шаблоны в инструкциях.
5. **Noise** — junk-классы и аннотации добавляются в самом конце, когда
целевой граф уже финален.
6. **DebugInfo последним** — strip/spoof отладочной информации после всех
трансформаций, чтобы не породить свежие `LineNumberTable`.
## Статус
| этап | что |
|---|---|
| **готово** | IR, модель, конфиг, seed/PRNG, маппинги, анализ, keep, переименование, шифры строк, гейт правил, `format::parse`/`write` поверх ристретто, вывод кадров `StackMapTable` |
| **P2** | пассы строк/констант/шума/потока, генератор декодера, CLI |
| **P2** | генерация класса-декодера, вывод мастер-секрета, остальные пассы |
| **P3** | CLI, метрики, интеграция с `java -Xverify:all` |
Детали выбора — [`stack.md`](stack.md), план — [`../todo.md`](../todo.md).

146
docs/learnings/lessons.md Normal file
View file

@ -0,0 +1,146 @@
# Уроки: что защита EvoVis сделала плохо
Источник: `info/evo-weaknesses.md` — полный разбор за один проход, без
рантайм-хаков. Здесь — что из этого следует для **нашей** архитектуры.
Правило, которое стоит держать головой: **защита ломается не в рантайме,
а на этапе, когда атакующий смотрит на статику.** Всё, что видно в `javap -p`
или в содержимом jar, — это утечка.
## Сводная таблица: провал → наше решение
| № | провал EvoVis | наше решение | пасс | статус |
|---|---|---|---|---|
| 1 | ключ лежит рядом с шифротекстом | вывод мастер-секрета в декодере, ключ из `master‖salt` | Strings | частично |
| 2 | переименованы типы, но не члены | rename полей/методов + семейства virtual | Rename | **готово** |
| 3 | отладочная информация нетронута | strip + ложный `SourceFile`/номера строк | DebugInfo | план |
| 4 | шаблонный flattening | 4 схемы CFG + мешанина внутри метода | Flow | план |
| 5 | opaque-предикаты = `A+B` | ротация тождеств + предикаты от состояния | Constants | план |
| 6 | junk-классы вне графа reachability | `weave_reachable` — обязательная вплетённость | Noise | план |
| 7 | имена говорят «я защита» | все артефакты получают данные псевдонимы | Rename | **готово** |
| 8 | вторичные компоненты без защиты | весь jar, а не только основной класс | Resources | план |
| 9 | шифр без MAC и без per-string salt | salt на строку, уникальный nonce, MAC | Strings | **готово** |
## 1. Ключ рядом с шифротекстом
`awc.d("<cipher>", <opaque-выражение>)` — ключ был **константным выражением
в вызове**. Свёртка выражения — ~200 строк любого парсера. Итог: 8993 вызова
расшифрованы детерминированно, **0 обращений к JVM**.
Хуже: один шифр на весь продукт (LCG с фиксированными `a/c`) — зная одну
пару «шифротекст → текст», остальные раскрываются по обучению.
**Что делаем иначе.**
- Ключ строки = `F(master, salt)`, `master` **выводится** в рантайме и не
является константой в `LDC`.
- `salt` лежит рядом с шифротекстом, но сам по себе бесполезен без `master`.
- **Ротация схем** (`xor-splitmix` / `xtea-ctr`): одна расшифровка не даёт
ключа ко всему jar. Плюс per-string `nonce`, чтобы пары «текст→шифр»
не повторялись и частотный анализ не работал.
- MAC: подмена байта не «тихо» портит текст.
Остаточный риск честно признан: целевой анализ декодера раскрывает текст.
Задача — поднять цену, а не сделать невозможным.
## 2. Члены не переименованы
`onDraw`, `getProjectiles`, `isAuthorized()` — открытые имена. Класс `wb`
опознаётся за секунду по трём понятным методам.
**Что делаем иначе:** переименование полей и методов входит в базовый
уровень, а не в «продвинутый». Уже готово, включая:
- **семейства virtual** — переименование на всю иерархию, чтобы не
разъединить переопределения и не сломать `invokevirtual`;
- **keep-список** — private/static/final можно сразу, `rename_public_members`
по умолчанию выключен для библиотек, чтобы не сломать API;
- **автодетект рефлексии по имени** — `Class.forName`, `getMethod`,
`getField` с литералами попадают в keep автоматически.
## 3. Отладочная информация
`SourceFile` оригинальные у 160 классов, `LineNumberTable` — 10 218,
`LocalVariableTable` — имена локальных переменных, `@rn(name, description)` —
каталог всех модулей с описаниями.
**Что делаем иначе:** пасс `DebugInfo` — strip `SourceFile`,
`LineNumberTable`, `LocalVariableTable`, `Signature`, `InnerClasses` +
**ложная** отладочная информация (чужие имена и номера строк, чтобы
деобфускатор выдавал правдоподобный, но чужой код).
Отсюда обязательное требование: **маппинги в отчёте** — иначе мы сами не
читаем свои stack trace'и.
## 4. Шаблонный flattening
Один regex нашёл 3530 автоматов: `while(true) { switch(i + i2) }`, метки —
константы, переходы — присваивания в одном basic block, тела блоков не
тронуты.
**Что делаем иначе:** не одна схема, а **четыре** (`state-spread`,
`computed-switch`, `butterfly`, `exception-goto`), с ротацией между методами
и с перемешиванием внутри метода. Плюс `butterfly` и `exception-goto`
ломают «все переходы — присваивания».
Это единственная техника с **высоким риском**: любая ошибка в CFG =
`VerifyError`. Поэтому блокером Flow объявлен не «напишем потом», а
`StackMapTable` — см. [`../stack.md`](../stack.md) §3. Без верификатора
Flow не включается.
## 5. Opaque-предикаты
`(A^B)+((A&B)<<1)` — школьное тождество, узнаётся регуляркой и даёт
бесплатный фолдинг остальных констант. Настоящих предикатов (зависящих
от состояния и всегда истинных) нет.
**Что делаем иначе:** ротация семейств тождеств (арифметика, побитово,
сдвиги, произведение соседних чисел, инвариант по модулю) + предикаты,
завязанные на состояние, а не на пару констант.
## 6. Reachability убивает junk
42 файла, ни одного входящего ссылока, `dangling=0` → BFS от 4
entrypoint'ов, которые открыто лежат в `fabric.mod.json`.
**Что делаем иначе:** `weave_reachable` — junk попадает в граф
достижимости, создаёт ложные рёбра и участвует в вызовах. Это свойство
пасса, а не настройка: без вплетённости junk не включается вовсе.
## 7. Самоописание защиты
`StringRuntime`, `KeyAssembler`, `AttestClient`, `RuntimeGuard`,
`SealedBoot`, `NativeGuard` — названия указывают атакующему, куда смотреть.
**Что делаем иначе:** псевдонимы генерируются одним `NameGenerator` для
всех сущностей — классы, пакеты, поля, методы, junk и даже имена атрибутов
и схем выглядят как обычные данные. Отдельного «защитного» стиля не
существует, поэтому отличить его от остального кода не по чему.
## 8. Вторичные компоненты
`evo-agent.jar` вообще без обфускации, .NET-лаунчер — статическая XOR-таблица.
**Что делаем иначе:** обфускация применяется ко **всему архиву**, включая
ресурсы (`fabric.mod.json`, mixins, refmap, access widener) — см. пасс
`Resources`. Отдельного «ядро защищено, остальное нет» не бывает.
## 9. Шифр без аутентификации
Нет MAC, нет per-string сида → повреждение не ловится, частотный анализ
работает.
**Уже сделано:** `salt` на строку, уникальный `nonce`, FNV-1a MAC в
`noxis-runtime::scheme`. `cipher:tag` лежат рядом — порча обнаруживается.
## Что подтвердилось и не нужно повторять в выводах
Разбор дал и **опровержения** — важно не строить защиту на мифах:
- «сдвиги в байткоде бывают отрицательными» — по 1277 классам **0** случаев
(32 559 сдвигов, константные всегда `1`);
- `awc.g(...)` (session-gate) в билде **не вызывался ни разу**.
Вывод: проверять гипотезу на статистике **до** того, как вкладываться
в технику против неё. Отсюда требование к `noxis-verify` — не «красиво
обфусцировано», а измеримые метрики ([`priorities.md`](priorities.md)).

View file

@ -0,0 +1,116 @@
# Что улучшим: стратегия и приоритеты
Материал собран из `info/evo-improvements.md` (план после разбора EvoVis) и
`info/evo-weaknesses.md` (провалы). Здесь — что мы берём, в каком порядке
и **что осознанно не делаем**.
## Стратегия в одном абзаце
Максимум защиты при минимуме риска сломать рантайм. Дешёвые техники,
дающие 80% результата, включаются по умолчанию; дорогие и опасные —
включаются **только** после того, как для них есть верификатор. Каждая
техника имеет измеримый критерий «сработала/не сработала», иначе это
не защита, а мнение.
## P0 — базовый уровень (дёшево, снимает главные утечки)
| пункт | что это | пасс | статус |
|---|---|---|---|
| A1 | переименование **всех** символов: классы, поля, методы, локальные переменные, параметры, record-компоненты, enum-константы, `SourceFile` | Rename | ✅ готово |
| A2 | вычистить `LineNumberTable`/`LocalVariableTable`/`SourceFile`/`InnerClasses`/`Signature`/`MethodParameters` | DebugInfo | план |
| A3 | никаких сигнатурных имён у защитного кода, защитный код смешан с обычным | Rename + Noise | ✅ / план |
| A4 | обфусцировать **весь** архив, включая ресурсы, mixin-JSON, access widener, service loader'ы | Resources | план |
| — | маппинг в отчёт (иначе не читаем свои же stack trace) | Rename | ✅ готово |
Это самый дешёвый блок и одновременно **самый большой утечка** в разобранном
образце: оригинальные `SourceFile` у 160 классов, имена полей и методов,
каталог модулей в атрибутах.
## P1 — строки и константы
| пункт | что это | пасс | статус |
|---|---|---|---|
| B1 | ключ на строку + ротация 3–5 схем + MAC + уникальный nonce | Strings | ✅ схемы готовы |
| B2 | **вывод** ключа в рантайме, а не константа в вызове | Strings + генератор декодера | ⛔ блокер |
| B3 | константы — не тождества, а вычисления, разнесённые по блокам | Constants | план |
| B4 | **ложная** отладочная информация: чужие имена переменных и строк | DebugInfo | план |
B2 — главное отличие от разобранного образца: у него ключ был константным
выражением в вызове и расшифровывался статически, без JVM. Мастер-секрет
должен собираться в рантайме из нескольких мест. Это требует сначала
собрать класс-декодер (P2) — потому и блокер, а не «сделаем потом».
## P2 — контрольный поток
| пункт | что это | статус |
|---|---|---|
| C1 | **несколько схем** флаттенинга вместо одной, с мешаниной внутри метода | план |
| C2 | верификатор обязателен: ошибка CFG = `VerifyError` | план |
| C3 | реальный объём ≠ раздувание: бюджет на размер и время | план |
Приоритет здесь строго подчинён: **C2 раньше C1**. Включать Flow без
проверки кадров `StackMapTable` — это гарантированный битый jar.
Подробности: [`../stack.md`](../stack.md) §3.
## P3 — измеримость (метрики, без которых ничего не доказано)
Обязательный блок, иначе фраза «обфускация есть» ничего не означает:
1. **Утечки имён.** Список эталонных осмысленных идентификаторов;
если хоть один остался в `strings`/константах — тест падает.
2. **Читаемость декомпилята.** Прогон JADX/CFR → доля распознанных структур
(try/catch, switch, циклы), доля классов с восстановленными именами.
3. **Противник.** Минимальный скрипт-деобфускатор (свёртка констант,
regex-паттерны). Если он снимает больше порога — техника провалена.
**Красный список паттернов держим в репозитории.**
4. **Семантика.** Эталонные программы до и после — 100% совпадение.
5. **Бюджеты.** Размер ≤ N%, время исполнения ≤ M%, отдельно — бюджет
раскрытия строк (кэш, чтобы не платить на каждый вызов).
6. **Стабильность между сборками.** Имена и схемы **меняются** от билда
к билду: у разобранного образца шифр был один на все версии — готовое
окно для обучения по частотам.
Это блок `noxis-verify`: красные паттерны + `java -Xverify:all` +
метрики. Единственное место, где «нам кажется защищённым» заменяется
цифрой.
## P4 — долгий горизонт (пока не делаем)
| пункт | что это | почему не сразу |
|---|---|---|
| D1 | маленький VM/interpreter для ценных кусков | цена окупается только на ключевом коде |
| D2 | split-function / раскрываемое по шагам тело | режет обычный профилировщик, сложная отладка |
| D3 | мягкий анти-анализ в рантайме | требует своего нативного компонента |
| D4 | честный anti-tamper, а не «зашейрь ключ» | целая инженерная задача, не пасс |
| E | собственный нативный/Rust-цель вместо JVM | другой продукт, не наша цель |
D1 — явно ограничен: включать только для действительно ценных функций,
остальное оправдания не имеет. E — отдельный продукт; если он когда-нибудь
понадобится, наш IR как раз делает его возможным (те же пассы, другой
writer).
## Что мы добавили сверх исходного плана
1. **Структурный гейт при компиляции.** Лимиты «250 строк / 4 файла»
гоняются в `build.rs` — см. [`../README.md`](../README.md). Без этого
план P0–P3 превращается в один гигантский файл через месяц.
2. **Собственный IR с метками.** Исходный план предполагал работу поверх
готового парсера. `Insn::Mark(Label)` убирает пересчёт смещений из
всех пассов — это меняет цену каждой техники, меняющей CFG.
3. **Детерминированный PRNG с доменным разделением.** Без этого
пункт «стабильность между сборками» невозможно проверить тестом.
4. **Keep как отдельный крейт (`noxis-analysis`), а не флаг CLI.**
Рефлексия, JNI, сериализация и entrypoint'ы — это анализ иерархии,
а не настройка, которую надо помнить руками.
## Порядок выполнения
Смотрите [`../../todo.md`](../../todo.md). Кратко:
```text
P0 DebugInfo + Resources → снимает главные утечки, почти бесплатно
P1 Strings (B2) + Constants → ключ выводится, схемы ротируются
P1 backend (ristretto) + frames
P2 Flow (C1) после верификатора
P3 метрики (F) и красные паттерны
```

150
docs/learnings/reference.md Normal file
View file

@ -0,0 +1,150 @@
# Референс: Fabric Mod Obfuscator (Java + ASM)
Разобранный образец лежит в `/tmp/opencode/obf_extract/OBF` (JAR из `OBF.rar`).
Нужен не как «правильный ответ», а как **карта решений, которые уже были
приняты и проверены на живых кейсах**.
## 1. Что это
| | |
|---|---|
| язык | Java 21, Maven, сборка `mvnw package` |
| движок | **ASM** (читает `.class`, строит маппинги, переписывает байткод) |
| объём | 18 классов в `org.example`, 3224 строки |
| цель | Fabric/Minecraft-моды |
| интерфейс | CLI + Swing GUI (drag-and-drop) |
## 2. Карта модулей → наш план
| модуль | LOC | что делает | у нас |
|---|---:|---|---|
| `JarRemapper` | 409 | обход архива, remap всех ссылок | `noxis-jar` + `rename::rewrite` |
| `InheritanceAnalyzer` | 373 | иерархия, безопасность переименования | `noxis-analysis::hierarchy` ✅ |
| `ObfuscatorGui` | 276 | GUI | не нужно (CLI) |
| `MappingGenerator` | 262 | маппинг → файл | `noxis-core::mapping` ✅ |
| `JunkClassGenerator` | 251 | junk-классы | пасс `noise` |
| `StringDecoderGenerator` | 205 | генерит класс-декодер | `noxis-runtime` + P2 генератор |
| `ObfuscatorRunner` | 202 | конвейер, оркестрация | `noxis-passes::standard_pipeline` ✅ |
| `NumberObfuscator` | 185 | константы / opaque | пасс `constants` |
| `ControlFlowObfuscator` | 183 | flattening | пасс `flow` (ждёт `StackMapTable`) |
| `FabricKeepRuleScanner` | 147 | keep из конфига Fabric | `noxis-analysis::keep` ✅ |
| `Main` | 134 | CLI-точки | `noxis` (clap) |
| `StringEncryptor` | 132 | AES-128 строк + chunk'и | `noxis-runtime::scheme` ✅ |
| `NameGenerator` | 131 | имена | `noxis-passes::namegen` ✅ |
| `JunkAnnotationInjector` | 79 | junk-аннотации | пасс `noise` |
| `PackageGenerator` | 78 | пакеты | `namegen::generate_packages` ✅ |
| `AccessWidener` | 73 | патч access widener | `noxis-jar` + пасс `resources` |
| `DebugInfoStripper` | 69 | strip отладки | пасс `debug` |
| `StringFilter` | 35 | что считать строкой | функция внутри `strings` |
**Вывод:** структура совпадает с нашей почти один в один — это подтверждает
раскладку крейтов. Ключевое отличие: у них всё живёт в одном jar'е и
одном проекте; у нас конвейер отделён от I/O и от CLI.
## 3. Что берём
### 3.1 Порядок пассов
У них: remap → strings → numbers → flow → junk → debug. Тот же инвариант,
что и у нас: **переименование раньше всех техник, порождающих новые ссылки**,
а `debug` — последним.
### 3.2 Наследование как условие безопасности
`InheritanceAnalyzer` — самый большой после remap'ера модуль. Совпадает с
нашим выводом: переименование virtual-методов **без** иерархии ломает
`invokevirtual`. У нас это `hierarchy` + семейства в `rename`.
### 3.3 Keep-сканер
`FabricKeepRuleScanner` читает правило из конфигурации Fabric и не даёт
сломать то, что JVM находит по имени. Мы делаем то же самое + расширяем
рефлексией по строкам.
### 3.4 Подпись срезается
`*.SF/*.RSA/*.DSA/*.EC` удаляются при перепаковке — после записи подпись
всё равно невалидна, а её наличие заставляет JVM падать. Это входит в
`noxis-jar`.
### 3.5 Маппинг в файл + ложные ограничения
`--mappings` пишет соответствие «новое → старое» — без него автор сам не
читает stack trace. Плюс честные off-ключи: `--no-strings`, `--no-flow`,
`--no-junk`, `--keep-lines`, `--keep-source`, `--keep-vars`, `--no-widen`.
Мы берём то же: **каждый пасс выключаем по отдельности**, иначе диагностика
проблемы «что сломало» превращается в угадывание.
### 3.6 Seed
`--seed` — детерминированный прогон. У нас это базовое свойство
`noxis-core`, без отдельного флага.
## 4. Что не берём
### 4.1 Java + ASM
Главное отличие стека. Пара Java+ASM даёт родную JVM, готовые visitor'ы и
`CheckClassAdapter` — но и:
- нужно раскатывать JDK для сборки и запуска тестов;
- наш проект — Rust (`deny(unsafe_code)`, свои лимиты, свои гейты);
- отсутствует возможность отдавать один бинарь.
Цена, которую мы платим: `StackMapTable` придётся считать самим (через
inference-верификатор — [`../stack.md`](../stack.md) §3). Это осознанно.
### 4.2 Swing GUI
Лишняя точка входа: GUI дублирует CLI и удваивает площадь тестирования.
У нас — только clap.
### 4.3 `StringDecoderGenerator` как «генератор текста»
Они генерируют Java-исходник декодера и компилируют. Для нас это означало бы
зависимость от `javac` на этапе обфускации. Наш путь — генерировать
**байткод напрямую** через наш IR (`noxis-runtime` держит схемы, генератор
собирает `ClassFile`).
### 4.4 AES-128 + Base64 как единственная схема
Одна схема на весь архив = тот же провал, что у EvoVis §1/§9. У нас
ротация схем, per-string salt, nonce и MAC.
### 4.5 `--flatten-flow` только «для простых методов»
Ограничение из их README: агрессивный switch-gate включается только на
простых методах. Это признаёт, что **верификация у них эмпирическая**.
Мы вместо этого делаем верификатор обязательным условием включения `flow`.
## 5. Что проверяем на их smoke-тестах
В `target/` референса лежат готовые проверочные кейсы — их стоит
переиспользовать как наш regression-набор:
| кейс | что ломает |
|---|---|
| `smoke-safe` | базовый round-trip, `Sample`/`MyMixin` |
| `smoke-inheritance` | `Base`/`Child`/`InheritedChild`/`ExternalChild` — семейства virtual и мосты |
| `smoke-flow` | `LoadAll` — загрузка всех классов, ловит `VerifyError` |
| `smoke-fields` | переименование полей и статических полей |
| `smoke-aes` | шифрование строк, `RunStrings` сверяет расшифровку |
| `Probe.java` | точечная проверка одной гипотезы |
Это ровно тот класс тестов, который нужен `noxis-verify`: **загрузить
и выполнить**, а не «выглядит обфусцированным».
## 6. Итог
| берём | не берём |
|---|---|
| порядок пассов | стек Java + ASM |
| иерархия как условие rename | Swing GUI |
| keep-сканер | генератор Java-исходника |
| срез подписей, патч ресурсов | единственная схема шифра |
| маппинг в отчёт, честные off-ключи | эмпирическая верификация flow |
| их smoke-кейсы как regression | — |
Полный перечень провалов защиты, которую они **не** закрывают, — в
[`lessons.md`](lessons.md).

242
docs/stack.md Normal file
View file

@ -0,0 +1,242 @@
# Стек: почему именно так
Документ фиксирует решения, которые трудно отменить. Для каждого —
альтернативы, критерии, цена и что меняет решение.
## 1. Собственный IR, а не API чужой библиотеки
**Решение:** пассы работают с `noxis-classfile::model::{Program, ClassFile,
Code, Insn}`, а не с типами парсера.
**Почему не «просто использовать готовую библиотеку целиком»:**
- библиотека диктует API — каждый пасс привязывается к чужим миграциям
мажорной версии (наш лимит «≤4 файлов» легко разрывается «транзитивно»);
- у нас есть конкретное требование, которого нет ни в одном парсере:
`Insn::Mark(Label)` вместо смещений;
- мы не знаём заранее, захочет ли пользователь backend'а ещё одну
библиотеку (например, для `StackMapTable`) — изоляция через IR даёт
смену backend'а без переписывания пассов.
**Что IR даёт на практике:**
| свойство | эффект |
|---|---|
| метки вместо смещений | пассы никогда не пересчитывают jump-офсеты |
| `Attribute::Raw` | round-trip без потерь: неизвестный атрибут возвращается как есть |
| `Program` включает ресурсы архива | переименование и патч ресурсов в одном прогоне |
| одна точка записи | пересчёт смещений, `max_stack` и `StackMapTable` — ровно один раз |
**Цена:** конвертация `model ↔ backend` на границах (чтение и запись) —
примерно один файл на направление. Дёшево.
## 2. Backend для чтения/записи — `ristretto_classfile`
**Решение:** `format::parse` / `format::write` реализуются поверх
`ristretto_classfile 0.34`, наш IR остаётся единим API для пассов.
Реализация — этап P1, до него обе функции возвращают `Unimplemented`.
### Кандидаты
| крейт | чтение | запись | верификация | активность | загрузки | MSRV |
|---|---|---|---|---|---|---|
| **`ristretto_classfile` 0.34** | ✓ | ✓ | ✓ (JVMS §4.10.2) | 2026-09 | 41k | **1.97.1** |
| `jclassfile` | ✓ | — (parser) | — | 2026-08 | 28k | — |
| `classfile-parser` | ✓ | — (parser) | — | 2024-10 (устарел) | 31k | — |
| `java-bytecode` | ✓ | ✓ | ? | 2026-10 | 41 | — |
| `jvm-assembler` | — | — | — | **2015, мёртв** | 3k | — |
### Критерии и что подтвердил spike
MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` (совместима с
нашим `deny(unsafe_code)`), запись, а не только чтение, разбор
`StackMapTable`, живой апстрим (CI, бенчи, релиз 2026-09). Спайк
`tests/spike_ristretto.rs` подтверждает round-trip, `verify()` и
конструирование кадров.
### Что отвергнуто
- **Писать парсер с нуля** — спецификация JVMS §4 по формату классов
плюс MUTF-8, constant pool, модули, records, переполнения — это месяцы
работы ради того, что уже написано и покрыто тестами. Это нарушает KISS
и не добавляет ни одной защиты.
- **`classfile-parser`, `jclassfile`** — только чтение, значит writer всё
равно наш; `classfile-parser` не обновлялся с 2024-10.
- **`java-bytecode`** — 41 загрузка: у проекта нет инерции и нет экосистемы.
- **`jvm-assembler`** — последний коммит в 2015.
## 3. `StackMapTable`: генерируем сами, пользуемся верификатором
**Проблема.** Классы версии ≥ 50 требуют `StackMapTable`. Любая техника,
меняющая CFG (Flow), и любая, добавляющая ветки (Strings, Constants, Noise),
делает сохранённые кадры неверными → `VerifyError`.
**Решение:** не считать кадры самим, а **вывести их типовым выводом
(JVMS §4.10.2)** из готового байткода:
```text
наш IR → Instruction[] → inference (worklist, LUB на merge-точках)
→ кадры на каждом target'е ветвления
→ StackMapTable → запись
```
`ristretto_classfile` содержит всё нужное **в исходном виде** — но
снаружи, к сожалению, отдаёт не всё (см. результат спайка ниже):
- `verifiers/bytecode/inference.rs` — итеративный dataflow, LUB, фикспоинт;
- `verifiers/bytecode/control_flow.rs` — `compute_successors`, worklist;
- `verifiers/bytecode/stackmap.rs` — декодер/кэш `StackMapTable`;
- `attributes/{max_stack,max_locals,instruction/stack_effect}.rs` —
пересчёт `max_stack`/`max_locals` из инструкций;
- `attributes/{stack_frame,verification_type}.rs` — типы для записи.
**Почему это лучше, чем свои кадры:** это ровно та часть JVMS, в которой
ошибка невидима в исходниках и всплывает только на `VerifyError` в чужом
рантайме. Писать такой верификатор — самая дорогая и самая опасная часть
всего проекта.
**Альтернативы, которые отвергнуты:**
- **Понижать версию класса до 49 и выкинуть `StackMapTable`** — ломает
`invokedynamic`/лямбды и включает дорогой fallback-верификатор; на Java 9+
это заметно и противоречит цели.
- **Не делать Flow вообще** — лишает технику, которая даёт ★★★★ к
читаемости. Не вариант: вычислим кадры.
**Результат spike'а (`tests/spike_frames.rs`) — главный вывод раздела:**
| что проверяли | что получили |
|---|---|
| `verify_class` + `FallbackStrategy::AlwaysInference` на классе с ветвлением и без `StackMapTable` | ✅ `success`, `path_used == Inference` — движок вывода типов работает |
| `FallbackStrategy::Strict` на том же классе | ✅ `Err("StackMapTable required ... with control flow")` — требование v50+ реально проверяется |
| `StackMapTable` с кадром **не на том** сдвиге | ✅ `Err("Merge point at offset N requires StackMapTable frame")` — ошибка локализована точно |
| получить сам кадр из крейта | ❌ **нельзя** |
Причина последней строки: в `ristretto_classfile/src/verifiers/bytecode.rs`
модули `inference`, `diagnostics`, `frame`, `stackmap`, `control_flow`
объявлены как `pub(crate)`. Публично отданы только `verify`, `verify_class`,
`verify_method`, `VerificationResult`, `VerifierConfig` и
`VerificationContext`. Диагностика в `VerificationResult.diagnostic`
заполняется лишь на отдельных путях отказа, а в строгом режиме крейт
возвращает `Err(VerifyError)` целиком — то есть **ожидаемый кадр до нас
не доходит**, только его смещение.
**Что это меняет для P1.** Кадры считаем сами, а крейт используем как
**приёмочный тест**:
```text
наш IR → Instruction[] → наши кадры → StackMapTable → запись ристретто
↓
verify_class(Strict) = Ok → артефакт валиден
```
Три способа получить сами кадры, в порядке предпочтения:
1. ~~**Вендор `verifiers::bytecode::inference`.**~~ — **отвергнут после
оценки объёма.** Один модуль не оторвать: в `inference.rs` 14 ссылок
`crate::`, включая private-модули `frame`, `type_system`, `handlers`,
`control_flow`, `diagnostics`. Вендорить пришлось бы весь
`verifiers/bytecode/` (~9 700 строк) или весь крейт (**61 193 строки
`src/`**, 2.3 MB) ради одной патчи `pub(crate)` → `pub`. Чужой код
становится третьей по объёму частью репозитория, отстаёт от апстрима и
не проходит наши правила (250 строк / 4 файла).
2. **Свой вывод на своём IR — принято.** Кадры считаются по `noxis::model`,
а не по ристретто: `format::parse` → IR → пассы → `compute_frames(IR)` →
`Attribute::StackMapTable` → `format::write`. Ристретто остаётся
**парсером, писателем и приёмочным тестом** из crates.io, без форка.
3. ~~Итеративная починка через `diagnostic`~~ — **отвергнута спайком**: поля
`expected`/`pre_frame` в этом пути недостижимы.
**Почему 2, а не 1.** Наш dataflow живёт на типах, которые и так нужны
пассам: `Insn`, метки вместо смещений, иерархия классов из
`noxis-analysis::hierarchy` для LUB на merge-точках. Кадры — это ровно
те же инструкции, что уже переписывает `rename::rewrite`, поэтому
промежуточная конвертация `model → Instruction[] → model` (требование
вендора) добавляет ещё один источник расхождений. Цена — своя реализация
(~1 000–1 500 строк: границы блоков, worklist, merge, LUB, эмиссия
`StackFrame`), но она покрывается двумя независимыми судьями:
```text
наш IR → compute_frames → StackMapTable → запись ристретто
↓ ↓
noxis-verify (red-паттерны) verify_class(Strict) = Ok
↓
java -Xverify:all jar
```
Второй судья — ристретто `verify_class` со стратегией `Strict` — ловит
ровно тот класс ошибок, ради которого вендор и рассматривался: неверный
сдвиг кадра, пропущенная merge-точка, неверный тип.
**Критерий готовности этапа:** на всех jar из `fixtures` (см. `todo.md`)
`verify_class(Strict) == Ok` и `java -Xverify:all` без `VerifyError`.
## 4. Генерация рантайма, а не вшитая таблица
**Решение:** `noxis-runtime` — это **заготовки Rust-кода**, из которых
собирается класс-декодер (P2), а не готовый бинарь.
**Почему:** ABI декодера — `d(Ljava/lang/String;I)Ljava/lang/String;`,
но схем шифрования несколько (`xor-splitmix`, `xtea-ctr`) и их число
растёт. Константная таблица селекторов рядом с текстом — ровно та ошибка,
которую мы разбирали (`info/evo-weaknesses.md` §1): **ключ рядом с
шифротекстом**. Генератор выбирает схемы на этапе сборки артефакта и
кодирует выбор в самих вызовах, а мастер-секрет выводится в рантайме.
**Что нельзя делать:** вшивать мастер-секрет как константу в `LDC`.
## 5. PRNG: свой, не `rand`
**Решение:** `xoshiro256++` + `splitmix64` + доменное разделение по FNV-1a.
- **Детерминированность обязана:** один seed на одинаковой версии →
байт-в-байт одинаковый jar. Это свойство, а не удобство: без него
воспроизводимая сборка, откат и сравнение версий невозможны.
- **Доменное разделение:** `ctx.rng("rename/classes")`,
`ctx.rng("noise/junk")` — сид деривается из имени домена, поэтому
случайности разных пассов не статистически коррелированы.
- **Зависимость `rand` тянет** OS-энтропию и меняющееся поведение между
версиями. Нам нужен ровно обратный эффект.
## 6. ZIP: `zip`
Критерии: чтение, запись, **сохранение порядка и сжатия**, контроль
дубликатов. Берём `zip` (стабильный релиз, без `time`/`aes`/`zstd` —
лишние фичи только раздувают бинарь; `default-features = false,
features = ["deflate"]`). Подпись (`*.SF/*.RSA/*.DSA/*.EC`) вырезаем на
уровне записей архива — после перепаковки она всё равно невалидна.
Альтернатива `zip6` — форк того же кода; при равном API он даёт меньше
тестов и меньшую инерцию. Нет причины платить за это.
**MSRV воркспейса поднят до 1.97** — это ровно наш тулчейн (rustc 1.97.1)
и минимум `ristretto_classfile 0.34`. Заявлять 1.85, когда зависимость
требует 1.97, — враньё в `Cargo.toml`.
## 7. CLI: `clap` (derive)
Стандарт, стабильный API, поддержка цветов и автодополнения. Альтернативы
(`bpaf`, `gumdrop`, ручной парсер) экономят размер сборки, но не решают
ни одной нашей задачи.
## 8. Верификация: `java -Xverify:all` первым делом
Единственная честная проверка «я ничего не сломал» — запустить настоящую
JVM. `noxis-verify` гоняет `java -Xverify:all` на обфусцированном jar
и по red-паттернам ищет дыры в защите.
**Запасной вариант, если JDK не гарантирован:** `ristretto_vm` —
встраиваемый JVM на Rust из того же репозитория. Для нас это означает
`noxis-verify` без внешних зависимостей. Стоит проверить в P3, пока
используем системный `java` (в окружении есть OpenJDK 21).
## Сводка
| решение | альтернатива | цена |
|---|---|---|
| свой IR + метки | писать пассы поверх чужого API | конвертер на границе |
| `ristretto_classfile` | свой парсер / parse-only крейты | зависимость 1 уровня |
| inference-кадры | свой верификатор / downgrade версии | spike на публичность API |
| генерация декодера | вшитая таблица селекторов | сложнее собирать |
| свой PRNG | `rand` | ~1 файл |
| `java -Xverify:all` | только красные паттерны | нужен JDK |

167
docs/techniques.md Normal file
View file

@ -0,0 +1,167 @@
# Как работает: техники, плюсы, минусы
Каждая техника описана одинаково: **как работает → что даёт → что стоит /
где ломается**. Цена указана честно: обфускация — это всегда обмен
читаемости и времени против аналитика.
Итоговая таблица — в конце.
## 1. Переименование (`Rename`)
**Как работает.** Классы получают случайные имена в случайных пакетах
(или в исходном пакете, если `spread_packages = false`). Поля и методы
переименовываются, если `method_rename_safety` считает их безопасными
(private/static/final — сразу; virtual — только «семейством» на всю
иерархию, чтобы не разъединить переопределения и мостовые методы).
Затем переписываются дескрипторы, `LDC`-классы, вызовы, поля,
исключительная таблица, `Signature`/`Exceptions`/`InnerClasses`.
**Плюсы.**
- Самая дешёвая и самая отдающая техника: читает `javap -p` любой анализатор.
- Не меняет семантику — риск сломать код минимален.
- Маппиинг пишется в отчёт → stack trace'и переводятся обратно.
**Минусы / цена.**
- Keep-список обязателен: рефлексия по имени (`Class.forName("pkg.C")`),
JNI, `serialVersionUID`, сериализация, entrypoint'ы. Пропустил — получил
`ClassNotFoundException` в рантайме.
- Рефлексия через строки мы ловим автоматически (анализ `Class.forName`,
`getMethod`/`getField` и т.п.), но динамическую конкатенацию — нет.
- Для библиотек `rename_public_members = false` по умолчанию: менять
публичный API библиотеки — сломать всех потребителей.
**Статус: готово.**
## 2. Шифрование строк (`Strings`)
**Как работает.** Каждый `LDC` со строкой короче `min_len` заменяется на
`LDC cipher; LDC salt; INVOKESTATIC d(String,I)String`. Ключ строки
выводится из мастер-секрета + salt, **не лежит рядом с шифротекстом**.
Между строками ротируются разные схемы (`xor-splitmix`, `xtea-ctr`), nonce
уникален на строку, MAC ловит порчу.
**Плюсы.**
- Строки — главный источник логики (`"admin"`, URL, SQL, regex).
- Ротация схем: один скрипт деобфускации не покрывает весь jar.
- MAC: подмена шифротекста не «молча» портит текст, а падает.
**Минусы / цена.**
- Декодер добавляет вызов на каждый доступ к строке → оверхед в рантайме.
- Целевой анализ декодера всё равно раскроет текст; задача — поднять цену,
а не сделать невозможным.
- Мастер-секрет собирается в декодере из двух частей (`k1 ^ k2`).
**Статус: готово** (подстановка + генератор декодера v49 + сквозной тест
исполнением под настоящим `java`).
## 3. Обфускация констант (`Constants`)
**Как работает.** Числовая константа не хранится как есть, а собирается из
разных выражений: `x ^ k`, `a + b`, `(a << 1) + c`. Плюс настоящие
opaque-предикаты — условия, истинность которых зависит от инварианта
(например, `i*(i+1) % 2 == 0`), а не от константы.
**Плюсы.**
- Дёшево, ломает поиск «магических чисел».
- Оpaque-предикаты добавляют ветки, по которым деобфускатор должен пройти.
**Минусы / цена.**
- Ребёнок числа раскрывается за минуты любым символном вычислителем
(`executor` для `LCONST/ICONST`-цепочек).
- Развдувание кода растёт по квадрату от числа вставок.
- Шаблонный opaque-предикат (всегда один и тот же `A+B`) узнаётся
паттерном — отсюда требование нескольких схем.
**Статус: готово** для `int`/`long` (`xor`/`add`/`shift` + opaque-предикаты);
`float`/`double` не трогаются.
## 4. Обфускация контрольного потока (`Flow`)
**Как работает.** CFG перестраивается одной из схем:
`state-spread` (таблица состояний + `switch`), `computed-switch`
(счётчик-переменная вместо меток), `butterfly` (обратные переходы),
`exception-goto` (управление через `throw/catch`).
**Плюсы.**
- Линейный проход кода больше не читается как исходник.
- В сочетании с opaque-предикатами резко поднимает стоимость трассировки.
**Минусы / цена.**
- **Самая дорогая и самая опасная техника.** Требует пересчёта
`StackMapTable` для классов версии ≥ 50 — иначе JVM не грузит класс.
- Ломает профилировщики, coverage-инструменты и отладку.
- Нужен верификатор: любая ошибка в CFG — это `VerifyError` в рантайме.
- Шаблонная схема (все методы перестроены одинаково) детектируется
гистограммой длины блоков.
**Статус: не реализован; блокер — `StackMapTable` (см. [`stack.md`](stack.md)).**
## 5. Шум (`Noise`)
**Как работает.** Junk-классы и junk-аннотации, вплетённые в граф
достижимости (`weave_reachable`): класс считается достижимым, если на него
есть ссылка из настоящего кода.
**Плюсы.**
- Дешёвый объём: файлов много, логики ноль.
- Мусор в дереве файлов ухудшает автоматический разбор.
**Минусы / цена.**
- Reachability-анализ отсеивает junk мгновенно: класс, на который никто
не ссылается, — мёртвый груз. Отсюда обязательное `weave_reachable`.
- Пустые методы/тела — признак, который ищут первым.
- Раздувание jar.
**Статус: готово** (junk-классы + `weave_reachable`; junk-аннотации
пропускаются — сырые атрибуты не несут свежие индексы пула).
## 6. Отладочная информация (`DebugInfo`)
**Как работает.** Strip `LineNumberTable`, `LocalVariableTable`; подмена
`SourceFile` на ложное имя; опционально strip `Signature`/`InnerClasses`.
Плюс *ложная* отладочная информация — чужие имена переменных и номера строк.
**Плюсы.**
- Практически бесплатно.
- Восстанавливает исходные имена переменных и файлов — прямая утечка.
**Минусы / цена.**
- Убивает нормальные stack trace'и (поэтому маппинги в отчёте обязательны).
- Слишком «чистый» класс (`LineNumberTable` есть, а `SourceFile` = `X.java`,
которого нет) — тоже признак.
**Статус: не реализован.**
## 7. Ресурсы (`Resources`)
**Как работает.** Патч текстовых ресурсов по маппингу: `fabric.mod.json`,
`*.mixins.json`, refmap, access widener, `META-INF/services/*`. Вырезание
старых подписей `*.SF/*.RSA/*.DSA/*.EC`. Ограничение дубликатов.
**Плюсы.**
- Без этого обфускация Fabric-мода просто не стартует.
- Убранные подписи — иначе jar считается подделанным.
**Минусы / цена.**
- Патч только текстовых ресурсов: бинарные (картинки, классы в `versions/`)
трогать нельзя.
- Промах в патче → тихо неработающий refmap, ловится только запуском.
**Статус: не реализован (крейт `noxis-jar` — план).**
## Сводная таблица
| техника | отдача | риск | цена | статус |
|---|---|---|---|---|
| Rename | ★★★★★ | низкий | низкая | готово |
| Strings | ★★★★ | средний | средняя | готово |
| Resources | ★★★★★ | низкий | низкая | план |
| DebugInfo | ★★★ | низкий | низкая | план |
| Constants | ★★ | низкий | низкая | готово (int/long) |
| Noise | ★ | низкий | раздувание | готово (без аннотаций) |
| Flow | ★★★★ | **высокий** | высокая | план, ждёт frames |
Вывод, который стоит держать в голове: **Rename + Resources + DebugInfo**
дают большую часть защиты за небольшую цену. Flow — единственная техника,
которая может сломать рантайм, и она же единственная, чья отдача
не покрывает риск без верификатора.