[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.

Í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:
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,ipswoudefaults.

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.

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.

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 de0x1para0x0.
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
