Вчера перевели в статус 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)


  1. fRoStBiT
    03.11.2021 15:33

    Интересная мысль, но в Java перестали пытаться сделать "безопасность" внутри процесса JVM, ибо всё равно получается дыряво. В Java 17 уже сделали то же самое с SecurityManager, а до этого - с апплетами, JWS и прочим подобным.

    Не очень понятно, от кого защищается такая "безопасная библиотека".


  1. kemsky
    03.11.2021 16:42
    +2

    Можно ведь сделать и без финализатора, если установить ref.set(this); прямо в конструкторе, который потом упадет.


    1. tagir_valeev Автор
      03.11.2021 17:11
      +2

      Не выйдет. Если вы роняете конструктор суперкласса, то до конструктора подкласса исполнение не дойдёт. Если же вы роняете конструктор подкласса, то суперкласс будет полностью инициализирован.


      1. 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 есть, но он в неизвестном состоянии.


        1. tagir_valeev Автор
          03.11.2021 20:03

          В данном коде вы полностью контролируете класс и можете что угодно написать в конструкторе. В моём примере вы не контролируете класс и можете модифицировать только подкласс.


          1. kemsky
            03.11.2021 20:34

            Я думаю принципиальной разницы нет. Есть немало кода который куда-то передает this из конструктора до его завершения, что может привести ровно к такой же проблеме. И это не последний вариант, если унаследовать, то можно объявить сериализуемым и обойтись без вызова конструктора, можно использовать unsafe, можно Object.clone, все эти методы дают возможность обойти вызов конструктора.


      1. Artyomcool
        15.11.2021 22:49

        Это не вся правда. Можно конструировать объекты вообще не вызывая конструктор, если вы инстанцируете объект из класса, унаследованного от этой магии:

        https://github.com/openjdk/jdk/blob/3789983e89c9de252ef546a1b98a732a7d066650/src/java.base/share/classes/jdk/internal/reflect/MagicAccessorImpl.java

        И схожие методы, вероятно, будут всегда, ну или как минимум ещё достаточно долго (есть и более простые, вроде clone или ObjectInputStream).

        Найти способ сломать инварианты в подконтрольном вам (даже не полностью)
        коде можно условно всегда.


  1. AlexanderAlexandrovich
    20.11.2021 18:24

    Ни разу не писал finalize