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