Команда Spring АйО перевела статью про ужесточение контроля за динамической загрузкой агентов, ограничения доступа к опасным методам работы с памятью и JNI в новых версиях JDK.


Java продолжает развиваться, делая упор на стабильность и устойчивость. Новые версии JDK отражают этот тренд: ограничивается динамическая загрузка агентов, прекращается поддержка опасных методов доступа к памяти, усиливается контроль над использованием JNI. В JDK 22 появилось Foreign Function and Memory (FFM) API, которое упрощает вызов внешнего кода и обеспечивает безопасный доступ к памяти без управления со стороны JVM. Эти изменения поддерживают концепцию "целостности по умолчанию" и формируют более предсказуемую и надёжную экосистему Java. В этой статье мы подробно разберём, как эти обновления укрепляют платформу и делают е безопаснее.

Целостность в платформе Java

В программном обеспечении термин “целостность” (integrity) означает гарантию того, что конструкции, из которых мы создаем программы, являются завершенными и надежными. Это означает, что спецификации Java Platform полностью покрывают предметную область, а ее реализации строго соответствуют этим спецификациям. Такая фундаментальная целостность позволяет вам создавать логику своего приложения с уверенностью в том, что вы можете положиться на конструкции платформы Java.

В программировании "целостность" (integrity) — это гарантия, что конструкции, используемые для создания программ, завершенные и надежные. В Java это означает, что спецификации платформы полностью охватывают предметную область, а их реализации строго соответствуют этим спецификациям. Такая заложенная в основу платформы целостность позволяет разработчикам уверенно создавать приложения, опираясь на проверенные механизмы платформы.

JEP "Целостность по умолчанию" (Integrity by Default) акцентирует внимание на защите кода и данных от нежелательного вмешательства. Хотя инкапсуляция — ключевой инструмент для обеспечения целостности, платформа Java всё ещё содержит небезопасные API, которые могут эту целостность подорвать. Это способно негативно повлиять на корректность, простоту в поддержке, безопасность, производительность и масштабируемость приложений или библиотек:

  • Instrumentation API позволяет агентам модифицировать байткод любого метода в любом классе.

  • Метод AccessibleObject::setAccessible(boolean) позволяет игнорировать границы инкапсуляции при рефлексии. Он предназначен для поддержки сериализации и десериализации объектов, но открывает доступ к вызову приватных методов, чтению и изменению приватных полей, а также записи в final поля. Это делает возможным вмешательство в структуру любого класса, нарушая принципы инкапсуляции.

  • Класс sun.misc.Unsafe включает методы, которые могут получать доступ к private методам и полям и записывать в final поля, игнорируя границы инкапсуляции.

  • Java Native Interface (JNI) позволяет нативному коду взаимодействовать с объектами Java. Однако он также предоставляет доступ к private методам и полям, а также возможность изменять final поля, обходя ограничения инкапсуляции.

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

Постепенное ограничение доступа к небезопасным API и альтернативы

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

В качестве развития темы целостности JDK 21, JEP 451 сфокусировался на рисках, связанных с динамической загрузкой агентов в работающую JVM. Агенты, появившиеся в JDK 5, позволяют оснащать классы инструментами, такими как профайлеры, для мониторинга приложений. Например, для отладки удаленного приложения вы, возможно, используете опцию -agentlib:jdwp, позволяющую включить встроенный в JVM агент при запуске. Внутри Java использует Attach API, который с соответствующими привилегиями на уровне ОС позволяет инструментам подключаться к работающей JVM.

Некоторые библиотеки используют Attach API, чтобы незаметно подключаться к JVM, загружать агенты динамически и получить практически неограниченные возможности в плане изменения кода на лету. Однако такие действия несут риски для целостности приложения. Начиная с JDK 21, JVM предупреждает о динамической загрузке агентов, чтобы информировать о возможных угрозах и подготовить к будущим релизам, где такие действия могут быть запрещены по умолчанию. Это изменение не затрагивает большинство инструментов, которые не требуют динамической загрузки агентов.

?Для соблюдения best practices библиотекам рекомендуется загружать агенты при запуске приложения с использованием опций -javaagent или -agentlib. Такой подход обеспечивает баланс между удобством обслуживания и сохранением целостности вашего приложения.

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

В течение долгого времени класс sun.misc.Unsafe предоставлял разработчикам доступ к операциям низкого уровня, в особенности к таким задачам, как:

  • Выполнение операций непосредственно с памятью, чтобы достичь лучшей производительности 

  • Управление off-heap памятью без ограничения на ByteBuffer

  • Выполнение атомарных операций типа compare-and-swap.

