obfuskator/docs/learnings/lessons.md
loki5512344 139aea9a8f
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.
2026-10-09 13:47:27 +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).