W3docs

Десериализация в Java

Десериализация объектов Java из байтов с помощью ObjectInputStream и проблемы безопасности.

Десериализация — это зеркальное отражение предыдущей главы: получив поток байтов, созданный ObjectOutputStream, восстановить граф объектов. API — ObjectInputStream.readObject(), и механизм для «доверенных байтов» почти так же прост, как и сторона записи. Сложность в том, что десериализация — это та часть механизма сериализации, которая имеет широко известную проблему безопасности; вторая половина этой главы посвящена именно этому.

try (ObjectInputStream in = new ObjectInputStream(
         new BufferedInputStream(Files.newInputStream(path)))) {
  User u = (User) in.readObject();                   // throws ClassNotFoundException, IOException
}

Это минимальный рецепт. Читатель получает байты, ищет каждый класс по имени в своём загрузчике классов, выделяет экземпляры без вызова конструкторов, заполняет поля через рефлексию и возвращает корень графа, приведённый к Object. Вы приводите его к ожидаемому типу.

Что возвращает readObject

Он возвращает корневой объект графа, который записал писатель. Статический возвращаемый тип — Object — читатель не может знать тип на этапе компиляции — поэтому приведение типа является частью идиомы:

Object raw = in.readObject();
if (raw instanceof User u) {                         // pattern match, recommended
  process(u);
} else {
  throw new IOException("expected User, got " + raw.getClass());
}

Проверка instanceof (или явная проверка getClass()) — это единственное место в обычном коде, где вы можете убедиться, что поток содержал именно то, что вы ожидали. Пропустите её, и специально созданный поток может передать вам объект другого типа, ваш код выбросит ClassCastException, и вы не поймёте почему.

Два проверяемых исключения

readObject объявляет два:

  • ClassNotFoundException — поток назвал класс (com.example.User), который загрузчик классов читателя не может найти. Вы записали User на диск; classpath читателя не включает User; десериализатор не может его восстановить.
  • IOException — всё остальное: усечённый поток, неверный магический заголовок, несоответствие схемы (InvalidClassException), повреждение потока (StreamCorruptedException).

Наиболее распространён случай несоответствия схемы. InvalidClassException выбрасывается, когда версия класса у читателя имеет другой serialVersionUID, чем тот, что находится в потоке, — обычно потому что класс изменился между записью и чтением, а UID не был обновлён (или был обновлён случайно). Сообщение об ошибке называет класс и оба UID — именно так вы его отлаживаете.

Конструкторы не вызываются

Это то, что удивляет всех: десериализация не вызывает конструкторы вашего класса. JDK выделяет «сырой» экземпляр класса, а затем заполняет поля напрямую через рефлексию из байтов. Любые инварианты, которые вы установили в конструкторе — поля, обязательно не равные null, проверки целых чисел в допустимом диапазоне, идемпотентная инициализация — молча обходятся стороной.

class User implements Serializable {
  private static final long serialVersionUID = 1L;
  String name;
  int age;
  User(String name, int age) {
    if (age < 0) throw new IllegalArgumentException("age >= 0");   // never runs on read
    this.name = name;
    this.age = age;
  }
}

Создайте вручную поток байтов, где age = -1, запустите readObject, и вы получите User с age == -1. Конструктор был пропущен. Если вам нужно, чтобы инвариант класса сохранялся при десериализации, добавьте хук readObject:

private void readObject(ObjectInputStream in)
    throws IOException, ClassNotFoundException {
  in.defaultReadObject();                            // do the normal field-by-field read
  if (age < 0) throw new InvalidObjectException("age must be >= 0");
}

Сигнатура точна: имя, тип параметра, список исключений. Это приватный метод, который JDK находит через рефлексию — нет интерфейса для объявления. Если вы напишете его правильно, он запустится в конце десериализации и вы получите чистую ошибку при некорректных данных.

Поля transient после чтения

Поля transientstatic) отсутствуют в потоке, поэтому читатель оставляет их со значениями по умолчанию: null для ссылок, 0 для числовых типов, false для булевых. Восстановленный объект имеет эти значения по умолчанию — таково правило из главы о сериализации, сформулированное со стороны чтения.

Для кешей это нормально. Для обязательных полей, которые вы пометили transient, чтобы не сохранять их (объект Connection, рабочий Thread, производный Map), десериализованный экземпляр находится в «неполном» состоянии до завершения инициализации. Хук readObject — подходящее место для этого:

private void readObject(ObjectInputStream in)
    throws IOException, ClassNotFoundException {
  in.defaultReadObject();
  this.cache = new ConcurrentHashMap<>();            // rebuild the transient
}

Тот же хук, другая причина — предыдущий раздел использовал его для валидации; этот использует для инициализации.

Проблема безопасности

Вот предупреждение, которое определяет современную позицию Java в отношении всего этого API: десериализация может выполнять произвольный код.

