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.
15 KiB
Как сделать лучше: план улучшений для нашего обфускатора
Привязка: референс - EvoVis (evo-obfuscation-full.md), провалы - evo-weaknesses.md.
Ниже - что делаем иначе, в порядке отдачи/цены. Для каждой техники - уровень угрозы,
которую она закрывает (скрипт-деобфускатор / статический анализ / ручной реверс).
A. Базовый уровень (делаем обязательно, дёшево)
A1. Обфускация всех символов, не только типов
- Имена классов, полей, методов, локальных переменных, параметров, конструкторов,
record-компонентов, enum-констант, label'ов, атрибутов
SourceFile. - Индивидуальный алфавит на каждый символ (см. B1), запрет ключевых слов и одинаковых имён в одном scope.
- Никаких «readable» ярлыков: у EvoVis
@rn(name=…)иdescriptionотдали каталог модулей целиком. Если метаданные нужны рантайму - шифруем их (C2).
A2. Вычистить отладочную информацию
-g→LineNumberTable/LocalVariableTable/SourceFile/InnerClasses/EnclosingMethod/MethodParameters/Signature- по policy: либо полностью выкинуть, либо заменить на шум (см. B4).- Ресурсы тоже часть кода: mixin-JSON,
fabric.mod.json, access widener, service loaders, SPI-файлы - шифруем/шифруем имена внутри, раскрываем в рантайме.
A3. Никаких сигнатурных имён у защитного кода
KeyAssembler, RuntimeGuard, NativeGuard → рандомные имена, а сам защитный код
смешиваем с обычным (секции, которые выглядят как мусорные утилиты).
Рефлексия по именам в открытом виде (Class.forName("aln")) - запрещена:
имена отдаются только из зашифрованной строки (C2), лучше - через indirection-таблицу.
A4. Обфусцировать весь артефакт, а не только ядро
Правило: нет незащищённого компонента. У EvoVis лаунчер и java-agent были открыты целиком. Пайплайн один на: основной код, агент/плагин, лаунчер/хостер, нативная часть, ресурсы.
B. Строки и константы
B1. Пер-строчный ключ + нелинейный шифр
Вместо общего LCG:
- seed =
random_u64на строку (храним неявно: derive из позиции/ид + master-seed); - алгоритм выбирается из набора (3–5 схем: RC4-like, chacha-с fragments, XTEA-блочный, plain XOR с keystream от BLAKE3, base85+flate перед шифром) - перемешивание схем между строками, чтобы один скрипт не покрыл всё;
- AAD/MAC:
HMAC(fragment, plaintext_hash)- повреждение ловится, подмена - тоже; - keystream не повторяется: nonce включает индекс строки.
- против обучения по частотам: словарь/частотный анализ не работает, если схема и nonce разные.
B2. Ключ строки выводится в рантайме, а не лежит рядом
Никаких d(cipher, literal_key):
- ключ =
KDF(master_key, class_id, method_id, string_index)либо приходит из защищённого контекста (см. D3); - вычисление - через серию операций, растворённых в теле вызывающего метода
(не выделенная функция
decrypt(s)- иначе её найдут и развернут); - мастер-секрет не в константах: собирается из нескольких мест + env/хост-фактора.
B3. Константы - не тождества, а вычисления
Отказ от (A^B)+((A&B)<<1) (опознаётся regex'ом). Вместо этого:
- константа собирается из нескольких узлов, разнесённых по разным basic block'ам и разным методам (возврат частей, сложение на месте использования);
- включаем в цепочку зависимые от окружения величины, которые статически вычислимы
не полностью:
hashCode()строки-литерала,System.identityHashCodeстатической константы, значение поля, инициализируемого в<clinit>, размер массива; - для
int/long/float/double- отдельные схемы (bit-cast, разложение по мантиссе), потому чтоdouble-константы особенно заметны. - opaque-предикаты делать настоящими: всегда истинные, но зависящие от состояния,
которое статически не выводится (напр. инвариант «массив отсортирован по заполнению»,
проверка
System.nanoTime()-мьютекса, сравнение двух полей, всегда равных после<clinit>, но не доказуемых в CFG).
B4. Ложная отладочная информация
Не только удалить LineNumberTable/LocalVariableTable, но и подложить
дезориентирующие: ложные имена локальных переменных, строки из другого проекта,
SourceFile с вводящим именем. Инструменты (jadx/CFR) и люди верят отладочке сильнее,
чем байткоду.
C. Контрольный поток
C1. Несколько схем флаттенинга вместо одной
Минимум набор (применять случайно и по-разному в разных методах):
- Switch по вычислимому индексу:
table[ (x*31 ^ y) & mask ]→ адрес возврата; переходы через индексы, а не метки. - Распределённое состояние: как у EvoVis (
i + i2), но одно слагаемое - из поля объекта/статики, второе - из параметра, обновление - через вызов метода; метки case не константные, а пересчитанные (opaque). - Бинарное дерево/бабочка: вместо линейного списка состояний - рандомизированный граф переходов, эквивалентный исходному CFG (проверяется верификатором).
- Вызов через стек/ретурн-адрес: эмуляция
gotoчерезathrow-как-прыжок (exception-as-goto),finally-цепочки, или virtual-dispatch по фейковому интерфейсу. - Прячем тела блоков: каждый блок зашифрован и раскрывается по очереди (см. D2), чтобы деобфускатор не мог читать тело, не эмулируя VM.
C2. Обфускация CFG должна сохранять семантику → нужен верификатор
Условие приёмки: анализ эквивалентности до/после (структурный: разбор в SSA, сравнение支配-графов; либо выполнение тестов на эталонных программах). Плюс автоматические метрики (см. F).
C3. Реальный объём ≠ раздувание
У EvoVis выигрыш - только в размере декомпилята, но и цена (читаемость для нас же). Оптимальнее: комбинировать обфускацию с компиляцией в другой целевой код (см. E), где раздувание не надо - там и так нечем читать.
D. Уровень «нужно эмулировать, а не читать»
D1. Маленький VM / interpreter для критичных кусков
Свой bytecode (10–20 опкодов: арифметика, ветвление, вызов, доступ к полю), инструкции в зашифрованном массиве, диспетчер - обычный код с обфусцированным CFG. Экспортируем в VM только чувствительное: лицензирование, ключи, критическую логику. Это единственный уровень, который реально останавливает ручной реверс.
D2. Раскрываемое по шагам тело (split-function / function-splitting)
Метод делится на фрагменты (по basic block'ам), каждый зашифрован отдельно, порядок выполнения задаётся таблицей. Деобфускатор обязан строить CFG, прежде чем увидит хоть строку.
D3. Анти-анализ в рантайме (мягкий)
- self-check целостности:
SHA-256своих классов/секций → при расхождении меняем поведение, а не падаем (у EvoVis падение выдавало мусорный ключ - правильно: не сообщать атакующему, что он пойман); - детект отладчика/трейсера/инструментов эмуляции - реакция: ложные данные, замедление, отключение функций;
- не детектить и не ломать легитимные сценарии (обновления, IDE, символы) - ложные срабатывания дороже, чем польза защиты.
D4. Честный anti-tamper, а не «зашейрь ключ»
У EvoVis сильная идея: ключ = изолированная доля в подменённом JVM + HMAC от hwid + порча при детекте. Минус - вся схема работает только на своём окружении и падает на другой ОС/сборке. Для нас: секрет должен переживать наши легитимные платформы, а ломаться только при модификации кода/подмене целостности.
E. Если цель - не JVM, а свой натив/Rust-бинарник
Референс JVM-слабости здесь не работают автоматически, но появляются свои:
- строки: все plaintext-строки в
.rodata- шифровать, раскрывать лениво; не держать AES-ключ рядом с шифротекстом (та же ошибка, что уawc.d); - имена:
strippedбинарник + рандомизация символов; в Rust - макрос, который переименовываетpub-символы и убирает#[doc]/Debug-имена; - panic/Debug-вывод:
{:?}отдаёт имена типов и полей - для чувствительных типов свойDebug, либо не выводить; - CFG: обфускация на уровне MIR/LLVM-IR (passes): bogus control flow, flattening
на
switchпо вычисленному индексу, вставка недостижимых блоков, размытие границ функций (inlining + outlined); - константы: как B3, плюс вынос в вычисляемые таблицы;
- анти-анализ:
ptrace-selfcheck, проверка/proc/self/mapsна свои секции, timing-checks; реакция - молчаливая порча данных; - обфускация метаданных Rust:
TypeId/Any,std::any::type_name, логиtracing- выключать в release.
F. Метрики качества (обязательно, иначе «обфускация есть» ничего не значит)
Автотесты на каждую технику, гоняются в CI:
- Утечки имён:
strings/анализ констант - не должно остаться ни одного осмысленного идентификатора из списка-эталона (ошибка = фейл). - Читаемость декомпилята: прогнать JADX/CFR на собранный артефакт → метрики: доля распознанных структур (try/catch, switch, циклы), доля классов с восстановленными именами, число строк «смыслового» кода.
- Автоматический деобфускатор-противник: минимальный скрипт-анализатор
(тот же
expr-свёртка, regex на паттерны) - если он снимает >X% строк/констант, техника провалена. Держим этот «красный список паттернов» в репозитории. - Семантика: тесты/эталонные программы до и после - 100% совпадение.
- Размер и скорость: бюджет (≤ N% размера, ≤ M% времени исполнения), отдельно - бюджет на рантайм-раскрытие строк (кэш, чтобы не платить на каждый вызов).
- Стабильность между сборками: имена/схемы меняются от билда к билду (у EvoVis шифр один на все версии - окно для обучения).
Приоритет
- A1+A2+A4 (символы, отладка, всё артефакты) - дёшево, biggest leak у EvoVis.
- B1+B2 (пер-строчные ключи, вывод ключа) - снимает главный провал
awc.d. - C1 несколько схем флаттенинга + C2 верификатор.
- F метрики - чтобы не спорить на глаз.
- C3/E (другой целевой код) - как долгосрочный путь.
- D1 VM - только для действительно ценных кусков, остальное не оправдывает цену.