Descompilar um aplicativo ou biblioteca tem como resultado um código-fonte que é obtido a partir do bytecode Java. Portanto, temos a inversão do processo de compilação, onde geramos o bytecode a partir do código-fonte.

Quando falamos em descompilar, normalmente pensa-se que é para fazer algo maldoso, como copiar um código-fonte proprietário e fazer algo similar, burlar algum software permitindo uso ilimitado, entre outros. No entanto, existem boas razões para que possamos descompilar um recurso como: recuperarmos um código que foi acidentalmente perdido, aprender sobre a implementação de algum recurso, solucionar problemas de uma aplicação ou uma biblioteca que não tenha uma boa documentação, corrigir algum bug urgente em um código de terceiro na qual não temos o código-fonte ou aprender a proteger o nosso código contra ataques hacker.

Mesmo que tenhamos a intensão de fazer algo bom na descompilação de um recurso, devemos atentar para o fato que alguns fornecedores não permitem que isso seja feito nos seus softwares. Dessa forma, devemos sempre verificar o acordo de licenciamento ou obter uma permissão explícita do fornecedor, ou então poderemos estar fazendo alguma ilegalidade.

Os ofuscadores de código são um meio de proteger a propriedade intelectual presente em programas Java. O ofuscamento de código é uma área específica de conhecimento que utiliza técnicas e ferramentas para atender às necessidades de proteção à propriedade intelectual que está presente no código-fonte em qualquer aplicação de software.

No restante do artigo veremos mais detalhes sobre o processo de descompilação e ofuscamento de código.

Confira os cursos de Java da DevMedia e aprenda mais sobre a linguagem que mais cresce no mercado.

Descompiladores

Para decompilar um recuso devemos possuir as ferramentas mais apropriadas para isso, de forma que o descompilador produza um código-fonte tão bom quanto o código-fonte original. Existem diferentes tipos de descompiladores, tanto gratuitos quanto comerciais.

Os descompiladores gratuitos normalmente são excelentes opções oferecendo suporte às construções mais avançadas da linguagem como classes internas ou classes anônimas. Vale ressaltar que o formato do bytecode tem sido bastante estável desde o JDK 1.1, porém mesmo assim é muito importante utilizarmos um descompilador que tem sido frequentemente atualizado pelos autores. Isso por que os novos recursos da linguagem incorporados no JDK mais atual exigirão atualizações nos descompiladores, por isso é sempre interessante verificarmos a data da distribuição da versão do descompilador que estamos utilizando.

Entre os descompiladores temos o JAD que é gratuito e uma excelente opção de descompilador. O descompilador JAD é bastante confiável, sofisticado e muito rápido, além de oferecer suporte completo a recursos avançados da linguagem. O código gerado pelo JAD é bastante limpo e os imports são bem organizados.

O JODE é outra excelente opção de descompilador sendo também gratuito e distribuído sob a licença pública GNU. Além de ser um descompilador muito bom, ele foi escrito em Java e seu código-fonte está disponível na plataforma Souceforge. Apesar do JODE não ser tão popular e rápido quando o JAD ele produz resultados muito claros, por vezes mais claros que o JAD.

Por fim, outro descompilador que também é gratuito é o Mocha, que foi o primeiro descompilador mais bem conhecido, produzindo excelentes resultados. O Mocha não tem sido atualizado por um bom tempo, mas a Borland introduziu esse descompilador no JBuilder dando continuidade ao descompilador apenas na IDE.

Embora existam outros descompiladores no mercado, inclusive comerciais, o JAD e o JODE, atendem bem às necessidades dos desenvolvedores. Essas ferramentas contam com algumas interfaces gráficas amigáveis como Defafe, Dj, Cavaj, entre outros. Essas ferramentas oferecem a interface gráfica e rodam o JAD ou JODE por trás que faz efetivamente o processo de descompilação.

Para exemplificarmos a qualidade dessas ferramentas de descompilação considere o código fonte da Listagem 1.


import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import javax.crypto.Cipher;
 
 
public class EncriptaDecriptaRSA {
 
  public static final String ALGORITHM = "RSA";
 
  /**
   * Local da chave privada no sistema de arquivos.
   */
  public static final String PATH_CHAVE_PRIVADA = "C:/keys/private.key";
 
  /**
   * Local da chave pública no sistema de arquivos.
   */
  public static final String PATH_CHAVE_PUBLICA = "C:/keys/public.key";
 
