Подписывайтесь на наш Telegram и не пропускайте важные новости! Перейти

Гайд Как защитить Java классы от дампа: путь от invokedynamic до custom JVM

...............
...............
Статус
Оффлайн
Регистрация
26 Янв 2024
Сообщения
9
Реакции
7
Вступление

Статья выросла из моего личного раздражения. Я несколько лет пилю защиту для Java приложений — в основном minecraft клиенты — и всё это время наблюдаю одну картину: любой платный обфускатор за 200 баксов, любой коммерческий клиент с билдбита, любое «защищённое» решение — всё сливается за вечер любым человеком у которого есть Recaf и желание потыкать. Дело даже не в том что обфускация слабая. Дело в том что вся индустрия Java реверса построена вокруг одной-единственной дыры, о которой толком никто не говорит, и которую невозможно закрыть стандартными средствами.

Я потратил несколько месяцев на копание исходников OpenJDK — ходил по HotSpot, читал пути вызовов, смотрел какие конкретно места в коде JVM приводят к тому что класс можно достать из процесса. Все файлы и номера строк из свежего дерева HotSpot, можешь проверить. После того как один раз видишь как это работает изнутри — понимаешь что весь мейнстрим-стек Java защиты это покраска ржавчины. А настоящее решение лежит сильно дальше и выглядит не так как я ожидал когда начинал копать.

Пишу без базы. Если не знаешь что такое classloader, байткод, JIT и примерно как работает HotSpot — сначала в JVMS, потом возвращайся. На русском по этой теме я толком ничего толкового не встречал, поэтому и взялся писать. Поехали.



Почему текущий стек защиты не работает

Начну с того что меня бесит. Типичный «защищённый» Java клиент сегодня это:

  1. Переименовали классы/методы через ProGuard
  2. Строки зашифровали XOR-ом с ключом в clinit
  3. Вызовы завернули в invokedynamic
  4. Прогнали через native-obfuscator
  5. Натянули сверху VMProtect

Смотрится солидно. На практике ломается стандартным инструментом дампера который инжектится в процесс жертвы и забирает классы на этапе когда они уже расшифрованы в памяти но ещё не провалидированы JVM-кой.

Работает это так: инструмент подпихивает свой код внутрь процесса (не важно как — подмена одной из загружаемых dll, маленький хелпер, шелл-код — вариантов хватает), там цепляется к внутреннему API HotSpot под названием JVMTI и подписывается на событие ClassFileLoadHook. Это событие дёргается когда любому классу надо пройти через парсер JVM. Включая те что обфускатор только что расшифровал у себя в clinit.

Результат: в дамп падают чистые .class файлы, строки расшифрованы, invokedynamic уже зарезолвнут, имена перемешаны но логика как на ладони. Дальше CFR или Vineflower и клиент слит.

Работает потому что ClassFileLoadHook вызывается ПОСЛЕ того как обфускатор отдал байты в JVM но ДО валидации и линковки. Инструмент сидит ровно между твоим декриптом и парсером, пишет всё на диск. Единственное что тебя спасёт — чтобы этого события вообще не существовало в JVM. Без шуток.

Ниже по слоям разберу что с этим можно сделать.



Векторы дампа — полный список

Прежде чем защищаться, надо понимать от чего. Я потратил дохрена времени на OpenJDK исходники и могу сказать — векторов ровно десять:

  1. JVMTI ClassFileLoadHookprims/jvmtiExport.cpp, функция post_class_file_load_hook около 1091 строки. Главная дыра. Любой код внутри процесса видит байты всех классов
  2. JVMTI GetBytecodesprims/jvmtiEnv.cpp ~3428. Читает байткод уже загруженного метода в рантайме. Внутри JvmtiClassFileReconstituter::copy_bytecodes разворачивает _fast_* опкоды обратно в спецификационные — даже переписанные HotSpot-ом байты отдаются чистыми
  3. JVMTI RetransformClasses — принудительно перепарсит загруженный класс и снова дёрнет CFLH. Юзается когда дампер прицепился после старта
  4. GetLoadedClassesprims/jvmtiGetLoadedClasses.cpp. Возвращает всё из ClassLoaderDataGraph. Неприятно: hidden classes тоже возвращает, фильтра is_hidden() там нет (сам был в обратном уверен пока не полез в сорцы)
  5. Serviceability Agent (SA) — jhsdb, jmap -F, jstack -F. Цепляется к процессу снаружи через ptrace/DebugActiveProcess, парсит сырую память по таблице VMStructs которую jvm.dll экспортирует как глобальный символ
  6. Прямое сканирование метаспейса — тот же путь что SA но ручками. Инжект dll, читаем gHotSpotVMStructs, ходим ClassLoaderDataGraph::_headInstanceKlassMethod*ConstMethod* и читаем (ConstMethod*)+1 на code_size байт
  7. Переписанный байткод в метаспейсеinterpreter/rewriter.cpp. HotSpot мутирует байткод при линковке, но restore_bytecodes() публична и reconstituter её дёргает
  8. JIT nmethod → байткод — nmethod в code cache содержит ScopeDesc с маппингом PC → BCI. Продвинутый реверсер восстановит структуру
  9. CDS mapped archivecds/filemap.cpp. Классы из .jsa лежат в замапленной странице
  10. Подмена jvm.dll / JNI хук — дампер подсовывает свою dll с теми же экспортами что настоящая jvm.dll, хукает JVM_DefineClass / Unsafe_DefineClass0. Самый ранний перехват

Блять, десять векторов. Смотрим что из этого прихлопывается флагами JVM:

Код:
Expand Collapse Copy
- startup агенты:                  НЕТ ТАКОГО ФЛАГА
- -agentpath/-agentlib:            НЕТ ТАКОГО ФЛАГА
- Attach API (jcmd/jmap/jstack):   -XX:+DisableAttachMechanism
- Dynamic agent load:              -XX:-EnableDynamicAgentLoading
- JVMTI целиком:                   НЕТ ТАКОГО ФЛАГА
- GetBytecodes:                    НЕТ ТАКОГО ФЛАГА
- SA attach:                       НЕТ ТАКОГО ФЛАГА
- GetLoadedClasses hidden filter:  НЕТ ТАКОГО ФЛАГА

Если атакующий контролирует командную строку запуска (а он контролирует её всегда, это его машина) — ни одна комбинация флагов стоковой JVM тебя не спасает. Факт. Всё что ты делаешь на стоке — поднимаешь планку для казуалов.



Hidden classes и почему они почти работают

Штука которую я долго недооценивал. В classfile/klassFactory.cpp на 191 строке:

C++:
Expand Collapse Copy
if (!cl_info.is_hidden()) {

    stream = check_class_file_load_hook(...);

Для hidden классов CFLH не вызывается. Классы загруженные через MethodHandles.Lookup.defineHiddenClass невидимы для дамперов на этапе загрузки. Звучит как серебряная пуля. Сейчас сломаю энтузиазм.

Подвох раз: передаёшь byte[] живущий в Java куче. Пока массив живой — его можно найти в памяти. Надо зануть через Arrays.fill(bytes, 0) сразу после defineHiddenClass.

Подвох два, злее: hidden classes не прячутся от GetBytecodes и GetLoadedClasses. Был уверен в обратном пока не полез в jvmtiGetLoadedClasses.cpp — там итерация loaded_classes_do_keepalive без фильтра is_hidden(). Если дампер прицепился ПОСЛЕ decrypt-а — дёрнет GetLoadedClasses, найдёт hidden класс (имя мусорное Foo/0x00007f... но оно есть), дёрнет GetBytecodes и получит восстановленный байткод через reconstituter. Сам байткод уже в метаспейсе, оттуда никак не стереть без патча JVM.

Hidden classes закрывают один сценарий — перехват на загрузке. Это половина атак, но не решение. Комбо -XX:+DisableAttachMechanism -XX:-EnableDynamicAgentLoading + проверка аргументов в clinit обходится за 10 минут: дампер патчит RuntimeImpl.getInputArguments и твоя проверка читает чистый список. Гонка проиграна до старта.



Condy + hidden class + classData — правильная база

Инструмент которым почти никто не пользуется для обфускации — CONSTANT_Dynamic (condy, JEP 309, с Java 11) плюс defineHiddenClassWithClassData (JEP 371, Java 16+).

Суть condy: элемент constant pool содержит вместо значения ссылку на bootstrap метод и статические аргументы. Первый ldc — JVM вызывает BSM, кеширует результат в resolved_references. Второй — чтение из массива. После C2 компиляции слот трактуется как @Stable final и значение запекается в native код как x86 immediate. Проверено на oops/constantPool.cpp ~1080, функция resolve_constant_at_impl, на 1097 ранний выход по кешу.

Что это значит: каждая строка, каждое число, каждая ссылка в классе — condy. Вместо ldc "Hello" стоит ldc #K где #K это CONSTANT_Dynamic с bootstrap-ом decryptString и аргументами [шифртекст, nonce]. BSM тянет AES ключ из classData:

Java:
Expand Collapse Copy
public static String decryptString(MethodHandles.Lookup caller,
                                   String unusedName, Class<?> unusedType,
                                   byte[] cipher, byte[] nonce) throws Throwable {
    byte[] key = MethodHandles.classDataAt(caller, "_", byte[].class, 0);
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
           new GCMParameterSpec(128, nonce));
    return new String(c.doFinal(cipher), StandardCharsets.UTF_8);
}

Имя condy обязано быть "_" (реальная проверка в MethodHandles.java, константа ConstantDescs.DEFAULT_NAME). Int аргумент — индекс в classData.

Загрузка hidden класса:

Java:
Expand Collapse Copy
public static Lookup loadEncrypted(byte[] encryptedBytes,
                                   byte[] aesKey, byte[] nonce) throws Throwable {
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"),
           new GCMParameterSpec(128, nonce));
    byte[] plainClass = c.doFinal(encryptedBytes);

    List<Object> classData = List.of(aesKey, Integer.valueOf(0xDEADBEEF));

    Lookup hidden = MethodHandles.lookup().defineHiddenClassWithClassData(
        plainClass, classData, true, Lookup.ClassOption.NESTMATE);

    Arrays.fill(plainClass, (byte) 0);
    return hidden;
}

Что происходит во времени:

  • В jar-е: внешний хостовый класс и byte[] с шифртекстом hidden класса
  • Старт: расшифровали → defineHiddenClassWithClassData → зануляем буфер. Plaintext живёт микросекунды
  • Первый вызов метода: каждый ldc #condy один раз вызывает decryptString
  • После C2: строка — x86 immediate в native, сама функция decryptString мёртвый код

В jar на диске — шифртексты и ссылки на BSM. В памяти после JIT — x86 immediates никак не связанные с расшифровкой. Ноль накладных на горячем пути. Не «малые», а прямо ноль — после warmup байты decrypt функции не исполняются.

Это очень круто против статики и против казуальных атак. Но есть нюанс которого я долго не видел: resolved_references слот это обычный объект в cp cache, и если дампер прицепится внутрь процесса после первого вызова — он прочитает расшифрованные строки прямо оттуда. Condy защищает файл на диске и статический анализ, но не защищает от дампера внутри процесса который ходит по метаспейсу.

Сток JVM с condy даёт тебе где-то 85% защиты. Против Recaf, CFR, narumii, threadtear — всё идёт в топку. Против дампера инжектящегося внутрь процесса с полным JVMTI — ты всё равно проигрываешь оставшиеся 15%.

Чтобы закрыть их — надо перестать играть в чужой песочнице.



Кастомная JVM — потому что флагов не хватает

Здесь начинается нормальная часть. Если не можем выключить JVMTI флагом — выключим его при компиляции самого HotSpot.

Главная штука которую я понял после недель копания, и из-за которой написана эта статья. HotSpot уже спроектирован с compile-time флагами ровно для этого. src/hotspot/share/utilities/macros.hpp около 62 строки содержит INCLUDE_JVMTI — макрос, ставится в 0 при сборке, и весь код обёрнутый в JVMTI_ONLY(...) превращается в пустоту. Это около 50 .cpp файлов в src/hotspot/share/prims/jvmti*.cpp. Рядом INCLUDE_SERVICES (убивает Attach Listener и SA), INCLUDE_JFR, INCLUDE_NMT.

В make/hotspot/lib/JvmFeatures.gmk пишешь:

Java:
Expand Collapse Copy
-DINCLUDE_JVMTI=0
-DINCLUDE_SERVICES=0
-DINCLUDE_JFR=0
-DINCLUDE_NMT=0

И получаешь jvm.dll в котором физически нет JVMTI. Нет кода. Парсер аргументов в runtime/arguments.cpp всё ещё видит -javaagent и пытается зарегистрировать агент, но путь загрузки уходит в пустоту. Можно дополнительно заткнуть:

C++:
Expand Collapse Copy
if (match_option(option, "-javaagent:", &tail)) {
    jio_fprintf(defaultStream::error_stream(), "Error: disabled\n");
    return JNI_ERR;
}

Выпиливаем модули: java.instrument, jdk.jdi, jdk.jdwp.agent, jdk.hotspot.agent, jdk.jcmd, jdk.jconsole, jdk.management.agent. После этого больше нет:

  • Java Instrumentation API (premain/agentmain мертвы)
  • JDWP (отладчик не прицепится)
  • Serviceability Agent (jhsdb мёртв)
  • jcmd, jmap, jstack, jinfo

Всё это — три дня работы. Реально изменения в одном мейкфайле + ~20 строк в парсере + удаление модулей из списка сборки. Всё остальное за нас сделали HotSpot-овские мейнтейнеры, потому что эти макросы задумывались для embedded Java где JVMTI не нужен. Мы просто используем готовую дорожку.

После этого из десяти векторов дампа — девять пропадают сами собой. Остаётся только внешнее чтение памяти через RPM + парсинг VMStructs. Его лечим так:

Удаляем VMStructs экспорты. runtime/vmStructs.cpp — таблицы gHotSpotVMStructs, gHotSpotVMTypes, gHotSpotVMIntConstants это глобальные символы. На них опираются все сторонние тулзы. Добавляем .def файл со списком экспортов только JNI_* и JVM_*, линкуем с /OPT:REF. Снаружи jvm.dll больше не описывает свой внутренний layout.

Strip-им PDB. Релиз без debug info. Реверсер не получит готовых оффсетов, придётся реверсить сам jvm.dll.

Layout drift. При каждой сборке добавляем в InstanceKlass, Method, ConstMethod несколько приватных полей. Это бесплатно сдвигает оффсеты структур. Публичные тулзы заточены под апстрим layout и ломаются на нашей сборке сразу.

После этого внешний дампер должен:

  1. Сам по сигнатурам найти ClassLoaderDataGraph::_head в нашем jvm.dll (без символов)
  2. Сам определить поля InstanceKlass (наш layout drift)
  3. Сам восстановить маппинг cpCache indices → CP indices (иначе _fast_* не раскодировать)