Однако, использование этих методов сопряжено с рисками:

  • Они могут вызывать непредсказуемое поведение, сбои приложения или снижение производительности, обходя оптимизации JVM.

  • Они раскрывают низкоуровневые детали внутренней структуры JVM, что создает проблемы совместимости между версиями Java.

  • Их небезопасная природа усложняет поддержку и снижает уровень безопасности.

Начиная с Java 23, JDK постепенно выводит из оборота методы доступа к памяти внутри sun.misc.Unsafe, по причине рисков и ограничений, связанных с небезопасными операциями. Вместо них предлагаются безопасные альтернативы: VarHandle API (введен в JDK 9) и Foreign Function and Memory API (введен в JDK 22).

Например, вы могли использовать Unsafe для атомарных обновлений при работе с on-heap памятью. Чтобы избежать рисков, рекомендуется заменить Unsafe на VarHandle, который позволяет достичь той же цели более безопасным способом:

// Migration example from Unsafe
private static final Unsafe UNSAFE = ...;
private static final long OFFSET;

static {
    try {
        OFFSET = UNSAFE.objectFieldOffset(Point.class.getDeclaredField("x"));
    } catch (Exception ex) {
        throw new AssertionError(ex);
    }
}

private int x;

public boolean update(int newValue) {
    return UNSAFE.compareAndSwapInt(this, OFFSET, x, newValue);
}

/// Use VarHandle to achieve the same
private static final VarHandle HANDLE = MethodHandles.lookup().findVarHandle(Point.class, "x", int.class);

public boolean update(int newValue) {
    return HANDLE.compareAndSet(this, x, newValue);
}

Аналогично, если вы использовали Unsafe для работы с off-heap памятью, рекомендуется перенести этот код на конструкции FFM API, такие как MemorySegment, и управлять жизненным циклом памяти с помощью Arena:

// Using Unsafe for off-heap memory
long address = UNSAFE.allocateMemory(1024);
UNSAFE.putInt(address, 42);
int value = UNSAFE.getInt(address);
UNSAFE.freeMemory(address);

// Using MemorySegment from FFM API to achieve the same
try (Arena arena = Arena.ofShared()) {
    long byteSize = ValueLayout.JAVA_INT.byteSize();
    MemorySegment segment = arena.allocate(byteSize);
    segment.set(ValueLayout.JAVA_INT, 0, 42);
    int value = segment.get(ValueLayout.JAVA_INT, 0);
}

Эти стандартные API предлагают вам безопасную, производительную альтернативу для большинства use cases, гарантируя совместимость с современными и будущими версиями Java.

? Вот несколько инструментов, которые помогут выявить зависимости от Unsafe:

  • Обращайте внимание на предупреждения компилятора javac.

  • Используйте JDK Flight Recorder (JFR) — событие jdk.DeprecatedInvocation фиксирует вызовы терминально устаревших методов.

  • Начиная с JDK 23, запускайте приложение с новой опцией командной строки --sun-misc-unsafe-memory-access={allow|warn|debug|deny}, чтобы отслеживать влияние депрекации и удаления этих методов на ваши зависимости.

В будущих релизах JDK методы доступа к памяти в sun.misc.Unsafe будут постепенно выводиться из использования. План выглядит так:

  1. JDK 23: методы помечены как deprecated, с предупреждениями во время компиляции и выполнения (по умолчанию --sun-misc-unsafe-memory-access=allow).

  2. JDK 24: согласно JEP 498, предупреждения в рантайме включены по умолчанию (по умолчанию --sun-misc-unsafe-memory-access=warn).

  3. JDK 26: неподдерживаемые операции будут выбрасывать исключения (по умолчанию --sun-misc-unsafe-memory-access=deny).

  4. После JDK 26: методы будут полностью удалены, а опция --sun-misc-unsafe-memory-access игнорироваться.

При миграции с методов доступа к памяти из sun.misc.Unsafe избегайте использования неподдерживаемых внутренних возможностей JDK. Это снизит риск сбоев приложения при изменениях в платформе.

Готовьтесь к ограничениям на использование JNI

С момента появления в JDK 1.1 Java Native Interface (JNI) упрощает взаимодействие между Java и нативным кодом. Однако, несмотря на его несомненную полезность, использование JNI может подорвать целостность приложения:

  • Вызов нативного кода может привести к непредсказуемому поведению, включая сбои JVM. Обмен данными между нативным кодом и Java часто осуществляется через прямые байтовые буферы — области памяти, неподконтрольные сборщику мусора JVM. Использование буфера, связанного с невалидной областью памяти, неизбежно приведёт к ошибкам и нестабильности.

  • Нативный код может получать доступ к полям через JNI, а также вызывать методы, обходя проверки доступа, выполняемые JVM.

  • Нативный код может некорректно использовать функции JNI, такие как GetPrimitiveArrayCritical, что приводит к нежелательному поведению сборщика мусора, проявляющемуся на всем протяжении работы программы.

