PT-BR - Beerus Framework: iOS - O canivete suíço para iOS
JOIN
JOIN
RESEARCH ENTRY 2026.09.18
pt-br

PT-BR - Beerus Framework: iOS - O canivete suíço para iOS

Daniel Franca Lima Gabriel Rodrigues
RESEARCHER Daniel Franca Lima, Gabriel Rodrigues
READ TIME20 MINUTES
PUBLISHED18 Sep 2026
PT-BR - Beerus Framework: iOS - O canivete suíço para iOS

Em julho deste ano, lançamos a v1.1 do Beerus Framework para Android. Agora, em setembro, damos mais um passo na evolução do projeto com o lançamento do Beerus Framework para iOS, trazendo a visão e os recursos do framework também para o ecossistema da Apple. Esse lançamento também contou com a contribuição dos membros do time de pentest da Hakai, Daniel "Daniboy" Lima e Gabriel "Texugo" Rodrigues e a contribuição de Kelvin Montini, que ajudou no desenvolvimento e na evolução do projeto.

Com isso, neste post vamos abordar sua estrutura, seus recursos, o contexto técnico por trás de algumas decisões e como cada um pode ajudar os usuários que dependem do Beerus no dia a dia para testes em dispositivos iOS.

Contexto

O Beerus Framework é uma ferramenta de segurança ofensiva para dispositivos móveis, criada para centralizar e simplificar o processo de pentesting diretamente no dispositivo. Agora disponível para iOS, o framework foi construído sobre o Frida e o Palera1n para oferecer, por meio de uma interface unificada, recursos como instrumentação dinâmica de aplicações, extração de dados do sandbox, memory dumping, configuração de proxy e extração de arquivos IPA. Com uma arquitetura modular e extensível, o Beerus busca agilizar tarefas recorrentes e facilitar a execução de diferentes técnicas durante testes de segurança em dispositivos iOS com Jailbreak.

Introdução Técnica

O Beerus Framework funciona como um hub de recursos para analistas de segurança que trabalham com dispositivos iOS. O app oferece uma interface gráfica intuitiva com acesso a uma variedade de ferramentas, aproveitando os próprios recursos do dispositivo e privilégios de root (projetado para trabalhar em maior sincronia com o Palera1n).

As funções disponíveis são:

Frida Server Setup

O recurso Frida Setup simplifica a configuração do Frida Server diretamente no dispositivo. O usuário pode selecionar uma versão e tocar em "Start Frida" para que o Beerus cuide automaticamente do download, da instalação e da inicialização do server. Por padrão, o framework oferece as 10 versões mais recentes, e também permite selecionar manualmente uma versão específica. Uma vez instalada, uma versão pode ser reutilizada sem um novo download, tornando mais rápido e conveniente preparar o ambiente de instrumentação dinâmica.

FridaChecker.swift (status check, Lines 13…35)

    /// Checks if frida-server is listening on the given port (synchronous, up to 1s timeout).
    static func isRunning(port: UInt16 = 27042) -> Bool {
        let sockfd = socket(AF_INET, SOCK_STREAM, 0)
        guard sockfd != -1 else { return false }
        defer { close(sockfd) }

        var timeout = timeval(tv_sec: 1, tv_usec: 0)
        setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))

        var addr = sockaddr_in(
            sin_len: UInt8(MemoryLayout<sockaddr_in>.size),
            sin_family: UInt8(AF_INET),
            sin_port: port.bigEndian,
            sin_addr: in_addr(s_addr: inet_addr("127.0.0.1")),
            sin_zero: (0, 0, 0, 0, 0, 0, 0, 0)
        )

        return withUnsafePointer(to: &addr) {
            $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
                connect(sockfd, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) == 0
            }
        }
    }

