[MHL] Captain Nohook - Bypassing protections and dumping Swift Objects returns from memory

Introdução

O principal objetivo deste desafio era realizar o bypass de múltiplas proteções client-side, como anti-debugging e detecção de jailbreak em um app iOS. Além disso, a flag nunca era exibida nem armazenada no dispositivo em texto claro: ela estava hardcoded no binário de forma criptografada e só era descriptografada em tempo de execução. Por isso, a solução exigia fazer hook das funções e capturar a flag em memória, que é provavelmente de onde vem o nome do desafio, "Captain No Hook".

Desafio

Welcome to the iOS Application Security Lab: Captain No Hook Anti-Debugging Challenge. This challenge focuses on a fictitious app called Captain No Hook, which implements advanced anti-debugging / jailbreak detection techniques. Your objective is to bypass these protections and retrieve the hidden flag within the app.

Logo do Captain No Hook

Índice

Analisando o App

Primeiro Contato

Como de costume, fiz o sideload do IPA no meu dispositivo com jailbreak. Ao abrir o app e clicar no botão para obter a flag, um alerta aparece imediatamente: "Noncompliant device detected! Yerr hook won't work!". Essa string de erro é o ponto de entrada para toda a análise.

Tela inicial do app Captain No Hook Alerta de dispositivo não compatível

Do lado do atacante: toda vez que você vê uma mensagem de erro apontando para uma proteção, isso é uma grande dica do que procurar. Em apps grandes e ofuscados, geralmente é difícil identificar onde uma proteção começa, mas saber onde ela termina já é suficiente para rastreá-la de volta.

Do lado do defensor: se você está construindo sua própria proteção, o recomendado é esconder o máximo de rastros possível, incluindo mensagens de erro e crashes, já que ambos são efetivamente o "fim da trilha" para um atacante. Prefira falhas silenciosas e fluxos condicionais, redirecione o usuário para uma tela específica, ou simplesmente faça todas as tentativas falharem com a mesma mensagem genérica, como retornar "Credencial incorreta" e bloquear o login mascarando a proteção com um erro genérico de autenticação, mantendo um ponto cego.

Engenharia Reversa com Hopper

Carreguei o binário no Hopper e procurei pela string de erro na aba Strings. Seguindo referências da memória de onde string está armazenada, cheguei diretamente à função por trás do botão "get flag":

_$s14Captain_Nohook14ViewControllerC7getFlagSSyF

Simplificando o output decompilado para o que realmente importa, a função começa assim:

