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:
commit
a641e69455
140 changed files with 17197 additions and 0 deletions
242
docs/stack.md
Normal file
242
docs/stack.md
Normal 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 |
|
||||
Loading…
Add table
Add a link
Reference in a new issue