[MHL] No Escape - Bypass de Detecção de Jailbreak no iOS

Introdução

O principal objetivo deste desafio era realizar o bypass da detecção de jailbreak. Espero que este writeup sirva como uma boa introdução sobre como fazer engenharia reversa de um arquivo IPA usando o Hopper, identificar funções e realizar instrumentação dinâmica, fazendo hook de funções e sobrescrevendo valores de retorno.

Desafio

Welcome to the iOS Application Security Lab: Jailbreak Detection Evasion Challenge. The challenge centers around a fictitious app called No Escape, designed with robust jailbreak detection mechanisms. Your mission is to bypass these mechanisms and gain full access to the app's functionalities using Frida.

Logo do No Escape

Índice

Analisando o App

Como em qualquer análise de app iOS, comecei instalando o arquivo IPA no meu dispositivo — um iPhone 7 rodando iOS 15 com jailbreak via Dopamine. Depois de instalar a aplicação e abri-la, a primeira tela exibiu uma mensagem de erro:

Mensagem de erro de jailbreak do No Escape

Para identificar como a detecção funciona, comecei a engenharia reversa do arquivo, primeiro identificando o binário com o código da aplicação e em seguida, utilizando o Hopper para decompilá-lo. Como arquivos IPA são basicamente arquivos "zip", eles podem ser extraídos usando um utilitário de arquivo, como o comando unzip, o Archive Utility no macOS, ou 7zip/WinRAR no Windows. Depois de extrair o arquivo IPA, podemos começar a análise olhando o Info.plist dentro da pasta Payloads/.

A extensão .plist significa Property List, e o arquivo Info.plist contém alguns metadados e propriedades da aplicação. Se você tentar apenas abrir o arquivo, vai notar que ele não é legível, e isso acontece porque arquivos .plist podem estar no formato binário, JSON ou XML. Normalmente, arquivos IPA contêm a versão binária pré-compilada do .plist, de forma semelhante ao AndroidManifest.xml (para os nerds de Android).

Existem várias formas de traduzi-lo para um formato legível (XML ou JSON), algumas opções incluem:

  • abrir o arquivo no Xcode;
  • fazer o parsing com a biblioteca Python plist;
  • usar ferramentas de linha de comando como plutil, ipsw ou defaults.

Info.plist no Xcode

No Bundle Name, é possível ver o nome do binário executado como entry point do arquivo IPA, e dentro dele temos toda a lógica da aplicação. Agora, podemos simplesmente identificar esse binário na pasta Payload e carregá-lo em uma ferramenta de decompilação para ler o assembly ou um código semelhante a Swift/Objective-C.

plutil em ação

No Hopper, podemos começar procurando a mensagem do alerta na aba de strings, no painel esquerdo. Em seguida, conseguimos identificar onde a string é usada para detectar a lógica da detecção de jailbreak antes de exibir a mensagem.

Hopper rastreando a mensagem de alerta

Seguindo o trace, acabamos em uma função com o nome:

_$s9No_Escape11AppDelegateC11application_29didFinishLaunchingWithOptionsSbSo13UIApplicationC_SDySo0k6LaunchJ3KeyaypGSgtF

Este é o nome demangled de uma função Swift, e essa função possui o seguinte código:

bool _$s9No_Escape11AppDelegateC11application_29didFinishLaunchingWithOptionsSbSo13UIApplicationC_SDySo0k6LaunchJ3KeyaypGSgtF(void * self, void * arg1, int arg2) {
    r1 = arg2;
    r31 = r31 - 0x50;
    var_10 = self;
    decomp_var_8 = r19;
    saved_fp = r29;
    decomp_var_m8 = r30;
    var_30 = self;
    if ((No_Escape.isJailbroken(self) & 0x1) != 0x0) {
            No_Escape.AppDelegate.showAlert(var_30, message: {Swift.String.init(var_30, _builtinStringLiteral: "Your device is jailbroken. This may compromise security. Quitting...", utf8CodeUnitCount: 0x44, isASCII: 0x1 & 0x1):r1});
            swift_bridgeObjectRelease(r1);
    }
    r0 = 0x1 & 0x1;
    return r0;
}

Como podemos ver, a condição valida o retorno da função No_Escape.isJailbroken(), que retorna um valor booleano. Se o retorno for true, ou 0x1 em hexadecimal, o app exibe o alerta com a mensagem encontrada. Parece que chegamos ao código relevante usado para detectar dispositivos com jailbreak. Olhando dentro da função, temos o seguinte:

bool _$s9No_Escape12isJailbrokenSbyF(void * self) {
    r20 = self;
    r31 = r31 - 0x20;
    saved_fp = r29;
    decomp_var_m8 = r30;
    if ((No_Escape.checkForJailbreakFiles() & 0x1) != 0x0) {
            var_4 = 0x1;
    }
    else {
            var_4 = No_Escape.checkForWritableSystemDirectories(r20);
    }
    if ((var_4 & 0x1) != 0x0) {
            var_8 = 0x1;
    }
    else {
            var_8 = No_Escape.canOpenCydia(r20);
    }
    if ((var_8 & 0x1) != 0x0) {
            var_C = 0x1;
    }
    else {
            var_C = No_Escape.checkSandboxViolation(r20);
    }
    if ((var_C & 0x1) != 0x0) {
            var_10 = 0x1;
    }
    else {
            var_10 = 0x0;
    }
    r0 = var_10 & 0x1;
    return r0;
}

A detecção de jailbreak parece sólida e, lendo as funções chamadas internamente, existem várias validações, como:

  • se existem arquivos comuns de jailbreak no sistema (su, bash, etc.);
  • se os diretórios do sistema podem ser escritos a partir do contexto do app;
  • se o sistema possui o app Cydia (um gerenciador de pacotes comum em dispositivos com jailbreak);
  • se há algum sinal de violação de sandbox, com verificações do diretório do apt (/private/var/lib/apt/).

No entanto, todas as funções dependem de uma única linha condicional no primeiro bloco de código: No_Escape.isJailbroken(). Como todas as funções são chamadas por essa função pai, tudo também retorna através dela. Portanto, podemos sobrescrever seu valor de retorno para contornar completamente toda a lógica interna usada para detectar dispositivos com jailbreak.

Bypass da Detecção de Jailbreak com Instrumentação Dinâmica usando Frida

Para sobrescrever a função, precisamos fazer hook da função Swift, e usei o Frida para isso. Desde o Frida 17, podemos usar Module.findGlobalExportByName(functionName) da API JavaScript do Frida para encontrar o endereço da função na aplicação.

Note que, se você copiou o nome da função do Hopper, deve remover o prefixo de underscore (_).

Então, com o endereço da função, podemos usar a API Interceptor para fazer hook da execução da função e manipular a memória:

  • no momento em que a função é chamada (onEnter), para verificar se fizemos hook da função correta;
  • e antes do seu retorno (onLeave), para sobrescrever o valor de retorno de 0x1 para 0x0.
var fn = Module.findGlobalExportByName("$s9No_Escape12isJailbrokenSbyF");
Interceptor.attach(fn, {
    onEnter(args) {
        console.log("[*] No_Escape.isJailbroken()");
    },
    onLeave(retval){
      retval.replace(0);
      console.log(`\tReplace return: ${retval}`)
    }
});

Executando esse script de bypass, contornamos as verificações e obtemos a flag.

frida -U -f "com.mobilehackinglab.No-Escape" -l bypass.js

Bypass da detecção de jailbreak com Frida

No Escape resolvido
MobileHackingLabsiOSJailbreak DetectionFrida