Skip to content

MASTG-TEST-0011: Teste de Memória para Dados Sensíveis

Visão Geral

A análise de memória pode ajudar os desenvolvedores a identificar as causas raiz de diversos problemas, como travamentos de aplicativos. No entanto, também pode ser usada para acessar dados sensíveis. Esta seção descreve como verificar a divulgação de dados por meio da memória do processo.

Primeiro, identifique informações sensíveis que estão armazenadas na memória. É provável que ativos sensíveis tenham sido carregados na memória em algum momento. O objetivo é verificar se essas informações são expostas pelo menor tempo possível.

Para investigar a memória de um aplicativo, você deve primeiro criar um despejo de memória. Também é possível analisar a memória em tempo real, por exemplo, por meio de um depurador. Independentemente da abordagem, o despejo de memória é um processo muito propenso a erros em termos de verificação, pois cada despejo contém a saída de funções executadas. Você pode deixar de executar cenários críticos. Além disso, é provável que dados sejam negligenciados durante a análise, a menos que você conheça a "pegada" dos dados (seja o valor exato ou o formato dos dados). Por exemplo, se o aplicativo criptografa com uma chave simétrica gerada aleatoriamente, é provável que você não consiga identificá-la na memória, a menos que possa reconhecer o valor da chave em outro contexto.

Portanto, é melhor começar com a análise estática.

Análise Estática

Ao realizar análise estática para identificar dados sensíveis expostos na memória, você deve:

  • Tentar identificar os componentes do aplicativo e mapear onde os dados são usados.
  • Garantir que dados sensíveis sejam manipulados pelo menor número possível de componentes.
  • Garantir que as referências de objetos sejam removidas adequadamente assim que o objeto que contém os dados sensíveis não for mais necessário.
  • Garantir que a coleta de lixo seja solicitada após a remoção das referências.
  • Garantir que os dados sensíveis sejam sobrescritos assim que não forem mais necessários.
    • Não represente tais dados com tipos de dados imutáveis (como String e BigInteger).
    • Evite tipos de dados não primitivos (como StringBuilder).
    • Sobrescreva referências antes de removê-las, fora do método finalize.
    • Preste atenção em componentes de terceiros (bibliotecas e frameworks). APIs públicas são bons indicadores. Determine se a API pública manipula os dados sensíveis conforme descrito neste capítulo.

A seção a seguir descreve armadilhas de vazamento de dados na memória e melhores práticas para evitá-las.

Não use estruturas imutáveis (por exemplo, String e BigInteger) para representar segredos. Anular essas estruturas será ineficaz: o coletor de lixo pode coletá-las, mas elas podem permanecer no heap após a coleta de lixo. No entanto, você deve solicitar a coleta de lixo após cada operação crítica (por exemplo, criptografia, análise de respostas do servidor que contenham informações sensíveis). Quando as cópias das informações não foram limpas adequadamente (conforme explicado abaixo), sua solicitação ajudará a reduzir o tempo durante o qual essas cópias ficam disponíveis na memória.

Para limpar adequadamente as informações sensíveis da memória, armazene-as em tipos de dados primitivos, como arrays de bytes (byte[]) e arrays de caracteres (char[]). Você deve evitar armazenar as informações em tipos de dados não primitivos mutáveis.

Certifique-se de sobrescrever o conteúdo do objeto crítico assim que o objeto não for mais necessário. Sobrescrever o conteúdo com zeros é um método simples e muito popular:

Exemplo em Java:

byte[] secret = null;
try{
    //get or generate the secret, do work with it, make sure you make no local copies
} finally {
    if (null != secret) {
        Arrays.fill(secret, (byte) 0);
    }
}

Exemplo em Kotlin:

val secret: ByteArray? = null
try {
     //get or generate the secret, do work with it, make sure you make no local copies
} finally {
    if (null != secret) {
        Arrays.fill(secret, 0.toByte())
    }
}

Isso, no entanto, não garante que o conteúdo será sobrescrito em tempo de execução. Para otimizar o bytecode, o compilador analisará e decidirá não sobrescrever os dados porque eles não serão usados posteriormente (ou seja, é uma operação desnecessária). Mesmo que o código esteja no DEX compilado, a otimização pode ocorrer durante a compilação just-in-time ou ahead-of-time na VM.