  /**
   * Gera a chave que contém um par de chave Privada e Pública usando 1025 bytes.
   * Armazena o conjunto de chaves nos arquivos private.key e public.key
   */
  public static void geraChave() {
    try {
      final KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM);
      keyGen.initialize(1024);
      final KeyPair key = keyGen.generateKeyPair();
 
      File chavePrivadaFile = new File(PATH_CHAVE_PRIVADA);
      File chavePublicaFile = new File(PATH_CHAVE_PUBLICA);
 
      // Cria os arquivos para armazenar a chave Privada e a chave Publica
      if (chavePrivadaFile.getParentFile() != null) {
        chavePrivadaFile.getParentFile().mkdirs();
      }
      
      chavePrivadaFile.createNewFile();
 
      if (chavePublicaFile.getParentFile() != null) {
        chavePublicaFile.getParentFile().mkdirs();
      }
      
      chavePublicaFile.createNewFile();
 
      // Salva a Chave Pública no arquivo
      ObjectOutputStream chavePublicaOS = new ObjectOutputStream(
          new FileOutputStream(chavePublicaFile));
      chavePublicaOS.writeObject(key.getPublic());
      chavePublicaOS.close();
 
      // Salva a Chave Privada no arquivo
      ObjectOutputStream chavePrivadaOS = new ObjectOutputStream(
          new FileOutputStream(chavePrivadaFile));
      chavePrivadaOS.writeObject(key.getPrivate());
      chavePrivadaOS.close();
    } catch (Exception e) {
      e.printStackTrace();
    }
 
  }
 
  /**
   * Verifica se o par de chaves Pública e Privada já foram geradas.
   */
  public static boolean verificaSeExisteChavesNoSO() {
 
    File chavePrivada = new File(PATH_CHAVE_PRIVADA);
    File chavePublica = new File(PATH_CHAVE_PUBLICA);
 
    if (chavePrivada.exists() && chavePublica.exists()) {
      return true;
    }
    
    return false;
  }
 
  /**
   * Criptografa o texto puro usando chave pública.
   */
  public static byte[] criptografa(String texto, PublicKey chave) {
    byte[] cipherText = null;
    
    try {
      final Cipher cipher = Cipher.getInstance(ALGORITHM);
      // Criptografa o texto puro usando a chave Púlica
      cipher.init(Cipher.ENCRYPT_MODE, chave);
      cipherText = cipher.doFinal(texto.getBytes());
    } catch (Exception e) {
      e.printStackTrace();
    }
    
    return cipherText;
  }
 
  /**
   * Decriptografa o texto puro usando chave privada.
   */
  public static String decriptografa(byte[] texto, PrivateKey chave) {
    byte[] dectyptedText = null;
    
    try {
      final Cipher cipher = Cipher.getInstance(ALGORITHM);
      // Decriptografa o texto puro usando a chave Privada
      cipher.init(Cipher.DECRYPT_MODE, chave);
      dectyptedText = cipher.doFinal(texto);
 
    } catch (Exception ex) {
      ex.printStackTrace();
    }
 
    return new String(dectyptedText);
  }
 
  /**
   * Testa o Algoritmo
   */
  public static void main(String[] args) {
 
    try {
 
      // Verifica se já existe um par de chaves, caso contrário gera-se as chaves..
      if (!verificaSeExisteChavesNoSO()) {
         // Método responsável por gerar um par de chaves usando o algoritmo RSA e
         // armazena as chaves nos seus respectivos arquivos.
        geraChave();
      }
 
      final String msgOriginal = "Exemplo de mensagem";
      ObjectInputStream inputStream = null;
 
      // Criptografa a Mensagem usando a Chave Pública
      inputStream = new ObjectInputStream(new FileInputStream(PATH_CHAVE_PUBLICA));
      final PublicKey chavePublica = (PublicKey) inputStream.readObject();
      final byte[] textoCriptografado = criptografa(msgOriginal, chavePublica);
 
      // Decriptografa a Mensagem usando a Chave Pirvada
      inputStream = new ObjectInputStream(new FileInputStream(PATH_CHAVE_PRIVADA));
      final PrivateKey chavePrivada = (PrivateKey) inputStream.readObject();
      final String textoPuro = decriptografa(textoCriptografado, chavePrivada);
 
      // Printing the Original, Encrypted and Decrypted Text
      System.out.println("Mensagem Original: " + msgOriginal);
      System.out.println("Mensagem Criptografada: " +textoCriptografado.toString());
      System.out.println("Mensagem Decriptografada: " + textoPuro);
 
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}
Listagem 1. Código de exemplo que encripta e descripta mensagens utilizando RSA

Após compilarmos a classe apresentada utilizamos o JAD para descompilar essa classe. Podemos verificar na Listagem 2 o código descompilado e como ele fica próximo do código originalmente criado no exemplo anterior.


import java.io.*;
import java.security.*;
import javax.crypto.Cipher;
 
public class EncriptaDecriptaRSA
{
 
    public EncriptaDecriptaRSA()
    {
    }
 
    public static void geraChave()
    {
        try
        {
            KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
            keyGen.initialize(1024);
            KeyPair key = keyGen.generateKeyPair();
            File chavePrivadaFile = new File("C:/keys/private.key");
            File chavePublicaFile = new File("C:/keys/public.key");
            if(chavePrivadaFile.getParentFile() != null)
                chavePrivadaFile.getParentFile().mkdirs();
            chavePrivadaFile.createNewFile();
            if(chavePublicaFile.getParentFile() != null)
                chavePublicaFile.getParentFile().mkdirs();
            chavePublicaFile.createNewFile();
            ObjectOutputStream chavePublicaOS = 
            new ObjectOutputStream(new FileOutputStream(chavePublicaFile));
            chavePublicaOS.writeObject(key.getPublic());
            chavePublicaOS.close();
            ObjectOutputStream chavePrivadaOS = 
            new ObjectOutputStream(new FileOutputStream(chavePrivadaFile));
            chavePrivadaOS.writeObject(key.getPrivate());
            chavePrivadaOS.close();
        }
        catch(Exception e)
        {
            e.printStackTrace();
        }
    }
 
    public static boolean verificaSeExisteChavesNoSO()
    {
        File chavePrivada = new File("C:/keys/private.key");
        File chavePublica = new File("C:/keys/public.key");
        return chavePrivada.exists() && chavePublica.exists();
    }
 
    public static byte[] criptografa(String texto, PublicKey chave)
    {
        byte cipherText[] = null;
        try
        {
            Cipher cipher = Cipher.getInstance("RSA");
            cipher.init(1, chave);
            cipherText = cipher.doFinal(texto.getBytes());
        }
        catch(Exception e)
        {
            e.printStackTrace();
        }
        return cipherText;
    }
 
    public static String decriptografa(byte texto[], PrivateKey chave)
    {
        byte dectyptedText[] = null;
        try
        {
            Cipher cipher = Cipher.getInstance("RSA");
            cipher.init(2, chave);
            dectyptedText = cipher.doFinal(texto);
        }
        catch(Exception ex)
        {
            ex.printStackTrace();
        }
        return new String(dectyptedText);
    }
 
    public static void main(String args[])
    {
        try
        {
            if(!verificaSeExisteChavesNoSO())
                geraChave();
            String msgOriginal = "Exemplo de mensagem";
            ObjectInputStream inputStream = null;
            inputStream = new ObjectInputStream(new FileInputStream("C:/keys/public.key"));
            PublicKey chavePublica = (PublicKey)inputStream.readObject();
            byte textoCriptografado[] = criptografa("Exemplo de mensagem", chavePublica);
            inputStream = new ObjectInputStream(new FileInputStream("C:/keys/private.key"));
            PrivateKey chavePrivada = (PrivateKey)inputStream.readObject();
            String textoPuro = decriptografa(textoCriptografado, chavePrivada);
            System.out.println("Mensagem Original: Exemplo de mensagem");
            System.out.println((new StringBuilder("Mensagem Criptografada: "))
            .append(textoCriptografado.toString()).toString());
            System.out.println((new StringBuilder("Mensagem Decriptografada: "))
            .append(textoPuro).toString());
        }
        catch(Exception e)
        {
            e.printStackTrace();
        }
    }
 
    public static final String ALGORITHM = "RSA";
    public static final String PATH_CHAVE_PRIVADA = "C:/keys/private.key";
    public static final String PATH_CHAVE_PUBLICA = "C:/keys/public.key";
}
Listagem 2. Código de exemplo anterior descompilado

Podemos verificar que o código é praticamente igual, mas algumas coisas que mudam é a ordem e formatação das variáveis e métodos, sendo que a lógica é praticamente a mesma. Um detalhe que podemos verificar é que os comentários foram perdidos.

Processo de Descompilação

O código-fonte do Java não é compilado para código de máquina como ocorre com C e C++. A compilação do código-fonte Java produz um bytecode intermediário, que se trata de uma representação do código-fonte independente de plataforma. Este bytecode pode ser interpretado ou compilado depois do carregamento, nesse ponto teremos a transformação da linguagem de programação de alto nível no código de máquina de baixo nível. Nesse passo intermediário é que o processo de descompilação é realizado tornando-a quase perfeita. O bytecode inclui todas as informações necessárias e significativas encontradas no código-fonte. Como o bytecode não representa a linguagem de máquina de mais baixo nível, o formato do código é bastante similar ao do código-fonte. A especificação da JVM define um conjunto de instruções que correspondem a operadores e palavras-chave da linguagem Java. Obviamente que a linguagem do bytecode é diferente da linguagem Java, mas há uma boa semelhança entre ambas.

Segue na Listagem 3 um trecho de código em Java.


public String getDisplayName() { 
         return getUserName() + " (" + getHostName() + ")";
}
Listagem 3. Código em linguagem Java

O código da Listagem 3 é representado pelo bytecode da Listagem 4.


new #4 <java/lang/StringBuffer>
dup
aload_0
invokevirtual #5 <convertjava/decompile/MessageInfoComplex.getUserName>
invokestatic #6 <java/lang/String.valueOf>
invokestatic #6 <java/lang/String.valueOf>
invokespecial #7 <java/lang/StringBuffer.<init>>
ldc #8 > (>
invokevirtual #9 <java/lang/StringBuffer.append>
aload_0
invokevirtual #10 <convertjava/decompile/MessageInfoComplex.getHostName>
invokevirtual #9 <java/lang/StringBuffer.append>
ldc #11 <)>
invokevirtual #9 <java/lang/StringBuffer.append>
invokestatic #6 <java/lang/String.valueOf>
invokestatic #6 <java/lang/String.valueOf>
areturn
Listagem 4. Código em bytecode

O descompilador carrega o bytecode e tenta reconstruir o código-fonte com base nas instruções do bytecode. Em geral, os nomes dos métodos e variáveis de classe são preservados, porém os nomes dos parâmetros do método e variáveis locais são perdidos.

Quando precisamos corrigir algum código de um terceiro a melhor forma é sempre procurarmos por strings, que podem ser descobertas nos arquivos de log da aplicação ou através dos logs do servidor de aplicação. Essa abordagem apesar de simples torna mais fácil a localização de um código responsável por uma funcionalidade. Isso é possível porque o bytecode armazena as strings como textos simples tornado assim possível pesquisar por uma string em todos os arquivos ".class". Um exemplo é quando nosso código lança uma exceção, por exemplo, com o texto "nome de usuário inválido". Utilizando essa string poderíamos percorrer todos os códigos para encontrar esse ponto do código. Também podemos fazer isso pesquisando labels da interface gráfica, uma string que é exibida na página após um processamento, entre outros.

No entanto, algumas exceções podem não possuir mensagens, o que dificulta um pouco o trabalho. Nesse caso, a melhor forma de encontrarmos o código é através da análise da pilha de chamadas. Os sistemas operacionais utilizam uma pilha para monitorar as chamadas de método, portanto, se o método A chama um método B, as informações de A são colocadas na pilha, se B chamar C posteriormente, as informações de B serão colocadas na pilha, e assim por diante. À medida que cada método retorna, a pilha é utilizada para determinar o método que será responsável por retomar a execução. Dessa forma, podemos utilizar a pilha de chamadas por meio de um depurador chamando printStackTrace() na exceção ou utilizando o método Thread.dumpStack(). Por fim, uma prática bastante eficaz é utilizarmos o rastreamento que consiste em gravar mensagens de depuração em um stream de saída durante a execução de uma aplicação. Entre as informações gravadas temos as operações realizadas pela aplicação em execução, a data e hora da execução, entre outros. As mensagens de rastreamento são gravadas em um arquivo de log. Atualmente existem duas APIs de logging dominantes para Java, primeiramente a Log4j da Apache que foi iniciada em 1999 pela IBM, e a segunda mais conhecida é a Java Logging API da Sun que não possui tantos recursos quanto a Log4j, mas também é uma excelente opção.

Geralmente podemos descompilar um código e recompilar, porém em algumas situações isso não é possível. Isso pode ocorrer, pois o bytecode pode ter sido ofuscado e os nomes atribuídos pelo ofuscador resultarem em uma ambiguidade na compilação. Um código ofuscado não se assemelha nem um pouco com o código-fonte e, além disso, o JAD produz um código-fonte que não pode ser compilado.

Ofuscando o código

Muitos fornecedores fazem a engenharia reversa de produtos dos concorrentes para tentar assimilar ou aprender como funciona um determinado produto do concorrente e muitas vezes imitam ou copiam esse código para produzir um produto similar. Porém, ao analisar esses produtos que sofreram engenharia reversa podemos verificar a falta de cuidado, às vezes mínima, que existe contra a descompilação.

O ofuscamento é um processo de transformar o bytecode em uma forma menos legível por humanos, dificultando assim a engenharia reversa. Esse processo consiste em remover informações relacionadas a depuração como tabelas de variáveis, número de linhas e renomear os pacotes, classes e métodos. As informações de depuração ajudam a depurar o código em execução, essas informações inseridas pelo javac podem conter informações como números de linhas, nomes de variáveis e nomes de arquivos fontes. Apesar das informações de depuração não serem necessárias para executar a classe, elas são utilizadas pelos depuradores para associar o bytecode ao código-fonte, o que ajuda na descompilação do código tornando-o muito semelhante ao original.

Alguns ofuscadores mais avançados alteram o fluxo de controle do código, reestruturando assim a lógica existente e inserindo códigos falsos que não funcionam, porém essas transformações não podem comprometer a validade do bytecode e a funcionalidade exposta.

Os ofuscadores carregam arquivos de classes Java, analisam os formatos desses arquivos e aplicam transformações com base nos recursos suportados. Após aplicar essas transformações, o bytecode é salvo como um novo arquivo de classe que possui uma estrutura interna diferente, mas comporta-se como o arquivo original.

Os ofuscadores não são utilizados apenas para Java, mas também para outras tecnologias em que a lógica de implementação está abertamente disponível para os usuários como o HTML e o JavaScript.

Ferramentas para Ofuscação

Entre os ofuscadores disponíveis temos o Klassmaster, Proguard, Retro Guard, Dash-O e JShrink. O Klassmaster e o Retro Guard são pagos, enquanto que o Proguard, Dash-O e JShrink são gratuitos.

O Klassmaster é o mais recomendado entre os pagos, oferecendo diversas funcionalidades como remoção de informações de depuração, desfiguração de nomes, codificação de strings, inserção de códigos corrompidos, eliminação de códigos não utilizados, otimização do bytecode, entre outras funcionalidades. Além disso, é o único que oferece alteração do fluxo de controle. Portanto, se tivermos disponível cerca de 200 dólares podemos investir nessa excelente ferramenta para realizar o ofuscamento de código.

O Proguard é a opção de ofuscador recomendado entre os gratuitos. Esta ferramenta oferece funcionalidades como remoção de informações de depuração, desfiguração de nomes e eliminação de código não utilizado. Essa ferramenta não oferece funcionalidades como codificação de strings, alteração de fluxo e inserção de código corrompido.

Apesar das ferramentas de ofuscação fazerem um bom trabalho em geral, devemos ter cuidado ao utiliza-las, pois em algumas situações podemos ter problemas gerados pelo ofuscamento. Entre eles devemos atentar para as classes carregadas dinamicamente utilizando Class.forName() ou ClassLoader.loadClass(), passando o nome original da classe que não está ofuscada. Quando utilizamos Reflection também podemos ter problemas, visto que esta requer conhecimento em tempo de compilação dos nomes de métodos e campos. Além disso, podemos ter problemas com serialização e violação de padrões do EJB que exige métodos com nomes e assinaturas específicas.

Os problemas mencionados acima podem ser um bom caminho quando desejamos encontrar uma determinada funcionalidade em um código ofuscado. Esses nomes de classes que não podem ser alterados e também os arquivos que estão no sistema podem ser bons caminhos que ajudariam a encontrar um código de terceiro que apresenta algum problema e que precisaríamos modificar.

Bibliografia: