Investigação de um arquivo desconhecido

Realocação. Nova cidade. Procura de emprego. Mesmo para um profissional de TI, isso pode levar muito tempo. Uma série de entrevistas, que, em geral, são muito semelhantes entre si. E como normalmente acontece quando você já encontra um emprego, depois de um tempo, um escritório interessante é anunciado.

Era difícil entender o que ela estava fazendo especificamente, no entanto, sua área de interesse era o estudo de software de outras pessoas. Parece intrigante, embora quando você percebe que esse não parece um fornecedor que libera software para segurança cibernética, você pára por um segundo e começa a coçar os nabos.



Resumindo: eles descartaram o arquivo e se ofereceram para examiná-lo como uma tarefa de teste e tentar calcular uma certa assinatura com base nos dados de entrada apresentados. Vale a pena notar que eu tinha muito pouca experiência em tais atividades e, provavelmente, é por isso que na primeira iteração da solução eu só tinha algumas horas - então a motivação para fazer isso não deu em nada. E sim, claro, a primeira coisa que tentei executá-lo no telefone / emulador - esse aplicativo é inválido.

O que temos: um arquivo com a extensão ".apk" . Coloquei a tarefa por baixo do spoiler para que não fosse indexada pelos mecanismos de busca: e se os caras não gostarem, eu coloco a solução no Habr?

Tarefa em si
O APK contém funcionalidade para gerar assinaturas para uma matriz associativa.
Tente obter uma assinatura para o seguinte conjunto de dados:

{ "user" : "LeetD3vM4st3R", "password": "__s33cr$$tV4lu3__", "hash": "34765983265937875692356935636464" } 


Arregaçar as mangas


Diz-se que o arquivo contém a funcionalidade de assinar uma matriz associativa. Pela extensão do arquivo, entendemos imediatamente que estamos lidando com um aplicativo escrito para Android. Primeiro, descompactamos o arquivo. De fato, este é um arquivo ZIP comum, e qualquer arquivador irá lidar com ele de ânimo leve. Usei o utilitário apktool e, como se viu, ignorou acidentalmente alguns ancinhos. Sim, acontece (geralmente o oposto, sim?). O feitiço é bem simples:

 apktool d task.zip 

Acontece que o código e os recursos no arquivo apk também são armazenados compactados em binários separados e será necessário outro software para extraí-los. O apktool retirou implicitamente os bytes da classe, os recursos e decompôs tudo em uma hierarquia de arquivos natural. Você pode prosseguir.

 ├── AndroidManifest.xml ├── apktool.yml ├── lib │   └── arm64-v8a ├── original │   ├── AndroidManifest.xml │   └── META-INF ├── res │   ├── anim │   ├── color │   ├── drawable │   ├── layout │   ├── layout-watch-v20 │   ├── mipmap-anydpi-v26 │   ├── values │   └── values-af ├── smali │   ├── android │   ├── butterknife │   ├── com │   ├── net │   └── org └── unknown   └── org 

Vemos uma hierarquia semelhante (deixou sua versão simplificada) e estamos tentando descobrir por onde começar. Vale a pena notar que, no entanto, uma vez eu escrevi alguns aplicativos pequenos para Android, portanto, a essência de parte dos diretórios e, em geral, os princípios dos aplicativos para dispositivos Android, são aproximadamente claros para mim.

Para começar, decido apenas "percorrer" os arquivos. Abro o AndroidManifest.xml e começo a ler de forma significativa. Minha atenção é atraída por um atributo estranho

 android:supportsRtl="true" 

Acontece que ele é responsável pelo suporte a idiomas com a letra "da direita para a esquerda" no aplicativo. Começamos a nos esforçar. Não é bom

