FlowPass rebuilds one method per scheme picked from FlowConfig: state-spread (dense tableswitch dispatcher), computed-switch (sparse lookupswitch over a counter local), butterfly (branch inversion), exception-goto (dispatcher under try with a dead catch-all), plus opaque goto-splices inside every method and StackMapTable recomputation afterwards. Now in standard_pipeline (between constants and noise); --no-flow is an honest off-flag. Acceptance is real JVM, not shapes: per-scheme verify, rotation spread, calc-prints-45 execution, paranoid smoke on lorep-1.1.0 (131 methods rebuilt, no new failures over standard). Two verifier rules learned the hard way are now construction invariants: no inserted mark shares a bytecode offset with a foreign one (nop pads), and no frameless gap follows a terminal (dead blocks are opaque-entered and throw). Also fixes a latent frame-emitter bug the pass exposed: wide stack values were written as slot pairs ([Double, Top]) while javac writes one entry ([Double]); HotSpot rejects the pair. Stacks now collapse like locals (emit::stack_vars), with a regression test in classfile/tests/frames.rs.
146 lines
10 KiB
Markdown
146 lines
10 KiB
Markdown
# Уроки: что защита EvoVis сделала плохо
|
||
|
||
Источник: `info/evo-weaknesses.md` — полный разбор за один проход, без
|
||
рантайм-хаков. Здесь — что из этого следует для **нашей** архитектуры.
|
||
|
||
Правило, которое стоит держать головой: **защита ломается не в рантайме,
|
||
а на этапе, когда атакующий смотрит на статику.** Всё, что видно в `javap -p`
|
||
или в содержимом jar, — это утечка.
|
||
|
||
## Сводная таблица: провал → наше решение
|
||
|
||
| № | провал EvoVis | наше решение | пасс | статус |
|
||
|---|---|---|---|---|
|
||
| 1 | ключ лежит рядом с шифротекстом | вывод мастер-секрета в декодере, ключ из `master‖salt` | Strings | **готово** |
|
||
| 2 | переименованы типы, но не члены | rename полей/методов + семейства virtual | Rename | **готово** |
|
||
| 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 | **готово** (без аннотаций) |
|
||
| 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)).
|