W3docs

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ê escreveu User no disco; o classpath do leitor não inclui User; 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 readObject em 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.

java— editable, runs on the server

O que observar na execução:

  • readObject() reconstruiu o grafo completo de Department em uma única chamada. A lista de Employees voltou populada, cada ponteiro Employee.department foi 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 d foi a porta que transformou um Object bruto em um Department tipado. Sem ela, um fluxo contendo um tipo diferente teria falhado no cast (Department) raw com ClassCastException — mais feio e mais difícil de diagnosticar. A forma instanceof é o idioma recomendado.
  • Todos os três campos passwordHash voltaram como null. Marcar o campo como transient o 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 InvalidClassException esperada: 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 declara serialVersionUID explicitamente e o incrementa apenas quando a mudança é incompatível.
  • Nada neste exemplo chamou qualquer construtor de Employee ou Department. 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 hook private 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.

Prática

Prática
Um invariante de classe — 'o salário deve ser maior que 0' — é aplicado no construtor de uma classe `Serializable`. Um atacante envia ao seu servidor um fluxo de bytes serializado onde o campo salary está codificado como -1. O que acontece quando seu código chama `readObject()`?
Um invariante de classe — 'o salário deve ser maior que 0' — é aplicado no construtor de uma classe `Serializable`. Um atacante envia ao seu servidor um fluxo de bytes serializado onde o campo salary está codificado como -1. O que acontece quando seu código chama `readObject()`?
Was this page helpful?