Além disso, meu olhar se apega à pasta desconhecida. Abaixo dele, há uma hierarquia no formato: org.apache.commons.codec.language.bm e um grande número de arquivos de texto com conteúdo obscuro. Pesquise no Google o nome completo do pacote e o que está armazenado aqui, algo relacionado ao algoritmo de busca por palavras foneticamente semelhantes ao dado. Francamente, aqui comecei a me esforçar mais. Tendo vasculhado um pouco os diretórios, encontrei o código em si e a diversão começou. Fui recebido não pelo código de código Java habitual, com o qual consegui brincar, mas por outra coisa. Muito parecido, mas diferente.

Como se viu, o Android tem sua própria máquina virtual - Dalvik. E, como toda máquina virtual respeitada, ela tem seu próprio bytecode. Parece que na primeira tentativa de resolver esse problema, foi com essa triste nota que anunciei o intervalo, fiz uma reverência, abaixei a cortina e joguei tudo por 4 meses até que minha curiosidade me acabasse completamente.

Arregace as mangas [2]


"Mas não é possível que tudo fique mais fácil?" - Essa é a pergunta que me fiz quando iniciei a tarefa pela segunda vez. Comecei a pesquisar na internet um descompilador de smali para Java. Vi apenas que é impossível realizar esse processo sem ambiguidade. Franzindo a testa um pouco, ele foi ao Github e colocou algumas frases-chave na linha de pesquisa. O primeiro veio smali2java .

 git clone gradle build java -jar smali2java.jar .. 

Erros Vejo um enorme rastreamento de pilha e erros em várias páginas do terminal. Depois de ler um pouco sobre a essência do conteúdo (e restringir as emoções pelo tamanho do rastreamento da pilha), acho que essa ferramenta funciona com base em uma certa gramática descrita e o código de código que ela conheceu claramente não corresponde a ela. Abro o bytecode smali e vejo anotações, métodos sintéticos e outras construções estranhas nele. Não havia tal coisa no bytecode Java! Quanto tempo? Excluir!

Mais detalhes
A máquina virtual Dalvik (assim como a JVM), como se viu, não está ciente da existência de conceitos como classes internas / externas (leia classes aninhadas), e o compilador gera os chamados métodos "sintéticos" para fornecer acesso da classe aninhada para campos externos, por exemplo.

Como exemplo:


Se a classe externa (OuterClass) tiver um campo

 public class OuterClass { List a; ... } 

Para que a classe privada possa acessar o campo da classe externa, o compilador gerará implicitamente o seguinte método:

 static synthetic java.util.List getList(OuterClass p1) { p1 = p1.a; return p1; } 

Além disso, devido a essa cozinha de “compartimento do motor”, o trabalho de alguns outros mecanismos que a linguagem fornece é alcançado.

Você pode começar a estudar essa questão em mais detalhes a partir daqui .

Não ajuda Ele até jura por um código de código aparentemente não suspeito. Abro o código-fonte do descompilador, leio e vejo algo muito estranho: mesmo programadores hindus (com todo o respeito) não teriam escrito isso. Um pensamento surge: não é realmente o código gerado. Rejeito a ideia por cerca de 30 minutos, tentando entender qual é o erro. COMPLICADO. Abro o Github novamente - e realmente, um analisador de gramática. E aqui está o próprio gerador. Pondo tudo de lado e tentando se aproximar do outro lado.

Vale a pena notar que, um pouco mais tarde, ainda tentei mudar a gramática e, em alguns lugares, até o próprio bytecode para que o descompilador ainda pudesse digeri-lo. Mas mesmo quando o bytecode se tornou válido em termos de gramática do descompilador, o programa simplesmente não retornou nada para mim. Código aberto ...

Folheio o bytecode e tropeço em constantes desconhecidas para mim. No Google, encontro o mesmo no livro sobre aplicativos reversos para Android. Lembro que esse é apenas o ID atribuído pelo pré-processador do compilador, atribuído aos recursos do aplicativo Android (a constante de tempo de gravação de código é R. *). Na próxima meia hora - hora, examinarei brevemente quais registros são responsáveis ​​por quê, em que ordem os argumentos são passados ​​e, em geral, nos aprofundamos na sintaxe.