JNI нельзя отключить, поэтому невозможно полностью исключить вызовы нативного кода с использованием опасных функций JNI. JVM загружает нативные библиотеки через методы load и loadLibrary класса java.lang.Runtime. Аналогичные методы в классе java.lang.System просто перенаправляют вызовы к Runtime. Загрузка нативной библиотеки сопряжена с риском, так как библиотека может выполнять нативный код через функции инициализации или метод JNI_OnLoad, вызываемый Java runtime. Из-за этих рисков в JDK 24 введены ограничения на использование методов load и loadLibrary.

В отличие от JNI, большинство функций Foreign Function and Memory (FFM) API безопасны по умолчанию. Многие сценарии, ранее реализованные через JNI и нативный код, теперь можно перенести на FFM API, который не нарушает целостность платформы Java.

При загрузке и связывании нативных библиотек через FFM API код Java может указать параметры, несовместимые с типами внешней функции. Вызов такого метода downcall может привести к сбоям виртуальной машины или непредсказуемому поведению. Однако небезопасные методы FFM API менее рискованны, чем функции JNI: например, они не позволяют изменять значения final полей в Java-объектах. По умолчанию использование небезопасных методов в FFM API разрешено, но при этом в рантайме отображается предупреждение:

WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: java.lang.foreign.Linker::downcallHandle has been called by ReadFileWithFopen in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

Указанный фрагмент предупреждает, что код Java использует небезопасный и ограниченный метод FFM API. Учитывая риск непредсказуемого поведения и сбоев JVM, разработчикам следует осторожно разрешать нативный доступ для кода Java на этапе запуска. Такое разрешение подтверждает необходимость загрузки и связывания нативных библиотек, снимая наложенные ограничения.

?Если библиотека использует JNI или FFM API, её документация должна информировать пользователей (например, разработчиков приложений) о необходимости явного разрешения нативного доступа. При разработке или деплойменте ответственность за выдачу такого разрешения ложится на вас. Например, если ваше приложение использует библиотеку, требующую опции --enable-native-access=ALL-UNNAMED в рантайме, помните, что эта опция снимает ограничения на нативный доступ через JNI и FFM API для всех классов внутри class path.

Опция --enable-native-access=ALL-UNNAMED имеет широкий охват, поэтому для минимизации рисков и улучшения целостности рекомендуется переместить JAR-файлы, использующие JNI или FFM API, в модульный путь. Это позволит включать нативный доступ только для этих JAR-файлов, а не для всего class path. Если вы переместите JAR-файл из class path в модульный путь без модуляризации, Java runtime будет рассматривать его как автоматический модуль и назначит ему имя на основании имени файла.

Если нативный доступ для модуля не объявлен, любые попытки выполнить ограниченные операции в этом модуле считаются нелегальными. Реакция Java runtime на такие операции регулируется опцией --illegal-native-access и может быть настроена следующим образом:

  • --illegal-native-access=allow — разрешает выполнение операций без предупреждений или исключений.

  • --illegal-native-access=warn — позволяет выполнение операций, но выдает предупреждение при первом фиксировании нелегального доступа в модуле. Только одно предупреждение выдается на модуль. В JDK 24 этот режим установлен по умолчанию.

  • --illegal-native-access=deny — выбрасывает исключение IllegalCallerException для каждой операции с нелегальным нативным доступом.

До JDK 24 попытки вызвать ограниченные методы FFM из модулей без разрешённого нативного доступа (через опцию --enable-native-access) приводили к выбросу IllegalCallerException. В JDK 24 поведение было смягчено для большей согласованности с традициями JNI: теперь нелегальный нативный доступ в FFM API вызывает предупреждения вместо исключений. Чтобы вернуть прежнее поведение, можно использовать следующую комбинацию опций:

java --enable-native-access=Module1,... --illegal-native-access=deny ...

Чтобы подготовить ваш код к предстоящим изменениям, рекомендуется запускать существующий код с опцией --enable-native-access=deny. Это поможет выявить участки кода, которые требуют нативного доступа.

Заключение

Приверженность Java Platform принципу целостности очевидна: это подтверждают как предложения по улучшению JDK, так и изменения в ее релизах. Внедряя современные безопасные альтернативы и постепенно отказываясь от небезопасных функций, платформа неизменно движется к соответствию best practices в разработке программного обеспечения.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Комментарии (0)