obfuskator/info/evo-improvements.md
loki5512344 a641e69455 Initial commit: noxis JVM bytecode obfuscator
Workspace of 9 crates (noxis, noxis-core, noxis-classfile, noxis-analysis,
noxis-passes, noxis-runtime, noxis-jar, noxis-verify, noxis-structure-check).
AGPL-3.0-only license, Rust 2024 edition, unsafe_code denied.
2026-10-08 22:01:03 +02:00

16 KiB
Raw Blame History

Как сделать лучше: план улучшений для нашего обфускатора

Привязка: референс — 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. Несколько схем флаттенинга вместо одной

Минимум набор (применять случайно и по-разному в разных методах):

  1. Switch по вычислимому индексу: table[ (x*31 ^ y) & mask ] → адрес возврата; переходы через индексы, а не метки.
  2. Распределённое состояние: как у EvoVis (i + i2), но одно слагаемое — из поля объекта/статики, второе — из параметра, обновление — через вызов метода; метки case не константные, а пересчитанные (opaque).
  3. Бинарное дерево/бабочка: вместо линейного списка состояний — рандомизированный граф переходов, эквивалентный исходному CFG (проверяется верификатором).
  4. Вызов через стек/ретурн-адрес: эмуляция goto через athrow-как-прыжок (exception-as-goto), finally-цепочки, или virtual-dispatch по фейковому интерфейсу.
  5. Прячем тела блоков: каждый блок зашифрован и раскрывается по очереди (см. 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:

  1. Утечки имён: strings/анализ констант — не должно остаться ни одного осмысленного идентификатора из списка-эталона (ошибка = фейл).
  2. Читаемость декомпилята: прогнать JADX/CFR на собранный артефакт → метрики: доля распознанных структур (try/catch, switch, циклы), доля классов с восстановленными именами, число строк «смыслового» кода.
  3. Автоматический деобфускатор-противник: минимальный скрипт-анализатор (тот же expr-свёртка, regex на паттерны) — если он снимает >X% строк/констант, техника провалена. Держим этот «красный список паттернов» в репозитории.
  4. Семантика: тесты/эталонные программы до и после — 100% совпадение.
  5. Размер и скорость: бюджет (≤ N% размера, ≤ M% времени исполнения), отдельно — бюджет на рантайм-раскрытие строк (кэш, чтобы не платить на каждый вызов).
  6. Стабильность между сборками: имена/схемы меняются от билда к билду (у EvoVis шифр один на все версии — окно для обучения).

Приоритет

  1. A1+A2+A4 (символы, отладка, всё артефакты) — дёшево, biggest leak у EvoVis.
  2. B1+B2 (пер-строчные ключи, вывод ключа) — снимает главный провал awc.d.
  3. C1 несколько схем флаттенинга + C2 верификатор.
  4. F метрики — чтобы не спорить на глаз.
  5. C3/E (другой целевой код) — как долгосрочный путь.
  6. D1 VM — только для действительно ценных кусков, остальное не оправдывает цену.