EN - Beerus Framework: iOS - The Swiss Army Knife for iOS
JOIN
JOIN
RESEARCH ENTRY 2026.09.18
en

EN - Beerus Framework: iOS - The Swiss Army Knife for iOS

Daniel Franca Lima Gabriel Rodrigues
RESEARCHER Daniel Franca Lima, Gabriel Rodrigues
READ TIME20 MINUTES
PUBLISHED18 Sep 2026
EN - Beerus Framework: iOS - The Swiss Army Knife for iOS

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 to connect straight to the frida-server port (127.0.0.1:27042) with a 1s timeout.
  • Returns true if connect returns 0 (frida-server port open), false otherwise. 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) posts statusDidChangeNotification so screens can refresh.

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

  • fridaDaemonExists() (111…114) tests whether the service plist exists via RootExec.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> (or killall frida-server if there's no plist).
    • Installed and stopped (selected version == installed) → start: launchctl bootstrap system <plist>.
    • New version → download and install (flow below).
  • Installation (from 148): detects the architecture with dpkg --print-architecture, builds the GitHub release URL, downloads the .deb with Requests.downloadFile (done by the app), copies it to the system tmp (RootExec.shell("cp …")), and installs via the daemon with RootExec.installFrida(from:); errors trigger Alert.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 runs RootExec.shell("ps aux | grep frida-server") (via the daemon) to see whether it's active and adjusts isRunning/the button label (Start/Stop).
  • It queries the version with RootExec.shell("<fridaServerPath> --version") and validates it with the regex ^\d+\.\d+\.\d+, filling in versionRunning/selectedVersion.

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

  • Every RootExec.* call above opens the UNIX socket /var/run/beerus.sock and sends a text message to the beerusd daemon, which runs as root (started by launchd via com.beerus.daemon.plist). The sandboxed app cannot start launchd services, install .deb packages, 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 replies error: 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 the SHELL <cmd> message. The SHELL handler (1755) resolves the shell (/bin/sh, or /var/jb/bin/sh on rootless) and calls runCommand (801), which posix_spawns sh -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:) becomes INSTALL_FRIDA <path>. The handler (1797) calls install_frida (709): for a .deb, install_from_deb (602) extracts the package with dpkg-deb --extract and replace_frida_binary swaps the frida-server binary into the system path, replying ok: frida-server installed … or an error.
  • On the Swift side, RootExec.shell (RootExec.swift 48…118) parses the \0EXIT: trailer (102) to split output from exitCode.
0:00
/0:29

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 with FridaChecker.isRunning(); if not, it prints an error and aborts. The injection itself talks to frida-server over TCP localhost:27042 (frida-server was previously installed and started as root by beerusd — see Frida Server Setup).
  • It prints "Starting script: " and "Waiting for app selection…".
  • It instantiates AppPickerViewController and presents it; onSelect updates the "PID: " label and calls executeOnTarget(pid:); onCancel sets .stopped and 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)

  • beginScript resets the device and gets the connection with getDevice(), which does deviceManager.addRemoteDevice(address: "localhost") — the local frida-server.
  • It attaches with attachWithRetry(device:pid:): up to 3 attempts, a 30s timeout each (via withTimeout around device.attach(to: pid)), with a 1s wait between failures; it throws FridaError.attachFailed if 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 calls continuation.yield(json); when it ends, continuation.finish().
  • Loads the script into the target process with script.load().
  • Returns a ScriptSession with the stream and a stop closure (cancels the eventTask, script.unload(), and session.detach()).

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

  • Stores the session in self.scriptSession.
  • Starts the eventTask: it consumes session.rawMessages and, for each raw JSON, calls handleRawMessage(rawJSON).
  • When the stream ends: prints "Event stream ended."; if nothing was produced, "(script produced no output)"; sets isRunning = false and 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)

  • handleRawMessage decodes the JSON into a dictionary and reads the type field.
  • "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(...)appendOutput on the MainActor. Sets didReceiveOutput = true.

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

  • stopExecution() (via stopTapped or when leaving the screen): if it isn't running, it returns; otherwise it sets isRunning = false.
  • Cancels and clears the eventTask.
  • Calls session.finish(), which fires the ScriptSession's stop closure: cancels the internal eventTask, runs script.unload() and session.detach().
  • stopTapped also sets status .stopped and prints "Stopped by user.".