Это уже не «запустил тулзу, получил дамп за минуту». Это несколько недель реверсинга под одну конкретную версию. Следующий билд с другим drift — работа заново.



Стирание байткода после JIT

То на что я потратил больше всего времени и что реально доставляет. Как живут классы в HotSpot после загрузки: байты в KlassFactory::create_from_stream → парсер создаёт InstanceKlass/Method/ConstMethod в метаспейсе → байткод inline после заголовка ConstMethod (code_base() { return (address)(this+1); }) → линковка мутирует байты на месте → горячий метод → C2 → nmethod в code cache → все вызовы идут через _from_compiled_entry, интерпретатор не трогается.

Вопрос: нужен ли байткод в метаспейсе после того как nmethod готов?

Полез смотреть. Нормальный путь исполнения не лезет в ConstMethod::_code. Единственное место где он нужен — деоптимизация. runtime/vframeArray.cpp ~199:

C++:
Expand Collapse Copy
bcp = method()->bcp_from(bci());
iframe()->interpreter_frame_set_bcp(bcp);

Занулим code_base, случится деопт — интерпретатор читает нули, бежит вперёд, крашится. Деопт триггерится от кучи вещей: uncommon trap на null check, bounds check, CHA invalidation, retransform, cold flush. Но у нас уже кастомная JVM, значит можем:

  1. Отключить CHA-деоптACC_FINAL + запрет subclass-ить, code/dependencies.cpp
  2. Убить OSR-XX:-UseOnStackReplacement
  3. Заменить uncommon traps на явные throw — C2 pass в opto/, вместо DeoptAction эмитим прямой throw через shared stub. Цена 2-5% throughput
  4. Retransform — уже бесплатно с INCLUDE_JVMTI=0
  5. Guard в fetch_unroll_info_helperdeoptimization.cpp ~474. Если nmethod sealed — vm_exit_during_runtime. Падение процесса вместо чтения нулей

После этих патчей деопт для помеченных методов невозможен по построению. Значит в Method::set_code сразу после публикации nmethod — memset(cm->code_base(), 0, cm->code_size()). Байткод больше не существует в памяти. Только x86 в code cache.

В nmethod.hpp добавляем два бита:

C++:
Expand Collapse Copy
_is_pinned:1,   // не релоцировать, не флашить
_is_sealed:1,   // байткод занулен

И в is_cold/is_relocatable ранний выход для pinned. Всё. Помеченный метод загружен из .avl → расшифрован на микросекунды → скомпилен C2 → байткод в метаспейсе занулен → nmethod запинен → работает только через _from_compiled_entry.

Класс физически не существует как байткод. Только как x86 в code cache. Ни один Java деобфускатор не умеет восстанавливать структуру из x86. На этом этапе de4dot-for-Java, narumii, threadtear, CFR, Recaf — бесполезный инструментарий.

Остаётся только реверсер с IDA готовый читать nmethod ассемблером. Уже x86 реверс, не Java. И наши C2 обфускации (ниже) добавляют ещё слоёв. Вот здесь кастомный форк окупается полностью — на стоке невозможно, деопт это first-class path и его не обойти.



JIT как соучастник обфускации

C2 — агрессивный оптимизатор, правильно кормишь — сам сделает код нечитаемым.

MethodHandle цепочки. callGenerator.cpp ~1019, for_method_handle_inline. Когда receiver MH — константа (Op_ConP), C2 извлекает целевой метод и инлайнит рекурсивно через всю цепочку:

Java:
Expand Collapse Copy
static final MethodHandle MH = MethodHandles.filterReturnValue(
    MethodHandles.insertArguments(
        MethodHandles.guardWithTest(test, target, fallback),
        0, constArg),
    filter);

После компиляции — вся цепочка исчезает из nmethod. Нет lambdaform.invoke, нет адаптеров, слитый воедино код всех четырёх функций с прямым je. Реверсер видит x86 блоб где четыре метода смешаны в один.

@ForceInline из jdk.internal.vm.annotation — C2 игнорирует все бюджеты инлайнинга. По умолчанию доступна только boot classloader-у (classFileParser.cpp ~1836), на своём форке проверку удаляем.

@Stable — поле trusted final после первой записи. @Stable MethodHandle[] TABLE — слоты после non-null сворачиваются как константы. Таблицы MH-ов становятся статическими для C2.

Escape analysis — объект не утекает, скаляризуется. Запись полей → работа с регистрами, аллокация исчезает. Промежуточное состояние в record — в байткоде new/putfield/getfield, в nmethod только регистровые перестановки.

Loop unrolling с -XX:LoopUnrollLimit=500 — маленькие циклы разворачиваются полностью, @Stable константы сворачиваются как immediates. Цикл XOR-а ключом → прямолинейная последовательность XOR-ов без ключа (запечён в инструкции).

ИнтринсикиMath.fmavfmadd231sd, Integer.bitCountpopcnt. Java реализация выкидывается, подставляется ручной ассемблер. Реверсер видит SIMD там где была простая логика.

Если у нас свой HotSpot — ещё пара грязных штук:

  • Register allocator PRNG (opto/chaitin.cpp Chaitin::Select) — per-compile seed в tie-break очереди спилов. Две сборки того же байткода → разные register maps. Нельзя диффать nmethod между билдами
  • Emitter noise (opto/output.cpp::fill_buffer) — семантические nop-ы между basic блоками (lea rax,[rax+0], разные кодировки nop). Per-compile PRNG. Сигнатурный фингерпринтинг не работает
  • Instruction selection (x86_64.ad) — некоторые match_rule имеют несколько энкодингов (mov reg,0 vs xor reg,reg). PRNG tie-breaker

В сумме: полиморфный байткод + рандомный register map + рандомный nop паттерн. Ни один инструмент диффа не найдёт стабильной точки опоры между сборками.



Если хочете «ещё круче» — kernel driver

Всё что выше — защита от Java-уровневых атак. От внешнего чтения процесса через RPM почти не работает. Тут ставим kernel driver. Я смотрел внутренности Vanguard, BattlEye, EAC — все они защитную часть делают через ~6 API:

  • ObRegisterCallbacks(PsProcessType, pre-op) — раздевает handle-ы открытые к нашему процессу. Снимает PROCESS_VM_READ, VM_WRITE, VM_OPERATION, DUP_HANDLE, CREATE_THREAD. Cheat Engine и Process Hacker сосут на этом
  • ObRegisterCallbacks(PsThreadType) — то же для потоков, внешний отладчик не прицепится
  • PsSetCreateProcessNotifyRoutineEx — трекаем создание нашего процесса
  • PsSetLoadImageNotifyRoutine — allowlist dll по SHA-256, неизвестная — user APC на завершение
  • PsSetCreateThreadNotifyRoutine — детект remote thread creation
  • Self-integrity DPC — каждые 100мс проверяем что наш ObCallback в списке, защита от DKOM атак

Всё. Шесть API, 1500-2500 строк C. У Vanguard ~40 тысяч строк, но 70% это детект читеров — нам не нужно. Подписывать через Microsoft Partner Center attestation signing, EV cert ~$300-600/год. Гипервизорные драйверы триггерят ручной ревью Microsoft, обычный ObRegisterCallbacks — нет.

Важно: на Win11 24H2 HVCI включён по умолчанию и форсит vulnerable driver blocklist — ~700 известных уязвимых драйверов (mhyprot2, capcom, RTCore64) не загружаются, что убивает BYOVD атаку. Наш драйвер при инициализации должен требовать HVCI. Vanguard делает так.

Про свой гипервизор (VT-x/EPT через наш драйвер) отдельно скажу чтобы никто не велся: на Win11 2026 не работает. Hyper-V уже в VMX root mode, твой VMXON делает #GP. Единственный путь — nested guest под Hyper-V, но перф падает в разы, плюс 4-6 месяцев разработки. Гипервизор — для DRM, не для Java защиты.



Собираем воедино

Финальная архитектура по слоям:

  1. Контейнер — кастомный .avl, классы как AES-GCM шифртексты
  2. Ключи — мастер из HWID + TPM-sealed + build salt, per-method через HKDF. Принципиально: HWID это ключевой материал, не булева проверка. Неправильный HWID = неправильный ключ = крэш. Булевы проверки нопятся за минуту, ключевой материал никогда
  3. Кастомная JVMINCLUDE_JVMTI=0/SERVICES=0/JFR=0, strip VMStructs/PDB, layout drift, убитые startup агенты, удалённые модули java.instrument/jdk.hotspot.agent/jdk.jdwp.agent
  4. ЛоадерAV_DefineEncryptedClass JNI entry: .avl → decrypt → JVM_LookupDefineClass с HIDDEN_CLASS → занулить буфер
  5. Condy — все строки и константы через CONSTANT_Dynamic, ключ в classData, после JIT — x86 immediates
  6. nmethod vaultConstMethod::_code зануляется после C2, nmethod запинен, деопт невозможен (CHA нейтрализован, OSR запрещён, uncommon traps → explicit throws)
  7. JIT-aware bytecode@Stable MethodHandle[], @ForceInline/@DontInline, интринсики, final sealed, record-holder-ы под EA
  8. JIT моды — register allocator PRNG, emitter noise, instruction selection tie-break
  9. Kernel driver (опционально) — ObRegisterCallbacks + Ps*Notify + self-integrity DPC, требует HVCI
  10. Per-build уникальность — build seed → разный condy layout, разные ключи, разные register maps, разные nop паттерны. Атака на одну сборку не переносится

Что остаётся возможным: ring-0 через BYOVD на машине без HVCI (редко), реверсер с IDA читающий nmethod ассемблером (недели работы), cold boot / DMA атаки (физический доступ).

Что невозможно: стандартные Java деобфускаторы, дамперы инжектящиеся в процесс, Serviceability Agent, паттерн «premain + ClassFileTransformer», RPM с парсингом VMStructs, Cheat Engine (с kernel driver), memory сканеры ищущие CAFEBABE.



Послесловие

Я два года писал обычный обфускатор «invokedynamic + hidden classes + JNI DLL». Кастомная VM поверх Java, opcode randomization, per-build ключи. Всё работало, но меня постоянно раздражало что любой школьник с готовым дампером разваливал защиту за секунду. Я пытался ловить это детектом — проверки RuntimeMXBean, поиск подозрительных dll в памяти, хеширование JNI function table. Всё легко обходится.

Недели копания OpenJDK чтобы понять простую вещь: я играл в чужой песочнице. JVM создана чтобы быть открытой для инструментирования. JVMTI это её фича, не баг. ClassFileLoadHook это задокументированный API. Всё что я делал — пытался прикрыть листиком дыру размером с форточку.

Правильный путь — владеть JVM. INCLUDE_JVMTI=0 это пятистрочное изменение в мейкфайле на котором заканчивается индустрия публичных Java-реверс инструментов применительно к тебе. HotSpot мейнтейнеры уже сделали работу за нас для embedded use cases. Мы просто используем ту же дорожку.

Если кто-то пилит своё в этом направлении или залипает по JVM internals — маякуйте в ЛС. Если нашёл ошибки в статье — тоже пишите, я где-то мог наврать.
 
Вступление

Статья выросла из моего личного раздражения. Я несколько лет пилю защиту для Java приложений — в основном minecraft клиенты — и всё это время наблюдаю одну картину: любой платный обфускатор за 200 баксов, любой коммерческий клиент с билдбита, любое «защищённое» решение — всё сливается за вечер любым человеком у которого есть Recaf и желание потыкать. Дело даже не в том что обфускация слабая. Дело в том что вся индустрия Java реверса построена вокруг одной-единственной дыры, о которой толком никто не говорит, и которую невозможно закрыть стандартными средствами.

Я потратил несколько месяцев на копание исходников OpenJDK — ходил по HotSpot, читал пути вызовов, смотрел какие конкретно места в коде JVM приводят к тому что класс можно достать из процесса. Все файлы и номера строк из свежего дерева HotSpot, можешь проверить. После того как один раз видишь как это работает изнутри — понимаешь что весь мейнстрим-стек Java защиты это покраска ржавчины. А настоящее решение лежит сильно дальше и выглядит не так как я ожидал когда начинал копать.

Пишу без базы. Если не знаешь что такое classloader, байткод, JIT и примерно как работает HotSpot — сначала в JVMS, потом возвращайся. На русском по этой теме я толком ничего толкового не встречал, поэтому и взялся писать. Поехали.



Почему текущий стек защиты не работает

Начну с того что меня бесит. Типичный «защищённый» Java клиент сегодня это:

  1. Переименовали классы/методы через ProGuard
  2. Строки зашифровали XOR-ом с ключом в clinit
  3. Вызовы завернули в invokedynamic
  4. Прогнали через native-obfuscator
  5. Натянули сверху VMProtect

Смотрится солидно. На практике ломается стандартным инструментом дампера который инжектится в процесс жертвы и забирает классы на этапе когда они уже расшифрованы в памяти но ещё не провалидированы JVM-кой.

Работает это так: инструмент подпихивает свой код внутрь процесса (не важно как — подмена одной из загружаемых dll, маленький хелпер, шелл-код — вариантов хватает), там цепляется к внутреннему API HotSpot под названием JVMTI и подписывается на событие ClassFileLoadHook. Это событие дёргается когда любому классу надо пройти через парсер JVM. Включая те что обфускатор только что расшифровал у себя в clinit.

Результат: в дамп падают чистые .class файлы, строки расшифрованы, invokedynamic уже зарезолвнут, имена перемешаны но логика как на ладони. Дальше CFR или Vineflower и клиент слит.

Работает потому что ClassFileLoadHook вызывается ПОСЛЕ того как обфускатор отдал байты в JVM но ДО валидации и линковки. Инструмент сидит ровно между твоим декриптом и парсером, пишет всё на диск. Единственное что тебя спасёт — чтобы этого события вообще не существовало в JVM. Без шуток.

Ниже по слоям разберу что с этим можно сделать.



Векторы дампа — полный список

