W3docs

Deserialización en Java

Deserializa objetos Java desde bytes con ObjectInputStream y comprende los riesgos de seguridad de la deserialización.

La deserialización es el espejo del capítulo anterior: dado un flujo de bytes producido por ObjectOutputStream, reconstruye el grafo de objetos. La API es ObjectInputStream.readObject(), y el mecanismo es — para "bytes de confianza" — casi tan sencillo como el lado de escritura. La complicación es que la deserialización es la parte del diseño de serialización con el conocido problema de seguridad; la segunda mitad de este capítulo trata sobre eso.

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

Esa es la receta mínima. El lector ve los bytes, busca cada clase por nombre en su propio cargador de clases, asigna instancias sin llamar a sus constructores, rellena los campos mediante reflexión y devuelve la raíz del grafo convertida a Object. Tú la conviertes al tipo que esperas.

Lo que devuelve readObject

Devuelve el objeto raíz del grafo que escribió el escritor. El tipo de retorno estático es Object — el lector no puede conocer el tipo en tiempo de compilación — por lo que una conversión de tipo es parte del idioma:

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

Esa comprobación con instanceof (o una comprobación explícita con getClass()) es el único lugar en código normal donde puedes verificar que el flujo contenía lo que esperabas. Omítela y un flujo manipulado puede entregarte un tipo diferente, tu código lanzará ClassCastException y no tendrás idea del por qué.

Dos excepciones verificadas

readObject declara dos:

  • ClassNotFoundException — el flujo nombró una clase (com.example.User) que el cargador de clases del lector no puede encontrar. Escribiste User en disco; el classpath del lector no incluye User; el deserializador no puede reconstruirla.
  • IOException — cualquier otra cosa: flujo truncado, encabezado mágico incorrecto, falta de coincidencia de esquema (InvalidClassException), corrupción del flujo (StreamCorruptedException).

El caso de falta de coincidencia de esquema es el más común. InvalidClassException se lanza cuando la versión de la clase del lector tiene un serialVersionUID diferente al que está en el flujo — generalmente porque la clase evolucionó entre la escritura y la lectura y el UID no se actualizó (o se actualizó accidentalmente). El mensaje nombra la clase y ambos UIDs; así es como lo depuras.

Los constructores no se ejecutan

Esta es la parte que sorprende a todos: la deserialización no llama a los constructores de tu clase. El JDK asigna una instancia en bruto de la clase, luego rellena los campos directamente mediante reflexión desde los bytes. Cualquier invariante que estableciste en el constructor — campos que no deben ser nulos, comprobaciones de enteros en rango, inicialización idempotente — se omite silenciosamente.

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;
  }
}

Crea manualmente un flujo de bytes donde age = -1, ejecuta readObject y obtendrás un User con age == -1. El constructor fue omitido. Si necesitas que un invariante de clase sobreviva a la deserialización, debes añadir un hook 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");
}

La firma es exacta: nombre, tipo de parámetro, lista de excepciones. Es un método privado que el JDK busca mediante reflexión — no hay ninguna interfaz que declarar. Si lo escribes correctamente, se ejecuta al final de la deserialización y obtienes un fallo limpio ante datos incorrectos.

Campos transient tras la lectura

Los campos transient (y static) no están en el flujo, por lo que el lector los deja en sus valores predeterminados: null para referencias, 0 para numéricos, false para booleanos. El objeto reconstruido tiene esos valores predeterminados — esa es la regla del capítulo de serialización, expresada desde el lado de la lectura.

Para cachés, está bien. Para los campos requeridos que marcaste como transient para evitar persistirlos (una Connection, un Thread trabajador, un Map derivado), la instancia deserializada está en un estado "incompleto" hasta que termines de inicializarla. El hook readObject es el lugar para hacerlo:

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

Mismo hook, razón diferente — la sección anterior lo usó para validación; esta lo usa para inicialización.

El problema de seguridad

Esta es la advertencia que impulsa la postura de Java moderno sobre toda esta API: la deserialización puede ejecutar código arbitrario.

El motivo: la deserialización consiste en "instanciar cualquier clase que los bytes nombren, luego ejecutar su hook readObject." Muchas clases en el JDK y en un classpath típico tienen hooks readObject que hacen cosas significativas — inicializar un hilo, abrir un archivo, construir un grafo de objetos que desencadena efectos secundarios mediante hashCode/equals. Un flujo cuidadosamente elaborado puede encadenar (una "cadena de gadgets") llamadas a readObject que, con el classpath correcto, terminan con Runtime.getRuntime().exec(...).

Esto no es teórico. El RCE de Apache Commons Collections de 2015, las vulnerabilidades de WebSphere/JBoss/Jenkins/Weblogic de 2016–2018, y la mayoría de los CVE de "deserialización Java" desde entonces son exactamente este patrón: el atacante te da bytes; tú llamas a readObject sobre ellos; su cadena de gadgets se ejecuta en tu proceso.