0:00
/0:12

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() pushes InstalledAppsViewController onto the navigation stack.
  • loadApps() calls FridaManager.shared.getInstalledApps() (via frida-server, enumerateApplications) to list the apps; tapping a row re-validates the list and calls showDumpConfirmation(for:).

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

  • performDump(app:loadingVC:) runs in a Task and drives the phases through LoadingViewController (.checking → .attaching → .dumping → .building → .complete).
  • checking: validates FridaChecker.isRunning(); if not, throws DumpError.fridaNotRunning.
  • dumping: sets dumpPath = /tmp/beerus_dump_<uuid> and calls FridaManager.shared.dumpExecutable(...), which attaches through frida-server, injects FridaScripts.dumpScript, and extracts the decrypted binary; it forwards each msg to loadingVC.addLog.
  • Verifies the binary was written (FileManager.fileExists) and builds an AppModel with the real bundle paths returned in BundleInfo.
  • building: IPABuilder.build(app:dumpedExecutable:) packages the .ipa locally (with log and progress callbacks updateProgress(done,total)).
  • Removes the temporary dump, marks .complete, calls showSuccess(ipaPath:), and, after ~2s, dismisses and opens the share sheet (showShareSheet(for:)). Any error falls into loadingVC.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:) and showError(_:) end the screen in a success/error state.
0:00
/0:21

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() pushes ProcessListViewController.
  • loadRunningApps() calls FridaManager.shared.getInstalledApps() and keeps only the ones that are running; on selection, startDump(for:) creates MemoryDumpLoadingViewController and calls performMemoryDump.

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

  • performMemoryDump(app:loadingVC:) runs in a cancelable dumpTask and drives the phases (.connecting → .attaching → .dumping → .packaging → .complete).
  • connecting: validates FridaChecker.isRunning() (otherwise DumpError.fridaNotRunning).
  • dumping: builds outputDir in tmp (beerus_memdump_<name>_<timestamp>), checks for cancellation, and calls FridaManager.shared.dumpMemory(pid:outputDir:), which attaches through frida-server and runs FridaScripts.memoryDumpScript; internally it uses executeScriptWithProgress, whose 120s idle timeout resets on every log/progress message (a long dump isn't mistaken for a hang).
  • packaging: compresses dump.bin + index.json into a .zip with ZipArchive.create(at:from:) (local compression via Compression/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)). CancellationError closes without an error; other errors go to showError(error).
0:00
/1:28

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. PINGPONG); if not, it marks .notInstalled.
  • Via the daemon, it runs a script that resolves the debugserver path (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, checks apt-get/apt-cache show debugserver, and installs with apt-get install -y debugserver via RootExec.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, calls attachToProcess(_:).
  • attachToProcess(_:) runs in a Task.detached: kills the previous debugserver (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.shell here is indispensable because the app can't kill other processes, write to the system logFile, or attach debugserver to another PID — all require root.
  • It polls (up to ~6×500ms) with ps … grep debugserver to get the serverPID; on success it applies .online and starts startLogStreaming(); otherwise .offline.

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

  • stopServer() runs killall -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 sends SHELL <cmd>. The SHELL handler (1755) runs sh -c <cmd> via runCommand/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 the serverPID from ps or the new log chunk from tail.
0:00
/3:13

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 sends SET_PROXY <value>, where <value> is host:port (e.g. 127.0.0.1:8083) or OFF.
  • In the daemon, the SET_PROXY handler (1571) does what the sandbox can't: it reads and rewrites /var/preferences/SystemConfiguration/preferences.plist (the system's network configuration) via CFPropertyList. It locates the active set/service and the Proxies dictionary; for host:port it validates the port and writes HTTPEnable=1, HTTPProxy, HTTPPort, and the HTTPS* equivalents (1699); for OFF it 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 proxy 127.0.0.1:8083.
  • addProfileTapped(_:) (17): collects name/proxy and persists via storage.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 before storage.deleteProfile(named:).

Persistence (ProxyProfilesStorage.swift, Lines 32…98)

  • ProxyProfile is Codable (name, proxy, isEnabled), saved to a JSON file within the sandbox (fileURL).
  • fetchProfiles()/saveProfiles(_:) read and write the list; addProfile, deleteProfile, setProfileEnabled, and disableAllProfiles mutate and rewrite it.
0:00
/0:21

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 by sendTapped/Return) reads and clears the input, stores it in history, and echoes root# <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), that beerusd is up; otherwise it prints an error.
  • Runs the command in a Task.detached via RootExec.shell(cmd), which sends SHELL <cmd>; the daemon posix_spawns sh -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) and printHelp() (269) just write text to the console; appendOutput (309) appends with a timestamp/color.
