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 fazerconnectdireto na porta do frida-server (127.0.0.1:27042) com timeout de 1s.- Retorna
trueseconnectretornar 0 (porta do frida-server aberta),falsecaso 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) publicastatusDidChangeNotificationpara 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 viaRootExec.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>(oukillall frida-serverse 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).
- Running → stop:
- Installation (a partir de 148): detecta a arquitetura com
dpkg --print-architecture, monta a URL da release no GitHub, baixa o.debcomRequests.downloadFile(feito pelo app), copia para o tmp do sistema (RootExec.shell("cp …")) e instala via daemon comRootExec.installFrida(from:); erros disparamAlert.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, rodaRootExec.shell("ps aux | grep frida-server")(via daemon) para ver se está ativo e ajustaisRunning/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+, preenchendoversionRunning/selectedVersion.
Daemon Bridge — client authentication (BeerusDaemon.c, Lines 429…457)
- Toda chamada
RootExec.*acima abre o socket UNIX/var/run/beerus.socke envia uma mensagem de texto para o daemonbeerusd, que roda como root (iniciado pelo launchd viacom.beerus.daemon.plist). O app em sandbox não pode iniciar serviços do launchd, instalar pacotes.debou 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, respondeerror: 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 mensagemSHELL <cmd>. O handlerSHELL(1755) resolve a shell (/bin/sh, ou/var/jb/bin/shem rootless) e chamarunCommand(801), que fazposix_spawndesh -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:)viraINSTALL_FRIDA <path>. O handler (1797) chamainstall_frida(709): para um.deb,install_from_deb(602) extrai o pacote comdpkg-deb --extractereplace_frida_binarytroca o binário do frida-server no caminho de sistema, respondendook: frida-server installed …ou um erro.
- No lado Swift,
RootExec.shell(RootExec.swift 48…118) faz o parse do trailer\0EXIT:(102) para separaroutputdeexitCode.
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 comFridaChecker.isRunning(); se não estiver, imprime um erro e aborta. A injeção em si conversa com o frida-server por TCPlocalhost:27042(o frida-server foi previamente instalado e iniciado como root pelobeerusd— veja Frida Server Setup).- Imprime "Starting script:
" e "Waiting for app selection…". - Instancia
AppPickerViewControllere o apresenta;onSelectatualiza o label "PID:— " e chama executeOnTarget(pid:);onCanceldefine.stoppede 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)
beginScriptreseta o device e obtém a conexão comgetDevice(), que fazdeviceManager.addRemoteDevice(address: "localhost")— o frida-server local.- Faz o attach com
attachWithRetry(device:pid:): até 3 tentativas, timeout de 30s cada (viawithTimeoutem torno dedevice.attach(to: pid)), com espera de 1s entre falhas; lançaFridaError.attachFailedse 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 chamacontinuation.yield(json); quando termina,continuation.finish(). - Carrega o script no processo alvo com
script.load(). - Retorna uma
ScriptSessioncom o stream e uma closure de parada (cancela a eventTask,script.unload()esession.detach()).
Event Consumption and the "Running" State (ScriptConsoleViewController.swift, Lines 287…312)
- Armazena a sessão em
self.scriptSession. - Inicia a eventTask: consome
session.rawMessagese, para cada JSON bruto, chamahandleRawMessage(rawJSON). - Quando o stream termina: imprime "Event stream ended."; se nada foi produzido, "(script produced no output)"; define
isRunning = falsee status.stopped. - Após iniciar o consumo: define
isRunning = true, status.runninge 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)
handleRawMessagedecodifica o JSON em um dicionário e lê o campotype.- "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(...)→appendOutputno MainActor. DefinedidReceiveOutput = true.
Session Teardown (ScriptConsoleViewController.swift, Lines 363…379 / FridaManager.swift 102…106)
stopExecution()(viastopTappedou ao sair da tela): se não estiver rodando, retorna; caso contrário, defineisRunning = false.- Cancela e limpa a eventTask.
- Chama
session.finish(), que dispara a closure de parada daScriptSession: cancela a eventTask interna, rodascript.unload()esession.detach(). stopTappedtambém define o status.stoppede imprime "Stopped by user.".
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()empilhaInstalledAppsViewControllerna navigation stack.loadApps()chamaFridaManager.shared.getInstalledApps()(via frida-server,enumerateApplications) para listar os apps; tocar em uma linha revalida a lista e chamashowDumpConfirmation(for:).
Dump → IPA Flow (InstalledAppsViewController.swift, Lines 101…190)
performDump(app:loadingVC:)roda em umaTaske conduz as fases através doLoadingViewController(.checking → .attaching → .dumping → .building → .complete).- checking: valida
FridaChecker.isRunning(); se não estiver, lançaDumpError.fridaNotRunning. - dumping: define
dumpPath = /tmp/beerus_dump_<uuid>e chamaFridaManager.shared.dumpExecutable(...), que faz o attach através do frida-server, injetaFridaScripts.dumpScripte extrai o binário descriptografado; encaminha cadamsgparaloadingVC.addLog. - Verifica se o binário foi gravado (
FileManager.fileExists) e monta umAppModelcom os caminhos reais do bundle retornados emBundleInfo. - building:
IPABuilder.build(app:dumpedExecutable:)empacota o.ipalocalmente (com callbacks de log e progressoupdateProgress(done,total)). - Remove o dump temporário, marca
.complete, chamashowSuccess(ipaPath:)e, após ~2s, dispensa a tela e abre a share sheet (showShareSheet(for:)). Qualquer erro cai emloadingVC.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:)eshowError(_:)encerram a tela em um estado de sucesso/erro.
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()empilhaProcessListViewController.loadRunningApps()chamaFridaManager.shared.getInstalledApps()e mantém apenas os que estão em execução; na seleção,startDump(for:)criaMemoryDumpLoadingViewControllere chamaperformMemoryDump.
Memory Dump Flow (ProcessListViewController.swift, Lines 81…155)
performMemoryDump(app:loadingVC:)roda em umdumpTaskcancelável e conduz as fases (.connecting → .attaching → .dumping → .packaging → .complete).- connecting: valida
FridaChecker.isRunning()(caso contrárioDumpError.fridaNotRunning). - dumping: monta
outputDirno tmp (beerus_memdump_<name>_<timestamp>), checa cancelamento e chamaFridaManager.shared.dumpMemory(pid:outputDir:), que faz o attach através do frida-server e rodaFridaScripts.memoryDumpScript; internamente usaexecuteScriptWithProgress, 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.jsonem um.zipcomZipArchive.create(at:from:)(compressão local viaCompression/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)).CancellationErrorfecha sem erro; outros erros vão parashowError(error).
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,PING→PONG); 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, checaapt-get/apt-cache show debugservere instala comapt-get install -y debugserverviaRootExec.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, chamaattachToProcess(_:).attachToProcess(_:)roda em umaTask.detached: mata odebugserveranterior (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.shellaqui é indispensável porque o app não pode matar outros processos, escrever nologFiledo sistema ou anexar odebugservera outro PID — tudo requer root. - Faz polling (até ~6×500ms) com
ps … grep debugserverpara obter oserverPID; em caso de sucesso aplica.onlinee iniciastartLogStreaming(); caso contrário.offline.
Stop and Log (LLDBServerViewController.swift, Lines 313…374)
stopServer()rodakillall -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 enviaSHELL <cmd>. O handlerSHELL(1755) rodash -c <cmd>viarunCommand/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 oserverPIDdopsou o novo trecho de log dotail.
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 enviaSET_PROXY <value>, onde<value>éhost:port(ex.:127.0.0.1:8083) ouOFF.- 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) viaCFPropertyList. Localiza o set/serviço ativo e o dicionárioProxies; parahost:portvalida a porta e escreveHTTPEnable=1,HTTPProxy,HTTPPorte os equivalentesHTTPS*(1699); paraOFFremove 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 fixo127.0.0.1:8083.addProfileTapped(_:)(17): coleta nome/proxy e persiste viastorage.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 destorage.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,setProfileEnablededisableAllProfilesa mutam e a reescrevem.
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 porsendTapped/Return) lê e limpa o input, o armazena no histórico e ecoaroot# <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 obeerusdestá no ar; caso contrário, imprime um erro. - Roda o comando em uma
Task.detachedviaRootExec.shell(cmd), que enviaSHELL <cmd>; o daemon fazposix_spawndesh -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) eprintHelp()(269) apenas escrevem texto no console;appendOutput(309) acrescenta com timestamp/cor.
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.appe lê oInfo.plist(NSDictionary(contentsOfFile:)) para obterCFBundleIdentifier, nome e ícone.- Coleta a lista de todos os arquivos
.plistdo bundle (plistFiles) e resolve odataContainerURLviaLSApplicationWorkspace, montando umAppInfopor bundle id. PlistReaderViewController.viewDidLoad(56…60) chamaappmanager.getApps()e popula a tabela.
Opening and Reading a Plist (PlistReaderViewController 119…166 / FileBottomSheet.swift 30…39)
- Selecionar um app (
didSelectRowAt/openPlistsMenu) abreBottomSheetViewController(plists:)com a lista de.plistdaquele app. - Ao escolher um arquivo,
FileBottomSheet.readPlist()carrega o.plistcomNSDictionary(contentsOfFile:), achata cada entrada emkey → valuee retorna os pares ordenados por chave ([(key, value)]) para exibição na tabela.
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 comPlistPayload.buildLoginPayload(form-urlencoded) e tenta novamente até 4 vezes.- Trata redirects, o código
-5000(credenciais inválidas), 2FA (quandoauthCodeestá ausente) e extraitoken+dsid, retornando/armazenando umAppStoreAccount. 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 consultahttps://<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.ipabruto para<dest>.tmpcomdownloadFile(callback de progresso) e aplica os patches comIPAProcessor.applyPatches, produzindo o.ipafinal.DownloadViewController.startDownload()conduz os passos na UI:purchase(tolera "already purchased") →download(barra/label em MB/%) → passos de patch → armazenadownloadedPathe habilita Share/Files.handleTokenExpired()(222) chamarevoke()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.ipalocalmente: injeta oiTunesMetadata.pliste, para cada caminho listado nosSinfPathsdoManifest.plist, escreve o.sinfcorrespondente (validando que as contagens batem), produzindo o.ipaassinado para a conta.
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), habilitaenableSpawnGating()(novos apps sobem pausados) e disparagatingTaskcom o loop de polling.pollPendingSpawns()chamaenumeratePendingSpawn()continuamente e, para cada spawn pendente, chamainjectBypassAndResume.injectBypassAndResume(device:spawn:): com um timeout de 5s, fazdevice.attach(to: pid), cria o script comJBBypassScript.sourceescript.load(); sempre chamadevice.resume(pid)no final (fail-open — não trava o app se a injeção falhar).stop()(46) cancela o loop e chamadisableSpawnGating().JBBypassScript.sourceé o JavaScript do Frida que intercepta as verificações de Jailbreak (ex.:Interceptor.attachemcanOpenURL, checagens de arquivo/caminho).
Shadow Installation (ShadowService.swift, Lines 44…170)
isInstalledverifica a presença deShadow.dylibnos caminhos do MobileSubstrate (rootless e rootful) — uma leitura local.fetchReleases(completion:)lista as releases.debdo Shadow no GitHub;selectRelease(_:)escolhe a versão. O app baixa o.debpara/var/tmp/shadow.deb.install(...)/uninstall(...)dependem do daemon: chamamRootExec.exec("<dpkg> -i '/var/tmp/shadow.deb' 2>&1")(edpkg -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(_:)enviaEXEC <cmd>. No daemon, o handlerEXEC(1781) chamarun_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 SpringBoardderespring()). 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()chamaservice.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 viaservice.enableBypass/disableBypassem background e atualiza a célula.respringTapped()/showRespringPrompt()fazem o respring após instalar/remover.
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