> ## Content Index
> Fetch the complete content index at: https://yokai.hakaisecurity.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# EN - Beerus Framework: iOS - The Swiss Army Knife for iOS
- URL: https://yokai.hakaisecurity.io/enbeerus-framework-ios-the-swiss-army-knife-for-ios/
- Published: 2026-09-18T13:49:34.000Z
- Updated: 2026-09-18T19:48:43.000Z
- Author: Daniel Franca Lima
- Tags: en, Research Blog, #pair-beerus-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**](https://frida.re/?ref=yokai.hakaisecurity.io) **and** [**Palera1n**](https://palera.in/?ref=yokai.hakaisecurity.io) 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)

```swift
    /// 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)

```swift
            // 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()
                    }
                }
            }

```

```swift
                // Copia para o tmp do sistema via daemon
                let copyResult = RootExec.shell("cp '\(fileURL.path)' '\(systemDebPath)'")

```

```swift
                // 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_spawn`s `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 

1× 

### Script Editor + Frida Auto Inject

The **Frida Auto Inject** feature lets you run and inject Frida scripts directly from the device using [**Frida Swift**](https://github.com/frida/frida-swift?ref=yokai.hakaisecurity.io), 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)

```swift
    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)

```swift
    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 

1× 

### 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)

```swift
                guard FridaChecker.isRunning() else {
                    throw DumpError.fridaNotRunning
                }

```

```swift
                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
                }

```

```swift
                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 

1× 

### 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)

```swift
                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 

1× 

### 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)

```swift
            _ = 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 `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 

1× 

### 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)

```swift
    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 

1× 

### 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)

```swift
        // 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_spawn`s `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 

1× 

### 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)

```swift
            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 }

```

```swift
            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)

```swift
    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 

1× 

### 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)

```swift
    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 += "/"
        }

```

```swift
            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)

```

```swift
    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 

1× 

### 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)

```swift
    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
        }
    }

```

```swift
    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 

1× 

## Conclusion

The **Beerus Framework** is available to download and builddirectlyfrom[**Hakai Offensive Security's** official repository](https://github.com/hakaioffsec/beerus-ios?ref=yokai.hakaisecurity.io), 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](https://github.com/frida/frida?ref=yokai.hakaisecurity.io) – Dynamic instrumentation toolkit for developers, reverse engineers, and security researchers.
- [Frida Swift](https://github.com/frida/frida-swift?ref=yokai.hakaisecurity.io) – Lets you control Frida directly from an app written in Swift.
- [Palera1n](https://palera.in/?ref=yokai.hakaisecurity.io) \- Jailbreak for iPhone, iPad, Macbooks, and AppleTV's for versions 15 and higher
- [Beerus Framework - iOS ](https://github.com/hakaioffsec/beerus-ios?ref=yokai.hakaisecurity.io)\- Repository for downloading the Beerus framework for iOS