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.
10 KiB
Уроки: что защита 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-stringnonce, чтобы пары «текст→шифр» не повторялись и частотный анализ не работал. - 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 §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).