if ((Captain_Nohook.is_noncompliant_device(r20) & 0x1) != 0x0) {
    r19[0x4f] = 0x0;
    r20 = type metadata accessor for __C.UIAlertController();
    r19[0xa3] = 0x1;
    r0 = Swift.String.init(r20, _builtinStringLiteral: "Noncompliant device detected!", utf8CodeUnitCount: 0x1d, isASCII: 0x1 & 0x1);
    // ...

Então is_noncompliant_device() é o único gate booleano protegendo a função inteira, e o app continua executando o restante de getFlag() de qualquer forma; ele apenas exibe o alerta primeiro quando acredita que há algo errado.

Bypass das Proteções

is_noncompliant_device() não faz nenhuma verificação por conta própria, apenas encaminha a chamada para amIReverseEngineered():

bool _$s14Captain_Nohook22is_noncompliant_deviceSbyF(void * self) {
    r0 = type metadata accessor for Captain_Nohook.ReverseEngineeringToolsChecker();
    r0 = static Captain_Nohook.ReverseEngineeringToolsChecker.amIReverseEngineered(r0);
    r0 = r0 & 0x1;
    return r0;
}

amIReverseEngineered() chama performChecks() e inverte o resultado. performChecks() retorna passed = true quando tudo parece limpo, e essa função quer o significado oposto, então ela faz XOR com 1 (um truque comum de bitwise para booleanos).

bool _$s14Captain_Nohook30ReverseEngineeringToolsCheckerC20amIReverseEngineeredSbyFZ(void * self) {
    var_C = static Captain_Nohook.ReverseEngineeringToolsChecker.performChecks(self);
    r0 = (var_C ^ 0x1) & 0x1;
    return r0;
}

performChecks() é onde o trabalho real acontece, e aqui temos vários tipos diferentes de validações. No entanto, reverter e bypassar cada uma delas não é necessário, já que todas as validações retornam para amIReverseEngineered() e são encaminhadas mais uma vez em is_noncompliant_device(). Então só precisamos sobrescrever o retorno da função externa para bypassar a validação, um bypass semelhante ao que já documentei no write-up do No Escape.

var is_noncompliant_device = Module.findGlobalExportByName('$s14Captain_Nohook22is_noncompliant_deviceSbyF');

Interceptor.attach(is_noncompliant_device, {
    onEnter(args) {
        console.log(`Captain_Nohook.is_noncompliant_device`);
    },
    onLeave(retval) {
        retval.replace(0x00);
        console.log(`\t return ${retval}`);
    }
});
Alerta de provocação No Hook

Depois de resolver o lab, fiquei curioso para saber se alguém havia bypassado todas as validações internas e documentado o processo, e encontrei este bom write-up que também vale a leitura.

Descriptografando a Flag

Executar o bypass eliminou o alerta, mas o botão ainda não me entregou uma flag, porque o alerta nunca foi o que retornava a flag: getFlag() estava fazendo outra coisa paralelamente.

Voltando ao Hopper, mais abaixo na mesma função encontrei um blob codificado em base64 sendo decodificado e depois passado por CryptoSwift.AES em modo CBC. Destacando o que importa, temos aproximadamente o seguinte código decompilado:

r19[0x42] = Swift.String.init(r20, _builtinStringLiteral: "HhRVZ1fdevIW2GfW42oy9J4XrAz330o5amXtNc/t8+s=", utf8CodeUnitCount: 0x2c, isASCII: 0x1 & 0x1);
// ....
if ((r8 & 0x1) == 0x0) {
	// ...
	if ((r8 & 0x1) == 0x0) {
		// ...
		if ((r8 & 0x1) == 0x0) {
			// ...
			Swift.String.init(r20, _builtinStringLiteral: "hackallthethings", utf8CodeUnitCount: 0x10, isASCII: 0x1 & 0x1);
			r20 = &var_A8;
			// ...
			if ((r8 & 0x1) == 0x0) {
				r19[0x17] = CryptoSwift.CBC.init(r20, iv: Foundation.Data.bytes.getter(r20));
				// ...
				r0 = CryptoSwift.AES.__allocating_init(r20);
				// ...
				if ((r8 & 0x1) == 0x0) {
					// ...
					r0 = Foundation.Data.bytes.getter(r20);
					// ...
					r0 = CryptoSwift.Cipher.decrypt(r20);

A chave estava ali mesmo como uma string literal ("hackallthethings"), mas o IV não estava: ele era gerado/obtido em tempo de execução, então não havia um valor estático para simplesmente copiar. Existem duas formas de lidar com isso:

  • fazer hook do inicializador AES/CBC para dump do IV enquanto ele é passado e então descriptografar manualmente; ou
  • ignorar totalmente a reconstrução da criptografia e simplesmente fazer hook do retorno de CryptoSwift.Cipher.decrypt(), já que no momento em que essa função retorna os bytes em plaintext já existem na memória.

Fiquei com a segunda opção, por ser mais simples:

var decrypt = Module.findGlobalExportByName('$s11CryptoSwift6CipherPAAE7decryptySays5UInt8VGAGKF');

Interceptor.attach(decrypt, {
    onEnter(args) {
        console.log(`[+] CryptoSwift.Cipher.decrypt()`);
    },
    onLeave(retval) {
        console.log(hexdump(ptr(retval), { length: 64 }));
        console.log('\n\n');
    }
});

O hexdump no onLeave mostra os bytes descriptografados diretamente da memória, sem precisar mexer nos parâmetros AES.

Durante esta solução, não encontrei uma forma de obter a string diretamente a partir do retorno da função. Isso acontece porque o retorno da função decrypt é um ponteiro nativo para o início da memória de um objeto Swift, não uma string C terminada em null. Portanto, decoders prontos do Frida como readUtf8String() ou readCString() acabam lendo os bytes errados (primeiro o header do próprio objeto Swift, não os dados crus da string). A solução mais fácil que encontrei foi fazer dump de um número suficiente de bytes de memória a partir de retval (hexdump(ptr(retval), { length: 64 })) para ter o objeto completo e os dados da flag em um formato mais amigável de leitura.

Exploit Final

Combinando ambos os hooks (o bypass de is_noncompliant_device e o dump de decrypt) em um único script e executando contra o app conseguimos a flag:

frida -U -f com.mobilehackinglab.Captain-Nohook -l bypass.js

Script completo aqui: https://github.com/luca-regne/mobilehackinglabs-solves/blob/main/CaptainNoHook/bypass.js

Captain No Hook resolvido

MobileHackingLabsiOSAnti-DebuggingJailbreak DetectionFrida