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