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.
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)).
|