Прежде чем защищаться, надо понимать от чего. Я потратил дохрена времени на OpenJDK исходники и могу сказать — векторов ровно десять:

  1. JVMTI ClassFileLoadHookprims/jvmtiExport.cpp, функция post_class_file_load_hook около 1091 строки. Главная дыра. Любой код внутри процесса видит байты всех классов
  2. JVMTI GetBytecodesprims/jvmtiEnv.cpp ~3428. Читает байткод уже загруженного метода в рантайме. Внутри JvmtiClassFileReconstituter::copy_bytecodes разворачивает _fast_* опкоды обратно в спецификационные — даже переписанные HotSpot-ом байты отдаются чистыми
  3. JVMTI RetransformClasses — принудительно перепарсит загруженный класс и снова дёрнет CFLH. Юзается когда дампер прицепился после старта
  4. GetLoadedClassesprims/jvmtiGetLoadedClasses.cpp. Возвращает всё из ClassLoaderDataGraph. Неприятно: hidden classes тоже возвращает, фильтра is_hidden() там нет (сам был в обратном уверен пока не полез в сорцы)
  5. Serviceability Agent (SA) — jhsdb, jmap -F, jstack -F. Цепляется к процессу снаружи через ptrace/DebugActiveProcess, парсит сырую память по таблице VMStructs которую jvm.dll экспортирует как глобальный символ
  6. Прямое сканирование метаспейса — тот же путь что SA но ручками. Инжект dll, читаем gHotSpotVMStructs, ходим ClassLoaderDataGraph::_headInstanceKlassMethod*ConstMethod* и читаем (ConstMethod*)+1 на code_size байт
  7. Переписанный байткод в метаспейсеinterpreter/rewriter.cpp. HotSpot мутирует байткод при линковке, но restore_bytecodes() публична и reconstituter её дёргает
  8. JIT nmethod → байткод — nmethod в code cache содержит ScopeDesc с маппингом PC → BCI. Продвинутый реверсер восстановит структуру
  9. CDS mapped archivecds/filemap.cpp. Классы из .jsa лежат в замапленной странице
  10. Подмена jvm.dll / JNI хук — дампер подсовывает свою dll с теми же экспортами что настоящая jvm.dll, хукает JVM_DefineClass / Unsafe_DefineClass0. Самый ранний перехват

Блять, десять векторов. Смотрим что из этого прихлопывается флагами JVM:

Код:
Expand Collapse Copy
- startup агенты:                  НЕТ ТАКОГО ФЛАГА
- -agentpath/-agentlib:            НЕТ ТАКОГО ФЛАГА
- Attach API (jcmd/jmap/jstack):   -XX:+DisableAttachMechanism
- Dynamic agent load:              -XX:-EnableDynamicAgentLoading
- JVMTI целиком:                   НЕТ ТАКОГО ФЛАГА
- GetBytecodes:                    НЕТ ТАКОГО ФЛАГА
- SA attach:                       НЕТ ТАКОГО ФЛАГА
- GetLoadedClasses hidden filter:  НЕТ ТАКОГО ФЛАГА

Если атакующий контролирует командную строку запуска (а он контролирует её всегда, это его машина) — ни одна комбинация флагов стоковой JVM тебя не спасает. Факт. Всё что ты делаешь на стоке — поднимаешь планку для казуалов.



Hidden classes и почему они почти работают

Штука которую я долго недооценивал. В classfile/klassFactory.cpp на 191 строке:

C++:
Expand Collapse Copy
if (!cl_info.is_hidden()) {

    stream = check_class_file_load_hook(...);

Для hidden классов CFLH не вызывается. Классы загруженные через MethodHandles.Lookup.defineHiddenClass невидимы для дамперов на этапе загрузки. Звучит как серебряная пуля. Сейчас сломаю энтузиазм.

Подвох раз: передаёшь byte[] живущий в Java куче. Пока массив живой — его можно найти в памяти. Надо зануть через Arrays.fill(bytes, 0) сразу после defineHiddenClass.

Подвох два, злее: hidden classes не прячутся от GetBytecodes и GetLoadedClasses. Был уверен в обратном пока не полез в jvmtiGetLoadedClasses.cpp — там итерация loaded_classes_do_keepalive без фильтра is_hidden(). Если дампер прицепился ПОСЛЕ decrypt-а — дёрнет GetLoadedClasses, найдёт hidden класс (имя мусорное Foo/0x00007f... но оно есть), дёрнет GetBytecodes и получит восстановленный байткод через reconstituter. Сам байткод уже в метаспейсе, оттуда никак не стереть без патча JVM.

Hidden classes закрывают один сценарий — перехват на загрузке. Это половина атак, но не решение. Комбо -XX:+DisableAttachMechanism -XX:-EnableDynamicAgentLoading + проверка аргументов в clinit обходится за 10 минут: дампер патчит RuntimeImpl.getInputArguments и твоя проверка читает чистый список. Гонка проиграна до старта.



Condy + hidden class + classData — правильная база

Инструмент которым почти никто не пользуется для обфускации — CONSTANT_Dynamic (condy, JEP 309, с Java 11) плюс defineHiddenClassWithClassData (JEP 371, Java 16+).

Суть condy: элемент constant pool содержит вместо значения ссылку на bootstrap метод и статические аргументы. Первый ldc — JVM вызывает BSM, кеширует результат в resolved_references. Второй — чтение из массива. После C2 компиляции слот трактуется как @Stable final и значение запекается в native код как x86 immediate. Проверено на oops/constantPool.cpp ~1080, функция resolve_constant_at_impl, на 1097 ранний выход по кешу.

Что это значит: каждая строка, каждое число, каждая ссылка в классе — condy. Вместо ldc "Hello" стоит ldc #K где #K это CONSTANT_Dynamic с bootstrap-ом decryptString и аргументами [шифртекст, nonce]. BSM тянет AES ключ из classData:

Java:
Expand Collapse Copy
public static String decryptString(MethodHandles.Lookup caller,
                                   String unusedName, Class<?> unusedType,
                                   byte[] cipher, byte[] nonce) throws Throwable {
    byte[] key = MethodHandles.classDataAt(caller, "_", byte[].class, 0);
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
           new GCMParameterSpec(128, nonce));
    return new String(c.doFinal(cipher), StandardCharsets.UTF_8);
}

Имя condy обязано быть "_" (реальная проверка в MethodHandles.java, константа ConstantDescs.DEFAULT_NAME). Int аргумент — индекс в classData.

Загрузка hidden класса:

Java:
Expand Collapse Copy
public static Lookup loadEncrypted(byte[] encryptedBytes,
                                   byte[] aesKey, byte[] nonce) throws Throwable {
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"),
           new GCMParameterSpec(128, nonce));
    byte[] plainClass = c.doFinal(encryptedBytes);

    List<Object> classData = List.of(aesKey, Integer.valueOf(0xDEADBEEF));

    Lookup hidden = MethodHandles.lookup().defineHiddenClassWithClassData(
        plainClass, classData, true, Lookup.ClassOption.NESTMATE);

    Arrays.fill(plainClass, (byte) 0);
    return hidden;
}

Что происходит во времени:

  • В jar-е: внешний хостовый класс и byte[] с шифртекстом hidden класса
  • Старт: расшифровали → defineHiddenClassWithClassData → зануляем буфер. Plaintext живёт микросекунды
  • Первый вызов метода: каждый ldc #condy один раз вызывает decryptString
  • После C2: строка — x86 immediate в native, сама функция decryptString мёртвый код

В jar на диске — шифртексты и ссылки на BSM. В памяти после JIT — x86 immediates никак не связанные с расшифровкой. Ноль накладных на горячем пути. Не «малые», а прямо ноль — после warmup байты decrypt функции не исполняются.

Это очень круто против статики и против казуальных атак. Но есть нюанс которого я долго не видел: resolved_references слот это обычный объект в cp cache, и если дампер прицепится внутрь процесса после первого вызова — он прочитает расшифрованные строки прямо оттуда. Condy защищает файл на диске и статический анализ, но не защищает от дампера внутри процесса который ходит по метаспейсу.

Сток JVM с condy даёт тебе где-то 85% защиты. Против Recaf, CFR, narumii, threadtear — всё идёт в топку. Против дампера инжектящегося внутрь процесса с полным JVMTI — ты всё равно проигрываешь оставшиеся 15%.

Чтобы закрыть их — надо перестать играть в чужой песочнице.



Кастомная JVM — потому что флагов не хватает

Здесь начинается нормальная часть. Если не можем выключить JVMTI флагом — выключим его при компиляции самого HotSpot.

Главная штука которую я понял после недель копания, и из-за которой написана эта статья. HotSpot уже спроектирован с compile-time флагами ровно для этого. src/hotspot/share/utilities/macros.hpp около 62 строки содержит INCLUDE_JVMTI — макрос, ставится в 0 при сборке, и весь код обёрнутый в JVMTI_ONLY(...) превращается в пустоту. Это около 50 .cpp файлов в src/hotspot/share/prims/jvmti*.cpp. Рядом INCLUDE_SERVICES (убивает Attach Listener и SA), INCLUDE_JFR, INCLUDE_NMT.

В make/hotspot/lib/JvmFeatures.gmk пишешь:

Java:
Expand Collapse Copy
-DINCLUDE_JVMTI=0
-DINCLUDE_SERVICES=0
-DINCLUDE_JFR=0
-DINCLUDE_NMT=0

И получаешь jvm.dll в котором физически нет JVMTI. Нет кода. Парсер аргументов в runtime/arguments.cpp всё ещё видит -javaagent и пытается зарегистрировать агент, но путь загрузки уходит в пустоту. Можно дополнительно заткнуть:

C++:
Expand Collapse Copy
if (match_option(option, "-javaagent:", &tail)) {
    jio_fprintf(defaultStream::error_stream(), "Error: disabled\n");
    return JNI_ERR;
}

Выпиливаем модули: java.instrument, jdk.jdi, jdk.jdwp.agent, jdk.hotspot.agent, jdk.jcmd, jdk.jconsole, jdk.management.agent. После этого больше нет:

  • Java Instrumentation API (premain/agentmain мертвы)
  • JDWP (отладчик не прицепится)
  • Serviceability Agent (jhsdb мёртв)
  • jcmd, jmap, jstack, jinfo

Всё это — три дня работы. Реально изменения в одном мейкфайле + ~20 строк в парсере + удаление модулей из списка сборки. Всё остальное за нас сделали HotSpot-овские мейнтейнеры, потому что эти макросы задумывались для embedded Java где JVMTI не нужен. Мы просто используем готовую дорожку.

После этого из десяти векторов дампа — девять пропадают сами собой. Остаётся только внешнее чтение памяти через RPM + парсинг VMStructs. Его лечим так:

Удаляем VMStructs экспорты. runtime/vmStructs.cpp — таблицы gHotSpotVMStructs, gHotSpotVMTypes, gHotSpotVMIntConstants это глобальные символы. На них опираются все сторонние тулзы. Добавляем .def файл со списком экспортов только JNI_* и JVM_*, линкуем с /OPT:REF. Снаружи jvm.dll больше не описывает свой внутренний layout.

Strip-им PDB. Релиз без debug info. Реверсер не получит готовых оффсетов, придётся реверсить сам jvm.dll.

Layout drift. При каждой сборке добавляем в InstanceKlass, Method, ConstMethod несколько приватных полей. Это бесплатно сдвигает оффсеты структур. Публичные тулзы заточены под апстрим layout и ломаются на нашей сборке сразу.

После этого внешний дампер должен:

  1. Сам по сигнатурам найти ClassLoaderDataGraph::_head в нашем jvm.dll (без символов)
  2. Сам определить поля InstanceKlass (наш layout drift)
  3. Сам восстановить маппинг cpCache indices → CP indices (иначе _fast_* не раскодировать)

Это уже не «запустил тулзу, получил дамп за минуту». Это несколько недель реверсинга под одну конкретную версию. Следующий билд с другим drift — работа заново.



Стирание байткода после JIT

То на что я потратил больше всего времени и что реально доставляет. Как живут классы в HotSpot после загрузки: байты в KlassFactory::create_from_stream → парсер создаёт InstanceKlass/Method/ConstMethod в метаспейсе → байткод inline после заголовка ConstMethod (code_base() { return (address)(this+1); }) → линковка мутирует байты на месте → горячий метод → C2 → nmethod в code cache → все вызовы идут через _from_compiled_entry, интерпретатор не трогается.

Вопрос: нужен ли байткод в метаспейсе после того как nmethod готов?

Полез смотреть. Нормальный путь исполнения не лезет в ConstMethod::_code. Единственное место где он нужен — деоптимизация. runtime/vframeArray.cpp ~199:

C++:
Expand Collapse Copy
bcp = method()->bcp_from(bci());
iframe()->interpreter_frame_set_bcp(bcp);

Занулим code_base, случится деопт — интерпретатор читает нули, бежит вперёд, крашится. Деопт триггерится от кучи вещей: uncommon trap на null check, bounds check, CHA invalidation, retransform, cold flush. Но у нас уже кастомная JVM, значит можем:

  1. Отключить CHA-деоптACC_FINAL + запрет subclass-ить, code/dependencies.cpp
  2. Убить OSR-XX:-UseOnStackReplacement
  3. Заменить uncommon traps на явные throw — C2 pass в opto/, вместо DeoptAction эмитим прямой throw через shared stub. Цена 2-5% throughput
  4. Retransform — уже бесплатно с INCLUDE_JVMTI=0
  5. Guard в fetch_unroll_info_helperdeoptimization.cpp ~474. Если nmethod sealed — vm_exit_during_runtime. Падение процесса вместо чтения нулей

После этих патчей деопт для помеченных методов невозможен по построению. Значит в Method::set_code сразу после публикации nmethod — memset(cm->code_base(), 0, cm->code_size()). Байткод больше не существует в памяти. Только x86 в code cache.

В nmethod.hpp добавляем два бита:

C++:
Expand Collapse Copy
_is_pinned:1,   // не релоцировать, не флашить
_is_sealed:1,   // байткод занулен

И в is_cold/is_relocatable ранний выход для pinned. Всё. Помеченный метод загружен из .avl → расшифрован на микросекунды → скомпилен C2 → байткод в метаспейсе занулен → nmethod запинен → работает только через _from_compiled_entry.

Класс физически не существует как байткод. Только как x86 в code cache. Ни один Java деобфускатор не умеет восстанавливать структуру из x86. На этом этапе de4dot-for-Java, narumii, threadtear, CFR, Recaf — бесполезный инструментарий.

Остаётся только реверсер с IDA готовый читать nmethod ассемблером. Уже x86 реверс, не Java. И наши C2 обфускации (ниже) добавляют ещё слоёв. Вот здесь кастомный форк окупается полностью — на стоке невозможно, деопт это first-class path и его не обойти.



JIT как соучастник обфускации

C2 — агрессивный оптимизатор, правильно кормишь — сам сделает код нечитаемым.

MethodHandle цепочки. callGenerator.cpp ~1019, for_method_handle_inline. Когда receiver MH — константа (Op_ConP), C2 извлекает целевой метод и инлайнит рекурсивно через всю цепочку:

Java:
Expand Collapse Copy
static final MethodHandle MH = MethodHandles.filterReturnValue(
    MethodHandles.insertArguments(
        MethodHandles.guardWithTest(test, target, fallback),
        0, constArg),
    filter);

После компиляции — вся цепочка исчезает из nmethod. Нет lambdaform.invoke, нет адаптеров, слитый воедино код всех четырёх функций с прямым je. Реверсер видит x86 блоб где четыре метода смешаны в один.

@ForceInline из jdk.internal.vm.annotation — C2 игнорирует все бюджеты инлайнинга. По умолчанию доступна только boot classloader-у (classFileParser.cpp ~1836), на своём форке проверку удаляем.

@Stable — поле trusted final после первой записи. @Stable MethodHandle[] TABLE — слоты после non-null сворачиваются как константы. Таблицы MH-ов становятся статическими для C2.

