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.
This commit is contained in:
Loki 2026-10-09 13:47:27 +02:00
parent 2172206e83
commit 139aea9a8f
Signed by: boba
GPG key ID: 253067914055423B
96 changed files with 1053 additions and 831 deletions

View file

@ -1,43 +1,43 @@
# Как работает: техники, плюсы, минусы
Каждая техника описана одинаково: **как работает → что даёт → что стоит /
где ломается**. Цена указана честно: обфускация — это всегда обмен
где ломается**. Цена указана честно: обфускация - это всегда обмен
читаемости и времени против аналитика.
Итоговая таблица — в конце.
Итоговая таблица - в конце.
## 1. Переименование (`Rename`)
**Как работает.** Классы получают случайные имена в случайных пакетах
(или в исходном пакете, если `spread_packages = false`). Поля и методы
переименовываются, если `method_rename_safety` считает их безопасными
(private/static/final — сразу; virtual — только «семейством» на всю
(private/static/final - сразу; virtual - только «семейством» на всю
иерархию, чтобы не разъединить переопределения и мостовые методы).
Затем переписываются дескрипторы, `LDC`-классы, вызовы, поля,
исключительная таблица, `Signature`/`Exceptions`/`InnerClasses`.
**Плюсы.**
- Самая дешёвая и самая отдающая техника: читает `javap -p` любой анализатор.
- Не меняет семантику — риск сломать код минимален.
- Не меняет семантику - риск сломать код минимален.
- Маппиинг пишется в отчёт → stack trace'и переводятся обратно.
**Минусы / цена.**
- Keep-список обязателен: рефлексия по имени (`Class.forName("pkg.C")`),
JNI, `serialVersionUID`, сериализация, entrypoint'ы. Пропустил — получил
JNI, `serialVersionUID`, сериализация, entrypoint'ы. Пропустил - получил
`ClassNotFoundException` в рантайме.
- Рефлексия через строки мы ловим автоматически (анализ `Class.forName`,
`getMethod`/`getField` и т.п.), но динамическую конкатенацию — нет.
`getMethod`/`getField` и т.п.), но динамическую конкатенацию - нет.
- JSON-(де)сериализация по именам полей (Gson/Jackson/`Codec`) не ловится:
биндинг идёт через `X.class`-литерал, не строку — см. `todo.md` §2.
биндинг идёт через `X.class`-литерал, не строку - см. `todo.md` §2.
- Для библиотек `rename_public_members = false` по умолчанию: менять
публичный API библиотеки — сломать всех потребителей.
публичный API библиотеки - сломать всех потребителей.
Шесть стилей имён (`ShortLatin`, `IlO0`, `Unicode`, `MixedLatin`,
`DecoyWords`, `Plausible`) — компромисс по-разному: `IlO0`/`Unicode` явно
`DecoyWords`, `Plausible`) - компромисс по-разному: `IlO0`/`Unicode` явно
сигнализируют «это обфускация», `Plausible` маскируется под обычный
прикладной код (`TextureCache`, `resolveToken`), ценой того, что при
ручном чтении он менее заметно отличается от настоящей логики, чем
намеренный мусор — скрыть обфускацию и скрыть её цену ценой чуть большей
намеренный мусор - скрыть обфускацию и скрыть её цену ценой чуть большей
путаницы для собственной команды при отладке маппингов.
**Статус: готово.**
@ -51,13 +51,13 @@
уникален на строку, MAC ловит порчу.
**Плюсы.**
- Строки — главный источник логики (`"admin"`, URL, SQL, regex).
- Строки - главный источник логики (`"admin"`, URL, SQL, regex).
- Ротация схем: один скрипт деобфускации не покрывает весь jar.
- MAC: подмена шифротекста не «молча» портит текст, а падает.
**Минусы / цена.**
- Декодер добавляет вызов на каждый доступ к строке → оверхед в рантайме.
- Целевой анализ декодера всё равно раскроет текст; задача — поднять цену,
- Целевой анализ декодера всё равно раскроет текст; задача - поднять цену,
а не сделать невозможным.
- Мастер-секрет собирается в декодере из двух частей (`k1 ^ k2`).
@ -68,7 +68,7 @@
**Как работает.** Числовая константа не хранится как есть, а собирается из
разных выражений: `x ^ k`, `a + b`, `(a << 1) + c`. Плюс настоящие
opaque-предикаты — условия, истинность которых зависит от инварианта
opaque-предикаты - условия, истинность которых зависит от инварианта
(например, `i*(i+1) % 2 == 0`), а не от константы.
**Плюсы.**
@ -80,7 +80,7 @@ opaque-предикаты — условия, истинность которы
(`executor` для `LCONST/ICONST`-цепочек).
- Развдувание кода растёт по квадрату от числа вставок.
- Шаблонный opaque-предикат (всегда один и тот же `A+B`) узнаётся
паттерном — отсюда требование нескольких схем.
паттерном - отсюда требование нескольких схем.
**Статус: готово** для `int`/`long` (`xor`/`add`/`shift` + opaque-предикаты);
`float`/`double` не трогаются.
@ -98,22 +98,22 @@ opaque-предикаты — условия, истинность которы
**Минусы / цена.**
- **Самая дорогая и самая опасная техника.** Требует пересчёта
`StackMapTable` для классов версии ≥ 50 — иначе JVM не грузит класс.
`StackMapTable` для классов версии ≥ 50 - иначе JVM не грузит класс.
- Ломает профилировщики, coverage-инструменты и отладку.
- Нужен верификатор: любая ошибка в CFG — это `VerifyError` в рантайме.
- Нужен верификатор: любая ошибка в CFG - это `VerifyError` в рантайме.
- Шаблонная схема (все методы перестроены одинаково) детектируется
гистограммой длины блоков.
**Статус: готов** (`noxis-passes/src/flow/`). Одна схема на метод (ротация
через PRNG) плюс нарезка тела `goto`-сплайсами во всех схемах; кадр
`StackMapTable` пересчитывается после каждой перестройки. Приёмка —
`StackMapTable` пересчитывается после каждой перестройки. Приёмка -
настоящий `java -Xverify:all` и исполнение под JVM (`calc` печатает 45
после всех схем). Ограничения: `<init>` пропускается (форма
super-call-first); методы с `jsr`/`ret`, короче 3 инструкций и длиннее
`max_insns` пропускаются; `encrypt_block_bodies` добавляет opaque-guard
на входе. Два правила, выученных на крови (см. `StackMapTable`):
вставленная метка не должна делить байт-смещение с чужой (иначе кадры
мерджатся из несовместимых состояний — после каждой такой метки `nop`),
мерджатся из несовместимых состояний - после каждой такой метки `nop`),
а инструкция после терминальной обязана иметь свой кадр (поэтому dead-блоки
входят через opaque-ветвь и кончаются `athrow`, а не висят недостижимыми).
@ -129,32 +129,32 @@ super-call-first); методы с `jsr`/`ret`, короче 3 инструкц
**Минусы / цена.**
- Reachability-анализ отсеивает junk мгновенно: класс, на который никто
не ссылается, — мёртвый груз. Отсюда обязательное `weave_reachable`.
- Пустые методы/тела — признак, который ищут первым.
не ссылается, - мёртвый груз. Отсюда обязательное `weave_reachable`.
- Пустые методы/тела - признак, который ищут первым.
- Раздувание jar.
**Статус: готово** (junk-классы + `weave_reachable`; junk-аннотации
пропускаются — сырые атрибуты не несут свежие индексы пула).
пропускаются - сырые атрибуты не несут свежие индексы пула).
## 6. Отладочная информация (`DebugInfo`)
**Как работает.** Strip `LineNumberTable`, `LocalVariableTable`; подмена
`SourceFile` на ложное имя; опционально strip `Signature`/`InnerClasses`.
Плюс *ложная* отладочная информация — чужие имена переменных и номера строк.
Плюс *ложная* отладочная информация - чужие имена переменных и номера строк.
**Плюсы.**
- Практически бесплатно.
- Восстанавливает исходные имена переменных и файлов — прямая утечка.
- Восстанавливает исходные имена переменных и файлов - прямая утечка.
**Минусы / цена.**
- Убивает нормальные stack trace'и (поэтому маппинги в отчёте обязательны).
- Слишком «чистый» класс (`LineNumberTable` есть, а `SourceFile` = `X.java`,
которого нет) — тоже признак.
которого нет) - тоже признак.
**Статус: готово** (strip + `spoof_source_file`, детерминированная подмена
от мастер-сида). Не реализовано: `fake_line_numbers` (ложные номера строк,
а не только `SourceFile`) и `strip_source_file` (полное удаление вместо
подмены) — см. [`todo.md`](../todo.md) §3.6.
подмены) - см. [`todo.md`](../todo.md) §3.6.
## 7. Ресурсы (`Resources`)
@ -164,7 +164,7 @@ super-call-first); методы с `jsr`/`ret`, короче 3 инструкц
**Плюсы.**
- Без этого обфускация Fabric-мода просто не стартует.
- Убранные подписи — иначе jar считается подделанным.
- Убранные подписи - иначе jar считается подделанным.
**Минусы / цена.**
- Патч только текстовых ресурсов: бинарные (картинки, классы в `versions/`)
@ -189,6 +189,6 @@ super-call-first); методы с `jsr`/`ret`, короче 3 инструкц
| Flow | ★★★★ | **высокий** | высокая | план, кадры готовы |
Вывод, который стоит держать в голове: **Rename + Resources + DebugInfo**
дают большую часть защиты за небольшую цену. Flow — единственная техника,
дают большую часть защиты за небольшую цену. Flow - единственная техника,
которая может сломать рантайм, и она же единственная, чья отдача
не покрывает риск без верификатора.