0:00
/0:11

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 .app folder, and reads Info.plist (NSDictionary(contentsOfFile:)) to get CFBundleIdentifier, name, and icon.
  • Collects the list of all the bundle's .plist files (plistFiles) and resolves the dataContainerURL via LSApplicationWorkspace, building an AppInfo per bundle id.
  • PlistReaderViewController.viewDidLoad (56…60) calls appmanager.getApps() and populates the table.

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

  • Selecting an app (didSelectRowAt/openPlistsMenu) opens BottomSheetViewController(plists:) with that app's .plist list.
  • On choosing a file, FileBottomSheet.readPlist() loads the .plist with NSDictionary(contentsOfFile:), flattens each entry into key → value, and returns the pairs sorted by key ([(key, value)]) for display in the table.
0:00
/0:22

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 with PlistPayload.buildLoginPayload (form-urlencoded), and retries up to 4 times.
  • Handles redirects, code -5000 (invalid credentials), 2FA (when authCode is missing), and extracts token + dsid, returning/storing an AppStoreAccount.
  • 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 queries https://<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 .ipa to <dest>.tmp with downloadFile (progress callback), and applies the patches with IPAProcessor.applyPatches, producing the final .ipa.
  • DownloadViewController.startDownload() drives the steps in the UI: purchase (tolerates "already purchased") → download (bar/label in MB/%) → patch steps → stores downloadedPath and enables Share/Files.
  • handleTokenExpired() (222) calls revoke() 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 .ipa zip locally: it injects iTunesMetadata.plist and, for each path listed in the Manifest.plist SinfPaths, writes the corresponding .sinf (validating that the counts match), producing the .ipa signed for the account.
0:00
/0:30

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), enables enableSpawnGating() (new apps come up paused), and fires gatingTask with the polling loop.
  • pollPendingSpawns() calls enumeratePendingSpawn() continuously and, for each pending spawn, calls injectBypassAndResume.
  • injectBypassAndResume(device:spawn:): with a 5s timeout, does device.attach(to: pid), creates the script with JBBypassScript.source and script.load(); it always calls device.resume(pid) at the end (fail-open — it doesn't hang the app if injection fails).
  • stop() (46) cancels the loop and calls disableSpawnGating().
  • JBBypassScript.source is the Frida JavaScript that intercepts the Jailbreak checks (e.g. Interceptor.attach on canOpenURL, file/path checks).

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

  • isInstalled checks for the presence of Shadow.dylib in the MobileSubstrate paths (rootless and rootful) — a local read.
  • fetchReleases(completion:) lists Shadow's .deb releases on GitHub; selectRelease(_:) picks the version. The app downloads the .deb to /var/tmp/shadow.deb.
  • install(...)/uninstall(...) depend on the daemon: they call RootExec.exec("<dpkg> -i '/var/tmp/shadow.deb' 2>&1") (and dpkg -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(_:) sends EXEC <cmd>. In the daemon, the EXEC handler (1781) calls run_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 SpringBoard from respring()). 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() calls service.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 via service.enableBypass/disableBypass in the background and updates the cell.
  • respringTapped()/showRespringPrompt() do the respring after installing/removing.
0:00
/0:14

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