Escape analysis — объект не утекает, скаляризуется. Запись полей → работа с регистрами, аллокация исчезает. Промежуточное состояние в record — в байткоде new/putfield/getfield, в nmethod только регистровые перестановки.

Loop unrolling с -XX:LoopUnrollLimit=500 — маленькие циклы разворачиваются полностью, @Stable константы сворачиваются как immediates. Цикл XOR-а ключом → прямолинейная последовательность XOR-ов без ключа (запечён в инструкции).

ИнтринсикиMath.fmavfmadd231sd, Integer.bitCountpopcnt. Java реализация выкидывается, подставляется ручной ассемблер. Реверсер видит SIMD там где была простая логика.

Если у нас свой HotSpot — ещё пара грязных штук:

  • Register allocator PRNG (opto/chaitin.cpp Chaitin::Select) — per-compile seed в tie-break очереди спилов. Две сборки того же байткода → разные register maps. Нельзя диффать nmethod между билдами
  • Emitter noise (opto/output.cpp::fill_buffer) — семантические nop-ы между basic блоками (lea rax,[rax+0], разные кодировки nop). Per-compile PRNG. Сигнатурный фингерпринтинг не работает
  • Instruction selection (x86_64.ad) — некоторые match_rule имеют несколько энкодингов (mov reg,0 vs xor reg,reg). PRNG tie-breaker

В сумме: полиморфный байткод + рандомный register map + рандомный nop паттерн. Ни один инструмент диффа не найдёт стабильной точки опоры между сборками.



Если хочете «ещё круче» — kernel driver

Всё что выше — защита от Java-уровневых атак. От внешнего чтения процесса через RPM почти не работает. Тут ставим kernel driver. Я смотрел внутренности Vanguard, BattlEye, EAC — все они защитную часть делают через ~6 API:

  • ObRegisterCallbacks(PsProcessType, pre-op) — раздевает handle-ы открытые к нашему процессу. Снимает PROCESS_VM_READ, VM_WRITE, VM_OPERATION, DUP_HANDLE, CREATE_THREAD. Cheat Engine и Process Hacker сосут на этом
  • ObRegisterCallbacks(PsThreadType) — то же для потоков, внешний отладчик не прицепится
  • PsSetCreateProcessNotifyRoutineEx — трекаем создание нашего процесса
  • PsSetLoadImageNotifyRoutine — allowlist dll по SHA-256, неизвестная — user APC на завершение
  • PsSetCreateThreadNotifyRoutine — детект remote thread creation
  • Self-integrity DPC — каждые 100мс проверяем что наш ObCallback в списке, защита от DKOM атак

Всё. Шесть API, 1500-2500 строк C. У Vanguard ~40 тысяч строк, но 70% это детект читеров — нам не нужно. Подписывать через Microsoft Partner Center attestation signing, EV cert ~$300-600/год. Гипервизорные драйверы триггерят ручной ревью Microsoft, обычный ObRegisterCallbacks — нет.

Важно: на Win11 24H2 HVCI включён по умолчанию и форсит vulnerable driver blocklist — ~700 известных уязвимых драйверов (mhyprot2, capcom, RTCore64) не загружаются, что убивает BYOVD атаку. Наш драйвер при инициализации должен требовать HVCI. Vanguard делает так.

Про свой гипервизор (VT-x/EPT через наш драйвер) отдельно скажу чтобы никто не велся: на Win11 2026 не работает. Hyper-V уже в VMX root mode, твой VMXON делает #GP. Единственный путь — nested guest под Hyper-V, но перф падает в разы, плюс 4-6 месяцев разработки. Гипервизор — для DRM, не для Java защиты.



Собираем воедино

Финальная архитектура по слоям:

  1. Контейнер — кастомный .avl, классы как AES-GCM шифртексты
  2. Ключи — мастер из HWID + TPM-sealed + build salt, per-method через HKDF. Принципиально: HWID это ключевой материал, не булева проверка. Неправильный HWID = неправильный ключ = крэш. Булевы проверки нопятся за минуту, ключевой материал никогда
  3. Кастомная JVMINCLUDE_JVMTI=0/SERVICES=0/JFR=0, strip VMStructs/PDB, layout drift, убитые startup агенты, удалённые модули java.instrument/jdk.hotspot.agent/jdk.jdwp.agent
  4. ЛоадерAV_DefineEncryptedClass JNI entry: .avl → decrypt → JVM_LookupDefineClass с HIDDEN_CLASS → занулить буфер
  5. Condy — все строки и константы через CONSTANT_Dynamic, ключ в classData, после JIT — x86 immediates
  6. nmethod vaultConstMethod::_code зануляется после C2, nmethod запинен, деопт невозможен (CHA нейтрализован, OSR запрещён, uncommon traps → explicit throws)
  7. JIT-aware bytecode@Stable MethodHandle[], @ForceInline/@DontInline, интринсики, final sealed, record-holder-ы под EA
  8. JIT моды — register allocator PRNG, emitter noise, instruction selection tie-break
  9. Kernel driver (опционально) — ObRegisterCallbacks + Ps*Notify + self-integrity DPC, требует HVCI
  10. Per-build уникальность — build seed → разный condy layout, разные ключи, разные register maps, разные nop паттерны. Атака на одну сборку не переносится

Что остаётся возможным: ring-0 через BYOVD на машине без HVCI (редко), реверсер с IDA читающий nmethod ассемблером (недели работы), cold boot / DMA атаки (физический доступ).

Что невозможно: стандартные Java деобфускаторы, дамперы инжектящиеся в процесс, Serviceability Agent, паттерн «premain + ClassFileTransformer», RPM с парсингом VMStructs, Cheat Engine (с kernel driver), memory сканеры ищущие CAFEBABE.



Послесловие

Я два года писал обычный обфускатор «invokedynamic + hidden classes + JNI DLL». Кастомная VM поверх Java, opcode randomization, per-build ключи. Всё работало, но меня постоянно раздражало что любой школьник с готовым дампером разваливал защиту за секунду. Я пытался ловить это детектом — проверки RuntimeMXBean, поиск подозрительных dll в памяти, хеширование JNI function table. Всё легко обходится.

Недели копания OpenJDK чтобы понять простую вещь: я играл в чужой песочнице. JVM создана чтобы быть открытой для инструментирования. JVMTI это её фича, не баг. ClassFileLoadHook это задокументированный API. Всё что я делал — пытался прикрыть листиком дыру размером с форточку.

Правильный путь — владеть JVM. INCLUDE_JVMTI=0 это пятистрочное изменение в мейкфайле на котором заканчивается индустрия публичных Java-реверс инструментов применительно к тебе. HotSpot мейнтейнеры уже сделали работу за нас для embedded use cases. Мы просто используем ту же дорожку.

Если кто-то пилит своё в этом направлении или залипает по JVM internals — маякуйте в ЛС. Если нашёл ошибки в статье — тоже пишите, я где-то мог наврать.
+rep прикольно
 
Вступление

Статья выросла из моего личного раздражения. Я несколько лет пилю защиту для Java приложений — в основном minecraft клиенты — и всё это время наблюдаю одну картину: любой платный обфускатор за 200 баксов, любой коммерческий клиент с билдбита, любое «защищённое» решение — всё сливается за вечер любым человеком у которого есть Recaf и желание потыкать. Дело даже не в том что обфускация слабая. Дело в том что вся индустрия Java реверса построена вокруг одной-единственной дыры, о которой толком никто не говорит, и которую невозможно закрыть стандартными средствами.

Я потратил несколько месяцев на копание исходников OpenJDK — ходил по HotSpot, читал пути вызовов, смотрел какие конкретно места в коде JVM приводят к тому что класс можно достать из процесса. Все файлы и номера строк из свежего дерева HotSpot, можешь проверить. После того как один раз видишь как это работает изнутри — понимаешь что весь мейнстрим-стек Java защиты это покраска ржавчины. А настоящее решение лежит сильно дальше и выглядит не так как я ожидал когда начинал копать.

Пишу без базы. Если не знаешь что такое classloader, байткод, JIT и примерно как работает HotSpot — сначала в JVMS, потом возвращайся. На русском по этой теме я толком ничего толкового не встречал, поэтому и взялся писать. Поехали.



Почему текущий стек защиты не работает

Начну с того что меня бесит. Типичный «защищённый» Java клиент сегодня это:

  1. Переименовали классы/методы через ProGuard
  2. Строки зашифровали XOR-ом с ключом в clinit
  3. Вызовы завернули в invokedynamic
  4. Прогнали через native-obfuscator
  5. Натянули сверху VMProtect

Смотрится солидно. На практике ломается стандартным инструментом дампера который инжектится в процесс жертвы и забирает классы на этапе когда они уже расшифрованы в памяти но ещё не провалидированы JVM-кой.

Работает это так: инструмент подпихивает свой код внутрь процесса (не важно как — подмена одной из загружаемых dll, маленький хелпер, шелл-код — вариантов хватает), там цепляется к внутреннему API HotSpot под названием JVMTI и подписывается на событие ClassFileLoadHook. Это событие дёргается когда любому классу надо пройти через парсер JVM. Включая те что обфускатор только что расшифровал у себя в clinit.

Результат: в дамп падают чистые .class файлы, строки расшифрованы, invokedynamic уже зарезолвнут, имена перемешаны но логика как на ладони. Дальше CFR или Vineflower и клиент слит.

Работает потому что ClassFileLoadHook вызывается ПОСЛЕ того как обфускатор отдал байты в JVM но ДО валидации и линковки. Инструмент сидит ровно между твоим декриптом и парсером, пишет всё на диск. Единственное что тебя спасёт — чтобы этого события вообще не существовало в JVM. Без шуток.

Ниже по слоям разберу что с этим можно сделать.



Векторы дампа — полный список

Прежде чем защищаться, надо понимать от чего. Я потратил дохрена времени на OpenJDK исходники и могу сказать — векторов ровно десять:

  1. JVMTI ClassFileLoadHookprims/jvmtiExport.cpp, функция post_class_file_load_hook около 1091 строки. Главная дыра. Любой код внутри процесса видит байты всех классов
  2. JVMTI GetBytecodesprims/jvmtiEnv.cpp ~3428. Читает байткод уже загруженного метода в рантайме. Внутри JvmtiClassFileReconstituter::copy_bytecodes разворачивает _fast_* опкоды обратно в спецификационные — даже переписанные HotSpot-ом байты отдаются чистыми
  3. JVMTI RetransformClasses — принудительно перепарсит загруженный класс и снова дёрнет CFLH. Юзается когда дампер прицепился после старта
  4. GetLoadedClassesprims/jvmtiGetLoadedClasses.cpp. Возвращает всё из ClassLoaderDataGraph. Неприятно: hidden classes тоже возвращает, фильтра is_hidden() там нет (сам был в обратном уверен пока не полез в сорцы)
  5. Serviceability Agent (SA) — jhsdb, jmap -F, jstack -F. Цепляется к процессу снаружи через ptrace/DebugActiveProcess, парсит сырую память по таблице VMStructs которую jvm.dll экспортирует как глобальный символ
  6. Прямое сканирование метаспейса — тот же путь что SA но ручками. Инжект dll, читаем gHotSpotVMStructs, ходим ClassLoaderDataGraph::_headInstanceKlassMethod*ConstMethod* и читаем (ConstMethod*)+1 на code_size байт
  7. Переписанный байткод в метаспейсеinterpreter/rewriter.cpp. HotSpot мутирует байткод при линковке, но restore_bytecodes() публична и reconstituter её дёргает
  8. JIT nmethod → байткод — nmethod в code cache содержит ScopeDesc с маппингом PC → BCI. Продвинутый реверсер восстановит структуру
  9. CDS mapped archivecds/filemap.cpp. Классы из .jsa лежат в замапленной странице
  10. Подмена jvm.dll / JNI хук — дампер подсовывает свою dll с теми же экспортами что настоящая jvm.dll, хукает JVM_DefineClass / Unsafe_DefineClass0. Самый ранний перехват

Блять, десять векторов. Смотрим что из этого прихлопывается флагами JVM:

Код:
Expand Collapse Copy
- startup агенты:                  НЕТ ТАКОГО ФЛАГА
- -agentpath/-agentlib:            НЕТ ТАКОГО ФЛАГА
- Attach API (jcmd/jmap/jstack):   -XX:+DisableAttachMechanism
- Dynamic agent load:              -XX:-EnableDynamicAgentLoading
- JVMTI целиком:                   НЕТ ТАКОГО ФЛАГА
- GetBytecodes:                    НЕТ ТАКОГО ФЛАГА
- SA attach:                       НЕТ ТАКОГО ФЛАГА
- GetLoadedClasses hidden filter:  НЕТ ТАКОГО ФЛАГА

Если атакующий контролирует командную строку запуска (а он контролирует её всегда, это его машина) — ни одна комбинация флагов стоковой JVM тебя не спасает. Факт. Всё что ты делаешь на стоке — поднимаешь планку для казуалов.



Hidden classes и почему они почти работают

Штука которую я долго недооценивал. В classfile/klassFactory.cpp на 191 строке:

C++:
Expand Collapse Copy
if (!cl_info.is_hidden()) {

    stream = check_class_file_load_hook(...);

Для hidden классов CFLH не вызывается. Классы загруженные через MethodHandles.Lookup.defineHiddenClass невидимы для дамперов на этапе загрузки. Звучит как серебряная пуля. Сейчас сломаю энтузиазм.

Подвох раз: передаёшь byte[] живущий в Java куче. Пока массив живой — его можно найти в памяти. Надо зануть через Arrays.fill(bytes, 0) сразу после defineHiddenClass.

Подвох два, злее: hidden classes не прячутся от GetBytecodes и GetLoadedClasses. Был уверен в обратном пока не полез в jvmtiGetLoadedClasses.cpp — там итерация loaded_classes_do_keepalive без фильтра is_hidden(). Если дампер прицепился ПОСЛЕ decrypt-а — дёрнет GetLoadedClasses, найдёт hidden класс (имя мусорное Foo/0x00007f... но оно есть), дёрнет GetBytecodes и получит восстановленный байткод через reconstituter. Сам байткод уже в метаспейсе, оттуда никак не стереть без патча JVM.

Hidden classes закрывают один сценарий — перехват на загрузке. Это половина атак, но не решение. Комбо -XX:+DisableAttachMechanism -XX:-EnableDynamicAgentLoading + проверка аргументов в clinit обходится за 10 минут: дампер патчит RuntimeImpl.getInputArguments и твоя проверка читает чистый список. Гонка проиграна до старта.



Condy + hidden class + classData — правильная база

Инструмент которым почти никто не пользуется для обфускации — CONSTANT_Dynamic (condy, JEP 309, с Java 11) плюс defineHiddenClassWithClassData (JEP 371, Java 16+).

Суть condy: элемент constant pool содержит вместо значения ссылку на bootstrap метод и статические аргументы. Первый ldc — JVM вызывает BSM, кеширует результат в resolved_references. Второй — чтение из массива. После C2 компиляции слот трактуется как @Stable final и значение запекается в native код как x86 immediate. Проверено на oops/constantPool.cpp ~1080, функция resolve_constant_at_impl, на 1097 ранний выход по кешу.

Что это значит: каждая строка, каждое число, каждая ссылка в классе — condy. Вместо ldc "Hello" стоит ldc #K где #K это CONSTANT_Dynamic с bootstrap-ом decryptString и аргументами [шифртекст, nonce]. BSM тянет AES ключ из classData:

Java:
Expand Collapse Copy
public static String decryptString(MethodHandles.Lookup caller,
                                   String unusedName, Class<?> unusedType,
                                   byte[] cipher, byte[] nonce) throws Throwable {
    byte[] key = MethodHandles.classDataAt(caller, "_", byte[].class, 0);
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
           new GCMParameterSpec(128, nonce));
    return new String(c.doFinal(cipher), StandardCharsets.UTF_8);
}

