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.
16 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 — только для действительно ценных кусков, остальное не оправдывает цену.