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.
This commit is contained in:
Loki 2026-10-09 13:47:27 +02:00
parent 2172206e83
commit 139aea9a8f
Signed by: boba
GPG key ID: 253067914055423B
96 changed files with 1053 additions and 831 deletions

View file

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