Имя condy обязано быть "_" (реальная проверка в MethodHandles.java, константа ConstantDescs.DEFAULT_NAME). Int аргумент — индекс в classData.

Загрузка hidden класса:

Java:
Expand Collapse Copy
public static Lookup loadEncrypted(byte[] encryptedBytes,
                                   byte[] aesKey, byte[] nonce) throws Throwable {
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"),
           new GCMParameterSpec(128, nonce));
    byte[] plainClass = c.doFinal(encryptedBytes);

    List<Object> classData = List.of(aesKey, Integer.valueOf(0xDEADBEEF));

    Lookup hidden = MethodHandles.lookup().defineHiddenClassWithClassData(
        plainClass, classData, true, Lookup.ClassOption.NESTMATE);

    Arrays.fill(plainClass, (byte) 0);
    return hidden;
}

Что происходит во времени:

  • В jar-е: внешний хостовый класс и byte[] с шифртекстом hidden класса
  • Старт: расшифровали → defineHiddenClassWithClassData → зануляем буфер. Plaintext живёт микросекунды
  • Первый вызов метода: каждый ldc #condy один раз вызывает decryptString
  • После C2: строка — x86 immediate в native, сама функция decryptString мёртвый код

В jar на диске — шифртексты и ссылки на BSM. В памяти после JIT — x86 immediates никак не связанные с расшифровкой. Ноль накладных на горячем пути. Не «малые», а прямо ноль — после warmup байты decrypt функции не исполняются.

Это очень круто против статики и против казуальных атак. Но есть нюанс которого я долго не видел: resolved_references слот это обычный объект в cp cache, и если дампер прицепится внутрь процесса после первого вызова — он прочитает расшифрованные строки прямо оттуда. Condy защищает файл на диске и статический анализ, но не защищает от дампера внутри процесса который ходит по метаспейсу.

Сток JVM с condy даёт тебе где-то 85% защиты. Против Recaf, CFR, narumii, threadtear — всё идёт в топку. Против дампера инжектящегося внутрь процесса с полным JVMTI — ты всё равно проигрываешь оставшиеся 15%.

Чтобы закрыть их — надо перестать играть в чужой песочнице.



Кастомная JVM — потому что флагов не хватает

Здесь начинается нормальная часть. Если не можем выключить JVMTI флагом — выключим его при компиляции самого HotSpot.

Главная штука которую я понял после недель копания, и из-за которой написана эта статья. HotSpot уже спроектирован с compile-time флагами ровно для этого. src/hotspot/share/utilities/macros.hpp около 62 строки содержит INCLUDE_JVMTI — макрос, ставится в 0 при сборке, и весь код обёрнутый в JVMTI_ONLY(...) превращается в пустоту. Это около 50 .cpp файлов в src/hotspot/share/prims/jvmti*.cpp. Рядом INCLUDE_SERVICES (убивает Attach Listener и SA), INCLUDE_JFR, INCLUDE_NMT.

В make/hotspot/lib/JvmFeatures.gmk пишешь:

Java:
Expand Collapse Copy
-DINCLUDE_JVMTI=0
-DINCLUDE_SERVICES=0
-DINCLUDE_JFR=0
-DINCLUDE_NMT=0

И получаешь jvm.dll в котором физически нет JVMTI. Нет кода. Парсер аргументов в runtime/arguments.cpp всё ещё видит -javaagent и пытается зарегистрировать агент, но путь загрузки уходит в пустоту. Можно дополнительно заткнуть:

C++:
Expand Collapse Copy
if (match_option(option, "-javaagent:", &tail)) {
    jio_fprintf(defaultStream::error_stream(), "Error: disabled\n");
    return JNI_ERR;
}

Выпиливаем модули: java.instrument, jdk.jdi, jdk.jdwp.agent, jdk.hotspot.agent, jdk.jcmd, jdk.jconsole, jdk.management.agent. После этого больше нет:

  • Java Instrumentation API (premain/agentmain мертвы)
  • JDWP (отладчик не прицепится)
  • Serviceability Agent (jhsdb мёртв)
  • jcmd, jmap, jstack, jinfo

Всё это — три дня работы. Реально изменения в одном мейкфайле + ~20 строк в парсере + удаление модулей из списка сборки. Всё остальное за нас сделали HotSpot-овские мейнтейнеры, потому что эти макросы задумывались для embedded Java где JVMTI не нужен. Мы просто используем готовую дорожку.

После этого из десяти векторов дампа — девять пропадают сами собой. Остаётся только внешнее чтение памяти через RPM + парсинг VMStructs. Его лечим так:

Удаляем VMStructs экспорты. runtime/vmStructs.cpp — таблицы gHotSpotVMStructs, gHotSpotVMTypes, gHotSpotVMIntConstants это глобальные символы. На них опираются все сторонние тулзы. Добавляем .def файл со списком экспортов только JNI_* и JVM_*, линкуем с /OPT:REF. Снаружи jvm.dll больше не описывает свой внутренний layout.

Strip-им PDB. Релиз без debug info. Реверсер не получит готовых оффсетов, придётся реверсить сам jvm.dll.

Layout drift. При каждой сборке добавляем в InstanceKlass, Method, ConstMethod несколько приватных полей. Это бесплатно сдвигает оффсеты структур. Публичные тулзы заточены под апстрим layout и ломаются на нашей сборке сразу.

После этого внешний дампер должен:

  1. Сам по сигнатурам найти ClassLoaderDataGraph::_head в нашем jvm.dll (без символов)
  2. Сам определить поля InstanceKlass (наш layout drift)
  3. Сам восстановить маппинг cpCache indices → CP indices (иначе _fast_* не раскодировать)

Это уже не «запустил тулзу, получил дамп за минуту». Это несколько недель реверсинга под одну конкретную версию. Следующий билд с другим drift — работа заново.



Стирание байткода после JIT

То на что я потратил больше всего времени и что реально доставляет. Как живут классы в HotSpot после загрузки: байты в KlassFactory::create_from_stream → парсер создаёт InstanceKlass/Method/ConstMethod в метаспейсе → байткод inline после заголовка ConstMethod (code_base() { return (address)(this+1); }) → линковка мутирует байты на месте → горячий метод → C2 → nmethod в code cache → все вызовы идут через _from_compiled_entry, интерпретатор не трогается.

Вопрос: нужен ли байткод в метаспейсе после того как nmethod готов?

Полез смотреть. Нормальный путь исполнения не лезет в ConstMethod::_code. Единственное место где он нужен — деоптимизация. runtime/vframeArray.cpp ~199:

C++:
Expand Collapse Copy
bcp = method()->bcp_from(bci());
iframe()->interpreter_frame_set_bcp(bcp);

Занулим code_base, случится деопт — интерпретатор читает нули, бежит вперёд, крашится. Деопт триггерится от кучи вещей: uncommon trap на null check, bounds check, CHA invalidation, retransform, cold flush. Но у нас уже кастомная JVM, значит можем:

  1. Отключить CHA-деоптACC_FINAL + запрет subclass-ить, code/dependencies.cpp
  2. Убить OSR-XX:-UseOnStackReplacement
  3. Заменить uncommon traps на явные throw — C2 pass в opto/, вместо DeoptAction эмитим прямой throw через shared stub. Цена 2-5% throughput
  4. Retransform — уже бесплатно с INCLUDE_JVMTI=0
  5. Guard в fetch_unroll_info_helperdeoptimization.cpp ~474. Если nmethod sealed — vm_exit_during_runtime. Падение процесса вместо чтения нулей

После этих патчей деопт для помеченных методов невозможен по построению. Значит в Method::set_code сразу после публикации nmethod — memset(cm->code_base(), 0, cm->code_size()). Байткод больше не существует в памяти. Только x86 в code cache.

В nmethod.hpp добавляем два бита:

C++:
Expand Collapse Copy
_is_pinned:1,   // не релоцировать, не флашить
_is_sealed:1,   // байткод занулен

И в is_cold/is_relocatable ранний выход для pinned. Всё. Помеченный метод загружен из .avl → расшифрован на микросекунды → скомпилен C2 → байткод в метаспейсе занулен → nmethod запинен → работает только через _from_compiled_entry.

Класс физически не существует как байткод. Только как x86 в code cache. Ни один Java деобфускатор не умеет восстанавливать структуру из x86. На этом этапе de4dot-for-Java, narumii, threadtear, CFR, Recaf — бесполезный инструментарий.

Остаётся только реверсер с IDA готовый читать nmethod ассемблером. Уже x86 реверс, не Java. И наши C2 обфускации (ниже) добавляют ещё слоёв. Вот здесь кастомный форк окупается полностью — на стоке невозможно, деопт это first-class path и его не обойти.



JIT как соучастник обфускации

C2 — агрессивный оптимизатор, правильно кормишь — сам сделает код нечитаемым.

MethodHandle цепочки. callGenerator.cpp ~1019, for_method_handle_inline. Когда receiver MH — константа (Op_ConP), C2 извлекает целевой метод и инлайнит рекурсивно через всю цепочку:

Java:
Expand Collapse Copy
static final MethodHandle MH = MethodHandles.filterReturnValue(
    MethodHandles.insertArguments(
        MethodHandles.guardWithTest(test, target, fallback),
        0, constArg),
    filter);

После компиляции — вся цепочка исчезает из nmethod. Нет lambdaform.invoke, нет адаптеров, слитый воедино код всех четырёх функций с прямым je. Реверсер видит x86 блоб где четыре метода смешаны в один.

@ForceInline из jdk.internal.vm.annotation — C2 игнорирует все бюджеты инлайнинга. По умолчанию доступна только boot classloader-у (classFileParser.cpp ~1836), на своём форке проверку удаляем.

@Stable — поле trusted final после первой записи. @Stable MethodHandle[] TABLE — слоты после non-null сворачиваются как константы. Таблицы MH-ов становятся статическими для C2.

Escape analysis — объект не утекает, скаляризуется. Запись полей → работа с регистрами, аллокация исчезает. Промежуточное состояние в record — в байткоде new/putfield/getfield, в nmethod только регистровые перестановки.

Loop unrolling с -XX:LoopUnrollLimit=500 — маленькие циклы разворачиваются полностью, @Stable константы сворачиваются как immediates. Цикл XOR-а ключом → прямолинейная последовательность XOR-ов без ключа (запечён в инструкции).

ИнтринсикиMath.fmavfmadd231sd, Integer.bitCountpopcnt. Java реализация выкидывается, подставляется ручной ассемблер. Реверсер видит SIMD там где была простая логика.

Если у нас свой HotSpot — ещё пара грязных штук:

  • Register allocator PRNG (opto/chaitin.cpp Chaitin::Select) — per-compile seed в tie-break очереди спилов. Две сборки того же байткода → разные register maps. Нельзя диффать nmethod между билдами
  • Emitter noise (opto/output.cpp::fill_buffer) — семантические nop-ы между basic блоками (lea rax,[rax+0], разные кодировки nop). Per-compile PRNG. Сигнатурный фингерпринтинг не работает
  • Instruction selection (x86_64.ad) — некоторые match_rule имеют несколько энкодингов (mov reg,0 vs xor reg,reg). PRNG tie-breaker

В сумме: полиморфный байткод + рандомный register map + рандомный nop паттерн. Ни один инструмент диффа не найдёт стабильной точки опоры между сборками.



Если хочете «ещё круче» — kernel driver

Всё что выше — защита от Java-уровневых атак. От внешнего чтения процесса через RPM почти не работает. Тут ставим kernel driver. Я смотрел внутренности Vanguard, BattlEye, EAC — все они защитную часть делают через ~6 API:

  • ObRegisterCallbacks(PsProcessType, pre-op) — раздевает handle-ы открытые к нашему процессу. Снимает PROCESS_VM_READ, VM_WRITE, VM_OPERATION, DUP_HANDLE, CREATE_THREAD. Cheat Engine и Process Hacker сосут на этом
  • ObRegisterCallbacks(PsThreadType) — то же для потоков, внешний отладчик не прицепится
  • PsSetCreateProcessNotifyRoutineEx — трекаем создание нашего процесса
  • PsSetLoadImageNotifyRoutine — allowlist dll по SHA-256, неизвестная — user APC на завершение
  • PsSetCreateThreadNotifyRoutine — детект remote thread creation
  • Self-integrity DPC — каждые 100мс проверяем что наш ObCallback в списке, защита от DKOM атак

Всё. Шесть API, 1500-2500 строк C. У Vanguard ~40 тысяч строк, но 70% это детект читеров — нам не нужно. Подписывать через Microsoft Partner Center attestation signing, EV cert ~$300-600/год. Гипервизорные драйверы триггерят ручной ревью Microsoft, обычный ObRegisterCallbacks — нет.

Важно: на Win11 24H2 HVCI включён по умолчанию и форсит vulnerable driver blocklist — ~700 известных уязвимых драйверов (mhyprot2, capcom, RTCore64) не загружаются, что убивает BYOVD атаку. Наш драйвер при инициализации должен требовать HVCI. Vanguard делает так.

Про свой гипервизор (VT-x/EPT через наш драйвер) отдельно скажу чтобы никто не велся: на Win11 2026 не работает. Hyper-V уже в VMX root mode, твой VMXON делает #GP. Единственный путь — nested guest под Hyper-V, но перф падает в разы, плюс 4-6 месяцев разработки. Гипервизор — для DRM, не для Java защиты.



Собираем воедино

