Você pensava que dados serializados eram seguros? Pense de novo.
Há alguns anos, enquanto trabalhava com a equipe de um software na construção de um aplicativo em linguagem Java, tive o benefício de conhecer um pouco mais que um programador médio sobre a Serialização de Objeto Java.
Mais ou menos um ano antes, um desenvolvedor responsável por gerenciar as configurações por usuário do aplicativo decidiu armazená-las em uma Hashtable, depois serializar a Hashtable em disco para persistência. Quando um usuário modificava suas configurações, a Hashtable era simplesmente regravada no disco.
Esse era um sistema de configurações aberto e elegante, mas que se tornou inútil quando a equipe decidiu migrar da Hashtable para o HashMap da biblioteca Java Collections.
Os formulários de disco da Hashtable e do HashMap são diferentes e incompatíveis. Com a necessidade de executar utilitários de conversão de alguns tipos de dados sobre cada uma das configurações de usuário persistentes (uma tarefa enorme), parecia que a Hashtable seria o formato de armazenamento do aplicativo para o restante de seu tempo de vida.
A equipe sentiu-se presa, somente porque eles não conheciam algo crucial (e por algum motivo desconhecido) sobre a Serialização Java: ela foi construída para permitir a evolução dos tipos ao longo do tempo. Quando mostrei a eles como fazer a substituição automática da serialização, a transição para o HashMap ocorreu conforme foi planejado.
Neste artigo vamos revelar fatos úteis sobre a plataforma Java, coisas desconhecidas que acabam sendo úteis na resolução de desafios de programação Java. A Serialização de Objeto Java é uma ótima API para iniciar, porque ela existe desde o começo: JDK 1.1. As cinco coisas que você aprenderá sobre Serialização devem convencer-lhe a verificar duas vezes até mesmo as APIs Java padrão.
Serialização Java 101
A Serialização de Objeto Java, introduzida como parte do conjunto inovador de recursos que construiu o JDK 1.1, funciona como um mecanismo para transformar um gráfico de objetos Java em um array de bytes para armazenamento ou transmissão, e os arrays de bytes podem ser transformados posteriormente em um gráfico de objetos Java.
Basicamente, a ideia da Serialização é “congelar” o gráfico do objeto, mover o gráfico (para um disco, pela rede, tanto faz), e depois “descongelar” um gráfico novamente como objetos Java utilizáveis. Tudo isso acontece de forma mais ou menos mágica, graças às classes ObjectInputStream/ObjectOutputStream, metadados de fidelidade completa e à disposição dos programadores para “incluírem-se” nesse processo marcando suas classes com a interface de marcador Serializable.
A Listagem 1 exibe uma classe Person implementando Serializable.
Listagem 1. Person Serializável
package com.tedneward;
public class Person
implements java.io.Serializable
{
public Person(String fn, String ln, int a)
{
this.firstName = fn; this.lastName = ln; this.age = a;
}
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }
public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }
public String toString()
{
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" age=" + age +
" spouse=" + spouse.getFirstName() +
"]";
}
private String firstName;
private String lastName;
private int age;
private Person spouse;
}
Quando Person estiver serializada, é bastante simples gravar um gráfico de objeto em disco e lê-lo novamente, como é demonstrado por esse teste da unidade JUnit 4.
Listagem 2. Desserialização de Person
public class SerTest
{
@Test public void serializeToDisk()
{
try
{
com.tedneward.Person ted = new com.tedneward.Person("Ted", "Neward", 39);
com.tedneward.Person charl = new com.tedneward.Person("Charlotte",
"Neward", 38);
ted.setSpouse(charl); charl.setSpouse(ted);
FileOutputStream fos = new FileOutputStream("tempdata.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(ted);
oos.close();
}
catch (Exception ex)
{
fail("Exception thrown during test: " + ex.toString());
}
try
{
FileInputStream fis = new FileInputStream("tempdata.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
com.tedneward.Person ted = (com.tedneward.Person) ois.readObject();
ois.close();
assertEquals(ted.getFirstName(), "Ted");
assertEquals(ted.getSpouse().getFirstName(), "Charlotte");
// Clean up the file
new File("tempdata.ser").delete();
}
catch (Exception ex)
{
fail("Exception thrown during test: " + ex.toString());
}
}
}
Nada que você tenha visto até agora é novo ou instigante, é a Serialização 101, mas é um bom começo. Usaremos Person para descobrir cinco coisas que você provavelmente não sabe sobre a Serialização de Objetos Java.
01. A Serialização permite a refatoração
A Serialização permite certa quantidade de variação de classes, para que, mesmo após a refatoração, o ObjectInputStream continue a ler.
As principais coisas que a especificação da Serialização de Objetos Java pode gerenciar automaticamente são:
- Adicionar novos campos a uma classe
- Alterar os campos de estático para não estático
- Alterar os campos de temporário para não temporário
Fazer o caminho inverso (de não estático para estático ou não temporário para temporário) ou excluir campos exige massaging adicional, dependendo do grau de compatibilidade com versões anteriores que você necessitar.
Refatorando uma classe serializada
Sabendo que a Serialização permite refatoração, vejamos o que acontece quando decidimos adicionar um novo campo à classe Person.
PersonV2, mostrada na Listagem 3, introduz um campo para gênero à classe Person original.
Listagem 3. Adicionando um novo campo à Person serializada
enum Gender
{
MALE, FEMALE
}
public class Person
implements java.io.Serializable
{
public Person(String fn, String ln, int a, Gender g)
{
this.firstName = fn; this.lastName = ln; this.age = a; this.gender = g;
}
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public Gender getGender() { return gender; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }
public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setGender(Gender value) { gender = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }
public String toString()
{
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" gender=" + gender +
" age=" + age +
" spouse=" + spouse.getFirstName() +
"]";
}
private String firstName;
private String lastName;
private int age;
private Person spouse;
private Gender gender;
}
A Serialização usa um hash calculado com base em praticamente tudo em determinado arquivo de origem – nomes de métodos, nomes de campos, tipos de campo, modificadores de acesso, chame como quiser – e compara o valor do hash com o valor do hash no fluxo serializado.
Para convencer o tempo de execução Java de que os dois tipos são na verdade o mesmo, a segunda versão e as versões subsequentes de Person devem possuir o mesmo hash de versão da serialização (armazenado como o campo estático privado final serialVersionUID) que a primeira. O que precisamos, portanto, é do campo serialVersionUID, que é calculado com a execução do comando JDK serialver contra a versão original (ou V1) da classe Person.
Após obter o serialVersionUID da Person, não só podemos criar objetos PersonV2 a partir dos dados serializados originais do objeto (onde os novos campos aparecem, eles terão como valor padrão qualquer valor padrão para um campo, quase sempre “null”), mas o contrário também é verdadeiro: podemos desfazer a serialização de objetos Person originais dos dados PersonV2, sem preocupações adicionais.
02. A Serialização não é segura
Uma surpresa desagradável e bastante comum para desenvolvedores Java é que o formato binário da Serialização é totalmente documentado e reversível. Na verdade, a exportação do conteúdo do fluxo serializado binário para o console é suficiente para descobrir com que classe ele é parecido e o que ele contém.
Isso possui apenas algumas implicações preocupantes em relação à segurança. Ao fazer chamadas remotas de método via RMI, por exemplo, quaisquer campos privados nos objetos sendo enviados pelo cabo aparecem no fluxo do soquete como um texto praticamente sem formatação, o que viola claramente as mais simples preocupações com segurança.
Felizmente, a Serialização permite que “capturemos” o processo de serialização e façamos a segurança (ou ocultemos) os dados do campo, antes da serialização e depois de desfazê-la. Isso pode ser feito com o fornecimento de um método writeObject em um objeto Serializable.
Ocultando dados serializados
Supondo que os dados sensíveis na classe Person sejam do campo idade. Afinal, uma dama nunca revela sua idade e um cavalheiro nunca a conta. Podemos ocultar esses dados girando os bits uma vez para a esquerda antes da serialização, e depois girá-los de volta depois que a serialização for desfeita. (Deixarei que vocês desenvolvam um algoritmo mais seguro, esse é apenas um exemplo.)
Para “capturar” o processo de serialização, implementaremos um método writeObject em Person; e para “capturar” o processo que desfaz a serialização, implementaremos um método readObject na mesma classe. É importante obter os detalhes em ambos os casos – se o modificador, os parâmetros ou o nome de acesso forem diferentes do que é exibido na Listagem 4, o código não funcionará, e a idade de nossa Person será visível a qualquer um que olhe.
Listagem 4. Ocultando dados serializados
public class Person
implements java.io.Serializable
{
public Person(String fn, String ln, int a)
{
this.firstName = fn; this.lastName = ln; this.age = a;
}
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }
public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }
private void writeObject(java.io.ObjectOutputStream stream)
throws java.io.IOException
{
// "Encrypt"/obscure the sensitive data
age = age >> 2;
stream.defaultWriteObject();
}
private void readObject(java.io.ObjectInputStream stream)
throws java.io.IOException, ClassNotFoundException
{
stream.defaultReadObject();
// "Decrypt"/de-obscure the sensitive data
age = age << 2;
}
public String toString()
{
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" age=" + age +
" spouse=" + (spouse!=null ? spouse.getFirstName() : "[null]") +
"]";
}
private String firstName;
private String lastName;
private int age;
private Person spouse;
}
Se precisarmos ver os dados ocultos, é sempre possível olhar o arquivo/fluxo dos dados serializados. E, como o formato é completamente documentado, é possível ler o conteúdo do fluxo serializado sem que a classe esteja disponível.
03. Dados serializados podem ser assinados e lacrados
A dica anterior parte do princípio de que você quer ocultar dados serializados e não criptografá-los ou assegurar que eles não sejam modificados. Embora criptografia e gerenciamento de assinatura sejam possíveis com writeObject e readObject, existe uma maneira melhor.
Se precisar criptografar e assinar todo um objeto, a coisa mais simples a ser feita é colocá-lo em um wrapper de javax.crypto.SealedObject e/ou java.security.SignedObject Ambos são serializados, portanto, colocar um objeto em um wrapper em SealedObject cria uma espécie de “embrulho” do objeto original. É necessária uma chave simétrica para fazer a criptografia, e a chave deve ser gerenciada de maneira independente. Da mesma forma, SignedObject pode ser usado para verificação dos dados, e mais uma vez a chave simétrica deve ser gerenciada de forma independente.
Ao mesmo tempo, esses dois objetos permitem lacrar e assinar dados serializados sem que haja preocupação com os detalhes da verificação ou criptografia da assinatura digital. Ótimo, não?
04. A Serialização pode por um proxy em seu fluxo
De tempo em tempo, uma classe contém um elemento principal de dados a partir do qual o restante dos campos da classe pode ser derivado ou recuperado. Nesses casos, a serialização do objeto completo é desnecessária. É possível marcar os campos como temporários, mas a classe ainda teria que produzir claramente um código para verificar se um campo é inicializado sempre que um método acessá-lo.
Como a preocupação principal é a serialização, é melhor designar um flyweight ou proxy para introduzir no fluxo. Fornecer um método writeReplace na Person original permite que um tipo diferente de objeto seja serializado em seu lugar. De modo semelhante, se um método readResolve for encontrado durante o processo que desfaz a serialização, ele é chamado para fornecer um objeto de substituição para o responsável pela chamada.
Embalando e desembalando o proxy
Ao mesmo tempo, os métodos writeReplace e readResolve permitem que uma classe Person embale uma PersonProxy com todos seus dados (ou alguns subconjuntos núcleo dela), coloque-a no fluxo e abra a embalagem posteriormente, quando a serialização for desfeita.
Listagem 5. Você me completa, eu te substituo
class PersonProxy
implements java.io.Serializable
{
public PersonProxy(Person orig)
{
data = orig.getFirstName() + "," + orig.getLastName() + "," + orig.getAge();
if (orig.getSpouse() != null)
{
Person spouse = orig.getSpouse();
data = data + "," + spouse.getFirstName() + "," + spouse.getLastName() + ","
+ spouse.getAge();
}
}
public String data;
private Object readResolve()
throws java.io.ObjectStreamException
{
String[] pieces = data.split(",");
Person result = new Person(pieces[0], pieces[1], Integer.parseInt(pieces[2]));
if (pieces.length > 3)
{
result.setSpouse(new Person(pieces[3], pieces[4], Integer.parseInt
(pieces[5])));
result.getSpouse().setSpouse(result);
}
return result;
}
}
public class Person
implements java.io.Serializable
{
public Person(String fn, String ln, int a)
{
this.firstName = fn; this.lastName = ln; this.age = a;
}
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }
private Object writeReplace()
throws java.io.ObjectStreamException
{
return new PersonProxy(this);
}
public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }
public String toString()
{
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" age=" + age +
" spouse=" + spouse.getFirstName() +
"]";
}
private String firstName;
private String lastName;
private int age;
private Person spouse;
}
Observe que a PersonProxy precisa controlar todos os dados da Person. Muitas vezes isso significa que a proxy precisará ser uma classe interna de Person para ter acesso a campos privados. A Proxy também precisará, às vezes, localizar outras referências do objeto e serializá-las manualmente, como o cônjuge da Person.
Esse truque é um dos poucos que não precisa ser equilibrado entre leitura/gravação. Por exemplo, uma versão de uma classe que foi refatorada em um tipo diferente poderia fornecer um método readResolve para transferir um objeto serializado para um novo tipo. De modo semelhante, o método writeReplace poderia ser implementado para obter classes antigas e serializá-las em novas versões.
05. Confie, mas confirme
Seria bom supor que os dados no fluxo serializado são sempre os mesmos que foram gravados originalmente no fluxo. Porém, como um ex-presidente dos Estados Unidos afirmou certa vez, uma política mais segura é “confiar, mas confirmar.”
No caso de objetos serializados, isso significa validar os campos para garantir que eles mantenham os valores legítimos depois que a serialização seja desfeita, “só para garantir.” É possível fazer isso implementando a interface ObjectInputValidation e substituindo o método validateObject(). Se algo parecer errado quando for chamado, lançamos um InvalidObjectException.
Conclusão
A Serialização de Objetos Java é mais flexível do que a maioria dos desenvolvedores Java pensa, oferecendo muitas oportunidades de escapar de situações adversas.
Felizmente, gems de codificação como essas estão espalhadas por toda a JVM. Basta apenas conhecê-las e mantê-las acessíveis para quando um problema complicado aparecer.
Divirta-se com a Serialização! Acompanhe ainda uma série de artigos sobre Java e faça download da especificação da Serialização Java em PDF.
*
artigo publicado originalmente em IBM developerWorks, por Ted Neward
Ted Neward é o diretor da Neward & Associates, onde ele dá
consultoria, orientação, ensina e faz apresentações sobre Java, .NET,
Serviços XML e outras plataformas. Ele reside perto de Seattle,
Washington.