obfuskator/docs/learnings/lessons.md
loki5512344 2172206e83
passes: implement control-flow pass (4 schemes + rotation)
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.
2026-10-09 11:11:55 +02:00

10 KiB
Raw Blame History

Уроки: что защита 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 §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).