...............
- Статус
- Оффлайн
- Регистрация
- 26 Янв 2024
- Сообщения
- 9
- Реакции
- 7
Вступление
Статья выросла из моего личного раздражения. Я несколько лет пилю защиту для Java приложений — в основном minecraft клиенты — и всё это время наблюдаю одну картину: любой платный обфускатор за 200 баксов, любой коммерческий клиент с билдбита, любое «защищённое» решение — всё сливается за вечер любым человеком у которого есть Recaf и желание потыкать. Дело даже не в том что обфускация слабая. Дело в том что вся индустрия Java реверса построена вокруг одной-единственной дыры, о которой толком никто не говорит, и которую невозможно закрыть стандартными средствами.
Я потратил несколько месяцев на копание исходников OpenJDK — ходил по HotSpot, читал пути вызовов, смотрел какие конкретно места в коде JVM приводят к тому что класс можно достать из процесса. Все файлы и номера строк из свежего дерева HotSpot, можешь проверить. После того как один раз видишь как это работает изнутри — понимаешь что весь мейнстрим-стек Java защиты это покраска ржавчины. А настоящее решение лежит сильно дальше и выглядит не так как я ожидал когда начинал копать.
Пишу без базы. Если не знаешь что такое classloader, байткод, JIT и примерно как работает HotSpot — сначала в JVMS, потом возвращайся. На русском по этой теме я толком ничего толкового не встречал, поэтому и взялся писать. Поехали.
Почему текущий стек защиты не работает
Начну с того что меня бесит. Типичный «защищённый» Java клиент сегодня это:
Смотрится солидно. На практике ломается стандартным инструментом дампера который инжектится в процесс жертвы и забирает классы на этапе когда они уже расшифрованы в памяти но ещё не провалидированы JVM-кой.
Работает это так: инструмент подпихивает свой код внутрь процесса (не важно как — подмена одной из загружаемых dll, маленький хелпер, шелл-код — вариантов хватает), там цепляется к внутреннему API HotSpot под названием JVMTI и подписывается на событие ClassFileLoadHook. Это событие дёргается когда любому классу надо пройти через парсер JVM. Включая те что обфускатор только что расшифровал у себя в clinit.
Результат: в дамп падают чистые .class файлы, строки расшифрованы, invokedynamic уже зарезолвнут, имена перемешаны но логика как на ладони. Дальше CFR или Vineflower и клиент слит.
Работает потому что ClassFileLoadHook вызывается ПОСЛЕ того как обфускатор отдал байты в JVM но ДО валидации и линковки. Инструмент сидит ровно между твоим декриптом и парсером, пишет всё на диск. Единственное что тебя спасёт — чтобы этого события вообще не существовало в JVM. Без шуток.
Ниже по слоям разберу что с этим можно сделать.
Векторы дампа — полный список
Прежде чем защищаться, надо понимать от чего. Я потратил дохрена времени на OpenJDK исходники и могу сказать — векторов ровно десять:
Блять, десять векторов. Смотрим что из этого прихлопывается флагами JVM:
Если атакующий контролирует командную строку запуска (а он контролирует её всегда, это его машина) — ни одна комбинация флагов стоковой JVM тебя не спасает. Факт. Всё что ты делаешь на стоке — поднимаешь планку для казуалов.
Hidden classes и почему они почти работают
Штука которую я долго недооценивал. В classfile/klassFactory.cpp на 191 строке:
Для 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:
Имя condy обязано быть "_" (реальная проверка в MethodHandles.java, константа ConstantDescs.DEFAULT_NAME). Int аргумент — индекс в classData.
Загрузка hidden класса:
Что происходит во времени:
В 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 пишешь:
И получаешь jvm.dll в котором физически нет JVMTI. Нет кода. Парсер аргументов в runtime/arguments.cpp всё ещё видит -javaagent и пытается зарегистрировать агент, но путь загрузки уходит в пустоту. Можно дополнительно заткнуть:
Выпиливаем модули: java.instrument, jdk.jdi, jdk.jdwp.agent, jdk.hotspot.agent, jdk.jcmd, jdk.jconsole, jdk.management.agent. После этого больше нет:
Всё это — три дня работы. Реально изменения в одном мейкфайле + ~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 и ломаются на нашей сборке сразу.
После этого внешний дампер должен:
Это уже не «запустил тулзу, получил дамп за минуту». Это несколько недель реверсинга под одну конкретную версию. Следующий билд с другим 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:
Занулим code_base, случится деопт — интерпретатор читает нули, бежит вперёд, крашится. Деопт триггерится от кучи вещей: uncommon trap на null check, bounds check, CHA invalidation, retransform, cold flush. Но у нас уже кастомная JVM, значит можем:
После этих патчей деопт для помеченных методов невозможен по построению. Значит в Method::set_code сразу после публикации nmethod — memset(cm->code_base(), 0, cm->code_size()). Байткод больше не существует в памяти. Только x86 в code cache.
В nmethod.hpp добавляем два бита:
И в 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 извлекает целевой метод и инлайнит рекурсивно через всю цепочку:
После компиляции — вся цепочка исчезает из 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.fma → vfmadd231sd, Integer.bitCount → popcnt. Java реализация выкидывается, подставляется ручной ассемблер. Реверсер видит SIMD там где была простая логика.
Если у нас свой HotSpot — ещё пара грязных штук:
В сумме: полиморфный байткод + рандомный register map + рандомный nop паттерн. Ни один инструмент диффа не найдёт стабильной точки опоры между сборками.
Если хочете «ещё круче» — kernel driver
Всё что выше — защита от Java-уровневых атак. От внешнего чтения процесса через RPM почти не работает. Тут ставим kernel driver. Я смотрел внутренности Vanguard, BattlEye, EAC — все они защитную часть делают через ~6 API:
Всё. Шесть 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 защиты.
Собираем воедино
Финальная архитектура по слоям:
Что остаётся возможным: 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 клиент сегодня это:
- Переименовали классы/методы через ProGuard
- Строки зашифровали XOR-ом с ключом в clinit
- Вызовы завернули в invokedynamic
- Прогнали через native-obfuscator
- Натянули сверху VMProtect
Смотрится солидно. На практике ломается стандартным инструментом дампера который инжектится в процесс жертвы и забирает классы на этапе когда они уже расшифрованы в памяти но ещё не провалидированы JVM-кой.
Работает это так: инструмент подпихивает свой код внутрь процесса (не важно как — подмена одной из загружаемых dll, маленький хелпер, шелл-код — вариантов хватает), там цепляется к внутреннему API HotSpot под названием JVMTI и подписывается на событие ClassFileLoadHook. Это событие дёргается когда любому классу надо пройти через парсер JVM. Включая те что обфускатор только что расшифровал у себя в clinit.
Результат: в дамп падают чистые .class файлы, строки расшифрованы, invokedynamic уже зарезолвнут, имена перемешаны но логика как на ладони. Дальше CFR или Vineflower и клиент слит.
Работает потому что ClassFileLoadHook вызывается ПОСЛЕ того как обфускатор отдал байты в JVM но ДО валидации и линковки. Инструмент сидит ровно между твоим декриптом и парсером, пишет всё на диск. Единственное что тебя спасёт — чтобы этого события вообще не существовало в JVM. Без шуток.
Ниже по слоям разберу что с этим можно сделать.
Векторы дампа — полный список
Прежде чем защищаться, надо понимать от чего. Я потратил дохрена времени на OpenJDK исходники и могу сказать — векторов ровно десять:
- JVMTI ClassFileLoadHook — prims/jvmtiExport.cpp, функция post_class_file_load_hook около 1091 строки. Главная дыра. Любой код внутри процесса видит байты всех классов
- JVMTI GetBytecodes — prims/jvmtiEnv.cpp ~3428. Читает байткод уже загруженного метода в рантайме. Внутри JvmtiClassFileReconstituter::copy_bytecodes разворачивает _fast_* опкоды обратно в спецификационные — даже переписанные HotSpot-ом байты отдаются чистыми
- JVMTI RetransformClasses — принудительно перепарсит загруженный класс и снова дёрнет CFLH. Юзается когда дампер прицепился после старта
- GetLoadedClasses — prims/jvmtiGetLoadedClasses.cpp. Возвращает всё из ClassLoaderDataGraph. Неприятно: hidden classes тоже возвращает, фильтра is_hidden() там нет (сам был в обратном уверен пока не полез в сорцы)
- Serviceability Agent (SA) — jhsdb, jmap -F, jstack -F. Цепляется к процессу снаружи через ptrace/DebugActiveProcess, парсит сырую память по таблице VMStructs которую jvm.dll экспортирует как глобальный символ
- Прямое сканирование метаспейса — тот же путь что SA но ручками. Инжект dll, читаем gHotSpotVMStructs, ходим ClassLoaderDataGraph::_head → InstanceKlass → Method* → ConstMethod* и читаем (ConstMethod*)+1 на code_size байт
- Переписанный байткод в метаспейсе — interpreter/rewriter.cpp. HotSpot мутирует байткод при линковке, но restore_bytecodes() публична и reconstituter её дёргает
- JIT nmethod → байткод — nmethod в code cache содержит ScopeDesc с маппингом PC → BCI. Продвинутый реверсер восстановит структуру
- CDS mapped archive — cds/filemap.cpp. Классы из .jsa лежат в замапленной странице
- Подмена jvm.dll / JNI хук — дампер подсовывает свою dll с теми же экспортами что настоящая jvm.dll, хукает JVM_DefineClass / Unsafe_DefineClass0. Самый ранний перехват
Блять, десять векторов. Смотрим что из этого прихлопывается флагами JVM:
Код:
- 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++:
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:
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:
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:
-DINCLUDE_JVMTI=0
-DINCLUDE_SERVICES=0
-DINCLUDE_JFR=0
-DINCLUDE_NMT=0
И получаешь jvm.dll в котором физически нет JVMTI. Нет кода. Парсер аргументов в runtime/arguments.cpp всё ещё видит -javaagent и пытается зарегистрировать агент, но путь загрузки уходит в пустоту. Можно дополнительно заткнуть:
C++:
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 и ломаются на нашей сборке сразу.
После этого внешний дампер должен:
- Сам по сигнатурам найти ClassLoaderDataGraph::_head в нашем jvm.dll (без символов)
- Сам определить поля InstanceKlass (наш layout drift)
- Сам восстановить маппинг 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++:
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, значит можем:
- Отключить CHA-деопт — ACC_FINAL + запрет subclass-ить, code/dependencies.cpp
- Убить OSR — -XX:-UseOnStackReplacement
- Заменить uncommon traps на явные throw — C2 pass в opto/, вместо DeoptAction эмитим прямой throw через shared stub. Цена 2-5% throughput
- Retransform — уже бесплатно с INCLUDE_JVMTI=0
- Guard в fetch_unroll_info_helper — deoptimization.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++:
_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:
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.fma → vfmadd231sd, Integer.bitCount → popcnt. 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 защиты.
Собираем воедино
Финальная архитектура по слоям:
- Контейнер — кастомный .avl, классы как AES-GCM шифртексты
- Ключи — мастер из HWID + TPM-sealed + build salt, per-method через HKDF. Принципиально: HWID это ключевой материал, не булева проверка. Неправильный HWID = неправильный ключ = крэш. Булевы проверки нопятся за минуту, ключевой материал никогда
- Кастомная JVM — INCLUDE_JVMTI=0/SERVICES=0/JFR=0, strip VMStructs/PDB, layout drift, убитые startup агенты, удалённые модули java.instrument/jdk.hotspot.agent/jdk.jdwp.agent
- Лоадер — AV_DefineEncryptedClass JNI entry: .avl → decrypt → JVM_LookupDefineClass с HIDDEN_CLASS → занулить буфер
- Condy — все строки и константы через CONSTANT_Dynamic, ключ в classData, после JIT — x86 immediates
- nmethod vault — ConstMethod::_code зануляется после C2, nmethod запинен, деопт невозможен (CHA нейтрализован, OSR запрещён, uncommon traps → explicit throws)
- JIT-aware bytecode — @Stable MethodHandle[], @ForceInline/@DontInline, интринсики, final sealed, record-holder-ы под EA
- JIT моды — register allocator PRNG, emitter noise, instruction selection tie-break
- Kernel driver (опционально) — ObRegisterCallbacks + Ps*Notify + self-integrity DPC, требует HVCI
- 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 — маякуйте в ЛС. Если нашёл ошибки в статье — тоже пишите, я где-то мог наврать.