SetupFridaViewController.swift (start/stop/install, Lines 134…149, 233…234, 252…253)

            // Stop Frida
            if fridaDaemonExists() {
                RootExec.shellAwait("\(BeerusStrings.launchctlBin) bootout system \(BeerusStrings.fridaDaemonPath)") { _ in
                    DispatchQueue.main.async {
                        self.buttonStart.isEnabled = true
                        self.checkFridaRunning()
                    }
                }
            } else {
                RootExec.shellAwait("killall frida-server") { _ in
                    DispatchQueue.main.async {
                        self.buttonStart.isEnabled = true
                        self.checkFridaRunning()
                    }
                }
            }
                // Copia para o tmp do sistema via daemon
                let copyResult = RootExec.shell("cp '\(fileURL.path)' '\(systemDebPath)'")
                // Instala usando o daemon
                if let response = RootExec.installFrida(from: systemDebPath) {

Status Check (FridaChecker.swift, Lines 14…35)

  • isRunning(port:) abre um socket TCP e tenta fazer connect direto na porta do frida-server (127.0.0.1:27042) com timeout de 1s.
  • Retorna true se connect retornar 0 (porta do frida-server aberta), false caso contrário. É o gate usado antes de rodar qualquer script.
  • checkAfterDelay(_:completion:) (38…44) reverifica após um delay (startup/shutdown) e responde na main thread.
  • notifyStatusChanged() (9…11) publica statusDidChangeNotification para que as telas possam se atualizar.

Toggle / Frida Installation (SetupFridaViewController.swift, Lines 116…230)

  • fridaDaemonExists() (111…114) testa se o plist do serviço existe via RootExec.shell("test -f …") — precisa do daemon apenas para ler um caminho de sistema fora do sandbox.
  • ToggleFrida(_:) (116) decide a ação com base no estado:
    • Running → stop: launchctl bootout system <plist> (ou killall frida-server se não houver plist).
    • Installed and stopped (versão selecionada == versão instalada) → start: launchctl bootstrap system <plist>.
    • New version → download e instalação (fluxo abaixo).
  • Installation (a partir de 148): detecta a arquitetura com dpkg --print-architecture, monta a URL da release no GitHub, baixa o .deb com Requests.downloadFile (feito pelo app), copia para o tmp do sistema (RootExec.shell("cp …")) e instala via daemon com RootExec.installFrida(from:); erros disparam Alert.show.
  • Cada branch chama checkFridaRunning() ao final para refletir o novo estado.

State and Version Check (SetupFridaViewController.swift, Lines 378…412)

  • checkFridaRunning() monta o texto de status/versão/botão.
  • Se startDownloading, mostra "Status: Downloading"; caso contrário, roda RootExec.shell("ps aux | grep frida-server") (via daemon) para ver se está ativo e ajusta isRunning/o label do botão (Start/Stop).
  • Consulta a versão com RootExec.shell("<fridaServerPath> --version") e a valida com a regex ^\d+\.\d+\.\d+, preenchendo versionRunning/selectedVersion.

Daemon Bridge — client authentication (BeerusDaemon.c, Lines 429…457)

  • Toda chamada RootExec.* acima abre o socket UNIX /var/run/beerus.sock e envia uma mensagem de texto para o daemon beerusd, que roda como root (iniciado pelo launchd via com.beerus.daemon.plist). O app em sandbox não pode iniciar serviços do launchd, instalar pacotes .deb ou inspecionar processos do sistema — então ele delega ao daemon.
  • Antes de atender qualquer comando, verify_client(fd) valida o cliente: obtém o PID do processo a partir do próprio socket (getsockopt … LOCAL_PEERPID), confirma que o caminho do executável termina no binário do Beerus e compara o CDHash do cliente com o autorizado (memcmp(client_hash, g_allowed_cdhash, …)). Se não bater, responde error: unauthorized. Assim, apenas o binário legítimo do Beerus pode emitir comandos como root.

Root Execution and Installation (BeerusDaemon.c, Lines 1755…1779, 801…885 e 709…740)

  • O que é enviado nos passos acima:
    • RootExec.shell("cp …" / "dpkg --print-architecture" / "ps aux | …") vira a mensagem SHELL <cmd>. O handler SHELL (1755) resolve a shell (/bin/sh, ou /var/jb/bin/sh em rootless) e chama runCommand (801), que faz posix_spawn de sh -c <cmd> com stdout e stderr redirecionados para um pipe e retorna a saída bruta seguida do trailer \n\0EXIT:<code>\0.
    • RootExec.installFrida(from:) vira INSTALL_FRIDA <path>. O handler (1797) chama install_frida (709): para um .deb, install_from_deb (602) extrai o pacote com dpkg-deb --extract e replace_frida_binary troca o binário do frida-server no caminho de sistema, respondendo ok: frida-server installed … ou um erro.
  • No lado Swift, RootExec.shell (RootExec.swift 48…118) faz o parse do trailer \0EXIT: (102) para separar output de exitCode.
0:00
/0:29

Script Editor + Frida Auto Inject

O recurso Frida Auto Inject permite rodar e injetar scripts Frida diretamente do dispositivo usando o Frida Swift, que cuida da integração do Frida com apps escritos em Swift. Por meio dele, o Beerus se comunica com o Frida Server em execução no dispositivo, permitindo controlar sessões de instrumentação e rodar scripts sem depender de um computador externo.
Todo o processo pode ser realizado inteiramente dentro do Beerus Framework: pelo Script Editor, o usuário inicia o Frida Server e pode então criar ou importar um script para o armazenamento do dispositivo. Depois de adicionar o script, basta editá-lo se necessário, selecionar a aplicação alvo e tocar em "Run". O Beerus então usa o Frida Swift para iniciar a aplicação e instrumentá-la com o script selecionado.

ScriptConsoleViewController.swift (Lines 306…341)

    private func executeOnTarget(pid: UInt) {
        Task {
            do {
                await log("Attaching to PID \(pid)…", systemColor)
                await MainActor.run { setStatus(.connecting) }

                let session = try await FridaManager.shared.beginScript(
                    source: scriptModel.source, pid: pid)
                self.scriptSession = session

                eventTask = Task { [weak self] in
                    guard let self else { return }
                    for await rawJSON in session.rawMessages {
                        await self.handleRawMessage(rawJSON)
                    }
                    await log("Event stream ended.", systemColor)
                    if !self.didReceiveOutput {
                        await log("(script produced no output)", systemColor)
                    }
                    await MainActor.run {
                        self.isRunning = false
                        self.setStatus(.stopped)
                    }
                }

                await MainActor.run {
                    isRunning = true
                    setStatus(.running)
                }
                await log("Script loaded and running.", systemColor)
            } catch {
                await log("Failed: \(error.localizedDescription)", errorColor)
                await MainActor.run { setStatus(.error) }
            }
        }
    }

FridaManager.swift (Lines 82…108)

    func beginScript(source: String, pid: UInt) async throws -> ScriptSession {
        device = nil
        let dev = try await getDevice()
        let session = try await attachWithRetry(device: dev, pid: pid)
        let script = try await session.createScript(source)

        let (stream, continuation) = AsyncStream<String>.makeStream()

        let eventTask = Task {
            for await event in script.events {
                guard case .message(let m, _) = event else { continue }
                if let data = try? JSONSerialization.data(withJSONObject: m),
                   let json = String(data: data, encoding: .utf8) {
                    continuation.yield(json)
                }
            }
            continuation.finish()
        }

        try await script.load()

        return ScriptSession(rawMessages: stream) {
            eventTask.cancel()
            try? await script.unload()
            try? await session.detach()
        }
    }

Pre-check and App Selection (ScriptConsoleViewController.swift, Lines 254…279)

  • startExecution() verifica se o frida-server está no ar com FridaChecker.isRunning(); se não estiver, imprime um erro e aborta. A injeção em si conversa com o frida-server por TCP localhost:27042 (o frida-server foi previamente instalado e iniciado como root pelo beerusd — veja Frida Server Setup).
  • Imprime "Starting script: " e "Waiting for app selection…".
  • Instancia AppPickerViewController e o apresenta; onSelect atualiza o label "PID: " e chama executeOnTarget(pid:); onCancel define .stopped e imprime "Cancelled — no app selected.".

Attaching to the Process — (ScriptConsoleViewController.swift, Lines 281…287)

  • executeOnTarget(pid:) imprime "Attaching to PID …" e define o status .connecting.
  • Chama FridaManager.shared.beginScript(source:pid:).

Attach — (FridaManager.swift, Lines 82…86 e 112…128)

  • beginScript reseta o device e obtém a conexão com getDevice(), que faz deviceManager.addRemoteDevice(address: "localhost") — o frida-server local.
  • Faz o attach com attachWithRetry(device:pid:): até 3 tentativas, timeout de 30s cada (via withTimeout em torno de device.attach(to: pid)), com espera de 1s entre falhas; lança FridaError.attachFailed se esgotar.

Script Creation and Loading (FridaManager.swift, Lines 86…107)

  • Cria o script com session.createScript(source) a partir do código-fonte do editor.
  • Cria um AsyncStream<String> (um par stream/continuation) para carregar as mensagens.
  • Inicia a eventTask: itera sobre script.events, filtra .message, serializa cada payload para JSON e chama continuation.yield(json); quando termina, continuation.finish().
  • Carrega o script no processo alvo com script.load().
  • Retorna uma ScriptSession com o stream e uma closure de parada (cancela a eventTask, script.unload() e session.detach()).

Event Consumption and the "Running" State (ScriptConsoleViewController.swift, Lines 287…312)

  • Armazena a sessão em self.scriptSession.
  • Inicia a eventTask: consome session.rawMessages e, para cada JSON bruto, chama handleRawMessage(rawJSON).
  • Quando o stream termina: imprime "Event stream ended."; se nada foi produzido, "(script produced no output)"; define isRunning = false e status .stopped.
  • Após iniciar o consumo: define isRunning = true, status .running e imprime "Script loaded and running.".
  • Em uma exceção de attach/load: imprime "Failed: " e status .error.

Handling Script Messages (ScriptConsoleViewController.swift, Lines 318…361)

  • handleRawMessage decodifica o JSON em um dicionário e lê o campo type.
  • "send": extrai texto/cor via parseSendPayload (distingue log/error/result pela cor) e imprime no console.
  • "error": imprime "ERROR: " e, se presente, a stack.
  • "log": imprime o payload usando uma cor baseada no nível (error → vermelho).
  • Toda a saída vai para a UI via log(...)appendOutput no MainActor. Define didReceiveOutput = true.

Session Teardown (ScriptConsoleViewController.swift, Lines 363…379 / FridaManager.swift 102…106)

  • stopExecution() (via stopTapped ou ao sair da tela): se não estiver rodando, retorna; caso contrário, define isRunning = false.
  • Cancela e limpa a eventTask.
  • Chama session.finish(), que dispara a closure de parada da ScriptSession: cancela a eventTask interna, roda script.unload() e session.detach().
  • stopTapped também define o status .stopped e imprime "Stopped by user.".
0:00
/0:12

IPA Extractor

O recurso IPA Extractor permite extrair o IPA de uma aplicação iOS usando scripts Frida para obter o executável descriptografado depois que ele é carregado na memória, removendo a proteção aplicada pelo FairPlay DRM e produzindo um pacote que pode ser usado para análise estática e trabalho de engenharia reversa.

InstalledAppsViewController.swift (dump → IPA orchestration, Lines 111…113, 130…140, 167…171)

                guard FridaChecker.isRunning() else {
                    throw DumpError.fridaNotRunning
                }
                let bundleInfo = try await FridaManager.shared.dumpExecutable(
                    bundleId: app.bundleIdentifier,
                    pid: app.pid,
                    outputPath: dumpPath
                ) { msg in
                    loadingVC.addLog(msg)
                }

                guard FileManager.default.fileExists(atPath: dumpPath) else {
                    throw DumpError.executableNotDumped
                }
                let ipaURL = try IPABuilder.build(app: appWithPaths, dumpedExecutable: dumpPath) { msg in
                    loadingVC.addLog(msg)
                } progressCallback: { done, total in
                    loadingVC.updateProgress(done, total: total)
                }

Entry and Listing (IPAExtractorViewController.swift 52…56 / InstalledAppsViewController.swift 44…67)

  • dumpButtonTapped() empilha InstalledAppsViewController na navigation stack.
  • loadApps() chama FridaManager.shared.getInstalledApps() (via frida-server, enumerateApplications) para listar os apps; tocar em uma linha revalida a lista e chama showDumpConfirmation(for:).

Dump → IPA Flow (InstalledAppsViewController.swift, Lines 101…190)

  • performDump(app:loadingVC:) roda em uma Task e conduz as fases através do LoadingViewController (.checking → .attaching → .dumping → .building → .complete).
  • checking: valida FridaChecker.isRunning(); se não estiver, lança DumpError.fridaNotRunning.
  • dumping: define dumpPath = /tmp/beerus_dump_<uuid> e chama FridaManager.shared.dumpExecutable(...), que faz o attach através do frida-server, injeta FridaScripts.dumpScript e extrai o binário descriptografado; encaminha cada msg para loadingVC.addLog.
  • Verifica se o binário foi gravado (FileManager.fileExists) e monta um AppModel com os caminhos reais do bundle retornados em BundleInfo.
  • building: IPABuilder.build(app:dumpedExecutable:) empacota o .ipa localmente (com callbacks de log e progresso updateProgress(done,total)).
  • Remove o dump temporário, marca .complete, chama showSuccess(ipaPath:) e, após ~2s, dispensa a tela e abre a share sheet (showShareSheet(for:)). Qualquer erro cai em loadingVC.showError(error).

Visual Feedback (LoadingViewController.swift, Lines 228…341)

  • updatePhase(_:) anima a mudança de fase; updateProgress(_:total:) atualiza a barra; addLog(_:) acrescenta linhas ao console.
  • showSuccess(ipaPath:) e showError(_:) encerram a tela em um estado de sucesso/erro.
0:00
/0:21

Memory Dump

O recurso Memory Dump faz localmente o dump da memória de um processo em execução e salva tudo em um arquivo compactado. Esse pacote pode ser baixado para análise posterior.

ProcessListViewController.swift (memory-dump orchestration, Lines 101…120)

                let result = try await FridaManager.shared.dumpMemory(
                    pid: app.pid,
                    outputDir: outputDir
                ) { msg in
                    loadingVC.addLog(msg)
                }

                try Task.checkCancellation()

                loadingVC.updatePhase(.packaging)

                // Zip only the single dump.bin + index.json (fast — just 2 files)
                let zipPath = NSTemporaryDirectory() + "\(safeName)_memdump.zip"
                try? FileManager.default.removeItem(atPath: zipPath)

                let zipURL = URL(fileURLWithPath: zipPath)
                try ZipArchive.create(
                    at: zipURL,
                    from: URL(fileURLWithPath: outputDir)
                )

Entry and Listing (MemoryDumpViewController.swift 54…56 / ProcessListViewController.swift 43…63)

  • dumpButtonTapped() empilha ProcessListViewController.
  • loadRunningApps() chama FridaManager.shared.getInstalledApps() e mantém apenas os que estão em execução; na seleção, startDump(for:) cria MemoryDumpLoadingViewController e chama performMemoryDump.

Memory Dump Flow (ProcessListViewController.swift, Lines 81…155)

  • performMemoryDump(app:loadingVC:) roda em um dumpTask cancelável e conduz as fases (.connecting → .attaching → .dumping → .packaging → .complete).
  • connecting: valida FridaChecker.isRunning() (caso contrário DumpError.fridaNotRunning).
  • dumping: monta outputDir no tmp (beerus_memdump_<name>_<timestamp>), checa cancelamento e chama FridaManager.shared.dumpMemory(pid:outputDir:), que faz o attach através do frida-server e roda FridaScripts.memoryDumpScript; internamente usa executeScriptWithProgress, cujo idle timeout de 120s reinicia a cada mensagem de log/progresso (um dump longo não é confundido com um travamento).
  • packaging: comprime dump.bin + index.json em um .zip com ZipArchive.create(at:from:) (compressão local via Compression/zlib, não o daemon) e remove o diretório bruto.
  • complete: showSuccess(message:) com a contagem/tamanho/tempo das regiões e, após ~2s, dispensa + share (showShareSheet(for: zipURL)). CancellationError fecha sem erro; outros erros vão para showError(error).
0:00
/1:28

LLDB Server

O recurso LLDB Server permite selecionar uma aplicação iOS e iniciar o debugserver anexado ao seu processo na porta "1234", habilitando uma conexão remota via LLDB para realizar análise dinâmica, como definir breakpoints, inspecionar memória e registradores e acompanhar a execução da aplicação.

LLDBServerViewController.swift (debugserver attach, Lines 282…310)

            _ = RootExec.shell("killall -9 debugserver 2>/dev/null")
            try? await Task.sleep(nanoseconds: 300_000_000)

            // Clear old log
            _ = RootExec.shell("> \(self.logFile)")

            await self.setProgress(0.4, "attaching to \(process.name) (\(process.pid))...")

            // Launch debugserver attached to the target PID, log output to file
            let launch = "( \(path) 0.0.0.0:\(self.port) --attach=\(process.pid) "
                       + "> \(self.logFile) 2>&1 & )"
            _ = RootExec.shell(launch)

            await self.setProgress(0.7, "verifying...")

            // Poll for the server process
            var serverPID = ""
            for i in 0..<6 {
                try? await Task.sleep(nanoseconds: 500_000_000)
                let check = RootExec.shell(
                    "ps -eo pid,comm 2>/dev/null | grep debugserver | grep -v grep | head -1 | sed 's/^[[:space:]]*//' | cut -d' ' -f1"
                )
                let p = check.output.trimmingCharacters(in: .whitespacesAndNewlines)
                if !p.isEmpty {
                    serverPID = p
                    break
                }
                await self.setProgress(0.7 + Float(i + 1) * 0.04, "verifying...")
            }

State Detection (LLDBServerViewController.swift, Lines 141…187)

  • refreshState() verifica se o daemon está no ar (RootExec.isRunning, ou seja, PINGPONG); se não, marca .notInstalled.
  • Via daemon, roda um script que resolve o caminho do debugserver (which) e procura o PID em execução (ps … grep debugserver), aplicando .online/.offline/.notInstalled.
  • actionTapped() (189) despacha: install / start (process picker) / stop dependendo do estado atual.

Installation (LLDBServerViewController.swift, Lines 198…235)

  • install() exige que o daemon esteja no ar, checa apt-get/apt-cache show debugserver e instala com apt-get install -y debugserver via RootExec.shell — instalar pacotes só é possível como root, daí o daemon.

Attaching to the Process (LLDBServerViewController.swift, Lines 237…311)

  • showProcessPicker() lista processos (RootExec.shell("ps -eo pid,comm")) e, na seleção, chama attachToProcess(_:).
  • attachToProcess(_:) roda em uma Task.detached: mata o debugserver anterior (killall -9), limpa o log e lança o debugserver anexado ao alvo: <path> 0.0.0.0:<port> --attach=<pid> > log 2>&1 &.
  • Cada RootExec.shell aqui é indispensável porque o app não pode matar outros processos, escrever no logFile do sistema ou anexar o debugserver a outro PID — tudo requer root.
  • Faz polling (até ~6×500ms) com ps … grep debugserver para obter o serverPID; em caso de sucesso aplica .online e inicia startLogStreaming(); caso contrário .offline.

Stop and Log (LLDBServerViewController.swift, Lines 313…374)

  • stopServer() roda killall -9 debugserver (via daemon) e retorna a .offline.
  • startLogStreaming() faz polling do log via daemon (tail -c +<offset> <logFile>, lendo apenas o que é novo) enquanto o processo estiver vivo, atualizando o console; quando detecta a saída, acrescenta "[debugserver exited]".

Daemon Bridge (BeerusDaemon.c, Lines 1755…1779 e 801…885)

  • Todo RootExec.shell(...) nesta tela envia SHELL <cmd>. O handler SHELL (1755) roda sh -c <cmd> via runCommand/posix_spawn (801) como root, com stdout+stderr no pipe, e retorna a saída + o trailer \n\0EXIT:<code>\0.
  • O que é passado é a linha de comando; o que volta é output + exitCode — usado, por exemplo, para extrair o serverPID do ps ou o novo trecho de log do tail.
0:00
/3:13

Proxy Profiles

O recurso Proxy Profiles permite criar diferentes perfis de proxy e habilitá-los ou desabilitá-los a qualquer momento, sem precisar configurar ou remover o proxy manualmente ao trocar de rede ou usar os apps normalmente. Para criar um perfil, basta informar um nome e o endereço IP com uma porta. Depois disso, você pode habilitar/desabilitar, editar ou excluir o perfil sempre que precisar.

ProxyProfilesViewController.swift (apply/toggle proxy, Lines 133…149)

    private func handleSwitchChange(_ sender: UISwitch) {
        let index = sender.tag
        guard profiles.indices.contains(index) else { return }

        let profile = profiles[index]
        let turningOn = sender.isOn
        let result = RootExec.setProxy(turningOn ? profile.proxy : "OFF")

        guard result.exitCode == 0 else {
            sender.setOn(!turningOn, animated: true)
            self.showAlert(title: "Erro", message: "Não foi possível aplicar o proxy.")
            return
        }

        storage.setProfileEnabled(named: profile.name, enabled: turningOn)
        reloadProfiles()
    }

Daemon Bridge — SET_PROXY (RootExec.swift 201…283 / BeerusDaemon.c 1571…1712)

  • setProxy(_:) (RootExec.swift 201) abre o socket e envia SET_PROXY <value>, onde <value> é host:port (ex.: 127.0.0.1:8083) ou OFF.
  • No daemon, o handler SET_PROXY (1571) faz o que o sandbox não pode: lê e reescreve /var/preferences/SystemConfiguration/preferences.plist (a configuração de rede do sistema) via CFPropertyList. Localiza o set/serviço ativo e o dicionário Proxies; para host:port valida a porta e escreve HTTPEnable=1, HTTPProxy, HTTPPort e os equivalentes HTTPS* (1699); para OFF remove essas chaves (1662).
  • Passado: a string do proxy. Esperado de volta: ok: proxy set to <ip>:<port> / ok: proxy disabled, ou um erro (formato/porta inválidos, falha ao ler as preferences).

Proxy Actions (ProxyProfilesViewController.swift, Lines 7…160)

  • proxyOff(_:) (7): RootExec.setProxy("OFF") + storage.disableAllProfiles().
  • proxyTest(_:) (13): aplica um proxy de teste fixo 127.0.0.1:8083.
  • addProfileTapped(_:) (17): coleta nome/proxy e persiste via storage.addProfile(name:proxy:).
  • handleSwitchChange(_:) (133): on → setProxy(profile.proxy); off → setProxy("OFF"); em ambos atualiza o estado no storage e recarrega.
  • handleDeleteProfile(_:) (150): se ativo, desliga o proxy antes de storage.deleteProfile(named:).

Persistence (ProxyProfilesStorage.swift, Lines 32…98)

  • ProxyProfile é Codable (name, proxy, isEnabled), salvo em um arquivo JSON dentro do sandbox (fileURL).
  • fetchProfiles()/saveProfiles(_:) leem e escrevem a lista; addProfile, deleteProfile, setProfileEnabled e disableAllProfiles a mutam e a reescrevem.
0:00
/0:21

Terminal

O recurso Terminal fornece um terminal integrado ao Beerus Framework, permitindo executar comandos diretamente no dispositivo com privilégios de root através do Daemon do framework.

TerminalViewController.swift (running root commands, Lines 236…266)

        // Check daemon
        guard RootExec.isRunning else {
            appendOutput("error: beerus daemon is not running\n",
                         color: UIColor(red: 1, green: 0.3, blue: 0.3, alpha: 1))
            return
        }

        isExecuting = true
        sendButton.isEnabled = false
        promptLabel.text = "..."

        Task.detached { [weak self] in
            let result = RootExec.shell(cmd)
            await MainActor.run {
                guard let self else { return }
                self.isExecuting = false
                self.sendButton.isEnabled = true
                self.promptLabel.text = "root#"

                if !result.output.isEmpty {
                    self.appendOutput(result.output + "\n", color: .white)
                }

                if result.exitCode != 0 {
                    self.appendOutput(
                        "exit: \(result.exitCode)\n",
                        color: UIColor(red: 1, green: 0.3, blue: 0.3, alpha: 1)
                    )
                }
            }
        }

Command Execution (TerminalViewController.swift, Lines 205…267)

  • executeCurrentInput() (também chamado por sendTapped/Return) lê e limpa o input, o armazena no histórico e ecoa root# <cmd>.
  • Trata built-ins locais: help, clear, history (sem ir ao daemon).
  • O daemon entra apenas depois de descartar os built-ins e confirmar, via RootExec.isRunning (PING), que o beerusd está no ar; caso contrário, imprime um erro.
  • Roda o comando em uma Task.detached via RootExec.shell(cmd), que envia SHELL <cmd>; o daemon faz posix_spawn de sh -c <cmd> como root com stdout/stderr capturados e retorna a saída + código. É indispensável porque todo o propósito da tela é rodar comandos como root, além do alcance do processo do app.
  • printWelcome() (166) e printHelp() (269) apenas escrevem texto no console; appendOutput (309) acrescenta com timestamp/cor.
0:00
/0:11

Plist Reader

O recurso Plist Reader permite que os usuários listem e leiam arquivos .plist associados à aplicação selecionada diretamente pela interface do Beerus Framework, eliminando a necessidade de acessar manualmente o dispositivo por um terminal. Essa funcionalidade simplifica a análise de arquivos de configuração da aplicação e de outras informações armazenadas nesse formato.

AppManager.swift (discovering apps and their .plist files, Lines 51…57, 80…88)

            let name = appFolder.components(separatedBy: ".app").first ?? ""
            let appPath = "\(uuidPath)/\(appFolder)"
            let plistPath = "\(appPath)/Info.plist"

            guard let plist = NSDictionary(contentsOfFile: plistPath) as? [String: Any] else { continue }

            guard let bundleId = plist["CFBundleIdentifier"] as? String else { continue }
            var plistFiles: [String] = []

            if let fileEnum = fileManager.enumerator(atPath: appPath) {
                for case let File as String in fileEnum {
                    if File.hasSuffix(".plist") {
                        plistFiles.append(appPath+"/"+File)
                    }
                }
            }

FileBottomSheet.swift (reading a .plist, Lines 29…38)

    func readPlist() -> [(key: String, value: String)] {
        var plistsValues: [String: String] = [:]
        if let dict = NSDictionary(contentsOfFile: fileText) as? [String: Any] {
           for (key, value) in dict {
               plistsValues["\(key)"] = "\(value)"
           }
        }

        return plistsValues.sorted { $0.key < $1.key }
    }

App Discovery (AppManager.swift, Lines 34…98)

  • getApps() varre /private/var/containers/Bundle/Application/, entra em cada UUID, encontra a pasta .app e lê o Info.plist (NSDictionary(contentsOfFile:)) para obter CFBundleIdentifier, nome e ícone.
  • Coleta a lista de todos os arquivos .plist do bundle (plistFiles) e resolve o dataContainerURL via LSApplicationWorkspace, montando um AppInfo por bundle id.
  • PlistReaderViewController.viewDidLoad (56…60) chama appmanager.getApps() e popula a tabela.

Opening and Reading a Plist (PlistReaderViewController 119…166 / FileBottomSheet.swift 30…39)

  • Selecionar um app (didSelectRowAt/openPlistsMenu) abre BottomSheetViewController(plists:) com a lista de .plist daquele app.
  • Ao escolher um arquivo, FileBottomSheet.readPlist() carrega o .plist com NSDictionary(contentsOfFile:), achata cada entrada em key → value e retorna os pares ordenados por chave ([(key, value)]) para exibição na tabela.
0:00
/0:22

APP Store

O recurso App Store integra o Beerus Framework à loja de aplicativos, permitindo selecionar e instalar versões mais antigas de aplicações diretamente da App Store, simplificando o processo de downgrade para testar e analisar diferentes versões.

AppStoreService.swift (login / download, Lines 168…175, 190…195, 524…565)

    func login(email: String, password: String, authCode: String = "") async throws -> AppStoreAccount {
        let guid = getGUID()
        var endpoint = try await fetchBag()

        // ponytail: ipatool requires trailing slash for /native/ auth endpoints
        if endpoint.contains("/native/") && !endpoint.hasSuffix("/") {
            endpoint += "/"
        }
            let payload = PlistPayload.buildLoginPayload(
                email: email, password: password,
                authCode: authCode, guid: guid, attempt: attempt
            )
            // ponytail: login uses form-urlencoded body, not plist
            let body = PlistPayload.encodeFormData(payload)
    func download(app: AppStoreApp, externalVersionID: String? = nil,
                  progress: ((Int64, Int64) -> Void)? = nil) async throws -> DownloadResult {
        let account = try accountInfo()
        let guid = getGUID()

        let item = try await fetchDownloadItem(
            account: account, appID: app.id, guid: guid,
            externalVersionID: externalVersionID
        )

        let version: String = {
            if let v = item.metadata["bundleShortVersionString"] { return "\(v)" }
            return "unknown"
        }()

        // ponytail: sanitize filename - remove chars that break shell commands
        let safeBundleID = app.bundleID.replacingOccurrences(of: "'", with: "")
        let safeVersion = version
            .replacingOccurrences(of: "'", with: "")
            .replacingOccurrences(of: " ", with: "_")
            .replacingOccurrences(of: "/", with: "-")
        let fileName = "\(safeBundleID)_\(app.id)_\(safeVersion).ipa"
        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        let destination = docs.appendingPathComponent(fileName).path

        // Unique per invocation so two concurrent downloads of the same bundleID/version don't race
        // on the same working file.
        let tmpPath = destination + ".\(UUID().uuidString.prefix(8)).tmp"
        try await downloadFile(from: item.url, to: tmpPath, progress: progress)

        try IPAProcessor.applyPatches(
            metadata: item.metadata,
            account: account,
            sinfs: item.sinfs,
            sourcePath: tmpPath,
            destinationPath: destination
        )

        try? FileManager.default.removeItem(atPath: tmpPath)

        return DownloadResult(destinationPath: destination, sinfs: item.sinfs)
    }

Authentication (AppStoreService.swift, Lines 117…345)

  • fetchBag() (117) resolve o endpoint de autenticação da Apple (com suporte a /native/).
  • login(email:password:authCode:) (168) obtém o GUID do dispositivo (getGUID), monta o payload com PlistPayload.buildLoginPayload (form-urlencoded) e tenta novamente até 4 vezes.
  • Trata redirects, o código -5000 (credenciais inválidas), 2FA (quando authCode está ausente) e extrai token + dsid, retornando/armazenando um AppStoreAccount.
  • accountInfo() (346) lê a conta armazenada; revoke() (355) a exclui (quando o token expira).

Search (AppStoreService.swift, Lines 361…450)

  • search(term:limit:) resolve o código do país (a partir do storeFront) e consulta https://<iTunesDomain>/search, mapeando o JSON para [AppStoreApp].
  • lookup(bundleID:) (413) é a variante por bundle-id.

Purchase and Download (AppStoreService.swift 451…560 / DownloadViewController.swift 132…180)

  • purchase(app:) (451) realiza a "compra" gratuita (STDQ) necessária para apps ainda não associados à conta.
  • download(app:externalVersionID:progress:) (524): busca o item (fetchDownloadItem), baixa o .ipa bruto para <dest>.tmp com downloadFile (callback de progresso) e aplica os patches com IPAProcessor.applyPatches, produzindo o .ipa final.
  • DownloadViewController.startDownload() conduz os passos na UI: purchase (tolera "already purchased") → download (barra/label em MB/%) → passos de patch → armazena downloadedPath e habilita Share/Files.
  • handleTokenExpired() (222) chama revoke() e pede um novo login; retryTapped() reinicia o fluxo.

Final Packaging (IPAProcessor.swift, Lines 5…70)

  • applyPatches(metadata:account:sinfs:sourcePath:destinationPath:) reescreve o zip do .ipa localmente: injeta o iTunesMetadata.plist e, para cada caminho listado nos SinfPaths do Manifest.plist, escreve o .sinf correspondente (validando que as contagens batem), produzindo o .ipa assinado para a conta.
0:00
/0:30

JB Bypass

O recurso Jailbreak Bypass permite contornar os mecanismos de detecção de Jailbreak implementados por certas aplicações, permitindo que elas rodem em dispositivos com Jailbreak quando as verificações usadas pelo app são compatíveis com o método de bypass.

SpawnGateService.swift (spawn injection — "Frida Auto Inject", Lines 21…44, 90…114)

    func start() async throws {
        guard !isRunning else { return }

        log("Starting spawn gating...")

        do {
            device = try await deviceManager.addRemoteDevice(address: "localhost")
            guard let device else { throw SpawnGateError.deviceNotAvailable }

            try await device.enableSpawnGating()
            isRunning = true
            onStatusChange?(true)
            log("Spawn gating enabled")

            gatingTask = Task { [weak self] in
                await self?.pollPendingSpawns()
            }
        } catch {
            isRunning = false
            onStatusChange?(false)
            log("Failed to enable spawn gating: \(error.localizedDescription)")
            throw error
        }
    }
    private func injectBypassAndResume(device: Device, spawn: SpawnDetails) async {
        let pid = spawn.pid
        let identifier = spawn.identifier ?? "pid:\(pid)"

        log("Spawn: \(identifier)")

        do {
            // Timeout: 5 seconds max for entire injection
            try await withTimeout(5) { [self] in
                let session = try await device.attach(to: pid)
                let script = try await session.createScript(JBBypassScript.source)
                try await script.load()
                self.log("Injected: \(identifier)")
            }
        } catch {
            log("Injection failed for \(identifier): \(error.localizedDescription)")
        }

        // ALWAYS resume - fail-open pattern
        do {
            try await device.resume(pid)
        } catch {
            log("Resume failed for \(identifier): \(error.localizedDescription)")
        }
    }

Spawn Gating + Auto Inject (SpawnGateService.swift, Lines 21…125)

  • start() conecta ao frida-server (localhost), habilita enableSpawnGating() (novos apps sobem pausados) e dispara gatingTask com o loop de polling.
  • pollPendingSpawns() chama enumeratePendingSpawn() continuamente e, para cada spawn pendente, chama injectBypassAndResume.
  • injectBypassAndResume(device:spawn:): com um timeout de 5s, faz device.attach(to: pid), cria o script com JBBypassScript.source e script.load(); sempre chama device.resume(pid) no final (fail-open — não trava o app se a injeção falhar).
  • stop() (46) cancela o loop e chama disableSpawnGating().
  • JBBypassScript.source é o JavaScript do Frida que intercepta as verificações de Jailbreak (ex.: Interceptor.attach em canOpenURL, checagens de arquivo/caminho).

Shadow Installation (ShadowService.swift, Lines 44…170)

  • isInstalled verifica a presença de Shadow.dylib nos caminhos do MobileSubstrate (rootless e rootful) — uma leitura local.
  • fetchReleases(completion:) lista as releases .deb do Shadow no GitHub; selectRelease(_:) escolhe a versão. O app baixa o .deb para /var/tmp/shadow.deb.
  • install(...)/uninstall(...) dependem do daemon: chamam RootExec.exec("<dpkg> -i '/var/tmp/shadow.deb' 2>&1") (e dpkg -r <packageId> para remover) — instalar/remover pacotes requer root.
  • enableBypass(for:)/disableBypass(for:)modifyAppConfig: montam o plist de config do Shadow em memória, o escrevem em um arquivo temporário no sandbox e usam o daemon para copiá-lo para o local do sistema com as permissões corretas: RootExec.exec("cp '<tmp>' '/var/mobile/Library/Preferences/me.jjolano.shadow.plist' && chmod 644 … && chown mobile:mobile …"). O app não pode escrever nesse caminho por conta própria.

Daemon Bridge — EXEC (RootExec.swift 11 / BeerusDaemon.c 1781…1787 e 520…585)

  • RootExec.exec(_:) envia EXEC <cmd>. No daemon, o handler EXEC (1781) chama run_shell_capture (520), que roda o comando como root e captura a saída em um buffer.
  • Passado: a linha de comando (dpkg -i, cp, killall -9 SpringBoard de respring()). Esperado de volta: a saída/status; o Shadow trata como sucesso quando o retorno é non-nil.

Module UI (JailbreakBypassViewController.swift, Lines 293…402)

  • installTapped()performInstall() chama service.install, mostra o progresso e atualiza a lista; confirmUninstall()/performUninstall() fazem o inverso.
  • loadInstalledApps() varre os bundles instalados para montar a lista de apps.
  • toggleApp(at:) habilita/desabilita o bypass para um app específico via service.enableBypass/disableBypass em background e atualiza a célula.
  • respringTapped()/showRespringPrompt() fazem o respring após instalar/remover.
0:00
/0:14

Conclusão

O Beerus Framework está disponível para download e build diretamente do repositório oficial da Hakai Offensive Security, reunindo em uma única ferramenta uma variedade de recursos voltados para análise de segurança e pentesting de aplicações móveis.
O projeto segue evoluindo, com novos recursos, automações e integrações sendo desenvolvidos para tornar cada vez mais simples o trabalho de pesquisadores e profissionais de segurança. Além disso, o Beerus é um projeto aberto à comunidade: contribuições, issues, pull requests e sugestões de novos recursos são sempre bem-vindas. Ainda há muito mais por vir. Até breve com mais novidades sobre o Beerus Framework! 0w0

Referências

  • Frida – Dynamic instrumentation toolkit for developers, reverse engineers, and security researchers.
  • Frida Swift – Lets you control Frida directly from an app written in Swift.
  • Palera1n - Jailbreak for iPhone, iPad, Macbooks, and AppleTV's for versions 15 and higher
  • Beerus Framework - iOS - Repositório para baixar o framework Beerus para iOS