Причина: десериализация — это «создать экземпляр любого класса, названного в байтах, затем запустить его хук readObject». Многие классы в JDK и на типичном classpath имеют хуки readObject, выполняющие значимые действия — инициализировать поток, открыть файл, построить граф объектов, запускающий побочные эффекты через hashCode/equals. Тщательно созданный поток может объединить («цепочка гаджетов») вызовы readObject, которые на нужном classpath завершатся вызовом Runtime.getRuntime().exec(...).

Это не теория. Уязвимость Apache Commons Collections 2015 года (RCE), уязвимости WebSphere/JBoss/Jenkins/Weblogic 2016–2018 годов и большинство CVE, связанных с «десериализацией Java» с тех пор — это именно такой паттерн: злоумышленник передаёт вам байты; вы вызываете readObject на них; их цепочка гаджетов выполняется в вашем процессе.

Правило, выработанное на основе всего этого:

Никогда не вызывайте readObject с байтами, которые вы полностью не контролируете.

«Полностью контролируете» означает: вы их записали, на той же машине, в файл или канал, к которому никто другой не может прикоснуться. Как только байты пересекают любую границу доверия — сетевой сокет, загрузка пользователя, сообщение в очереди — ObjectInputStream является неправильным инструментом. Используйте JSON или Protocol Buffers; эти форматы не создают экземпляры классов по имени.

ObjectInputFilter: частичное смягчение

Java 9 добавила ObjectInputFilter — хук, позволяющий отклонять классы во время десериализации. Установите процессный фильтр при запуске, и любой класс вне белого списка вызовет InvalidClassException до запуска его хука readObject:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.example.*;java.util.*;!*"                   // allow these packages; reject everything else
);
ObjectInputFilter.Config.setSerialFilter(filter);

Это сужает поверхность атаки — гаджет, которому нужен класс вне белого списка, не сможет сработать. Это не делает десериализацию безопасной; гаджеты существуют внутри java.util.*, и белый список должен включать классы, которые вы не писали. Используйте это как «глубокую защиту», а не как основной контроль. Основной контроль по-прежнему — «не десериализовывать недоверенные байты».

Для нового кода ответом остаётся JSON.

Практический пример: полный цикл, эволюция и отказ

Программа ниже расширяет пример из главы о сериализации, считывая байты обратно. Она десериализует граф Department/Employee, проверяет, что обратные ссылки были восстановлены, демонстрирует возврат поля transient как null и завершается режимом отказа при несовпадении версий: поток записан с одним serialVersionUID, а читается классом с другим.

java— editable, runs on the server

Что нужно вынести из выполнения:

  • readObject() восстановил полный граф Department за один вызов. Список Employee вернулся заполненным, каждый указатель Employee.department был установлен корректно, а обратная ссылка (сотрудник → тот же экземпляр отдела) была сохранена как идентичность объекта, а не как копия. Именно это делает сериализацию «граф-образной», а не «древовидной» — JDK отслеживал, какие ссылки уже видел, и перестроил их.
  • Проверка instanceof Department d была воротами, превратившими «сырой» Object в типизированный Department. Без неё поток, содержащий объект другого типа, потерпел бы неудачу при приведении (Department) raw с ClassCastException — более неприятной и труднодиагностируемой ошибкой. Форма instanceof является идиомой.
  • Все три поля passwordHash вернулись как null. Пометка поля transient исключила его из потока; читатель не имел значения для присвоения, поэтому поле осталось на своём значении по умолчанию. Это правило из главы о сериализации, подтверждённое здесь со стороны чтения.
  • Блок несовпадения версий вызвал ожидаемое InvalidClassException: поток сказал «UID = 1», а класс сказал «UID = 2», поэтому JDK отказался создавать экземпляр. Сообщение об ошибке называет оба UID — именно так вы узнаёте, какой класс изменился. Код производственного уровня явно объявляет serialVersionUID и увеличивает его только тогда, когда изменение является несовместимым.
  • Ни в этом примере не был вызван ни один конструктор Employee или Department. Объекты появились через рефлексию, поля были заполнены напрямую. Любая валидация во время выполнения конструктора (if (salary < 0) throw ...) была обойдена; если вам нужно, чтобы она выполнялась на стороне чтения, для этого и существует хук private readObject. Практический вопрос в конце разбирает именно этот момент.

Что дальше

Сериализация и десериализация завершили потоковую сторону java.io — байты, символы и графы объектов, всё записанное в виде потоков. Следующая глава, Java NIO Overview, переходит к другому семейству API: java.nio и java.nio.file. NIO заменяет часть java.io, дополняет остальную и является домом современных классов Path и Files, которые главы о файлах уже незаметно использовали.

Практика

Практика
Инвариант класса — 'зарплата должна быть больше 0' — соблюдается в конструкторе класса `Serializable`. Злоумышленник передаёт вашему серверу сериализованный поток байтов, где поле salary закодировано как -1. Что произойдёт, когда ваш код вызовет `readObject()`?
Инвариант класса — 'зарплата должна быть больше 0' — соблюдается в конструкторе класса `Serializable`. Злоумышленник передаёт вашему серверу сериализованный поток байтов, где поле salary закодировано как -1. Что произойдёт, когда ваш код вызовет `readObject()`?
Was this page helpful?