Финальная архитектура по слоям:

  1. Контейнер — кастомный .avl, классы как AES-GCM шифртексты
  2. Ключи — мастер из HWID + TPM-sealed + build salt, per-method через HKDF. Принципиально: HWID это ключевой материал, не булева проверка. Неправильный HWID = неправильный ключ = крэш. Булевы проверки нопятся за минуту, ключевой материал никогда
  3. Кастомная JVMINCLUDE_JVMTI=0/SERVICES=0/JFR=0, strip VMStructs/PDB, layout drift, убитые startup агенты, удалённые модули java.instrument/jdk.hotspot.agent/jdk.jdwp.agent
  4. ЛоадерAV_DefineEncryptedClass JNI entry: .avl → decrypt → JVM_LookupDefineClass с HIDDEN_CLASS → занулить буфер
  5. Condy — все строки и константы через CONSTANT_Dynamic, ключ в classData, после JIT — x86 immediates
  6. nmethod vaultConstMethod::_code зануляется после C2, nmethod запинен, деопт невозможен (CHA нейтрализован, OSR запрещён, uncommon traps → explicit throws)
  7. JIT-aware bytecode@Stable MethodHandle[], @ForceInline/@DontInline, интринсики, final sealed, record-holder-ы под EA
  8. JIT моды — register allocator PRNG, emitter noise, instruction selection tie-break
  9. Kernel driver (опционально) — ObRegisterCallbacks + Ps*Notify + self-integrity DPC, требует HVCI
  10. Per-build уникальность — build seed → разный condy layout, разные ключи, разные register maps, разные nop паттерны. Атака на одну сборку не переносится

Что остаётся возможным: ring-0 через BYOVD на машине без HVCI (редко), реверсер с IDA читающий nmethod ассемблером (недели работы), cold boot / DMA атаки (физический доступ).

Что невозможно: стандартные Java деобфускаторы, дамперы инжектящиеся в процесс, Serviceability Agent, паттерн «premain + ClassFileTransformer», RPM с парсингом VMStructs, Cheat Engine (с kernel driver), memory сканеры ищущие CAFEBABE.



Послесловие

Я два года писал обычный обфускатор «invokedynamic + hidden classes + JNI DLL». Кастомная VM поверх Java, opcode randomization, per-build ключи. Всё работало, но меня постоянно раздражало что любой школьник с готовым дампером разваливал защиту за секунду. Я пытался ловить это детектом — проверки RuntimeMXBean, поиск подозрительных dll в памяти, хеширование JNI function table. Всё легко обходится.

Недели копания OpenJDK чтобы понять простую вещь: я играл в чужой песочнице. JVM создана чтобы быть открытой для инструментирования. JVMTI это её фича, не баг. ClassFileLoadHook это задокументированный API. Всё что я делал — пытался прикрыть листиком дыру размером с форточку.

Правильный путь — владеть JVM. INCLUDE_JVMTI=0 это пятистрочное изменение в мейкфайле на котором заканчивается индустрия публичных Java-реверс инструментов применительно к тебе. HotSpot мейнтейнеры уже сделали работу за нас для embedded use cases. Мы просто используем ту же дорожку.

Если кто-то пилит своё в этом направлении или залипает по JVM internals — маякуйте в ЛС. Если нашёл ошибки в статье — тоже пишите, я где-то мог наврать.
самая большая м полезная тема на юг в сфере кубов и вообщем всей java
 
Вступление

Статья выросла из моего личного раздражения. Я несколько лет пилю защиту для Java приложений — в основном minecraft клиенты — и всё это время наблюдаю одну картину: любой платный обфускатор за 200 баксов, любой коммерческий клиент с билдбита, любое «защищённое» решение — всё сливается за вечер любым человеком у которого есть Recaf и желание потыкать. Дело даже не в том что обфускация слабая. Дело в том что вся индустрия Java реверса построена вокруг одной-единственной дыры, о которой толком никто не говорит, и которую невозможно закрыть стандартными средствами.

Я потратил несколько месяцев на копание исходников OpenJDK — ходил по HotSpot, читал пути вызовов, смотрел какие конкретно места в коде JVM приводят к тому что класс можно достать из процесса. Все файлы и номера строк из свежего дерева HotSpot, можешь проверить. После того как один раз видишь как это работает изнутри — понимаешь что весь мейнстрим-стек Java защиты это покраска ржавчины. А настоящее решение лежит сильно дальше и выглядит не так как я ожидал когда начинал копать.

Пишу без базы. Если не знаешь что такое classloader, байткод, JIT и примерно как работает HotSpot — сначала в JVMS, потом возвращайся. На русском по этой теме я толком ничего толкового не встречал, поэтому и взялся писать. Поехали.



Почему текущий стек защиты не работает

Начну с того что меня бесит. Типичный «защищённый» Java клиент сегодня это:

  1. Переименовали классы/методы через ProGuard
  2. Строки зашифровали XOR-ом с ключом в clinit
  3. Вызовы завернули в invokedynamic
  4. Прогнали через native-obfuscator
  5. Натянули сверху VMProtect

Смотрится солидно. На практике ломается стандартным инструментом дампера который инжектится в процесс жертвы и забирает классы на этапе когда они уже расшифрованы в памяти но ещё не провалидированы JVM-кой.

Работает это так: инструмент подпихивает свой код внутрь процесса (не важно как — подмена одной из загружаемых dll, маленький хелпер, шелл-код — вариантов хватает), там цепляется к внутреннему API HotSpot под названием JVMTI и подписывается на событие ClassFileLoadHook. Это событие дёргается когда любому классу надо пройти через парсер JVM. Включая те что обфускатор только что расшифровал у себя в clinit.

Результат: в дамп падают чистые .class файлы, строки расшифрованы, invokedynamic уже зарезолвнут, имена перемешаны но логика как на ладони. Дальше CFR или Vineflower и клиент слит.

Работает потому что ClassFileLoadHook вызывается ПОСЛЕ того как обфускатор отдал байты в JVM но ДО валидации и линковки. Инструмент сидит ровно между твоим декриптом и парсером, пишет всё на диск. Единственное что тебя спасёт — чтобы этого события вообще не существовало в JVM. Без шуток.

Ниже по слоям разберу что с этим можно сделать.



Векторы дампа — полный список

Прежде чем защищаться, надо понимать от чего. Я потратил дохрена времени на OpenJDK исходники и могу сказать — векторов ровно десять:

  1. JVMTI ClassFileLoadHookprims/jvmtiExport.cpp, функция post_class_file_load_hook около 1091 строки. Главная дыра. Любой код внутри процесса видит байты всех классов
  2. JVMTI GetBytecodesprims/jvmtiEnv.cpp ~3428. Читает байткод уже загруженного метода в рантайме. Внутри JvmtiClassFileReconstituter::copy_bytecodes разворачивает _fast_* опкоды обратно в спецификационные — даже переписанные HotSpot-ом байты отдаются чистыми
  3. JVMTI RetransformClasses — принудительно перепарсит загруженный класс и снова дёрнет CFLH. Юзается когда дампер прицепился после старта
  4. GetLoadedClassesprims/jvmtiGetLoadedClasses.cpp. Возвращает всё из ClassLoaderDataGraph. Неприятно: hidden classes тоже возвращает, фильтра is_hidden() там нет (сам был в обратном уверен пока не полез в сорцы)
  5. Serviceability Agent (SA) — jhsdb, jmap -F, jstack -F. Цепляется к процессу снаружи через ptrace/DebugActiveProcess, парсит сырую память по таблице VMStructs которую jvm.dll экспортирует как глобальный символ
  6. Прямое сканирование метаспейса — тот же путь что SA но ручками. Инжект dll, читаем gHotSpotVMStructs, ходим ClassLoaderDataGraph::_headInstanceKlassMethod*ConstMethod* и читаем (ConstMethod*)+1 на code_size байт
  7. Переписанный байткод в метаспейсеinterpreter/rewriter.cpp. HotSpot мутирует байткод при линковке, но restore_bytecodes() публична и reconstituter её дёргает
  8. JIT nmethod → байткод — nmethod в code cache содержит ScopeDesc с маппингом PC → BCI. Продвинутый реверсер восстановит структуру
  9. CDS mapped archivecds/filemap.cpp. Классы из .jsa лежат в замапленной странице
  10. Подмена jvm.dll / JNI хук — дампер подсовывает свою dll с теми же экспортами что настоящая jvm.dll, хукает JVM_DefineClass / Unsafe_DefineClass0. Самый ранний перехват

Блять, десять векторов. Смотрим что из этого прихлопывается флагами JVM:

Код:
Expand Collapse Copy
- startup агенты:                  НЕТ ТАКОГО ФЛАГА
- -agentpath/-agentlib:            НЕТ ТАКОГО ФЛАГА
- Attach API (jcmd/jmap/jstack):   -XX:+DisableAttachMechanism
- Dynamic agent load:              -XX:-EnableDynamicAgentLoading
- JVMTI целиком:                   НЕТ ТАКОГО ФЛАГА
- GetBytecodes:                    НЕТ ТАКОГО ФЛАГА
- SA attach:                       НЕТ ТАКОГО ФЛАГА
- GetLoadedClasses hidden filter:  НЕТ ТАКОГО ФЛАГА

Если атакующий контролирует командную строку запуска (а он контролирует её всегда, это его машина) — ни одна комбинация флагов стоковой JVM тебя не спасает. Факт. Всё что ты делаешь на стоке — поднимаешь планку для казуалов.



Hidden classes и почему они почти работают

Штука которую я долго недооценивал. В classfile/klassFactory.cpp на 191 строке:

C++:
Expand Collapse Copy
if (!cl_info.is_hidden()) {

    stream = check_class_file_load_hook(...);

Для hidden классов CFLH не вызывается. Классы загруженные через MethodHandles.Lookup.defineHiddenClass невидимы для дамперов на этапе загрузки. Звучит как серебряная пуля. Сейчас сломаю энтузиазм.

Подвох раз: передаёшь byte[] живущий в Java куче. Пока массив живой — его можно найти в памяти. Надо зануть через Arrays.fill(bytes, 0) сразу после defineHiddenClass.

Подвох два, злее: hidden classes не прячутся от GetBytecodes и GetLoadedClasses. Был уверен в обратном пока не полез в jvmtiGetLoadedClasses.cpp — там итерация loaded_classes_do_keepalive без фильтра is_hidden(). Если дампер прицепился ПОСЛЕ decrypt-а — дёрнет GetLoadedClasses, найдёт hidden класс (имя мусорное Foo/0x00007f... но оно есть), дёрнет GetBytecodes и получит восстановленный байткод через reconstituter. Сам байткод уже в метаспейсе, оттуда никак не стереть без патча JVM.

Hidden classes закрывают один сценарий — перехват на загрузке. Это половина атак, но не решение. Комбо -XX:+DisableAttachMechanism -XX:-EnableDynamicAgentLoading + проверка аргументов в clinit обходится за 10 минут: дампер патчит RuntimeImpl.getInputArguments и твоя проверка читает чистый список. Гонка проиграна до старта.



Condy + hidden class + classData — правильная база

Инструмент которым почти никто не пользуется для обфускации — CONSTANT_Dynamic (condy, JEP 309, с Java 11) плюс defineHiddenClassWithClassData (JEP 371, Java 16+).

Суть condy: элемент constant pool содержит вместо значения ссылку на bootstrap метод и статические аргументы. Первый ldc — JVM вызывает BSM, кеширует результат в resolved_references. Второй — чтение из массива. После C2 компиляции слот трактуется как @Stable final и значение запекается в native код как x86 immediate. Проверено на oops/constantPool.cpp ~1080, функция resolve_constant_at_impl, на 1097 ранний выход по кешу.

Что это значит: каждая строка, каждое число, каждая ссылка в классе — condy. Вместо ldc "Hello" стоит ldc #K где #K это CONSTANT_Dynamic с bootstrap-ом decryptString и аргументами [шифртекст, nonce]. BSM тянет AES ключ из classData:

Java:
Expand Collapse Copy
public static String decryptString(MethodHandles.Lookup caller,
                                   String unusedName, Class<?> unusedType,
                                   byte[] cipher, byte[] nonce) throws Throwable {
    byte[] key = MethodHandles.classDataAt(caller, "_", byte[].class, 0);
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
           new GCMParameterSpec(128, nonce));
    return new String(c.doFinal(cipher), StandardCharsets.UTF_8);
}

Имя condy обязано быть "_" (реальная проверка в MethodHandles.java, константа ConstantDescs.DEFAULT_NAME). Int аргумент — индекс в classData.

Загрузка hidden класса:

Java:
Expand Collapse Copy
public static Lookup loadEncrypted(byte[] encryptedBytes,
                                   byte[] aesKey, byte[] nonce) throws Throwable {
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"),
           new GCMParameterSpec(128, nonce));
    byte[] plainClass = c.doFinal(encryptedBytes);

    List<Object> classData = List.of(aesKey, Integer.valueOf(0xDEADBEEF));

    Lookup hidden = MethodHandles.lookup().defineHiddenClassWithClassData(
        plainClass, classData, true, Lookup.ClassOption.NESTMATE);

    Arrays.fill(plainClass, (byte) 0);
    return hidden;
}

Что происходит во времени:

  • В jar-е: внешний хостовый класс и byte[] с шифртекстом hidden класса
  • Старт: расшифровали → defineHiddenClassWithClassData → зануляем буфер. Plaintext живёт микросекунды
  • Первый вызов метода: каждый ldc #condy один раз вызывает decryptString
  • После C2: строка — x86 immediate в native, сама функция decryptString мёртвый код

В jar на диске — шифртексты и ссылки на BSM. В памяти после JIT — x86 immediates никак не связанные с расшифровкой. Ноль накладных на горячем пути. Не «малые», а прямо ноль — после warmup байты decrypt функции не исполняются.

Это очень круто против статики и против казуальных атак. Но есть нюанс которого я долго не видел: resolved_references слот это обычный объект в cp cache, и если дампер прицепится внутрь процесса после первого вызова — он прочитает расшифрованные строки прямо оттуда. Condy защищает файл на диске и статический анализ, но не защищает от дампера внутри процесса который ходит по метаспейсу.

Сток JVM с condy даёт тебе где-то 85% защиты. Против Recaf, CFR, narumii, threadtear — всё идёт в топку. Против дампера инжектящегося внутрь процесса с полным JVMTI — ты всё равно проигрываешь оставшиеся 15%.

Чтобы закрыть их — надо перестать играть в чужой песочнице.



Кастомная JVM — потому что флагов не хватает

Здесь начинается нормальная часть. Если не можем выключить JVMTI флагом — выключим его при компиляции самого HotSpot.

Главная штука которую я понял после недель копания, и из-за которой написана эта статья. HotSpot уже спроектирован с compile-time флагами ровно для этого. src/hotspot/share/utilities/macros.hpp около 62 строки содержит INCLUDE_JVMTI — макрос, ставится в 0 при сборке, и весь код обёрнутый в JVMTI_ONLY(...) превращается в пустоту. Это около 50 .cpp файлов в src/hotspot/share/prims/jvmti*.cpp. Рядом INCLUDE_SERVICES (убивает Attach Listener и SA), INCLUDE_JFR, INCLUDE_NMT.

В make/hotspot/lib/JvmFeatures.gmk пишешь:

Java:
Expand Collapse Copy
-DINCLUDE_JVMTI=0
-DINCLUDE_SERVICES=0
-DINCLUDE_JFR=0
-DINCLUDE_NMT=0