Como é isso?


Encontrei o layout da janela principal do aplicativo e, a partir dele, já entendi o que estava acontecendo no aplicativo: na tela principal (Activity) existe um RecyclerView (condicionalmente, um View que pode reutilizar objetos da interface do usuário que não são exibidos atualmente para utilização da memória) com campos de entrada pares chave / valor, alguns botões responsáveis ​​por adicionar um novo par chave / valor a um determinado contêiner abstrato e um botão que gera uma assinatura (assinatura) para esse contêiner.

Observando as anotações e observando uma certa quantidade de código suspeitamente semelhante à gerada, começo a pesquisar no Google. O projeto usa a biblioteca ButterKnife, que permite o uso de anotações para aumentar automaticamente os elementos da interface do usuário () -> bind () . Se houver anotações na classe, o processador de anotação ButterKnife implicitamente cria outra classe de fichários no formato <original_class> __ViewBinding , que faz todo o trabalho sujo por baixo do capô. Na verdade, obtive todas essas informações em apenas um arquivo MainActivity depois de recriar manualmente a semelhança da fonte Java. Depois de meia hora, percebi que as anotações dessa biblioteca também podem definir um retorno de chamada nas ações dos botões e encontrei as principais funções que eram realmente responsáveis ​​por adicionar um par de chave / valor ao contêiner e gerar uma assinatura.

É claro que, durante o estudo, eu tive que entrar nas "miudezas" de várias bibliotecas e plugins, porque mesmo belos landos com cookies não cobrem todos os casos de uso e detalhes, o que para qualquer "inversor", eu acho, é uma prática comum.

Preguiça é amigo de um programador


Depois de passar mais algum tempo na segunda fonte, fiquei completamente cansado e percebi que não era possível cozinhar mingau. Estou subindo no Github novamente e desta vez estou olhando mais de perto. Acho o projeto Smali2PsuedoJava - um decompilador em "código pseudo-Java". Mesmo que esse utilitário, pelo menos alguma coisa possa levar a uma aparência humana, então para mim o autor é uma caneca de sua cerveja favorita (bem, ou pelo menos coloque um asterisco no Github, para iniciantes).

E realmente funciona! Efeito no rosto:



Conheça Cipher.so


Um pouco mais tarde, estudando o pseudo-código Java do projeto e comparando-o incrivelmente com o bytecode smali, encontro uma biblioteca estranha no código - Cipher.so. Pesquisando no Google, descobri que é a criptografia de um conjunto de valores de tempo de compilação dentro do arquivo APK. Isso geralmente é necessário quando o aplicativo usa constantes do formulário: endereços IP, credenciais para um banco de dados externo, tokens para autorização etc. - o que pode ser obtido com a ajuda da engenharia reversa do aplicativo. É verdade que o autor escreve claramente que esse projeto é abandonado, dizem eles, desaparece. Isso está ficando interessante.

Essa biblioteca fornece acesso aos valores por meio da biblioteca Java, onde o método específico é a chave de seu interesse. Isso apenas alimenta meu interesse e começo a subir mais fundo.

Em resumo, o que o Cipher.so faz e como funciona:

  • no arquivo Gradle do nosso projeto, as chaves e os valores correspondentes são registrados
  • todos os valores-chave serão compactados automaticamente em uma biblioteca dinâmica separada (.so), que será gerada no momento da compilação. Sim - sim, SERÁ gerado.
  • essas chaves podem ser obtidas dos métodos Java gerados pelo Cipher.so
  • depois de criar o APK, os nomes das chaves são divididos em hash pelo MD5 (para maior segurança, é claro)

Tendo encontrado a biblioteca dinâmica de que preciso na pasta de arquivamento, prossigo para selecioná-la. Para começar, como um reverso experiente (não), tento começar com um simples - decido olhar para a seção com constantes e para linhas interessantes em um binário semelhante a ELF. Infelizmente, os usuários do mac prontamente ausentes estão faltando e, antes do início, dizemos o querido:

 brew install binuitls 

