obfuskator/docs/stack.md
loki5512344 139aea9a8f
frames,jar,passes: verifier-driven hardening from real jars
Proven by paranoid runs over lorep (17 classes, fully green),
LoVisual (4081 classes) and oc2r (1278 classes) with per-class
java -Xverify:all, each fix first reproduced as a red test:

- merge: Top absorbs instead of Top-wins (javac declares Top for
  slots unwritten on some inflow; precise types reject those edges).
  Regression: loop_head_keeps_top_for_loop_local.
- emit: collapse [Long|Double, Top] pairs on operand stacks
  (javac writes one entry; the pair is rejected). Regression:
  wide_stack_join_emits_single_entries.
- xfer ArrayLoad: push the wide pair, fixing lcmp underflow.
  Regression: array_load_wide_pushes_pair.
- xfer <init>: convert only the receiver's own allocation site
  instead of every Uninitialized (nested construction clobbered
  the outer object). Regression: nested_construction_keeps_outer.
- xfer ANewArray: array operands gain one dimension ([B] -> [[B)
  instead of an illegal L-wrap ([L[B;). Covered by the same test.
- jar load: duplicate class names (multi-release module-info
  twins) keep the first occurrence instead of crashing save.
  Test: duplicate_class_names_keep_first.
- strings/constants: skip methods past MAX_METHOD_INSNS (8000)
  so giants cannot overflow the 64KB writer ceiling. Tests:
  oversized_methods_are_skipped in both passes.
- merge errors now name both types being merged.

Also: em-dash removed repo-wide (724 spots, rule added to
todo.md), allow-lints only in tests (scratch example deleted),
code comments migrated to English in every file this change
touches.
2026-10-09 13:47:27 +02:00

16 KiB
Raw Permalink Blame History

Стек: почему именно так

Документ фиксирует решения, которые трудно отменить. Для каждого - альтернативы, критерии, цена и что меняет решение.

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) из готового байткода:

наш 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. Кадры считаем сами, а крейт используем как приёмочный тест:

наш 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), но она покрывается двумя независимыми судьями:

наш 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