Por Bernardo Melo

Introdução

Quando questionado por alguns membros do time de Consultoria Técnica da Tempest sobre qual tema escolheria para a última etapa do programa de estágio — uma pesquisa sobre um determinado tema de segurança —, inicialmente não sabia como responder. Entre sugestões e temas escolhidos por outros estagiários, senti que gostaria de abordar algum tópico que já tivesse encontrado, previamente, mas ainda fosse suficientemente desafiador. Então, dentre algumas das ideias sugeridas por integrantes do time, optei por desserialização insegura, abordando casos do problema em aplicações projetadas na linguagem Java.

Incluída no OWASP Top10 pela primeira vez na edição de 2017, falhas de desserialização insegura são caracterizadas por resultarem em danos severos nas aplicações que as contém, podendo levar a problemas como execução de código remoto e escrita/leitura de arquivos arbitrários, além da quebra de regras de negócio específicas à aplicação.

Mas que “diabos” é desserialização insegura, e quais problemas a vulnerabilidade representa para aplicações projetadas em Java nos dias de hoje?

Para responder a essas perguntas, primeiramente precisamos entender alguns conceitos importantes.

O que é desserialização?

No contexto de linguagens de programação, serialização é o nome dado ao processo em que um objeto é convertido em uma sequência de bytes, uma stream, com o intuito de ser armazenado ou transmitido. A partir de um objeto em memória, obtemos uma representação deste, que preserva seu estado, e possibilita-o de ser reconstruído em um momento futuro.

Analogamente, a desserialização consiste no processo contrário, no qual um objeto é reconstruído dentro de um programa em execução, a partir da informação presente em uma stream.

Serialização e desserialização são constantemente utilizadas em aplicações web projetadas em PHP, .NET e Java, entre outras tecnologias. Muitas vezes, elas desempenham papel crucial para o funcionamento das aplicações, seja transmitindo informações serializadas do usuário ou armazenando um determinado objeto para uso posterior.

Serialização em Java

A linguagem Java dá suporte ao processo de serialização nativamente: ela conta com duas interfaces específicas, que são implementadas pelas classes a serem serializadas, e responsáveis por serializar e desserializar os objetos desejados. São elas:

Serializable – Interface padrão, não possui nenhum método em sua assinatura, e define quais classes utilizam o protocolo de serialização. Nela, a lógica de serialização é de responsabilidade da JVM, e o desenvolvedor não tem controle sobre a mesma. A seguir temos a assinatura da interface em questão:

Externalizable – Interface que estende a interface Serializable, e transfere a responsabilidade da lógica de serialização para o desenvolvedor, que precisa implementar os métodos que serão responsáveis por realizar a serialização/desserialização. A seguir a implementação nativa da mesma:

Comumente podemos encontrar o processo de serialização em aplicações Java, em cenários como parâmetros de requests HTTP, valores de cookies, entre outros. Além disso, a API de invocação de métodos remotamente (Remote Method Invocation, ou RMI) também faz uso do protocolo de serialização em sua comunicação.

Certo, mas como exatamente esse processo ocorre?

Serialização e desserialização – Como funcionam?

Após incluir a interface Serializable na definição de uma classe, é possível fazer chamadas a métodos específicos para serializar ou desserializar um objeto pertencente a ela. Tais métodos são:

Java.io.ObjectOutputStream.writeObject, para serialização,

Java.io.ObjectInputSteram.readObject, para desserialização.

Além disso, alguns métodos específicos podem ser definidos pelas classes que implementam uma das interfaces discutidas anteriormente: os Magic Methods.

Magic Methods, o que são?

Definidos dentro das classes que implementam uma das interfaces discutidas, Magic Methods são métodos especiais, que permitem com que o programador insira lógica adicional, que virá a ser executada durante o processo.

Dentre os vários Magic Methods existentes, destacam-se os métodos writeObject e readObject, comumente utilizados para adicionar funcionalidades aos processos de serialização e desserialização.

Vale destacar que, como podemos observar a partir de suas assinaturas, esses métodos não tratam dos mesmos métodos discutidos na seção anterior (sim, é confuso mesmo!). Embora possuam os mesmos nomes, os Magic Methods são métodos chamados durante a serialização, e são implementados pelo programador. Já os métodos writeObject e readObject, discutidos previamente, são invocados para dar início ao processo de serialização e desserialização, e são chamados em outra parte do código da aplicação.