Não há uma solução perfeita para esse problema, pois diferentes soluções têm consequências diferentes. Por exemplo, você pode realizar cálculos adicionais (por exemplo, aplicar XOR nos dados em um buffer dummy), mas não há como saber a extensão da análise de otimização do compilador. Por outro lado, usar os dados sobrescritos fora do escopo do compilador (por exemplo, serializando-os em um arquivo temporário) garante que serão sobrescritos, mas obviamente impacta o desempenho e a manutenção.

Além disso, usar Arrays.fill para sobrescrever os dados é uma má ideia porque o método é um alvo óbvio de hooking (consulte Enganchamento de Métodos para mais detalhes).

O problema final com o exemplo acima é que o conteúdo foi sobrescrito apenas com zeros. Você deve tentar sobrescrever objetos críticos com dados aleatórios ou conteúdo de objetos não críticos. Isso tornará muito difícil a construção de scanners que possam identificar dados sensíveis com base em seu gerenciamento.

Abaixo está uma versão melhorada do exemplo anterior:

Exemplo em Java:

byte[] nonSecret = somePublicString.getBytes("ISO-8859-1");
byte[] secret = null;
try{
    //get or generate the secret, do work with it, make sure you make no local copies
} finally {
    if (null != secret) {
        for (int i = 0; i < secret.length; i++) {
            secret[i] = nonSecret[i % nonSecret.length];
        }

        FileOutputStream out = new FileOutputStream("/dev/null");
        out.write(secret);
        out.flush();
        out.close();
    }
}

Exemplo em Kotlin:

val nonSecret: ByteArray = somePublicString.getBytes("ISO-8859-1")
val secret: ByteArray? = null
try {
     //get or generate the secret, do work with it, make sure you make no local copies
} finally {
    if (null != secret) {
        for (i in secret.indices) {
            secret[i] = nonSecret[i % nonSecret.size]
        }

        val out = FileOutputStream("/dev/null")
        out.write(secret)
        out.flush()
        out.close()
        }
}

Para mais informações, consulte Securely Storing Sensitive Data in RAM.

Na seção "Análise Estática", mencionamos a maneira adequada de manipular chaves criptográficas ao usar AndroidKeyStore ou SecretKey.

Para uma implementação melhor de SecretKey, veja a classe SecureSecretKey abaixo. Embora a implementação provavelmente esteja faltando algum código boilerplate que tornaria a classe compatível com SecretKey, ela aborda as principais preocupações de segurança:

  • Não há manipulação de dados sensíveis entre contextos. Cada cópia da chave pode ser limpa dentro do escopo em que foi criada.
  • A cópia local é limpa de acordo com as recomendações fornecidas acima.

Exemplo em Java:

  public class SecureSecretKey implements javax.crypto.SecretKey, Destroyable {
      private byte[] key;
      private final String algorithm;

      /** Constructs SecureSecretKey instance out of a copy of the provided key bytes.
        * The caller is responsible of clearing the key array provided as input.
        * The internal copy of the key can be cleared by calling the destroy() method.
        */
      public SecureSecretKey(final byte[] key, final String algorithm) {
          this.key = key.clone();
          this.algorithm = algorithm;
      }

      public String getAlgorithm() {
          return this.algorithm;
      }

      public String getFormat() {
          return "RAW";
      }

      /** Returns a copy of the key.
        * Make sure to clear the returned byte array when no longer needed.
        */
      public byte[] getEncoded() {
          if(null == key){
              throw new NullPointerException();
          }

          return key.clone();
      }

      /** Overwrites the key with dummy data to ensure this copy is no longer present in memory.*/
      public void destroy() {
          if (isDestroyed()) {
              return;
          }

          byte[] nonSecret = new String("RuntimeException").getBytes("ISO-8859-1");
          for (int i = 0; i < key.length; i++) {
            key[i] = nonSecret[i % nonSecret.length];
          }

          FileOutputStream out = new FileOutputStream("/dev/null");
          out.write(key);
          out.flush();
          out.close();

          this.key = null;
          System.gc();
      }

      public boolean isDestroyed() {
          return key == null;
      }
  }

Exemplo em Kotlin:

class SecureSecretKey(key: ByteArray, algorithm: String) : SecretKey, Destroyable {
    private var key: ByteArray?
    private val algorithm: String
    override fun getAlgorithm(): String {
        return algorithm
    }

    override fun getFormat(): String {
        return "RAW"
    }

    /** Returns a copy of the key.
     * Make sure to clear the returned byte array when no longer needed.
     */
    override fun getEncoded(): ByteArray {
        if (null == key) {
            throw NullPointerException()
        }
        return key!!.clone()
    }