E não se esqueça de escrever o caminho para / usr / local no PATH, porque o brew o protege de tudo de maneira gentil ...

 greadelf -p .rodata lib/arm64-v8a/libcipher-lib.so | head -n 15 

Limitamos a saída às 15 primeiras linhas; caso contrário, isso pode causar choque para um engenheiro despreparado.



Nos endereços inferiores, notamos linhas suspeitas. Como descobri, estudando as fontes do Cipher.so, as chaves e os valores são colocados no std :: map usual : isso fornece pouca informação, mas sabemos que no binar junto com as senhas criptografadas também existem chaves ofuscadas.

Como é a criptografia de valores? Estudando a fonte, descobri que a criptografia ocorre usando o AES - o sistema de criptografia simétrica padrão. Portanto, se houver valores criptografados, a chave deve estar próxima ... Depois de estudá-la por um tempo, deparei-me com um problema no mesmo projeto com o provocativo título “Armazenamento inseguro de chaves: segredos são muito fáceis de recuperar” . De fato, descobri que a chave está armazenada de forma clara no binar e encontrei o algoritmo de descriptografia. No exemplo, a chave estava no endereço zero e, embora eu entenda que o compilador possa colocá-lo em outro lugar na seção .rodata do arquivo binário, decidi que essa unidade suspeita no endereço zero é a chave.

Tentativa 1: procuro decifrar os valores e acredito que a chave de criptografia é a mesma. O erro O OpenSSL sugere que algo não está certo. Depois de ler as fontes de Cipher.so um pouco, entendo que, se o usuário não especificar uma chave durante a montagem, a chave padrão será usada - Cipher.so@DEFAULT .

Tentativa 2: Erro novamente. Hmm ... É realmente redefinido por esta constante? É muito simples cometer um erro: código confuso escrito em Gradle, com formatação "sumida". Eu verifico novamente. Tudo parece ser assim.

Em vez das chaves estão os hashes MD5, e então tento tentar a sorte e abrir um serviço com tabelas arco-íris. Voila - uma das chaves é a palavra "senha". Não há segundo. Dá-nos, é claro, não muito. Ambas as chaves estão nos endereços 240 e 2a2, respectivamente. Em princípio, é fácil reconhecê-los imediatamente - 32 caracteres (MD5).

Eu verifiquei tudo de novo e tentei descriptografar com todas as outras linhas (que estão nos endereços inferiores) como uma chave para descriptografia - tudo é em vão.
Portanto, há outra chave secreta, o algoritmo de ações parece estar correto. Jogo essa tarefa de lado e tento não me enterrar.

Tendo vasculhado um pouco o algoritmo de assinatura do contêiner, ainda vejo chamadas para a biblioteca e código Cipher.so que também usam as funções criptográficas da biblioteca Java.

Um enigma (que eu nunca resolvi)