И получаешь jvm.dll в котором физически нет JVMTI. Нет кода. Парсер аргументов в runtime/arguments.cpp всё ещё видит -javaagent и пытается зарегистрировать агент, но путь загрузки уходит в пустоту. Можно дополнительно заткнуть:

C++:
Expand Collapse Copy
if (match_option(option, "-javaagent:", &tail)) {
    jio_fprintf(defaultStream::error_stream(), "Error: disabled\n");
    return JNI_ERR;
}

Выпиливаем модули: java.instrument, jdk.jdi, jdk.jdwp.agent, jdk.hotspot.agent, jdk.jcmd, jdk.jconsole, jdk.management.agent. После этого больше нет:

  • Java Instrumentation API (premain/agentmain мертвы)
  • JDWP (отладчик не прицепится)
  • Serviceability Agent (jhsdb мёртв)
  • jcmd, jmap, jstack, jinfo

Всё это — три дня работы. Реально изменения в одном мейкфайле + ~20 строк в парсере + удаление модулей из списка сборки. Всё остальное за нас сделали HotSpot-овские мейнтейнеры, потому что эти макросы задумывались для embedded Java где JVMTI не нужен. Мы просто используем готовую дорожку.

После этого из десяти векторов дампа — девять пропадают сами собой. Остаётся только внешнее чтение памяти через RPM + парсинг VMStructs. Его лечим так:

Удаляем VMStructs экспорты. runtime/vmStructs.cpp — таблицы gHotSpotVMStructs, gHotSpotVMTypes, gHotSpotVMIntConstants это глобальные символы. На них опираются все сторонние тулзы. Добавляем .def файл со списком экспортов только JNI_* и JVM_*, линкуем с /OPT:REF. Снаружи jvm.dll больше не описывает свой внутренний layout.

Strip-им PDB. Релиз без debug info. Реверсер не получит готовых оффсетов, придётся реверсить сам jvm.dll.

Layout drift. При каждой сборке добавляем в InstanceKlass, Method, ConstMethod несколько приватных полей. Это бесплатно сдвигает оффсеты структур. Публичные тулзы заточены под апстрим layout и ломаются на нашей сборке сразу.

После этого внешний дампер должен:

  1. Сам по сигнатурам найти ClassLoaderDataGraph::_head в нашем jvm.dll (без символов)
  2. Сам определить поля InstanceKlass (наш layout drift)
  3. Сам восстановить маппинг cpCache indices → CP indices (иначе _fast_* не раскодировать)

Это уже не «запустил тулзу, получил дамп за минуту». Это несколько недель реверсинга под одну конкретную версию. Следующий билд с другим drift — работа заново.



Стирание байткода после JIT

То на что я потратил больше всего времени и что реально доставляет. Как живут классы в HotSpot после загрузки: байты в KlassFactory::create_from_stream → парсер создаёт InstanceKlass/Method/ConstMethod в метаспейсе → байткод inline после заголовка ConstMethod (code_base() { return (address)(this+1); }) → линковка мутирует байты на месте → горячий метод → C2 → nmethod в code cache → все вызовы идут через _from_compiled_entry, интерпретатор не трогается.

Вопрос: нужен ли байткод в метаспейсе после того как nmethod готов?

Полез смотреть. Нормальный путь исполнения не лезет в ConstMethod::_code. Единственное место где он нужен — деоптимизация. runtime/vframeArray.cpp ~199:

C++:
Expand Collapse Copy
bcp = method()->bcp_from(bci());
iframe()->interpreter_frame_set_bcp(bcp);

Занулим code_base, случится деопт — интерпретатор читает нули, бежит вперёд, крашится. Деопт триггерится от кучи вещей: uncommon trap на null check, bounds check, CHA invalidation, retransform, cold flush. Но у нас уже кастомная JVM, значит можем:

  1. Отключить CHA-деоптACC_FINAL + запрет subclass-ить, code/dependencies.cpp
  2. Убить OSR-XX:-UseOnStackReplacement
  3. Заменить uncommon traps на явные throw — C2 pass в opto/, вместо DeoptAction эмитим прямой throw через shared stub. Цена 2-5% throughput
  4. Retransform — уже бесплатно с INCLUDE_JVMTI=0
  5. Guard в fetch_unroll_info_helperdeoptimization.cpp ~474. Если nmethod sealed — vm_exit_during_runtime. Падение процесса вместо чтения нулей

После этих патчей деопт для помеченных методов невозможен по построению. Значит в Method::set_code сразу после публикации nmethod — memset(cm->code_base(), 0, cm->code_size()). Байткод больше не существует в памяти. Только x86 в code cache.

В nmethod.hpp добавляем два бита:

C++:
Expand Collapse Copy
_is_pinned:1,   // не релоцировать, не флашить
_is_sealed:1,   // байткод занулен

И в is_cold/is_relocatable ранний выход для pinned. Всё. Помеченный метод загружен из .avl → расшифрован на микросекунды → скомпилен C2 → байткод в метаспейсе занулен → nmethod запинен → работает только через _from_compiled_entry.

Класс физически не существует как байткод. Только как x86 в code cache. Ни один Java деобфускатор не умеет восстанавливать структуру из x86. На этом этапе de4dot-for-Java, narumii, threadtear, CFR, Recaf — бесполезный инструментарий.

Остаётся только реверсер с IDA готовый читать nmethod ассемблером. Уже x86 реверс, не Java. И наши C2 обфускации (ниже) добавляют ещё слоёв. Вот здесь кастомный форк окупается полностью — на стоке невозможно, деопт это first-class path и его не обойти.



JIT как соучастник обфускации

C2 — агрессивный оптимизатор, правильно кормишь — сам сделает код нечитаемым.

MethodHandle цепочки. callGenerator.cpp ~1019, for_method_handle_inline. Когда receiver MH — константа (Op_ConP), C2 извлекает целевой метод и инлайнит рекурсивно через всю цепочку:

Java:
Expand Collapse Copy
static final MethodHandle MH = MethodHandles.filterReturnValue(
    MethodHandles.insertArguments(
        MethodHandles.guardWithTest(test, target, fallback),
        0, constArg),
    filter);

После компиляции — вся цепочка исчезает из nmethod. Нет lambdaform.invoke, нет адаптеров, слитый воедино код всех четырёх функций с прямым je. Реверсер видит x86 блоб где четыре метода смешаны в один.

@ForceInline из jdk.internal.vm.annotation — C2 игнорирует все бюджеты инлайнинга. По умолчанию доступна только boot classloader-у (classFileParser.cpp ~1836), на своём форке проверку удаляем.

@Stable — поле trusted final после первой записи. @Stable MethodHandle[] TABLE — слоты после non-null сворачиваются как константы. Таблицы MH-ов становятся статическими для C2.

Escape analysis — объект не утекает, скаляризуется. Запись полей → работа с регистрами, аллокация исчезает. Промежуточное состояние в record — в байткоде new/putfield/getfield, в nmethod только регистровые перестановки.

Loop unrolling с -XX:LoopUnrollLimit=500 — маленькие циклы разворачиваются полностью, @Stable константы сворачиваются как immediates. Цикл XOR-а ключом → прямолинейная последовательность XOR-ов без ключа (запечён в инструкции).

ИнтринсикиMath.fmavfmadd231sd, Integer.bitCountpopcnt. Java реализация выкидывается, подставляется ручной ассемблер. Реверсер видит SIMD там где была простая логика.

Если у нас свой HotSpot — ещё пара грязных штук:

  • Register allocator PRNG (opto/chaitin.cpp Chaitin::Select) — per-compile seed в tie-break очереди спилов. Две сборки того же байткода → разные register maps. Нельзя диффать nmethod между билдами
  • Emitter noise (opto/output.cpp::fill_buffer) — семантические nop-ы между basic блоками (lea rax,[rax+0], разные кодировки nop). Per-compile PRNG. Сигнатурный фингерпринтинг не работает
  • Instruction selection (x86_64.ad) — некоторые match_rule имеют несколько энкодингов (mov reg,0 vs xor reg,reg). PRNG tie-breaker

В сумме: полиморфный байткод + рандомный register map + рандомный nop паттерн. Ни один инструмент диффа не найдёт стабильной точки опоры между сборками.



Если хочете «ещё круче» — kernel driver

Всё что выше — защита от Java-уровневых атак. От внешнего чтения процесса через RPM почти не работает. Тут ставим kernel driver. Я смотрел внутренности Vanguard, BattlEye, EAC — все они защитную часть делают через ~6 API:

  • ObRegisterCallbacks(PsProcessType, pre-op) — раздевает handle-ы открытые к нашему процессу. Снимает PROCESS_VM_READ, VM_WRITE, VM_OPERATION, DUP_HANDLE, CREATE_THREAD. Cheat Engine и Process Hacker сосут на этом
  • ObRegisterCallbacks(PsThreadType) — то же для потоков, внешний отладчик не прицепится
  • PsSetCreateProcessNotifyRoutineEx — трекаем создание нашего процесса
  • PsSetLoadImageNotifyRoutine — allowlist dll по SHA-256, неизвестная — user APC на завершение
  • PsSetCreateThreadNotifyRoutine — детект remote thread creation
  • Self-integrity DPC — каждые 100мс проверяем что наш ObCallback в списке, защита от DKOM атак

Всё. Шесть API, 1500-2500 строк C. У Vanguard ~40 тысяч строк, но 70% это детект читеров — нам не нужно. Подписывать через Microsoft Partner Center attestation signing, EV cert ~$300-600/год. Гипервизорные драйверы триггерят ручной ревью Microsoft, обычный ObRegisterCallbacks — нет.

Важно: на Win11 24H2 HVCI включён по умолчанию и форсит vulnerable driver blocklist — ~700 известных уязвимых драйверов (mhyprot2, capcom, RTCore64) не загружаются, что убивает BYOVD атаку. Наш драйвер при инициализации должен требовать HVCI. Vanguard делает так.

Про свой гипервизор (VT-x/EPT через наш драйвер) отдельно скажу чтобы никто не велся: на Win11 2026 не работает. Hyper-V уже в VMX root mode, твой VMXON делает #GP. Единственный путь — nested guest под Hyper-V, но перф падает в разы, плюс 4-6 месяцев разработки. Гипервизор — для DRM, не для Java защиты.



Собираем воедино

Финальная архитектура по слоям:

  1. Контейнер — кастомный .avl, классы как AES-GCM шифртексты
  2. Ключи — мастер из HWID + TPM-sealed + build salt, per-method через HKDF. Принципиально: HWID это ключевой материал, не булева проверка. Неправильный HWID = неправильный ключ = крэш. Булевы проверки нопятся за минуту, ключевой материал никогда
  3. Кастомная JVMINCLUDE_JVMTI=0/SERVICES=0/JFR=0, strip VMStructs/PDB, layout drift, убитые startup агенты, удалённые модули java.instrument/jdk.hotspot.agent/jdk.jdwp.agent
  4. ЛоадерAV_DefineEncryptedClass JNI entry: .avl → decrypt → JVM_LookupDefineClass с HIDDEN_CLASS → занулить буфер
  5. Condy — все строки и константы через CONSTANT_Dynamic, ключ в classData, после JIT — x86 immediates
  6. nmethod vaultConstMethod::_code зануляется после C2, nmethod запинен, деопт невозможен (CHA нейтрализован, OSR запрещён, uncommon traps → explicit throws)
  7. JIT-aware bytecode@Stable MethodHandle[], @ForceInline/@DontInline, интринсики, final sealed, record-holder-ы под EA
  8. JIT моды — register allocator PRNG, emitter noise, instruction selection tie-break
  9. Kernel driver (опционально) — ObRegisterCallbacks + Ps*Notify + self-integrity DPC, требует HVCI
  10. Per-build уникальность — build seed → разный condy layout, разные ключи, разные register maps, разные nop паттерны. Атака на одну сборку не переносится

Что остаётся возможным: ring-0 через BYOVD на машине без HVCI (редко), реверсер с IDA читающий nmethod ассемблером (недели работы), cold boot / DMA атаки (физический доступ).

Что невозможно: стандартные Java деобфускаторы, дамперы инжектящиеся в процесс, Serviceability Agent, паттерн «premain + ClassFileTransformer», RPM с парсингом VMStructs, Cheat Engine (с kernel driver), memory сканеры ищущие CAFEBABE.



Послесловие

Я два года писал обычный обфускатор «invokedynamic + hidden classes + JNI DLL». Кастомная VM поверх Java, opcode randomization, per-build ключи. Всё работало, но меня постоянно раздражало что любой школьник с готовым дампером разваливал защиту за секунду. Я пытался ловить это детектом — проверки RuntimeMXBean, поиск подозрительных dll в памяти, хеширование JNI function table. Всё легко обходится.

Недели копания OpenJDK чтобы понять простую вещь: я играл в чужой песочнице. JVM создана чтобы быть открытой для инструментирования. JVMTI это её фича, не баг. ClassFileLoadHook это задокументированный API. Всё что я делал — пытался прикрыть листиком дыру размером с форточку.

Правильный путь — владеть JVM. INCLUDE_JVMTI=0 это пятистрочное изменение в мейкфайле на котором заканчивается индустрия публичных Java-реверс инструментов применительно к тебе. HotSpot мейнтейнеры уже сделали работу за нас для embedded use cases. Мы просто используем ту же дорожку.

Если кто-то пилит своё в этом направлении или залипает по JVM internals — маякуйте в ЛС. Если нашёл ошибки в статье — тоже пишите, я где-то мог наврать.
/up годно
 
Я несколько лет пилю защиту для Java приложений
да по этому посту, сказать можно только то, что ты несколько лет нейросетями пользуешься здраво так
INCLUDE_JVMTI=0 это пятистрочное изменение в мейкфайле на котором заканчивается индустрия публичных Java-реверс инструментов применительно к тебе.
просто мразь хаах, защита уровня БОГ
Если атакующий контролирует командную строку запуска (а он контролирует её всегда, это его машина) — ни одна комбинация флагов стоковой JVM тебя не спасает.
зачем мне вообще жвмку запускать у меня вопрос, если я могу спокойно декрипт вытащить.
Заменить uncommon traps на явные throw — C2 pass в opto/, вместо DeoptAction эмитим прямой throw через shared stub. Цена 2-5% throughput
ты эти воздушные цифры из какого говна взял. чувак буквально C2 нужен для спекулятивной оптимизации, заменяя на экзепшены, ты буквально всю оптимизацию в ноль стираешь, плюсом экзепшены могут вылетать везде.
на Win11 2026 не работает
1775396715630.png

