Вчера перевели в статус Candidate новый JEP 421: Deprecate Finalization for Removal. Путь к удалению механизма финализации из Java начался в Java 9, когда метод Object.finalize()
был впервые объявлен deprecated. Рано или поздно механизм исчезнет из Java, поэтому если вы его используете, самое время задуматься об альтернативах. Однако статья не об этом.
Я думал, что довольно хорошо представляю себе все минусы механизма финализации. Многие из них перечислены, например, в этой статье. Однако, прочитав JEP, я узнал об уязвимости, о которой раньше и не думал. Оказывается, с помощью финализации можно создать объект со сломанными инвариантами.
Вот для примера возьмём стандартный библиотечный класс HashSet
. Внутри него объявлено приватное поле map
, потому что HashSet
— это обёртка над HashMap
. Поле инициализируется в конструкторе и после этого не меняется. Предположим, мы хотим сломать HashSet
и записать в это поле null
. В старые добрые времена, когда все друг другу доверяли, можно было сделать так:
HashSet<String> 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<String> 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<HashSet<String>> ref = new AtomicReference<>();
try {
new HashSet<String>(null) {
@Override
protected void finalize() {
ref.set(this);
}
};
} catch (NullPointerException e) {
}
while (ref.get() == null) {
System.gc();
}
HashSet<String> set = ref.get();
Мы игнорируем NullPointerException
, который вывалится из конструктора и вызываем сборку мусора пока finalize()
не выполнится и не заполнит ссылку ref
. В итоге мы получаем недоконструированный объект HashSet
с нарушенным инвариантом.
В данном случае это несильно помогает что-нибудь сломать. Результирующий HashSet
будет просто кидать NullPointerException
на любую операцию. Однако могут быть и другие классы, экземпляры которых в недоинициализированном виде могут позволить сделать интересные вещи, которые нельзя сделать так просто. Как-то не хочется об этом постоянно думать, если вы разрабатываете особо безопасную библиотеку.
В общем, finalize
позволяет делать грязные вещи не хуже Unsafe
. Не используйте его и выкашивайте из кодовой базы. И на всякий случай объявляйте свои классы final
(или sealed
с Java 17), чтобы их не наследовал кто попало.
Комментарии (8)
kemsky
03.11.2021 16:42+2Можно ведь сделать и без финализатора, если установить ref.set(this); прямо в конструкторе, который потом упадет.
tagir_valeev Автор
03.11.2021 17:11+2Не выйдет. Если вы роняете конструктор суперкласса, то до конструктора подкласса исполнение не дойдёт. Если же вы роняете конструктор подкласса, то суперкласс будет полностью инициализирован.
kemsky
03.11.2021 18:21-1Я имею ввиду вот это:
class Main { private static Main Value = null; Main (){ Main.Value = this; throw new RuntimeException("OK"); } public static void main(String[] args) { try{ Main val = new Main(); } catch (Throwable e) { System.out.println(Main.Value.toString()); } } }
Объект в поле Value есть, но он в неизвестном состоянии.
tagir_valeev Автор
03.11.2021 20:03В данном коде вы полностью контролируете класс и можете что угодно написать в конструкторе. В моём примере вы не контролируете класс и можете модифицировать только подкласс.
kemsky
03.11.2021 20:34Я думаю принципиальной разницы нет. Есть немало кода который куда-то передает this из конструктора до его завершения, что может привести ровно к такой же проблеме. И это не последний вариант, если унаследовать, то можно объявить сериализуемым и обойтись без вызова конструктора, можно использовать unsafe, можно Object.clone, все эти методы дают возможность обойти вызов конструктора.
Artyomcool
15.11.2021 22:49Это не вся правда. Можно конструировать объекты вообще не вызывая конструктор, если вы инстанцируете объект из класса, унаследованного от этой магии:
И схожие методы, вероятно, будут всегда, ну или как минимум ещё достаточно долго (есть и более простые, вроде clone или ObjectInputStream).
Найти способ сломать инварианты в подконтрольном вам (даже не полностью)
коде можно условно всегда.
fRoStBiT
Интересная мысль, но в Java перестали пытаться сделать "безопасность" внутри процесса JVM, ибо всё равно получается дыряво. В Java 17 уже сделали то же самое с SecurityManager, а до этого - с апплетами, JWS и прочим подобным.
Не очень понятно, от кого защищается такая "безопасная библиотека".