Na função responsável pela criptografia, no início há uma verificação de chaves no contêiner.

 public byte[] a(java/util/Map p1) { v0 = p1.size() v1 = 0x0; if (v0 != 0) goto :cond_0 p1 = new byte[v1]; return p1; :cond_0 v0 = "user"; v0 = p1.containsKey(v0) if (v0 == 0) goto :cond_1 p1 = new byte[v1]; return p1; ... 

Literalmente: se houver uma chave "usuário", esse contêiner não será assinado (uma assinatura zero será retornada). Um sentimento estranho: parece que o problema está resolvido, mas parece de alguma forma simples. Então, por que inventar todo o resto? Desviar-se? Então, por que não estudei esse código fluentemente antes? Hmm ...

Não, não é verdade. Especifiquei a resposta de um determinado usuário em um messenger azul, cujos contatos me foram fornecidos ao atribuir a tarefa. Cavando mais. Talvez a chave / valor de entrada definido de alguma forma seja alterado à medida que é adicionado ao contêiner? Eu li o código com cuidado.

Observe que o descompilador removeu anotações do código smali. E se ele removeu algo importante? Eu verifico os arquivos principais - ao que parece, nada significativo. Tudo importante está no lugar, mas o significado não está perdido. Verifico as funções de retorno de chamada responsáveis ​​por escrever um par de chave / valor do TextBox condicional para contêineres internos. Não encontrei nada de criminoso.

Tornei-me o mais cético possível sobre cada linha de código - não posso mais confiar em ninguém.

Solução simples # 2: notei que o procedimento de assinatura começa verificando a presença de algum valor (substring na cadeia de caracteres) na assinatura do certificado com o qual o aplicativo foi assinado.

 @OnClick //   protected void huvot324yo873yvo837yvo() { String signature = "no data"; boolean result = some_packages.isKeyInSignature(this); if result { Map map = new HashMap(); ... 

O significado em si, é claro, está criptografado naquele binário infeliz. E, na verdade, se esse valor não estiver na assinatura, o algoritmo não assinará nada, mas simplesmente retornará a string "no data", como a assinatura ... Novamente, somos escolhidos para Cipher ...

Luta final de descriptografia de chaves


Para entender a escala da tragédia, fiquei confuso assim:

Fiz um despejo hexagonal desta seção e espiei as duas primeiras linhas, cujas suspeitas não desapareceram desde o início.



Se você prestar atenção, o caractere que separa as linhas aqui é '0x00'. Também é comumente usado pela biblioteca C padrão, em funções de string. Por isso não é menos interessante, que tipo de caractere de espaço está no meio da primeira linha? Em seguida, tentativas malucas começam, onde está a chave:

  • primeira linha inteira
  • primeira linha antes do espaço
  • primeira linha do espaço até o fim
  • ...

O grau de paranóia já pode ser estimado. Quando você não entende o quão difícil e astuta a tarefa deve ser, começa a dirigir. E, no entanto, não é isso. Então, o pensamento veio à minha mente: “O algoritmo funciona corretamente devido a problemas na minha máquina?”. Em geral, a sequência de ações é lógica e não levantou questões, mas a pergunta é: os comandos na minha máquina fazem o que é necessário? Então o que você acha?

Depois de verificar todas as etapas manualmente, verificou-se que

 echo "some_base64_input" | openssl base64 -d 

em alguns argumentos de entrada, ele repentinamente retorna uma string vazia. Hmm.

Substituindo-o pelo primeiro decodificador base64 na máquina e classificando os candidatos principais, uma chave adequada foi encontrada imediatamente e as chaves foram descriptografadas de acordo.

Recuperando assinaturas de um certificado


 class a { public static boolean isKeyInSignature(android.content.Context p1) { v0 = 0x0; try TRY_0{ v1 = p1.getPackageManager() p0 = p1.getPackageName() v2 = 0x40; // GET_SIGNATURES PackageInfo p0 = v1.getPackageInfo(p0, v2) android.content.pm.Signature[] p0 = p0.signatures; // Order are not guaranteed v1 = p0.length; v2 = 0x0; :goto_0 if (v2 >= v1) goto :cond_1 v3 = p0[v2]; String v3 = v3.toCharsString() String v4 = net.idik.lib.cipher.so.CipherClient.a() v3 = v3.contains(v4) }TRY_0 catch TRY_0 (android/content/pm/PackageManager$NameNotFoundException) goto :catch_0; if (v3 == 0) goto :cond_0 p1 = 0x1; return p1; :cond_0 v2 = v2 + 0x1; goto :goto_0 :catch_0 p0 = Thrown Exception p1.printStackTrace() :cond_1 return v0; } 

É assim que o pseudocódigo gerado se parece com minhas edições menores. Confunde algumas coisas:

  • pouco conhecimento de criptografia e a "cozinha" dos certificados do dispositivo
  • de acordo com a documentação, esse método não garante a ordem dos certificados na coleção retornada e, portanto, não seria possível fazer um loop na mesma ordem - e se o aplicativo fosse assinado com mais de um certificado?
  • falta de conhecimento sobre como extrair o certificado do APK, já que não está claro o que o Android Runtime faz neste caso

Eu tive que me aprofundar em todos esses problemas e o resultado foi o seguinte:

  • o próprio certificado está no diretório original / META-INF / CERT.RSA

    neste diretório, existe apenas um arquivo com esta extensão - o que significa que o aplicativo é assinado com apenas um certificado
  • No site sobre engenharia de pesquisa de aplicativos Android, foi encontrada uma lista que pode extrair a assinatura necessária como o Android. Segundo o autor, pelo menos.

Ao executar esse código, posso descobrir a assinatura e, na realidade, a chave que precisamos é uma substring. Vá em frente. A Solução Simples # 2 está sendo varrida.

De fato, a chave está no certificado, resta apenas entender o que vem a seguir, porque se tivermos a chave de "usuário", todos receberemos uma assinatura zero e, como aprendemos acima, esta é a resposta errada.

Escreva a documentação com cuidado!


Pesquisas adicionais sobre o fato de os dados inseridos nos campos de texto serem alterados são descartadas por falta de evidência. A paranóia rola com vigor renovado: talvez o código que retirou a assinatura do certificado esteja incorreto ou seja uma implementação de código para versões antigas do Android? Abro a documentação novamente e vejo o seguinte: ( https://developer.android.com/reference/android/content/pm/Signature.html#toChars () ):



Nota: a função codifica a assinatura como texto ASCII. A saída que recebi acima foi uma representação hexadecimal dos dados. Essa API me pareceu estranha, mas se você acredita na documentação, acontece que fiquei paralisada novamente e a chave criptografada não é uma substring da assinatura. Depois de ficar pensando um pouco no código por um tempo, não aguentei mais e abri o código-fonte para esta classe. https://android.googlesource.com/platform/frameworks/base/+/e639da7/core/java/android/content/pm/Signature.java

A resposta não demorou a chegar. E, na verdade, no próprio código - uma pintura a óleo: o formato de saída é uma sequência hexadecimal comum. E agora pense: ou eu não entendo alguma coisa ou a documentação está escrita "ligeiramente" incorretamente. Tendo repreendido de maneira alguma, comecei a trabalhar novamente.

Sumário


As seguintes n horas se passaram:

  • verificar a correção do trabalho no código com o RecyclerView e verificar seu comportamento através do código-fonte desde Novamente, nem todos os pontos são abordados em detalhes no dock e mesmo no Stackoverflow
  • descompilação manual do fragmento de código responsável por assinar a coleção no Java compilado. Eu assumi a suposição de que ainda perdi algo e a primeira chave no contêiner ("usuário") foi eliminada implicitamente da coleção. Decidi definir o restante dos dados no código.

Em geral, esse código se recusou a assinar até os argumentos restantes (além disso, ao trabalhar com criptografia, esses argumentos me jogaram implicitamente à distância).

Não.Acabou que você não pode assinar esta entrada. Infelizmente, não poderei aprovar este trabalho e descobrir se realmente é assim. Que pena. Por um tempo, ocupou meus pensamentos, mas me tranquilizei de ter feito tudo o que pude.

Na verdade, passei muito tempo nessa tarefa e, ao mesmo tempo, na restauração de lacunas de conhecimento. Foi realmente útil. Você pode rastrear todo o caminho e prestar atenção em como eu me apeguei a partes absolutamente não-decisórias. Talvez isso ajude alguém a entender como os iniciantes resolvem problemas desse tipo, porque geralmente lemos “histórias de sucesso”, onde todas as etapas são lógicas, consistentes e levam ao resultado certo.

Se alguém quiser tentar aprofundar um pouco mais essa tarefa ou fazer uma pergunta - escreva-me no mensageiro azul arturbrsg .

Fique atento.

Source: https://habr.com/ru/post/pt450394/


All Articles