Desserialização em Java
Desserialize objetos Java a partir de bytes com ObjectInputStream e entenda os problemas de segurança da desserialização.
A desserialização é o espelho do capítulo anterior: dado um fluxo de bytes produzido pelo ObjectOutputStream, reconstruir o grafo de objetos. A API é ObjectInputStream.readObject(), e o mecanismo é — para "bytes confiáveis" — quase tão simples quanto o lado da escrita. A complicação é que a desserialização é a parte do design de serialização com o conhecido problema de segurança; a segunda metade deste capítulo trata exatamente disso.
try (ObjectInputStream in = new ObjectInputStream(
new BufferedInputStream(Files.newInputStream(path)))) {
User u = (User) in.readObject(); // throws ClassNotFoundException, IOException
}Essa é a receita mínima. O leitor vê os bytes, busca cada classe pelo nome em seu próprio class loader, aloca instâncias sem chamar seus construtores, preenche os campos por reflexão e retorna a raiz do grafo convertida para Object. Você faz o cast para o tipo que espera.
O que readObject retorna
Ele retorna o objeto raiz do grafo que o escritor escreveu. O tipo de retorno estático é Object — o leitor não pode conhecer o tipo em tempo de compilação — então um cast faz parte do idioma:
Object raw = in.readObject();
if (raw instanceof User u) { // pattern match, recommended
process(u);
} else {
throw new IOException("expected User, got " + raw.getClass());
}Essa verificação com instanceof (ou uma verificação explícita com getClass()) é o único lugar no código normal onde você pode confirmar que o fluxo continha o que você esperava. Omita-a e um fluxo manipulado pode entregar um tipo diferente; seu código lançará ClassCastException, e você não saberá por quê.
Duas exceções verificadas
readObject declara duas:
ClassNotFoundException— o fluxo nomeou uma classe (com.example.User) que o class loader do leitor não consegue encontrar. Você escreveuUserno disco; o classpath do leitor não incluiUser; o desserializador não consegue reconstruí-la.IOException— qualquer outra coisa: fluxo truncado, cabeçalho mágico errado, incompatibilidade de esquema (InvalidClassException), corrupção do fluxo (StreamCorruptedException).
O caso de incompatibilidade de esquema é o mais comum. InvalidClassException é lançada quando a versão da classe do leitor tem um serialVersionUID diferente do que está no fluxo — geralmente porque a classe evoluiu entre a escrita e a leitura e o UID não foi atualizado (ou foi atualizado acidentalmente). A mensagem nomeia a classe e ambos os UIDs; é assim que você depura o problema.
Construtores não são chamados
Esta é a parte que surpreende a todos: a desserialização não chama os construtores da sua classe. O JDK aloca uma instância bruta da classe e preenche os campos diretamente via reflexão a partir dos bytes. Qualquer invariante que você estabeleceu no construtor — campos obrigatoriamente não nulos, verificações de inteiros em intervalo, inicialização idempotente — é silenciosamente ignorado.
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;
}
}Crie manualmente um fluxo de bytes onde age = -1, execute readObject, e você obterá um User com age == -1. O construtor foi ignorado. Se você precisa que um invariante de classe sobreviva à desserialização, adicione um 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");
}A assinatura é exata: nome, tipo do parâmetro, lista de exceções. É um método privado que o JDK busca por reflexão — não há interface para declarar. Se você o escrever corretamente, ele será executado ao final da desserialização e você obterá uma falha limpa em dados inválidos.
Campos transient após a leitura
Campos transient (e static) não estão no fluxo, então o leitor os mantém em seus valores padrão: null para referências, 0 para numéricos, false para booleanos. O objeto reconstruído terá esses padrões — essa é a regra do capítulo de serialização, vista agora do lado da leitura.
Para caches, isso é aceitável. Para campos obrigatórios que você marcou como transient para evitar persistência (uma Connection, uma Thread de trabalho, um Map derivado), a instância desserializada está em um estado "incompleto" até que você termine de inicializá-la. O hook readObject é o lugar para fazer isso:
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.cache = new ConcurrentHashMap<>(); // rebuild the transient
}Mesmo hook, razão diferente — a seção anterior o usou para validação; esta o usa para inicialização.
O problema de segurança
Aqui está o aviso que norteia a posição do Java moderno sobre toda essa API: a desserialização pode executar código arbitrário.
A razão: desserialização significa "instanciar qualquer classe nomeada pelos bytes e executar seu hook readObject." Muitas classes no JDK e em um classpath típico têm hooks readObject que fazem coisas significativas — inicializar uma thread, abrir um arquivo, construir um grafo de objetos que desencadeia efeitos colaterais via hashCode/equals. Um fluxo cuidadosamente criado pode encadear (uma "gadget chain") chamadas readObject que, no classpath certo, terminam com Runtime.getRuntime().exec(...).
Isso não é teórico. O RCE do Apache Commons Collections de 2015, as vulnerabilidades do WebSphere/JBoss/Jenkins/Weblogic de 2016–2018 e a maioria dos CVEs de "desserialização Java" desde então são exatamente esse padrão: o atacante fornece bytes; você chama readObject neles; a gadget chain é executada no seu processo.
A regra que surgiu de tudo isso:
Nunca chame
readObjectem bytes que você não controla totalmente.
"Controlar totalmente" significa: você os escreveu, na mesma máquina, em um arquivo ou pipe que ninguém mais pode tocar. No momento em que os bytes cruzam qualquer tipo de fronteira de confiança — um socket de rede, um upload de usuário, uma mensagem de fila — ObjectInputStream é a ferramenta errada. Use JSON ou Protocol Buffers; esses formatos não instanciam classes por nome.
ObjectInputFilter: a mitigação parcial
O Java 9 adicionou ObjectInputFilter, um hook que permite rejeitar classes durante a desserialização. Defina um filtro para todo o processo na inicialização e qualquer classe fora da lista de permissões lançará InvalidClassException antes que seu hook readObject seja executado:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.*;java.util.*;!*" // allow these packages; reject everything else
);
ObjectInputFilter.Config.setSerialFilter(filter);Isso reduz a superfície de ataque — um gadget que precisa de uma classe fora da lista de permissões não pode ser ativado. Isso não torna a desserialização segura; gadgets existem dentro de java.util.*, e a lista de permissões precisa incluir classes que você não escreveu. Use-o como defesa em profundidade, não como controle primário. O controle primário ainda é "não desserialize bytes não confiáveis."
Para código novo, a resposta continua sendo JSON.
Um exemplo completo: round-trip, evolução e uma falha
O programa abaixo estende o exemplo do capítulo de serialização lendo os bytes de volta. Ele desserializa o grafo Department/Employee, verifica que as referências reversas foram reconectadas, demonstra o campo transient voltando como null, e termina com o modo de falha por incompatibilidade de versão: um fluxo escrito com um serialVersionUID e lido por uma classe com um diferente.
O que observar na execução:
readObject()reconstruiu o grafo completo deDepartmentem uma única chamada. A lista deEmployees voltou populada, cada ponteiroEmployee.departmentfoi definido corretamente, e a referência reversa (funcionário → mesma instância de departamento) foi preservada como identidade de objeto, não uma cópia. Esse último ponto é o que torna a serialização "em formato de grafo" em vez de "em formato de árvore" — o JDK rastreou quais referências havia visto e as reconectou.- A verificação
instanceof Department dfoi a porta que transformou umObjectbruto em umDepartmenttipado. Sem ela, um fluxo contendo um tipo diferente teria falhado no cast(Department) rawcomClassCastException— mais feio e mais difícil de diagnosticar. A formainstanceofé o idioma recomendado. - Todos os três campos
passwordHashvoltaram comonull. Marcar o campo comotransiento excluiu do fluxo; o leitor não tinha valor para atribuir, então o campo ficou no seu padrão. Essa é a regra do capítulo de serialização, confirmada aqui na direção da leitura. - O bloco de incompatibilidade de versão produziu a
InvalidClassExceptionesperada: o fluxo dizia "UID = 1" e a classe dizia "UID = 2," então o JDK recusou instanciar. A mensagem de erro nomeia ambos os UIDs — é assim que você descobre qual classe mudou. Código de nível de produção declaraserialVersionUIDexplicitamente e o incrementa apenas quando a mudança é incompatível. - Nada neste exemplo chamou qualquer construtor de
EmployeeouDepartment. Os objetos vieram à existência via reflexão, com os campos preenchidos diretamente. Qualquer validação em tempo de construção (if (salary < 0) throw ...) foi ignorada; se você precisar que ela seja executada no lado da leitura, é para isso que serve o hookprivate readObject. A questão prática abaixo reforça esse ponto.
O que vem a seguir
Serialização e desserialização encerraram o lado de streaming do java.io — bytes, caracteres e grafos de objetos, todos escritos como fluxos. O próximo capítulo, Java NIO Overview, avança para uma família de APIs diferente: java.nio e java.nio.file. NIO substitui parte do java.io, complementa o restante, e é o lar das classes modernas Path e Files que os capítulos relacionados a arquivos já estavam usando silenciosamente.