Сток JVM с condy даёт тебе где-то 85% защиты
опять же цифры выдуманные нейросетью, причем какой то жестко бредовой, ты буквально сказал, что если итеририровать хип, то все строки можно дернуть, в чем прикол этой воздушной цифры тогда
Per-build уникальность — build seed → разный condy layout, разные ключи, разные register maps, разные nop паттерны. Атака на одну сборку не переносится
никак это не спасет, вообще никак. паттерн или сид всей этой залупы можно спокойно автоматизировать, говно абсолютное предложение.
После C2 компиляции слот трактуется как @Stable final и значение запекается в native код как x86 immediate
ну на уши говно не лей хоть, чувак буквально строка это oop в хипе, ее клинер сдвинет при компакте, так как тогда она может быть имидейтом, если она является релокейшен рекордом в нметоде
Я потратил несколько месяцев на копание исходников OpenJDK
и за эти несколько месяцев выдуманных нейросетью, не понял, что деоптимизация, это не то что надо 100% везде и вся выпиливать
Шесть API, 1500-2500 строк C.
ахахаххахахааа, просто чудовищно страшно. сейчас не у всех школьников секьюр бут есть, а тут даун защиту на драйвере предлагает держать, причем с такими описанными триками, что у каждого говноеда бсодить будет
и че это дает, метаспейс и без вмструктур можно спокойно выпаршивать
смотрел внутренности Vanguard, BattlEye, EAC — все они защитную часть делают через ~6 API:
ахереть))
Значит в Method::set_code сразу после публикации nmethod — memset(cm->code_base(), 0, cm->code_size()). Байткод больше не существует в памяти. Только x86 в code cache.
технологии, о которых миру еще не было никогда видимо известно. ты буквально зануляешь байткод, клинер сдвигает обьект, но при деопте у тебя все ложиться. я не знаю, что за проклятая нейросеть это выписывала
Layout drift. При каждой сборке добавляем в InstanceKlass, Method, ConstMethod несколько приватных полей. Это бесплатно сдвигает оффсеты структур. Публичные тулзы заточены под апстрим layout и ломаются на нашей сборке сразу.
буквально оффсеты зашиты в интерпретаторе, C2, клинере, да везде. добавишь поле в икшник, и ОЧКО твоей технологии

короче я просто пробежался глазами, даже отвечать на это не хотел, просто увидел, что этот нейрослоп, везде своей нейронкой отвечает. в общем и обобщенно говорю, в этот проклятый дерьмовый пост вникать вообще не стоит, написанное нейросетью вообще аннулируется, ладно бы он свалидировал, то что ему нейросеть наклипала, но чувак буквально копипастнул то что ему нейронка написала. те люди которые ему ставили хоть какие то положительные реакции, врядли имеют хоть какое то понимание о жвмке, да и скорее всего обо всем яп явы.
 
да по этому посту, сказать можно только то, что ты несколько лет нейросетями пользуешься здраво так

просто мразь хаах, защита уровня БОГ

зачем мне вообще жвмку запускать у меня вопрос, если я могу спокойно декрипт вытащить.

ты эти воздушные цифры из какого говна взял. чувак буквально C2 нужен для спекулятивной оптимизации, заменяя на экзепшены, ты буквально всю оптимизацию в ноль стираешь, плюсом экзепшены могут вылетать везде.

Посмотреть вложение 332421

опять же цифры выдуманные нейросетью, причем какой то жестко бредовой, ты буквально сказал, что если итеририровать хип, то все строки можно дернуть, в чем прикол этой воздушной цифры тогда

никак это не спасет, вообще никак. паттерн или сид всей этой залупы можно спокойно автоматизировать, говно абсолютное предложение.

ну на уши говно не лей хоть, чувак буквально строка это oop в хипе, ее клинер сдвинет при компакте, так как тогда она может быть имидейтом, если она является релокейшен рекордом в нметоде

и за эти несколько месяцев выдуманных нейросетью, не понял, что деоптимизация, это не то что надо 100% везде и вся выпиливать

ахахаххахахааа, просто чудовищно страшно. сейчас не у всех школьников секьюр бут есть, а тут даун защиту на драйвере предлагает держать, причем с такими описанными триками, что у каждого говноеда бсодить будет

и че это дает, метаспейс и без вмструктур можно спокойно выпаршивать

ахереть))

технологии, о которых миру еще не было никогда видимо известно. ты буквально зануляешь байткод, клинер сдвигает обьект, но при деопте у тебя все ложиться. я не знаю, что за проклятая нейросеть это выписывала

буквально оффсеты зашиты в интерпретаторе, C2, клинере, да везде. добавишь поле в икшник, и ОЧКО твоей технологии

короче я просто пробежался глазами, даже отвечать на это не хотел, просто увидел, что этот нейрослоп, везде своей нейронкой отвечает. в общем и обобщенно говорю, в этот проклятый дерьмовый пост вникать вообще не стоит, написанное нейросетью вообще аннулируется, ладно бы он свалидировал, то что ему нейросеть наклипала, но чувак буквально копипастнул то что ему нейронка написала. те люди которые ему ставили хоть какие то положительные реакции, врядли имеют хоть какое то понимание о жвмке, да и скорее всего обо всем яп явы.
Начнем с того что ты сам себе противоречишь. сначала пишешь "ты буквально всю оптимизацию в ноль стираешь" про замену uncommon traps на explicit throws а потом через два абзаца "после C2 компиляции значение запекается как immediate - ну на уши говно не лей". чувак определись либо c2 у меня не работает потому что я оптимизации выпилил, либо работает и запекает immediates. одно из двух. 2-5% это усредненка по всему клиенту а не по отдельному sealed методу, понятно что для конкретной функции с тяжелым null check потеря больше. но sealed методов у тебя 20-50 штук а не 20 тысяч, на fps в майне этого не видно вообще.

"зачем мне жвмку запускать если декрипт вытащить можно". ну вытащи. ключ где лежит, подскажи? в jvm.dll в plaintext я его не клал нигде, он derive-ится из hwid + tpm seal + challenge response с сервером при первом запуске. вытащишь jvm.dll - получишь декриптер без ключа, декриптить нечем. это в статье в разделе про слои было явно написано но ты видимо до туда не долистал. про "hyper-v работает на win11 2026, я загуглил" ну конечно работает блять, он и есть причина почему твой VMXON делает #GP. hyper-v сидит в VMX root mode по дефолту когда VBS включен, а VBS включен по умолчанию на win11 24H2 на большинстве новых машин. твой собственный гипервизор не может зайти в root когда там уже microsoft. ты спросил у гугла одно а я писал про другое, ai overview тебе не помог в этот раз.

про "если хип итерировать, можно все строки дернуть". через что итерировать собрался? JVMTI heap iteration вырезан. external memory walker ходит через VMStructs, VMStructs стрипнут. остается реверсить layout хипа ручками в иде, и это уже не "можно спокойно дернуть". разница между "теоретически возможно" и "спокойно дернуть" существенная.

immediates и GC compact - вот это реально смешно. ты пишешь "строка это oop в хипе, клинер сдвинет при компакте, как она может быть имидейтом". immediates в nmethod это не сами строки это oop references, то есть указатели на объекты. когда GC двигает объект HotSpot обновляет эти указатели через oop relocation table в nmethod. смотри nmethod::oops_do и fix_oop_relocations в code/nmethod.cpp, это стандартная механика хотспота уже лет пятнадцать. @Stable гарантирует константность значения а не адреса, GC безопасно перемещает объекты без поломки immediates. если бы это не работало то весь lambda metafactory, все invokedynamic call site и вообще весь современный jdk бы не работал. про "шесть API 1500-2500 строк, ахаха у школьников секюрбут" ты не понял о чем речь вообще. шесть API это количество kernel api которые я использую в драйвере (ObRegisterCallbacks, Ps*NotifyRoutine и тд), это не аргумент защиты от школьников, это описание обьема кода. у vanguard'а 40к+ строк потому что у них 70% это детект читеров, нам этого не надо, только protection layer. к школьникам и secure boot это вообще отношения не имеет, ты перепутал два разных абзаца.

per-build уникальность. ты говоришь "паттерн или сид всей этой залупы можно спокойно автоматизировать". теоретически да. практически написать универсальный диффер который автоматически вытаскивает condy keys из одной сборки и применяет к другой требует понимания семантики каждого слоя - condy layout, register allocation, nop injection, constant folding в джите. это пишется но стоит дороже чем проанализировать одну сборку ручками. идея per-build не в "автоматизация невозможна" а в "стоимость автоматизации выше чем стоимость одной цели". для массового рынка это значит выигрыш, для единичной цели нет. про "нейрослоп, нейросеть наклепала" забавно что человек который в одном посте путает "hyper-v существует" с "ты можешь сделать свой гипервизор" и не знает про oop relocation в nmethod - называет нейрослопом статью где написано и про то и про другое корректно. такие дела.
 
Condy + hidden class + classData — правильная база
мастер криптографии че на счет List.of(aesKey, ...) который ты передал в defineHiddenClassWithClassData

RPM -> хип -> java.lang.Class -> поле classData -> вот твой аес ключ. Без jvmti, без твоей кастомной жвмки и прочей хуйни, ты бля ебунявый дур@чок ты нулишь буфер, оставляя ключ открытый
JIT nmethod → байткод — nmethod в code cache содержит ScopeDesc с маппингом PC → BCI. Продвинутый реверсер восстановит структуру
а почему тебе нейронка одну половину статьи написала, а про вторую забыла

ты стираешь байты после жита потом нулишь code_base(), а че с scopes_data/scopes_pcs в nmethod
а что будет с твоей оптимизацией майнкав чита когда у тебя код кеш забит, у тебя майнкрафт грузит тысячи классов у тебя jvm откатывает на интерпретатор, интерпретатор читает твои зануленные байты
CHA нейтрализован
HashMap.get()?????????? ArrayList.iterator()?????????????? братан, а как ты в них ACC_FINAL повесишь
Cipher.getInstance("AES/GCM/NoPadding");
500 Cipher.getInstance("AES/GCM/NoPadding") + init() + doFinal() )))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))
 
мастер криптографии че на счет List.of(aesKey, ...) который ты передал в defineHiddenClassWithClassData

RPM -> хип -> java.lang.Class -> поле classData -> вот твой аес ключ. Без jvmti, без твоей кастомной жвмки и прочей хуйни, ты бля ебунявый дур@чок ты нулишь буфер, оставляя ключ открытый

а почему тебе нейронка одну половину статьи написала, а про вторую забыла

ты стираешь байты после жита потом нулишь code_base(), а че с scopes_data/scopes_pcs в nmethod

а что будет с твоей оптимизацией майнкав чита когда у тебя код кеш забит, у тебя майнкрафт грузит тысячи классов у тебя jvm откатывает на интерпретатор, интерпретатор читает твои зануленные байты

HashMap.get()?????????? ArrayList.iterator()?????????????? братан, а как ты в них ACC_FINAL повесишь

500 Cipher.getInstance("AES/GCM/NoPadding") + init() + doFinal() )))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))
List.of(aesKey) в classData. братан ты буквально описал путь хуяки блять который работает ТОЛЬКО если ты не читал раздел про слои. в статье черным по белому написано что это обзорный пример апи, а реальная схема мастер ключ вообще не в java heap, он в нативе через jni, в classData идет session key дериват с коротким lifetime который обнуляется reflection-ом на внутренний массив List после того как класс загружен в метаспейс. если ты из "на, пример кода показан" делаешь вывод "ага, значит мастер ключ будет лежать в хипе вечно" это не дыра в статье, это твоя невнимательность. в продакшене никто List.of с мастер ключом не передает, это пример для понимания механики classData а не готовый код для копипасты.

scopes_data/scopes_pcs в nmethod. справедливо что в статье это не раскрыто явно, я это писал в обзорном ключе упомянул стирание code_base как основную идею. в реальной имплементации стираются все metadata регионы nmethod которые содержат BCI маппинги: scopes_data, scopes_pcs, dependencies, nul_chk_table все что может быть использовано для восстановления структуры. плюс флаг -XX:-DebugNonSafepoints уменьшает количество scope entries на старте. то что в статье было только про code_base это упрощение для читаемости а не полный чеклист. если бы я расписывал каждую metadata структуру hotspot-а то статья была бы не 40к символов а 400к.

code cache pressure в майне с тысячами классов. ты сам себе ответил на этот вопрос но не понял. в статье был _is_pinned бит который делает is_cold() и is_relocatable() возвращающими false для sealed методов это значит sweeper физически не может их выкинуть из code cache никогда. на длинной сессии с cache pressure флашатся НЕпомеченные методы, sealed остаются навсегда. плюс очевидно что sealed это не для всех тысяч классов майна, а для конкретных 20-50 критичных методов клиента. помечать весь майнкрафт sealed-ом это идиотизм и никто так делать не будет, ты атакуешь соломенное чучело.

HashMap.get() и ArrayList.iterator() как ACC_FINAL повесить. вот тут ты частично попал. согласен, на java.util. ты ACC_FINAL не поставишь, это сломает пол-ждк. в статье этот момент не раскрыт потому что он специфичен для конкретной имплементации. на практике решается двумя путями: либо sealed методы не используют внешние коллекции (держат свое состояние в arrays и примитивах что для критичной логики норм), либо CHA dependency регистрация перехватывается per-method в dependencies.cpp и игнорируется для sealed. оба пути рабочие, но оба требуют архитектурных компромиссов на sealed код. в статье я этого не описывал потому что это уже имплементационные детали конкретного форка, а статья про концепцию.

Cipher.getInstanc("AES/GCM/NoPadding") 500 раз. справедливо, тут ты прав. в статье дан образец BSM для наглядности который показывает ИДЕЮ а не оптимальный код. очевидно что в проде Cipher инстанс кешируется в static final поле, либо вообще используется ручная имплементация AES-GCM через нативные intrinsics без обертки javax.crypto. либо ChaCha20-Poly1305 который стримовый и дешевле на мелких блоках. то что BSM в статье написан неоптимально это цена читаемого примера, а не прод код.

по итогу из пяти пунктов два справедливых замечания хах (scopes metadata и Cipher per-call), одно частично справедливое (CHA на external коллекциях), и два на которых ты ловишь соломенное чучело (classData мастер ключ которого никто туда не кладет, и sealed на тысячах классов которого никто не делает). статья обзорная, детали имплементации каждого слоя это отдельная тема на десять таких же статей. если бы я расписывал каждый едж кейс то никто бы не дочитал. спасибо тебе дур@чек конечно за замечания про scopes_data и Cipher, по сути это уточнения которые стоит добавить в следующую версию.
 
он derive-ится из hwid + tpm seal + challenge response с сервером при первом запуске
и после деривации куда он попадает?
immediates в nmethod это не сами строки это oop references
relocation table в nmethod это список всех mbedded oop-ов с оффсетами, ты буквально можешь парсить все из nmethod и получать адреса всех строк и констант
через что итерировать собрался?
простой ReadProcessMemory, а че?
 
чудик не перестает пользоваться нейросетью, очень страшный нейрослоп
да я вахуе хули он так быстро отвечает мне
в classData идет session key дериват с коротким lifetime который обнуляется reflection-ом
сука List.of() возвращает лист из java.util.ImmutableCollections$ListN ты туда полезешь нулить @Stable Object[] elements? или че
. в реальной имплементации стираются все metadata
handler_table тоже стер?
 
Назад
Сверху Снизу