Десериализация в 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 после чтения
Поля transient (и static) отсутствуют в потоке, поэтому читатель оставляет их со значениями по умолчанию: 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, а читается классом с другим.
Что нужно вынести из выполнения:
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, которые главы о файлах уже незаметно использовали.