Também vale comentar que a serialização é efetivamente realizada pelos Magic Methods writeExternal e readExternal, que devem ser implementados pelo programador quando a interface utilizada é a Externalizable. Dentro de suas definições, é possível utilizar os métodos das interfaces ObjectOutput e ObjectInput para definir o comportamento da lógica serialização.

Serialização – Exemplos

Abaixo, temos um exemplo de um objeto sendo serializado. Nele, podemos observar como o método writeObject é invocado para iniciar o processo.

Na imagem a seguir, temos a definição da classe User, que será serializada. Observe que ela implementa a interface Serializable:

Podemos observar seus atributos, que já possuem valores definidos por fins demonstrativos.

Em seguida, vemos o trecho de código onde o método writeObject é chamado, passando o objeto user como parâmetro. As informações contidas nele são escritas no objeto oos, da classe ObjectOutputStream, que, por sua vez, é instanciado a partir de um objeto da classe FileOutputStream.

Após o término do processo, obtemos a stream representada na imagem abaixo. Seus bytes são agrupados em conjuntos, e representam informações importantes, como nomes, valores de atributos e metadados, que servirão de referência para reconstruir o objeto posteriormente.

Um detalhe importante é que os primeiros bytes, ac ed 00 05, são sempre os mesmos, pois tratam-se de um conjunto de flags que têm como intuito indicar o início de um objeto serializado em Java. Tal característica é muito conveniente no momento de identificar objetos Java serializados em requisições HTTP, onde geralmente encontram-se codificados em Base64. Nesse formato, os bytes iniciais assumem a forma de “rO0AB”.

Ufa! Após todas essas informações, finalmente temos o conhecimento necessário para entender o problema em si.

Então… O que exatamente é desserialização insegura?

Desserialização insegura – O que é?

Desserialização insegura de objetos é o nome dado quando uma aplicação desserializa dados não confiáveis, passíveis de serem modificados pelo usuário. Isso cria a oportunidade de que tais dados sejam construídos de maneira maliciosa, permitindo com que um atacante altere o funcionamento da aplicação sob certas circunstâncias.

De maneira geral, as falhas de desserialização insegura em Java podem ser separadas em dois tipos:

– falhas onde valores de estruturas já presentes no objeto serializado são modificadas, resultando na possibilidade de ataques relacionados a controle de acesso, quebra de regras de negócio, etc.

– falhas onde o atacante se aproveita de classes acessíveis pela aplicação para alterar o comportamento da desserialização a ser realizada. Falhas desse tipo tendem a ser bastantes severas, podendo criar problemas que variam desde leitura ou escrita arbitrária de arquivos, até mesmo execução de comandos, em casos mais graves. É importante salientar que uma classe não precisa necessariamente estar sendo utilizada pela aplicação para que um objeto que pertença a ela seja desserializado. Qualquer classe disponível na aplicação faz parte da superfície de ataque, desde que possa ser desserializada, ou seja, desde que implemente uma das interfaces discutidas anteriormente.

Tá, mas como isso acontece?

Lembra que os Magic Methods dão liberdade para que desenvolvedores insiram lógica adicional na desserialização? Esses métodos serão invocados durante o processo em si. Sabendo disso, atacantes podem alterar o objeto a ser desserializado que é passado como entrada, de forma a criar uma cadeia de invocações de métodos já presentes na aplicação (os famosos gadgets), que podem acabar comprometendo-a severamente caso o processo seja realizado sem os devidos cuidados.

Gadgets – O que são?

Resumidamente, gadgets são trechos de código já presentes na aplicação que auxiliam um atacante a alcançar um determinado objetivo. Eles não são necessariamente danosos, podendo ter como função passar a entrada recebida para um outro método.

O problema surge quando um atacante consegue concatenar uma série de gadgets, resultando na passagem de dados maliciosos até um último gadget, que será responsável por causar o dano real. Estes são conhecidos como sink gadgets.