La regla que surgió de todo esto:

Nunca llames a readObject con bytes que no controlas completamente.

"Controlar completamente" significa: tú los escribiste, en la misma máquina, en un archivo o pipe que nadie más puede tocar. En el momento en que los bytes cruzan cualquier tipo de límite de confianza — un socket de red, una carga de usuario, un mensaje en cola — ObjectInputStream es la herramienta equivocada. Usa JSON o Protocol Buffers; esos formatos no instancian clases por nombre.

ObjectInputFilter: la mitigación parcial

Java 9 añadió ObjectInputFilter, un hook que permite rechazar clases durante la deserialización. Establece un filtro a nivel de proceso al inicio y cualquier clase fuera de la lista de permitidos lanza InvalidClassException antes de que se ejecute su hook readObject:

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

Esto reduce la superficie de ataque — un gadget que necesita una clase fuera de la lista de permitidos no puede activarse. No hace que la deserialización sea segura; existen gadgets dentro de java.util.*, y la lista de permitidos debe incluir clases que tú no escribiste. Úsalo como defensa en profundidad, no como control primario. El control primario sigue siendo "no deserialices bytes que no sean de confianza."

Para código nuevo, la respuesta sigue siendo JSON.

Un ejemplo completo: ida y vuelta, evolución y un fallo

El programa a continuación extiende el ejemplo del capítulo de serialización leyendo los bytes de vuelta. Deserializa el grafo Department/Employee, verifica que las referencias inversas se reconectaron, demuestra que el campo transient vuelve como null y termina con el modo de fallo por discordancia de versiones: un flujo escrito con un serialVersionUID y leído por una clase con uno diferente.

java— editable, runs on the server

Lo que se puede extraer de la ejecución:

  • readObject() reconstruyó el grafo completo de Department en una sola llamada. La lista de Employees volvió poblada, cada puntero Employee.department se estableció correctamente, y la referencia inversa (empleado → misma instancia de departamento) se preservó como identidad de objeto, no como copia. Ese último punto es lo que hace que la serialización tenga "forma de grafo" en lugar de "forma de árbol" — el JDK registró qué referencias había visto y las reconectó.
  • La comprobación instanceof Department d fue la puerta que convirtió un Object en bruto en un Department tipado. Sin ella, un flujo que contuviera un tipo diferente habría fallado en la conversión (Department) raw con ClassCastException — más feo y más difícil de diagnosticar. La forma con instanceof es el idioma.
  • Los tres campos passwordHash volvieron como null. Marcar el campo como transient lo excluyó del flujo; el lector no tenía ningún valor que asignar, por lo que el campo se quedó en su valor predeterminado. Esa es la regla del capítulo de serialización, confirmada aquí en la dirección de lectura.
  • El bloque de discordancia de versiones produjo la InvalidClassException que debes esperar: el flujo decía "UID = 1" y la clase decía "UID = 2", por lo que el JDK se negó a instanciar. El mensaje de error nombra ambos UIDs — así es como descubres qué clase se desvió. El código de nivel de producción declara serialVersionUID explícitamente y lo actualiza solo cuando el cambio es incompatible.
  • Nada en este ejemplo llamó a ningún constructor de Employee o Department. Los objetos cobraron existencia mediante reflexión, con los campos rellenados directamente. Cualquier validación en tiempo de construcción (if (salary < 0) throw ...) fue omitida; si necesitas que se ejecute en el lado de la lectura, para eso existe el hook private readObject. La pregunta de práctica al final profundiza en ese punto.

Qué sigue

La serialización y la deserialización cerraron el lado de streaming de java.io — bytes, caracteres y grafos de objetos, todo escrito como flujos. El siguiente capítulo, Java NIO Overview, da un paso hacia una familia de API diferente: java.nio y java.nio.file. NIO reemplaza parte de java.io, complementa el resto, y es el hogar de las modernas clases Path y Files que los capítulos relacionados con archivos ya han estado usando discretamente.

Práctica

Práctica
Un invariante de clase — 'el salario debe ser mayor que 0' — se aplica en el constructor de una clase `Serializable`. Un atacante entrega a tu servidor un flujo de bytes serializado donde el campo de salario está codificado como -1. ¿Qué ocurre cuando tu código llama a `readObject()`?
Un invariante de clase — 'el salario debe ser mayor que 0' — se aplica en el constructor de una clase `Serializable`. Un atacante entrega a tu servidor un flujo de bytes serializado donde el campo de salario está codificado como -1. ¿Qué ocurre cuando tu código llama a `readObject()`?
Was this page helpful?