Deserializzazione in Java
Deserializza oggetti Java da byte con ObjectInputStream e comprendi i rischi di sicurezza della deserializzazione.
La deserializzazione è il contrario del capitolo precedente: dato uno stream di byte prodotto da ObjectOutputStream, ricostruisce il grafo di oggetti. L'API è ObjectInputStream.readObject(), e il meccanismo è — per i "byte fidati" — quasi altrettanto semplice del lato di scrittura. La complicazione è che la deserializzazione è la parte del design della serializzazione con il problema di sicurezza ampiamente pubblicizzato; la seconda metà di questo capitolo tratta proprio quello.
try (ObjectInputStream in = new ObjectInputStream(
new BufferedInputStream(Files.newInputStream(path)))) {
User u = (User) in.readObject(); // throws ClassNotFoundException, IOException
}Questa è la ricetta minimale. Il lettore riceve i byte, cerca ogni classe per nome nel proprio class loader, alloca istanze senza chiamare i loro costruttori, riempie i campi tramite reflection e restituisce la radice del grafo come Object. Lo si converte al tipo atteso.
Cosa restituisce readObject
Restituisce l'oggetto radice del grafo che lo scrittore ha scritto. Il tipo di ritorno statico è Object — il lettore non può conoscere il tipo in fase di compilazione — quindi un cast è parte dell'idioma:
Object raw = in.readObject();
if (raw instanceof User u) { // pattern match, recommended
process(u);
} else {
throw new IOException("expected User, got " + raw.getClass());
}Quel controllo instanceof (o un controllo esplicito con getClass()) è l'unico punto nel codice normale in cui è possibile verificare che lo stream contenga ciò che ci si aspettava. Ometterlo e uno stream artigianale può restituire un tipo diverso, il codice lancerà ClassCastException e non sarà chiaro il motivo.
Due eccezioni checked
readObject ne dichiara due:
ClassNotFoundException— lo stream ha nominato una classe (com.example.User) che il class loader del lettore non riesce a trovare. Si è scrittaUsersu disco; il classpath del lettore non includeUser; il deserializzatore non riesce a ricostruirla.IOException— qualsiasi altra cosa: stream troncato, intestazione magica errata, mancata corrispondenza dello schema (InvalidClassException), corruzione dello stream (StreamCorruptedException).
Il caso di mancata corrispondenza dello schema è il più comune. InvalidClassException viene lanciata quando la versione della classe del lettore ha un serialVersionUID diverso da quello nello stream — solitamente perché la classe è evoluta tra la scrittura e la lettura e l'UID non è stato aggiornato (o è stato aggiornato accidentalmente). Il messaggio nomina la classe e entrambi gli UID; è così che si effettua il debug.
I costruttori non vengono eseguiti
Questo è il punto che sorprende tutti: la deserializzazione non chiama i costruttori della classe. Il JDK alloca un'istanza grezza della classe, poi riempie i campi direttamente tramite reflection dai byte. Qualsiasi invariante stabilito nel costruttore — campi obbligatoriamente non-null, controlli interi-in-intervallo, inizializzazione idempotente — viene silenziosamente ignorato.
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;
}
}Si crea a mano uno stream di byte in cui age = -1, si esegue readObject e si otterrà un User con age == -1. Il costruttore è stato saltato. Se si ha bisogno che un invariante di classe sopravviva alla deserializzazione, bisogna aggiungere 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 è esatta: nome, tipo di parametro, lista di eccezioni. È un metodo privato che il JDK cerca tramite reflection — non c'è alcuna interfaccia da dichiarare. Se lo si scrive correttamente, viene eseguito alla fine della deserializzazione e si ottiene un fallimento pulito sui dati errati.
Campi transient dopo la lettura
I campi transient (e static) non sono nello stream, quindi il lettore li lascia ai loro valori predefiniti: null per i riferimenti, 0 per i numerici, false per i booleani. L'oggetto ricostruito ha quei valori predefiniti — questa è la regola del capitolo sulla serializzazione, espressa dal lato della lettura.
Per le cache, va bene. Per i campi obbligatori che si sono marcati transient per evitarne la persistenza (una Connection, un Thread worker, una Map derivata), l'istanza deserializzata è in uno stato "incompleto" finché non si finisce di inizializzarla. L'hook readObject è il posto giusto per farlo:
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.cache = new ConcurrentHashMap<>(); // rebuild the transient
}Stesso hook, ragione diversa — la sezione precedente lo usava per la validazione; questa lo usa per l'inizializzazione.
Il problema di sicurezza
Ecco l'avvertimento che guida la posizione del Java moderno su questa intera API: la deserializzazione può eseguire codice arbitrario.
Il motivo: la deserializzazione equivale a "istanziare qualsiasi classe che i byte nominano, poi eseguire il suo hook readObject". Molte classi nel JDK e in un tipico classpath hanno hook readObject che fanno cose significative — inizializzano un thread, aprono un file, costruiscono un grafo di oggetti che scatena effetti collaterali tramite hashCode/equals. Uno stream accuratamente costruito può concatenare (una "catena gadget") chiamate readObject che, sul classpath giusto, terminano con Runtime.getRuntime().exec(...).
Questo non è teorico. L'RCE di Apache Commons Collections del 2015, le vulnerabilità di WebSphere/JBoss/Jenkins/Weblogic del 2016–2018, e la maggior parte dei CVE della "deserializzazione Java" da allora sono esattamente questo schema: l'attaccante fornisce i byte; si chiama readObject su di essi; la loro catena gadget viene eseguita nel processo.
La regola emersa da tutto ciò:
Non chiamare mai
readObjectsu byte che non si controllano completamente.
"Controllare completamente" significa: li si è scritti, sulla stessa macchina, in un file o pipe che nessun altro può toccare. Nel momento in cui i byte attraversano qualsiasi tipo di confine di fiducia — un socket di rete, un upload utente, un messaggio in coda — ObjectInputStream è lo strumento sbagliato. Si usi JSON o Protocol Buffers; quei formati non istanziano classi per nome.
ObjectInputFilter: la mitigazione parziale
Java 9 ha aggiunto ObjectInputFilter, un hook che permette di rifiutare classi durante la deserializzazione. Impostare un filtro a livello di processo all'avvio e qualsiasi classe fuori dalla lista consentita lancia InvalidClassException prima che il suo hook readObject venga eseguito:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.*;java.util.*;!*" // allow these packages; reject everything else
);
ObjectInputFilter.Config.setSerialFilter(filter);Questo riduce la superficie di attacco — un gadget che necessita di una classe fuori dalla lista consentita non può attivarsi. Non rende la deserializzazione sicura; i gadget esistono all'interno di java.util.*, e la lista consentita deve includere classi che non si sono scritte. Va usato come difesa in profondità, non come controllo primario. Il controllo primario resta "non deserializzare byte non attendibili."
Per il nuovo codice, la risposta rimane JSON.
Un esempio pratico: round-trip, evoluzione e un fallimento
Il programma sottostante estende l'esempio del capitolo sulla serializzazione rileggendo i byte. Deserializza il grafo Department/Employee, verifica che i back-reference siano stati riconnessi, dimostra che il campo transient torna come null, e conclude con la modalità di fallimento per mancata corrispondenza della versione: uno stream scritto con un serialVersionUID e letto da una classe con uno diverso.
Cosa trarre dall'esecuzione:
readObject()ha ricostruito l'intero grafoDepartmentin una sola chiamata. La lista diEmployeeè tornata popolata, ogni puntatoreEmployee.departmentè stato impostato correttamente, e il back-reference (employee → stessa istanza department) è stato preservato come identità di oggetto, non come copia. Quest'ultimo punto è ciò che rende la serializzazione "a forma di grafo" piuttosto che "a forma di albero" — il JDK ha tracciato i riferimenti già visti e li ha ricablati.- Il controllo
instanceof Department dè stato il cancello che ha trasformato unObjectgrezzo in unDepartmenttipizzato. Senza di esso, uno stream contenente un tipo diverso avrebbe fallito al cast(Department) rawconClassCastException— più brutto e difficile da diagnosticare. La formainstanceofè l'idioma. - Tutti e tre i campi
passwordHashsono tornati comenull. Marcare il campotransientlo ha escluso dallo stream; il lettore non aveva alcun valore da assegnare, quindi il campo è rimasto al suo valore predefinito. Questa è la regola del capitolo sulla serializzazione, confermata qui nella direzione di lettura. - Il blocco di mancata corrispondenza della versione ha prodotto l'
InvalidClassExceptionattesa: lo stream diceva "UID = 1" e la classe diceva "UID = 2", quindi il JDK si è rifiutato di istanziare. Il messaggio di errore nomina entrambi gli UID — è così che si scopre quale classe è cambiata. Il codice di produzione dichiaraserialVersionUIDesplicitamente e lo aggiorna solo quando la modifica è incompatibile. - Niente in questo esempio ha chiamato alcun costruttore di
EmployeeoDepartment. Gli oggetti sono venuti in esistenza tramite reflection, con i campi riempiti direttamente. Qualsiasi validazione nel costruttore (if (salary < 0) throw ...) è stata aggirata; se la si vuole eseguire anche nel lato lettura, è a questo che serve l'hookprivate readObject. La domanda di pratica in fondo approfondisce questo punto.
Cosa viene dopo
Serializzazione e deserializzazione hanno concluso il lato streaming di java.io — byte, caratteri e grafi di oggetti, tutti scritti come stream. Il prossimo capitolo, Java NIO Overview, passa a una famiglia di API diversa: java.nio e java.nio.file. NIO sostituisce parte di java.io, ne integra il resto, ed è la casa delle moderne classi Path e Files che i capitoli sui file stavano già usando in modo discreto.