Sink gadgets podem, por exemplo, executar comandos arbitrários a partir de sua entrada, ou realizar escrita em arquivos. Ao conjunto de gadgets concatenados com o intuito de explorar alguma falha, damos o nome de gadget chain.

Gadgets e Gadget Chains: Exemplos (e exercícios!)

Agora, que tal dar uma olhada em alguns exemplos?

Abaixo, temos alguns cenários onde uma aplicação se encontra vulnerável a falhas de desserialização insegura. Será que você consegue identificá-los?

Exemplo 1 – Regras (de negócio) foram feitas para serem quebradas

Suponhamos que estejamos testando uma aplicação. Durante o teste, percebemos que uma requisição, como o exemplo abaixo, foi realizada:

É possível perceber que o cookie user possui como valor uma string em um formato que já vimos antes: ele tem rO0AB como seus caracteres iniciais! Isso sugere fortemente que estamos lidando com um objeto Java serializado. Podemos confirmar nossa suspeita sem muita dificuldade. Para isso, decodificamos a string encontrada, que atualmente está em base64.

Obtemos a seguinte string como resultado:

De fato, o valor de user se trata de um objeto Java serializado! Vamos analisar o objeto um pouco melhor com a ajuda de um hex editor:

A partir da análise da stream encontrada, podemos inferir atributos pertencentes a classe que originou o objeto serializado. Vejamos:

Em amarelo, temos os nomes dos campos serializados: Idade, userID e nome. Já em vermelho, temos seus valores: 26, 3793 e “Bob

Caso reconstruíssemos a classe que serializou a stream acima, levando em consideração os tipos de cada campo, teríamos algo similar a imagem abaixo:

Agora que sabemos a estrutura da classe, que tipo de alterações poderíamos realizar no objeto serializado que causaria algum tipo de impacto no funcionamento da aplicação?

Exemplo 2 – De elo em elo

Você foi contratado para realizar um code review em uma aplicação financeira construída em Java, com objetivo de encontrar possíveis falhas de segurança. Durante o teste, você se depara com a seguinte classe:

Você foi informado pelos desenvolvedores que a aplicação recebe um objeto serializado, pertencente à classe acima, via um parâmetro de uma requisição HTTP, que parte do navegador dos usuários.

Por se tratar de uma aplicação financeira, no qual a segurança é crucial, os desenvolvedores decidiram realizar checagens adicionais no objeto recebido de seus usuários. Para isso, implementaram o Magic Method readObject, chamado durante a desserialização. Nele, o método queryDB() é invocado, onde uma consulta ao banco de dados é realizada, com o intuito de completar as devidas checagens de validação do usuário.

Logo após a consulta, o método run() do objeto logger também é invocado, para que as operações realizadas sejam registradas e possam ser analisadas posteriormente em caso de incidentes de segurança.

logger é do tipo Runnable, uma interface. Em orientação a objetos, objetos que fazem parte de uma estrutura hierárquica podem receber instâncias de qualquer Classe/Interface de sua estrutura. Dessa forma, qualquer objeto de uma classe que implemente a interface em questão pode ser atribuído a uma variável complexa (neste caso, um objeto do tipo Runnable).

A assinatura da interface Runnable pode ser vista na imagem abaixo:

Como é possível observar, a interface possui apenas um método: run().

Na aplicação, o objeto logger costuma ser instanciado como um objeto da classe UserLog, que faz parte de uma biblioteca importada pela aplicação. Sua definição é mostrada abaixo:

Percebendo que a classe User desempenha um papel de alta importância na aplicação, você realiza algumas anotações e decide prosseguir com a revisão.

Alguns dias depois, enquanto continua o code review, você acaba se deparando com a seguinte classe:

Sabendo que a classe acima está no mesmo código do projeto em revisão, o que um eventual atacante poderia fazer com isso? E mais importante, como seria o payload malicioso?

Desserialização insegura – Medidas de mitigação

Finalmente, o que deveríamos fazer para mitigar problemas como os mostrados acima?

A resposta é simples: não desserializar objetos de origens que não sejam totalmente confiáveis!

Contudo, isso nem sempre é possível. Muitas aplicações foram projetadas de maneira que a desserialização de objetos, que podem ser modificados por usuários, não é somente inevitável, mas também essencial. Alterar esse funcionamento implicaria em realizar modificações estruturais profundas, que muitas vezes acabam sendo limitadas pelas tecnologias utilizadas na construção da própria aplicação.