    /** Overwrites the key with dummy data to ensure this copy is no longer present in memory. */
    override fun destroy() {
        if (isDestroyed) {
            return
        }
        val nonSecret: ByteArray = String("RuntimeException").toByteArray(charset("ISO-8859-1"))
        for (i in key!!.indices) {
            key!![i] = nonSecret[i % nonSecret.size]
        }
        val out = FileOutputStream("/dev/null")
        out.write(key)
        out.flush()
        out.close()
        key = null
        System.gc()
    }

    override fun isDestroyed(): Boolean {
        return key == null
    }

    /** Constructs SecureSecretKey instance out of a copy of the provided key bytes.
     * The caller is responsible of clearing the key array provided as input.
     * The internal copy of the key can be cleared by calling the destroy() method.
     */
    init {
        this.key = key.clone()
        this.algorithm = algorithm
    }
}

Dados seguros fornecidos pelo usuário são o tipo final de informação segura geralmente encontrada na memória. Isso geralmente é gerenciado pela implementação de um método de entrada personalizado, para o qual você deve seguir as recomendações fornecidas aqui. No entanto, o Android permite que as informações sejam parcialmente apagadas dos buffers EditText por meio de uma Editable.Factory personalizada.

EditText editText = ...; //  point your variable to your EditText instance
EditText.setEditableFactory(new Editable.Factory() {
  public Editable newEditable(CharSequence source) {
  ... // return a new instance of a secure implementation of Editable.
  }
});

Consulte o exemplo SecureSecretKey acima para uma implementação de exemplo de Editable. Observe que você poderá manipular com segurança todas as cópias feitas por editText.getText se fornecer sua factory. Você também pode tentar sobrescrever o buffer interno do EditText chamando editText.setText, mas não há garantia de que o buffer já não tenha sido copiado. Se você optar por confiar no método de entrada padrão e no EditText, não terá controle sobre o teclado ou outros componentes que são usados. Portanto, você deve usar essa abordagem apenas para informações semi-confidenciais.

Em todos os casos, certifique-se de que os dados sensíveis na memória sejam limpos quando um usuário sair do aplicativo. Finalmente, certifique-se de que informações altamente sensíveis sejam limpas no momento em que o evento onPause de uma Activity ou Fragment for acionado.

Observe que isso pode significar que um usuário terá que se autenticar novamente sempre que o aplicativo for retomado.

Análise Dinâmica

A análise estática ajudará a identificar problemas potenciais, mas não pode fornecer estatísticas sobre por quanto tempo os dados ficaram expostos na memória, nem pode ajudar a identificar problemas em dependências de código fechado. É aqui que a análise dinâmica entra em cena.

Existem várias maneiras de analisar a memória de um processo, por exemplo, análise em tempo real por meio de um depurador/instrumentação dinâmica e análise de um ou mais despejos de memória.

Recuperação e Análise de um Despejo de Memória

Seja usando um dispositivo root ou não root, você pode despejar a memória do processo do aplicativo com objection e Fridump. Você pode encontrar uma explicação detalhada desse processo em Exploração de Processos, no capítulo "Tampering and Reverse Engineering on Android".

Após a memória ter sido despejada (por exemplo, em um arquivo chamado "memory"), dependendo da natureza dos dados que você está procurando, você precisará de um conjunto de ferramentas diferentes para processar e analisar esse despejo de memória. Por exemplo, se você está focado em strings, pode ser suficiente executar o comando strings ou rabin2 -zz do rabin2 para extrair essas strings.

# usando strings
$ strings memory > strings.txt

# usando rabin2
$ rabin2 -ZZ memory > strings.txt

Abra strings.txt em seu editor favorito e examine-o para identificar informações sensíveis.

No entanto, se você quiser inspecionar outros tipos de dados, é melhor usar radare2 e suas capacidades de pesquisa. Consulte a ajuda do radare2 no comando de pesquisa (/?) para obter mais informações e uma lista de opções. O seguinte mostra apenas um subconjunto delas:

$ r2 <name_of_your_dump_file>

[0x00000000]> /?
Usage: /[!bf] [arg]  Search stuff (see 'e??search' for options)
|Use io.va for searching in non virtual addressing spaces
| / foo\x00                    search for string 'foo\0'
| /c[ar]                       search for crypto materials
| /e /E.F/i                    match regular expression
| /i foo                       search for string 'foo' ignoring case
| /m[?][ebm] magicfile         search for magic, filesystems or binary headers
| /v[1248] value               look for an `cfg.bigendian` 32bit value
| /w foo                       search for wide string 'f\0o\0o\0'
| /x ff0033                    search for hex string
| /z min max                   search for strings of given size
...

