Сломать объект с помощью финализации
Вчера перевели в статус Candidate новый JEP 421: Deprecate Finalization for Removal. Путь к удалению механизма финализации из Java начался в Java 9, когда метод Object.finalize()
был впервые объявлен deprecated. Рано или поздно механизм исчезнет из Java, поэтому если вы его используете, самое время задуматься об альтернативах. Однако статья не об этом.
Я думал, что довольно хорошо представляю себе все минусы механизма финализации. Многие из них перечислены, например, в этой статье. Однако, прочитав JEP, я узнал об уязвимости, о которой раньше и не думал. Оказывается, с помощью финализации можно создать объект со сломанными инвариантами.
Вот для примера возьмём стандартный библиотечный класс HashSet
. Внутри него объявлено приватное поле map
, потому что HashSet
— это обёртка над HashMap
. Поле инициализируется в конструкторе и после этого не меняется. Предположим, мы хотим сломать HashSet
и записать в это поле null
. В старые добрые времена, когда все друг другу доверяли, можно было сделать так:
HashSet set = new HashSet<>();
Field map = HashSet.class.getDeclaredField("map");
map.setAccessible(true);
map.set(set, null);
Однако если включена строгая инкапсуляция, этот код упадёт с исключением вида
java.lang.reflect.InaccessibleObjectException: Unable to make field private transient java.util.HashMap java.util.HashSet.map accessible: module java.base does not «opens java.util» to unnamed module @682a0b20
Строгая инкапсуляция с Java 16 включена по дефолту, а с Java 17 её нельзя выключить вообще, только давать явные разрешения конкретным модулями через --add-opens
. Да, у нас всё ещё есть лазейка в виде sun.misc.Unsafe
из модуля jdk.unsupported
. Мы можем сделать вот так:
HashSet set = new HashSet<>();
Field map = HashSet.class.getDeclaredField("map");
Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
unsafe.putObject(set, unsafe.objectFieldOffset(map), null);
Однако это известная дырочка и рано или поздно уйдёт и она, потому что Java должна быть безопасной.
И тут я узнал, что аналогичного эффекта можно добиться вообще без reflection, эксплуатируя механизм финализации. Правда сломаем мы не сам класс HashSet
, а его подкласс, но этого вполне может быть достаточно. Его можно будет присвоить в переменную типа HashSet
, пройдут все проверки типа instanceof HashSet
, но инвариант будет сломан.
Обычно если выполнение конструктора завершается исключением, то мы считаем, что объект никто не видит. Однако если объект содержит непустой метод finalize()
, то он регистрируется для финализации до выполнения конструктора. Если конструктор завершился ошибочно, объект всё равно остался в куче, пусть на него и нету ссылок. А значит, сборщик мусора до него доберётся и добавит в очередь финализации, и тогда выполнится finalize()
, который может оживить объект. Конечно, у HashSet
нет своего метода finalize()
, но ничего не мешает объявить его у наследника.
Уронить конструктор HashSet
несложно, достаточно нарушить предусловие. Например, конструируя от коллекции, передать туда null
. В итоге имеем:
AtomicReference> ref = new AtomicReference<>();
try {
new HashSet(null) {
@Override
protected void finalize() {
ref.set(this);
}
};
} catch (NullPointerException e) {
}
while (ref.get() == null) {
System.gc();
}
HashSet set = ref.get();
Мы игнорируем NullPointerException
, который вывалится из конструктора и вызываем сборку мусора пока finalize()
не выполнится и не заполнит ссылку ref
. В итоге мы получаем недоконструированный объект HashSet
с нарушенным инвариантом.
В данном случае это несильно помогает что-нибудь сломать. Результирующий HashSet
будет просто кидать NullPointerException
на любую операцию. Однако могут быть и другие классы, экземпляры которых в недоинициализированном виде могут позволить сделать интересные вещи, которые нельзя сделать так просто. Как-то не хочется об этом постоянно думать, если вы разрабатываете особо безопасную библиотеку.
В общем, finalize
позволяет делать грязные вещи не хуже Unsafe
. Не используйте его и выкашивайте из кодовой базы. И на всякий случай объявляйте свои классы final
(или sealed
с Java 17), чтобы их не наследовал кто попало.