Já sei! Então, como alternativa deveríamos encontrar e eliminar os gadgets da aplicação, certo?

Não!

Embora possa fazer sentido em um primeiro momento, “caçar” gadgets não é uma boa ideia, pois não ataca a raiz do problema: desserializar objetos não confiáveis. Novos gadgets estão sendo descobertos a todo momento, e não encontrar gadgets conhecidos em sua aplicação não necessariamente significa que ela está livre da desserialização insegura de objetos.
Fora isso, projetos de grande porte possuem centenas de bibliotecas e milhares de classes, e examinar cada uma delas minuciosamente em busca de gadgets é um trabalho extremamente demorado, ineficiente e propenso a erros.

Diante de tudo isso, temos algumas alternativas:

  • Restringir a desserialização: por padrão, a ObjectInputStream desserializa qualquer classe que implementa a interface Serializable. É possível alterar esse comportamento por meio da utilização de allow/deny lists, de forma que apenas as classes desejadas possam ser desserializadas. Isso pode ser feito de algumas formas: Implementando uma nova classe que herda de ObjectInputStream e alterando sua implementação de ResolveClass, método chamado internamente no início da desserialização, ou utilizando bibliotecas já existentes que oferecem funcionalidades parecidas, como a SerialKiller e a SafeObjectInputStream. Após isso, chamadas a ObjectInputStream no código devem então ser substituídas por chamadas a uma das novas classes. Abordagens desse tipo são conhecidas como look-ahead deserialization.
  • Implementação de checagem de integridade: uma outra alternativa é, caso possível, utilizar mecanismos de checagem de integridade, como assinaturas digitais, para garantir que a stream a ser desserializada não tenha sido modificada desde sua criação.
  • Executar o código que realiza a desserialização em ambientes de privilégio mais baixo: essa técnica ajuda a restringir a severidade de problemas que podem surgir caso a desserialização ocorra em ambientes de alto privilégio.

Além de tudo isso, deve-se também monitorar operações de desserialização na aplicação por meio de logging, permitindo que erros, problemas e atividades suspeitas sejam analisadas posteriormente caso necessário.

Por último, vale ressaltar que as técnicas aqui descritas não são soluções definitivas, e a abordagem ideal envolveria empregar múltiplas técnicas em conjunto, com intuito de minimizar as chances de ocorrência de problemas.

Conclusão

É isso! Espero que este (não tão breve!) artigo tenha contribuído para o entendimento desse problema, que impactou bastante a internet por volta de 2015, mas que ainda continua longe de estar extinto.

Referências

Abaixo, deixo alguns links interessantes para aqueles que desejam ir um pouco mais a fundo:

CONTRAST-SECURITY-OSS. Contrast-rO0 – SafeObjectInputStream. Disponível em: https://github.com/Contrast-Security-OSS/contrast-rO0. Acesso em: 21 jun. 2023.

FROHOFF, Chris; LAWRENCE, Gabriel. Marshalling Pickles – Chris Frohoff & Gabriel Lawrence – OWASP AppSec California 2015. Disponível em: https://www.youtube.com/watch?v=KSA7vUkXGSg. Acesso em: 21 jun. 2023.

FROHOFF, Chris. OWASP SD: Deserialize my shorts. Disponível em: https://frohoff.github.io/owaspsd-deserialize-my-shorts/. Acesso em: 21 jun. 2023.

FROHOFF, Chris. Ysoserial. Disponível em: https://github.com/frohoff/ysoserial. Acesso em: 21 jun. 2023.

IKKISOFT. SerialKiller. Disponível em: https://github.com/ikkisoft/SerialKiller. Acesso em: 21 jun. 2023.

ORACLE. Especificação do protocolo de serialização de objetos do Java. Disponível em: https://docs.oracle.com/javase/8/docs/platform/serialization/spec/serialTOC.html. Acesso em: 21 jun. 2023.

PORT SWIGGER. Web Security Academy – Insecure Deserialization. Disponível em: https://portswigger.net/web-security/deserialization. Acesso em: 21 jun. 2023.