This past July, we released v1.1 of the Beerus Framework for Android. Now, in September, we take another step in the project's evolution with the launch of the Beerus Framework for iOS, bringing the framework's vision and features to Apple's ecosystem as well. This release also benefited from the contributions of Hakai's pentest team members Daniel "Daniboy" Lima and Gabriel "Texugo" Rodrigues, as well as Kelvin Montini, who contributed to the development and evolution of the project.
With that, in this post we'll cover its structure, features, the technical context behind some decisions, and how each one can help the users who rely on Beerus day to day for testing on iOS devices.
Context
The Beerus Framework is a mobile offensive-security tool built to centralize and simplify the pentesting process directly on the device. Now available for iOS, the framework was built on top of Frida and Palera1n to offer, through a unified interface, features such as dynamic instrumentation of applications, sandbox data extraction, memory dumping, proxy configuration, and IPA file extraction. With a modular and extensible architecture, Beerus aims to streamline recurring tasks and make it easier to run different techniques during security testing on jailbroken iOS devices.
Technical Introduction
The Beerus Framework works as a hub of features for security analysts working with iOS devices. The app provides an intuitive graphical interface with access to a range of tools, leveraging the device's own resources and root privileges (designed to work in tighter sync with Palera1n).
The available functions are:
Frida Server Setup
The Frida Setup feature simplifies configuring the Frida Server directly on the device. The user can select a version and tap "Start Frida" so that Beerus automatically handles downloading, installing, and starting the server. By default, the framework offers the 10 most recent versions, and it also allows manually selecting a specific version. Once installed, a version can be reused without a new download, making it faster and more convenient to prepare the dynamic-instrumentation environment.
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:)opens a TCP socket and tries toconnectstraight to the frida-server port (127.0.0.1:27042) with a 1s timeout.- Returns
trueifconnectreturns 0 (frida-server port open),falseotherwise. It's the gate used before running any script. checkAfterDelay(_:completion:)(38…44) re-checks after a delay (startup/shutdown) and replies on the main thread.notifyStatusChanged()(9…11) postsstatusDidChangeNotificationso screens can refresh.
Toggle / Frida Installation (SetupFridaViewController.swift, Lines 116…230)
fridaDaemonExists()(111…114) tests whether the service plist exists viaRootExec.shell("test -f …")— it needs the daemon only to read a system path outside the sandbox.ToggleFrida(_:)(116) decides the action based on state:- Running → stop:
launchctl bootout system <plist>(orkillall frida-serverif there's no plist). - Installed and stopped (selected version == installed) → start:
launchctl bootstrap system <plist>. - New version → download and install (flow below).
- Running → stop:
- Installation (from 148): detects the architecture with
dpkg --print-architecture, builds the GitHub release URL, downloads the.debwithRequests.downloadFile(done by the app), copies it to the system tmp (RootExec.shell("cp …")), and installs via the daemon withRootExec.installFrida(from:); errors triggerAlert.show. - Each branch calls
checkFridaRunning()at the end to reflect the new state.
State and Version Check (SetupFridaViewController.swift, Lines 378…412)
checkFridaRunning()builds the status/version/button text.- If
startDownloading, it shows "Status: Downloading"; otherwise it runsRootExec.shell("ps aux | grep frida-server")(via the daemon) to see whether it's active and adjustsisRunning/the button label (Start/Stop). - It queries the version with
RootExec.shell("<fridaServerPath> --version")and validates it with the regex^\d+\.\d+\.\d+, filling inversionRunning/selectedVersion.
Daemon Bridge — client authentication (BeerusDaemon.c, Lines 429…457)
- Every
RootExec.*call above opens the UNIX socket/var/run/beerus.sockand sends a text message to thebeerusddaemon, which runs as root (started by launchd viacom.beerus.daemon.plist). The sandboxed app cannot start launchd services, install.debpackages, or inspect system processes — so it delegates to the daemon. - Before serving any command,
verify_client(fd)validates the client: it gets the process PID from the socket itself (getsockopt … LOCAL_PEERPID), confirms the executable path ends in the Beerus binary, and compares the client's CDHash with the authorized one (memcmp(client_hash, g_allowed_cdhash, …)). If it doesn't match, it replieserror: unauthorized. Thus only the legitimate Beerus binary can issue root commands.
Root Execution and Installation (BeerusDaemon.c, Lines 1755…1779, 801…885 and 709…740)
- What gets sent in the steps above:
RootExec.shell("cp …" / "dpkg --print-architecture" / "ps aux | …")becomes theSHELL <cmd>message. TheSHELLhandler (1755) resolves the shell (/bin/sh, or/var/jb/bin/shon rootless) and callsrunCommand(801), whichposix_spawnssh -c <cmd>with stdout and stderr redirected to a pipe and returns the raw output followed by the trailer\n\0EXIT:<code>\0.RootExec.installFrida(from:)becomesINSTALL_FRIDA <path>. The handler (1797) callsinstall_frida(709): for a.deb,install_from_deb(602) extracts the package withdpkg-deb --extractandreplace_frida_binaryswaps the frida-server binary into the system path, replyingok: frida-server installed …or an error.
- On the Swift side,
RootExec.shell(RootExec.swift 48…118) parses the\0EXIT:trailer (102) to splitoutputfromexitCode.
Script Editor + Frida Auto Inject
The Frida Auto Inject feature lets you run and inject Frida scripts directly from the device using Frida Swift, which handles Frida's integration with apps written in Swift. Through it, Beerus communicates with the Frida Server running on the device, allowing you to control instrumentation sessions and run scripts without relying on an external computer.
The whole process can be carried out entirely within the Beerus Framework: through the Script Editor, the user starts the Frida Server and can then create or import a script into the device's storage. After adding the script, simply edit it if needed, select the target application, and tap "Run". Beerus then uses Frida Swift to launch the application and instrument it with the selected script.
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()checks whether frida-server is up withFridaChecker.isRunning(); if not, it prints an error and aborts. The injection itself talks to frida-server over TCPlocalhost:27042(frida-server was previously installed and started as root bybeerusd— see Frida Server Setup).- It prints "Starting script:
" and "Waiting for app selection…". - It instantiates
AppPickerViewControllerand presents it;onSelectupdates the "PID:— " label and calls executeOnTarget(pid:);onCancelsets.stoppedand prints "Cancelled — no app selected.".
Attaching to the Process — (ScriptConsoleViewController.swift, Lines 281…287)
executeOnTarget(pid:)prints "Attaching to PID …" and sets status.connecting.- It calls
FridaManager.shared.beginScript(source:pid:).
Attach — (FridaManager.swift, Lines 82…86 and 112…128)
beginScriptresets the device and gets the connection withgetDevice(), which doesdeviceManager.addRemoteDevice(address: "localhost")— the local frida-server.- It attaches with
attachWithRetry(device:pid:): up to 3 attempts, a 30s timeout each (viawithTimeoutarounddevice.attach(to: pid)), with a 1s wait between failures; it throwsFridaError.attachFailedif it runs out.
Script Creation and Loading (FridaManager.swift, Lines 86…107)
- Creates the script with
session.createScript(source)from the editor's source code. - Creates an
AsyncStream<String>(a stream/continuation pair) to carry the messages. - Starts the eventTask: it iterates
script.events, filters.message, serializes each payload to JSON, and callscontinuation.yield(json); when it ends,continuation.finish(). - Loads the script into the target process with
script.load(). - Returns a
ScriptSessionwith the stream and a stop closure (cancels the eventTask,script.unload(), andsession.detach()).
Event Consumption and the "Running" State (ScriptConsoleViewController.swift, Lines 287…312)
- Stores the session in
self.scriptSession. - Starts the eventTask: it consumes
session.rawMessagesand, for each raw JSON, callshandleRawMessage(rawJSON). - When the stream ends: prints "Event stream ended."; if nothing was produced, "(script produced no output)"; sets
isRunning = falseand status.stopped. - After starting consumption: sets
isRunning = true, status.running, and prints "Script loaded and running.". - On an attach/load exception: prints "Failed:
" and status .error.
Handling Script Messages (ScriptConsoleViewController.swift, Lines 318…361)
handleRawMessagedecodes the JSON into a dictionary and reads thetypefield.- "send": extracts text/color via
parseSendPayload(distinguishes log/error/result by color) and prints to the console. - "error": prints "ERROR:
" and, if present, the stack. - "log": prints the payload using a color based on the level (error → red).
- All output goes to the UI via
log(...)→appendOutputon the MainActor. SetsdidReceiveOutput = true.
Session Teardown (ScriptConsoleViewController.swift, Lines 363…379 / FridaManager.swift 102…106)
stopExecution()(viastopTappedor when leaving the screen): if it isn't running, it returns; otherwise it setsisRunning = false.- Cancels and clears the eventTask.
- Calls
session.finish(), which fires theScriptSession's stop closure: cancels the internal eventTask, runsscript.unload()andsession.detach(). stopTappedalso sets status.stoppedand prints "Stopped by user.".
IPA Extractor
The IPA Extractor feature lets you extract the IPA of an iOS application using Frida scripts to obtain the decrypted executable after it is loaded into memory, removing the protection applied by FairPlay DRM and producing a package that can be used for static analysis and reverse-engineering work.
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()pushesInstalledAppsViewControlleronto the navigation stack.loadApps()callsFridaManager.shared.getInstalledApps()(via frida-server,enumerateApplications) to list the apps; tapping a row re-validates the list and callsshowDumpConfirmation(for:).
Dump → IPA Flow (InstalledAppsViewController.swift, Lines 101…190)
performDump(app:loadingVC:)runs in aTaskand drives the phases throughLoadingViewController(.checking → .attaching → .dumping → .building → .complete).- checking: validates
FridaChecker.isRunning(); if not, throwsDumpError.fridaNotRunning. - dumping: sets
dumpPath = /tmp/beerus_dump_<uuid>and callsFridaManager.shared.dumpExecutable(...), which attaches through frida-server, injectsFridaScripts.dumpScript, and extracts the decrypted binary; it forwards eachmsgtoloadingVC.addLog. - Verifies the binary was written (
FileManager.fileExists) and builds anAppModelwith the real bundle paths returned inBundleInfo. - building:
IPABuilder.build(app:dumpedExecutable:)packages the.ipalocally (with log and progress callbacksupdateProgress(done,total)). - Removes the temporary dump, marks
.complete, callsshowSuccess(ipaPath:), and, after ~2s, dismisses and opens the share sheet (showShareSheet(for:)). Any error falls intoloadingVC.showError(error).
Visual Feedback (LoadingViewController.swift, Lines 228…341)
updatePhase(_:)animates the phase change;updateProgress(_:total:)updates the bar;addLog(_:)appends lines to the console.showSuccess(ipaPath:)andshowError(_:)end the screen in a success/error state.
Memory Dump
The Memory Dump feature locally dumps the memory of a running process and saves everything to a compressed file. This package can be downloaded for later analysis.
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()pushesProcessListViewController.loadRunningApps()callsFridaManager.shared.getInstalledApps()and keeps only the ones that are running; on selection,startDump(for:)createsMemoryDumpLoadingViewControllerand callsperformMemoryDump.
Memory Dump Flow (ProcessListViewController.swift, Lines 81…155)
performMemoryDump(app:loadingVC:)runs in a cancelabledumpTaskand drives the phases (.connecting → .attaching → .dumping → .packaging → .complete).- connecting: validates
FridaChecker.isRunning()(otherwiseDumpError.fridaNotRunning). - dumping: builds
outputDirin tmp (beerus_memdump_<name>_<timestamp>), checks for cancellation, and callsFridaManager.shared.dumpMemory(pid:outputDir:), which attaches through frida-server and runsFridaScripts.memoryDumpScript; internally it usesexecuteScriptWithProgress, whose 120s idle timeout resets on every log/progress message (a long dump isn't mistaken for a hang). - packaging: compresses
dump.bin+index.jsoninto a.zipwithZipArchive.create(at:from:)(local compression viaCompression/zlib, not the daemon) and removes the raw directory. - complete:
showSuccess(message:)with the region count/size/time and, after ~2s, dismiss + share (showShareSheet(for: zipURL)).CancellationErrorcloses without an error; other errors go toshowError(error).
LLDB Server
The LLDB Server feature lets you select an iOS application and start the debugserver attached to its process on port "1234", enabling a remote connection via LLDB to perform dynamic analysis such as setting breakpoints, inspecting memory and registers, and following the application's execution.
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()checks whether the daemon is up (RootExec.isRunning, i.e.PING→PONG); if not, it marks.notInstalled.- Via the daemon, it runs a script that resolves the
debugserverpath (which) and looks for the running PID (ps … grep debugserver), applying.online/.offline/.notInstalled. actionTapped()(189) dispatches: install / start (process picker) / stop depending on the current state.
Installation (LLDBServerViewController.swift, Lines 198…235)
install()requires the daemon to be up, checksapt-get/apt-cache show debugserver, and installs withapt-get install -y debugserverviaRootExec.shell— installing packages is only possible as root, hence the daemon.
Attaching to the Process (LLDBServerViewController.swift, Lines 237…311)
showProcessPicker()lists processes (RootExec.shell("ps -eo pid,comm")) and, on selection, callsattachToProcess(_:).attachToProcess(_:)runs in aTask.detached: kills the previousdebugserver(killall -9), clears the log, and launches debugserver attached to the target:<path> 0.0.0.0:<port> --attach=<pid> > log 2>&1 &.- Each
RootExec.shellhere is indispensable because the app can't kill other processes, write to the systemlogFile, or attachdebugserverto another PID — all require root. - It polls (up to ~6×500ms) with
ps … grep debugserverto get theserverPID; on success it applies.onlineand startsstartLogStreaming(); otherwise.offline.
Stop and Log (LLDBServerViewController.swift, Lines 313…374)
stopServer()runskillall -9 debugserver(via the daemon) and returns to.offline.startLogStreaming()polls the log via the daemon (tail -c +<offset> <logFile>, reading only what's new) while the process is alive, updating the console; when it detects the exit, it appends "[debugserver exited]".
Daemon Bridge (BeerusDaemon.c, Lines 1755…1779 and 801…885)
- Every
RootExec.shell(...)on this screen sendsSHELL <cmd>. TheSHELLhandler (1755) runssh -c <cmd>viarunCommand/posix_spawn(801) as root, with stdout+stderr in the pipe, and returns the output + the trailer\n\0EXIT:<code>\0. - What's passed is the command line; what comes back is
output+exitCode— used, for example, to extract theserverPIDfrompsor the new log chunk fromtail.
Proxy Profiles
The Proxy Profiles feature lets you create different proxy profiles and enable or disable them at any time, without having to manually configure or remove the proxy when switching networks or using apps normally.
To create a profile, just provide a name and the IP address with a port. After that, you can enable/disable, edit, or delete the profile whenever needed.
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) opens the socket and sendsSET_PROXY <value>, where<value>ishost:port(e.g.127.0.0.1:8083) orOFF.- In the daemon, the
SET_PROXYhandler (1571) does what the sandbox can't: it reads and rewrites/var/preferences/SystemConfiguration/preferences.plist(the system's network configuration) viaCFPropertyList. It locates the active set/service and theProxiesdictionary; forhost:portit validates the port and writesHTTPEnable=1,HTTPProxy,HTTPPort, and theHTTPS*equivalents (1699); forOFFit removes those keys (1662). - Passed: the proxy string. Expected back:
ok: proxy set to <ip>:<port>/ok: proxy disabled, or an error (invalid format/port, failure to read the preferences).
Proxy Actions (ProxyProfilesViewController.swift, Lines 7…160)
proxyOff(_:)(7):RootExec.setProxy("OFF")+storage.disableAllProfiles().proxyTest(_:)(13): applies a fixed test proxy127.0.0.1:8083.addProfileTapped(_:)(17): collects name/proxy and persists viastorage.addProfile(name:proxy:).handleSwitchChange(_:)(133): on →setProxy(profile.proxy); off →setProxy("OFF"); in both it updates the state in storage and reloads.handleDeleteProfile(_:)(150): if active, turns the proxy off beforestorage.deleteProfile(named:).
Persistence (ProxyProfilesStorage.swift, Lines 32…98)
ProxyProfileisCodable(name, proxy, isEnabled), saved to a JSON file within the sandbox (fileURL).fetchProfiles()/saveProfiles(_:)read and write the list;addProfile,deleteProfile,setProfileEnabled, anddisableAllProfilesmutate and rewrite it.
Terminal
The Terminal feature provides a terminal integrated into the Beerus Framework, allowing commands to be run directly on the device with root privileges through the framework's Daemon.
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()(also called bysendTapped/Return) reads and clears the input, stores it in history, and echoesroot# <cmd>.- Handles local built-ins:
help,clear,history(without going to the daemon). - The daemon comes in only after discarding the built-ins and confirming, via
RootExec.isRunning(PING), thatbeerusdis up; otherwise it prints an error. - Runs the command in a
Task.detachedviaRootExec.shell(cmd), which sendsSHELL <cmd>; the daemonposix_spawnssh -c <cmd>as root with stdout/stderr captured and returns the output + code. It's indispensable because the whole point of the screen is running commands as root, beyond the app process's reach. printWelcome()(166) andprintHelp()(269) just write text to the console;appendOutput(309) appends with a timestamp/color.
Plist Reader
The Plist Reader feature allows users to list and read .plist files associated with the selected application directly through the Beerus Framework interface, eliminating the need to manually access the device through a terminal. This functionality simplifies the analysis of application configuration files and other information stored in this format.
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()scans/private/var/containers/Bundle/Application/, enters each UUID, finds the.appfolder, and readsInfo.plist(NSDictionary(contentsOfFile:)) to getCFBundleIdentifier, name, and icon.- Collects the list of all the bundle's
.plistfiles (plistFiles) and resolves thedataContainerURLviaLSApplicationWorkspace, building anAppInfoper bundle id. PlistReaderViewController.viewDidLoad(56…60) callsappmanager.getApps()and populates the table.
Opening and Reading a Plist (PlistReaderViewController 119…166 / FileBottomSheet.swift 30…39)
- Selecting an app (
didSelectRowAt/openPlistsMenu) opensBottomSheetViewController(plists:)with that app's.plistlist. - On choosing a file,
FileBottomSheet.readPlist()loads the.plistwithNSDictionary(contentsOfFile:), flattens each entry intokey → value, and returns the pairs sorted by key ([(key, value)]) for display in the table.
APP Store
The App Store feature integrates the Beerus Framework with the App Store, letting you select and install older versions of applications directly from the App Store, simplifying the downgrade process for testing and analyzing different versions.
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) resolves Apple's authentication endpoint (with/native/support).login(email:password:authCode:)(168) gets the device GUID (getGUID), builds the payload withPlistPayload.buildLoginPayload(form-urlencoded), and retries up to 4 times.- Handles redirects, code
-5000(invalid credentials), 2FA (whenauthCodeis missing), and extractstoken+dsid, returning/storing anAppStoreAccount. accountInfo()(346) reads the stored account;revoke()(355) deletes it (when the token expires).
Search (AppStoreService.swift, Lines 361…450)
search(term:limit:)resolves the country code (from the storeFront) and querieshttps://<iTunesDomain>/search, mapping the JSON to[AppStoreApp].lookup(bundleID:)(413) is the by-bundle-id variant.
Purchase and Download (AppStoreService.swift 451…560 / DownloadViewController.swift 132…180)
purchase(app:)(451) performs the free "buy" (STDQ) required for apps not yet associated with the account.download(app:externalVersionID:progress:)(524): fetches the item (fetchDownloadItem), downloads the raw.ipato<dest>.tmpwithdownloadFile(progress callback), and applies the patches withIPAProcessor.applyPatches, producing the final.ipa.DownloadViewController.startDownload()drives the steps in the UI:purchase(tolerates "already purchased") →download(bar/label in MB/%) → patch steps → storesdownloadedPathand enables Share/Files.handleTokenExpired()(222) callsrevoke()and asks for a new login;retryTapped()restarts the flow.
Final Packaging (IPAProcessor.swift, Lines 5…70)
applyPatches(metadata:account:sinfs:sourcePath:destinationPath:)rewrites the.ipazip locally: it injectsiTunesMetadata.plistand, for each path listed in theManifest.plistSinfPaths, writes the corresponding.sinf(validating that the counts match), producing the.ipasigned for the account.
JB Bypass
The JB Bypass feature lets you bypass the Jailbreak-detection mechanisms implemented by certain applications, allowing them to run on Jailbreak devices when the checks used by the app are compatible with the bypass method.
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()connects to frida-server (localhost), enablesenableSpawnGating()(new apps come up paused), and firesgatingTaskwith the polling loop.pollPendingSpawns()callsenumeratePendingSpawn()continuously and, for each pending spawn, callsinjectBypassAndResume.injectBypassAndResume(device:spawn:): with a 5s timeout, doesdevice.attach(to: pid), creates the script withJBBypassScript.sourceandscript.load(); it always callsdevice.resume(pid)at the end (fail-open — it doesn't hang the app if injection fails).stop()(46) cancels the loop and callsdisableSpawnGating().JBBypassScript.sourceis the Frida JavaScript that intercepts the Jailbreak checks (e.g.Interceptor.attachoncanOpenURL, file/path checks).
Shadow Installation (ShadowService.swift, Lines 44…170)
isInstalledchecks for the presence ofShadow.dylibin the MobileSubstrate paths (rootless and rootful) — a local read.fetchReleases(completion:)lists Shadow's.debreleases on GitHub;selectRelease(_:)picks the version. The app downloads the.debto/var/tmp/shadow.deb.install(...)/uninstall(...)depend on the daemon: they callRootExec.exec("<dpkg> -i '/var/tmp/shadow.deb' 2>&1")(anddpkg -r <packageId>to remove) — installing/removing packages requires root.enableBypass(for:)/disableBypass(for:)→modifyAppConfig: they build Shadow's config plist in memory, write it to a temporary file in the sandbox, and use the daemon to copy it to the system location with the correct permissions:RootExec.exec("cp '<tmp>' '/var/mobile/Library/Preferences/me.jjolano.shadow.plist' && chmod 644 … && chown mobile:mobile …"). The app can't write to that path on its own.
Daemon Bridge — EXEC (RootExec.swift 11 / BeerusDaemon.c 1781…1787 and 520…585)
RootExec.exec(_:)sendsEXEC <cmd>. In the daemon, theEXEChandler (1781) callsrun_shell_capture(520), which runs the command as root and captures the output into a buffer.- Passed: the command line (
dpkg -i,cp,killall -9 SpringBoardfromrespring()). Expected back: the output/status; Shadow treats it as success when the return is non-nil.
Module UI (JailbreakBypassViewController.swift, Lines 293…402)
installTapped()→performInstall()callsservice.install, shows progress, and updates the list;confirmUninstall()/performUninstall()do the reverse.loadInstalledApps()scans the installed bundles to build the app list.toggleApp(at:)enables/disables the bypass for a specific app viaservice.enableBypass/disableBypassin the background and updates the cell.respringTapped()/showRespringPrompt()do the respring after installing/removing.
Conclusion
The Beerus Framework is available to download and build directly from Hakai Offensive Security's official repository, bringing together in a single tool a range of features aimed at security analysis and mobile application pentesting.
The project keeps evolving, with new features, automations, and integrations being developed to make the work of researchers and security professionals ever simpler. On top of that, Beerus is an open, community-driven project: contributions, issues, pull requests, and suggestions for new features are always welcome.
There's still much more to come. See you soon with more news about the Beerus Framework! 0w0
References
- 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 - Repository for downloading the Beerus framework for iOS