Análise de Memória em Tempo de Execução

Em vez de despejar a memória para o seu computador host, você pode alternativamente usar r2frida. Com ele, você pode analisar e inspecionar a memória do aplicativo enquanto ele está em execução. Por exemplo, você pode executar os comandos de pesquisa anteriores do r2frida e pesquisar na memória por uma string, valores hexadecimais, etc. Ao fazer isso, lembre-se de prefixar o comando de pesquisa (e quaisquer outros comandos específicos do r2frida) com uma barra invertida : após iniciar a sessão com r2 frida://usb//<nome_do_seu_app>.

Para mais informações, opções e abordagens, consulte Exploração de Processos para mais detalhes.

Despejo e Análise Explícita do Heap Java

Para análise rudimentar, você pode usar as ferramentas integradas do Android Studio. Elas estão na guia Android Monitor. Para despejar memória, selecione o dispositivo e o aplicativo que deseja analisar e clique em Dump Java Heap. Isso criará um arquivo .hprof no diretório captures, que está no caminho do projeto do aplicativo.

Para navegar pelas instâncias de classe que foram salvas no despejo de memória, selecione a Visualização em Árvore de Pacotes na guia que mostra o arquivo .hprof.

Para análise mais avançada do despejo de memória, use o Eclipse Memory Analyzer Tool (MAT). Ele está disponível como um plugin do Eclipse e como um aplicativo independente.

Para analisar o despejo no MAT, use a ferramenta de plataforma hprof-conv, que vem com o Android SDK.

./hprof-conv memory.hprof memory-mat.hprof

O MAT fornece várias ferramentas para analisar o despejo de memória. Por exemplo, o Histogram fornece uma estimativa do número de objetos que foram capturados de um determinado tipo, e o Thread Overview mostra os threads dos processos e os stack frames. O Dominator Tree fornece informações sobre dependências de keep-alive entre objetos. Você pode usar expressões regulares para filtrar os resultados que essas ferramentas fornecem.

O Object Query Language studio é um recurso do MAT que permite consultar objetos do despejo de memória com uma linguagem semelhante a SQL. A ferramenta permite transformar objetos simples invocando métodos Java neles e fornece uma API para construir ferramentas sofisticadas sobre o MAT.

SELECT * FROM java.lang.String

No exemplo acima, todos os objetos String presentes no despejo de memória serão selecionados. Os resultados incluirão a classe do objeto, endereço de memória, valor e contagem de retenção. Para filtrar essas informações e ver apenas o valor de cada string, use o seguinte código:

SELECT toString(object) FROM java.lang.String object

Ou

SELECT object.toString() FROM java.lang.String object

O SQL também suporta tipos de dados primitivos, então você pode fazer algo como o seguinte para acessar o conteúdo de todos os arrays de char:

SELECT toString(arr) FROM char[] arr

Não se surpreenda se obtiver resultados semelhantes aos anteriores; afinal, String e outros tipos de dados Java são apenas wrappers em torno de tipos de dados primitivos. Agora vamos filtrar os resultados. O seguinte código de exemplo selecionará todos os arrays de bytes que contêm o OID ASN.1 de uma chave RSA. Isso não implica que um determinado array de bytes realmente contenha uma RSA (a mesma sequência de bytes pode fazer parte de outra coisa), mas é provável.

SELECT * FROM byte[] b WHERE toString(b).matches(".*1\.2\.840\.113549\.1\.1\.1.*")

Finalmente, você não precisa selecionar objetos inteiros. Considere uma analogia com SQL: classes são tabelas, objetos são linhas e campos são colunas. Se você quiser encontrar todos os objetos que têm um campo "password", pode fazer algo como o seguinte:

SELECT password FROM ".*" WHERE (null != password)

Durante sua análise, procure por:

  • Nomes de campos indicativos: "password", "pass", "pin", "secret", "private", etc.
  • Padrões indicativos (por exemplo, pegadas de RSA) em strings, arrays de char, arrays de bytes, etc.
  • Segredos conhecidos (por exemplo, um número de cartão de crédito que você inseriu ou um token de autenticação fornecido pelo backend)
  • etc.

Repetir testes e despejos de memória ajudará você a obter estatísticas sobre o tempo de exposição dos dados. Além disso, observar a maneira como um segmento de memória específico (por exemplo, um array de bytes) muda pode levar você a alguns dados sensíveis não reconhecíveis de outra forma (mais sobre isso na seção "Remediação" abaixo).