Beerus Framework: v1.1 – KernelSU, gRPC Transport and Fixes

Escrito por  Tricta, Daniel Chactoura

A few months after the initial release of the Beerus Framework, we are publishing version v1.1, a minor update that brings support for new root environments, a relevant architectural change in data transport, integrated auto-update, and a set of accumulated fixes since the first release.

In this post, we detail each change included in this version, the technical context behind each decision, and what it means in practice for those who use Beerus in their day-to-day Android testing.


TL;DR: Beerus Framework v1.1 expands compatibility and improves the operational experience for Android pentesting, introducing KernelSU support, fixes to Trusted System Certificates, Memory Dump performance optimizations, and an optional gRPC transport for memory dumps and sandbox exfiltration. This release also fixes startup freezes in Root Modules, upgrades the Frida Auto Injector with a native code editor, live console, and early-process injection, adds integrated auto-updates, introduces iptables as an alternative to Proxy Profiles, and delivers a broader UX/UI refactor. Finally, we highlight the project’s first external contribution, improving Magisk detection, alongside the full changelog and installation and contribution details.


Context

The Beerus Framework was created to consolidate the main mobile pentest operations into a single interface installed directly on the target device. Instead of relying on multiple external tools, standalone scripts, and repetitive manual configurations, Beerus exposes these capabilities in a centralized way, from Frida injection to memory dumps and sandbox extraction.

Version 1.0 established this foundation. v1.1 fixes what was broken, expands compatibility, reshapes interfaces, and paves the way for upcoming developments.


What’s New

KernelSU Support

Beerus assumed Magisk as the main root backend. This worked well for most lab devices but limited the framework to a specific root manager model. In practice, users with KernelSU could have root on the device, but Beerus still treated the environment as if it necessarily had to be Magisk-based.

v1.1 changes this behavior by adding a specific validation for KernelSU in the initialization flow. Now, the application first tries to detect Magisk. If it is not present, Beerus runs a second validation using ksud, the daemon responsible for managing KernelSU. If this validation returns a valid version, the framework considers that a compatible root environment exists and normally enables all features that depend on elevated privileges.

fun detectKernelSu(callback: (Boolean) -> Unit) {
    val ok = try {
        val p = Runtime.getRuntime().exec(arrayOf("su", "-c", "ksud -V"))
        val output = p.inputStream.bufferedReader().readText()
        p.waitFor()

        Regex("""\bksud\s+\d+\.\d+\.\d+\b""").containsMatchIn(output)
    } catch (_: Throwable) {
        false
    }

    callback(ok)
}

The main difference between Magisk and KernelSU lies in the architecture. Magisk operates during the boot process, modifying the initramfs to create a systemless environment capable of providing root access, modules, and system changes without directly modifying the original partitions. KernelSU, on the other hand, implements privilege management directly in the kernel, using ksud to control the granting of root permissions to authorized applications.

For this reason, Beerus no longer depends exclusively on Magisk detection. The initialization flow was adjusted to treat Magisk and KernelSU as two supported root managers. When Magisk is found, the behavior remains unchanged. Otherwise, the application tries to detect KernelSU and, if it is available, enables the rest of the flow normally, allowing root-dependent features to be used regardless of the installed manager.

LaunchedEffect(Unit) {
    detectMagisk { isMagisk ->
        if (isMagisk) {
            updateHasRoot(true)

            getAllModules { paths ->
                mainHandler.post {
                    RootModulesState.setModulePaths(paths)
                }
            }

            detectRootModuleInstalled { isModuleInstalled ->
                if (!isModuleInstalled) {
                    showsRootModuleInstallerDialog()
                } else {
                    updateHasModule(true)
                }
            }
        } else {
            detectKernelSu { isKernelSu ->
                if (isKernelSu) {
                    updateHasRoot(true)

                    getAllModules { paths ->
                        mainHandler.post {
                            RootModulesState.setModulePaths(paths)
                        }
                    }

                    detectRootModuleInstalled { isModuleInstalled ->
                        if (!isModuleInstalled) {
                            showsRootModuleInstallerDialog()
                        } else {
                            updateHasModule(true)
                        }
                    }
                }
            }
        }
    }
}

For the user, the main practical difference is that the Beerus Framework is no longer exclusive to Magisk-based environments. On devices with KernelSU, it is enough to grant the application Root Access by UID permission through the manager itself. After this authorization, Beerus gains the necessary privileges to execute its functionalities normally.

With this change, root-dependent features such as Frida Core, system property manipulation, Boot Options, Trusted System Certificates, and module management now also work in KernelSU-based environments, expanding the framework’s compatibility without altering the experience for those who continue using Magisk.


Fix: Trusted System Certificates

One of the Beerus Framework‘s features is the promotion of user-installed certificates to the trust store used by the system, enabling TLS interception in applications that do not accept certificates present only in the user trust store — a common behavior in apps with restrictive network security configurations or compiled for API level 24+.

In v1.0, this flow followed a simpler approach: certificates present in /data/misc/user/0/cacerts-added were copied to /system/etc/security/cacerts. Although this technique worked for many years, more recent versions of Android started using the trust store provided by the Conscrypt module through the APEX /apex/com.android.conscrypt/cacerts, making it insufficient to modify only the traditional system certificates path.

In practice, the certificate promoted by Beerus could be present in /system/etc/security/cacerts, but the mechanism responsible for TLS validation continued consulting the trust store exposed by Conscrypt, causing the certificate not to be recognized as a valid trust anchor.

The fix completely reworked the certificate promotion process. Now, when the Trusted System Certificates option is active, Beerus creates a temporary trust store containing the original system certificates and the user-installed certificates. After applying the proper permissions and SELinux context (u:object_r:system_security_cacerts_file:s0), the framework uses bind mounts to expose this consolidated trust store at the paths actually used by the device, including /apex/com.android.conscrypt/cacerts and, when applicable, /system/etc/security/cacerts.

...
if [ "$systemTrustedCerts" = "true" ]; then
    if [ -d /apex/com.android.conscrypt/cacerts ]; then
        tmp_cacerts_dir=$MODDIR/conscrypt_cacerts
        mkdir -p $tmp_cacerts_dir
        rm -f $tmp_cacerts_dir/*
        cp -f /apex/com.android.conscrypt/cacerts/* $tmp_cacerts_dir/
        cp -f /data/misc/user/0/cacerts-added/* $tmp_cacerts_dir/ 2>/dev/null
        set_perm_recursive $tmp_cacerts_dir root shell 755 644 u:object_r:system_security_cacerts_file:s0
        mount --bind $tmp_cacerts_dir /apex/com.android.conscrypt/cacerts
    fi

    if [ -d /system/etc/security/cacerts ]; then
        tmp_cacerts_dir=$MODDIR/etc_cacerts
        mkdir -p $tmp_cacerts_dir
        rm -f $tmp_cacerts_dir/*
        cp -f /system/etc/security/cacerts/* $tmp_cacerts_dir/
        cp -f /data/misc/user/0/cacerts-added/* $tmp_cacerts_dir/ 2>/dev/null
        set_perm_recursive $tmp_cacerts_dir root root 755 644 u:object_r:system_security_cacerts_file:s0
        mount --bind $tmp_cacerts_dir /system/etc/security/cacerts
    fi
fi
...

With this, Beerus now provides a unified view of the system trust store, preserving Android’s original certificates and adding user-installed certificates in the locations actually consulted during the TLS validation process.


Fix: Memory Dump Performance Optimization

Beyond the transport change, the Memory Dump process also received a direct optimization in the flow for collecting strings from the target process’s memory.

In the previous implementation, Beerus iterated through all readable regions present in /proc/<pid>/maps. For each region, the code calculated the memory range and performed the read on /proc/<pid>/mem using dd with bs=1, meaning byte by byte. In processes with many mapped regions or very large regions, this approach could make the dump extremely slow, which is why the interface displayed a warning that the operation could take between 20 and 30 minutes.

The fix changed this strategy to reduce the volume of analyzed memory and make reading more efficient. Now, before executing the dump, Beerus filters /proc/<pid>/maps looking only for regions with read and write permissions related to areas more useful for string extraction, such as heap, anonymous regions, and Android runtime artifacts (.dex, .odex, and .oat).

Additionally, empty or invalid regions are ignored, and regions larger than 20MB are no longer processed to prevent a single memory area from compromising the total operation time. Reading also stopped being done byte by byte: dd now uses 4096-byte pages, calculating skip and count based on the page size, which significantly reduces the read overhead on /proc/<pid>/mem.

@SuppressLint("SimpleDateFormat")
    private fun quickDump(context: Context, server: String, isUSB: Boolean, PID: String, onComplete: (String) -> Unit) {
        runSuCommand("""
            echo "==== maps ====" && cat /proc/$PID/maps && \
            echo "\n==== stack ====" && cat /proc/$PID/stack && \
            echo "\n==== .so loaded ====" && cat /proc/$PID/maps | grep -oE '/[^ ]+\.so' | sort -u && \
            echo "\n==== envs ====" && tr '\0' '\n' < /proc/$PID/environ
        """.trimIndent()) { output ->
            runSuCommand("cat /proc/$PID/cmdline") { processName ->
                val safeProcessName = processName.trim()
                    .replace(Regex("[^a-zA-Z0-9._-]+"), "_")
                    .trim('_')

                val date = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss").format(Date())
                val baseDir = File(context.filesDir, "dumps").apply { mkdirs() }

                val quickFile = File(baseDir, "$date-$safeProcessName-quick-dump.txt")
                quickFile.writeText(output)

                val pid = PID.trim()
                val stringDumpDir = File(baseDir, "$date-$safeProcessName-string-dump")
                val tarFile = File(baseDir, "$date-$safeProcessName.tar.gz")

                runSuCommand("""
                    PAGE=4096
                    MAX_SIZE=$((20 * 1024 * 1024))
    
                    mkdir -p "${stringDumpDir.absolutePath}"
    
                    grep -E "rw.*(heap|anon|\.dex|\.odex|\.oat)" /proc/$pid/maps | \
                    while read -r line; do
                        RANGE=$(echo "${'$'}line" | awk '{print ${'$'}1}')
    
                        START_HEX=0x${'$'}{RANGE%-*}
                        END_HEX=0x${'$'}{RANGE#*-}
    
                        START=$(printf "%u" "${'$'}START_HEX")
                        END=$(printf "%u" "${'$'}END_HEX")
                        SIZE=$((END - START))
    
                        [ "${'$'}SIZE" -le 0 ] && continue
                        [ "${'$'}SIZE" -gt "${'$'}MAX_SIZE" ] && continue
    
                        SKIP=$((START / PAGE))
                        COUNT=$((SIZE / PAGE))
    
                        [ "${'$'}COUNT" -le 0 ] && continue
    
                        OUT_FILE="${stringDumpDir.absolutePath}/${'$'}RANGE"
    
                        dd if=/proc/$pid/mem \
                           bs=${'$'}PAGE \
                           skip=${'$'}SKIP \
                           count=${'$'}COUNT \
                           status=none 2>/dev/null | strings > "${'$'}OUT_FILE"
                    done
    
                    cd "${baseDir.absolutePath}" && \
                    tar -czf "${tarFile.absolutePath}" \
                        "${quickFile.name}" \
                        "${stringDumpDir.name}" && \
                    rm -rf "${quickFile.absolutePath}" "${stringDumpDir.absolutePath}"
                """.trimIndent()) {
                    if (!isUSB) {
                        sendFile(tarFile.absolutePath, server) {
                            runSuCommand("rm -f ${tarFile.absolutePath}") {
                                onComplete("OK")
                            }
                        }
                    } else {
                        runSuCommand("cp ${tarFile.absolutePath} /data/local/tmp") {
                            runSuCommand("rm -f ${tarFile.absolutePath}") {
                                onComplete("OK")
                            }
                        }
                    }
                }
            }
        }
    }

The result is a faster and more predictable Memory Dump. Instead of trying to extract strings from every readable mapping of the process, Beerus now focuses on regions with a higher chance of containing useful data for analysis, avoiding excessively large reads and reducing the operational cost of collection.


gRPC Transport for Memory Dump and Sandbox Exfiltration

The two heaviest operations in the framework are Memory Dump and Sandbox Exfiltration. Until now, these features could already send collected artifacts to the Beerus Server through the existing HTTP/HTTPS flow, which remains available and continues as the default communication mode.

The v1.1 change does not remove the previous path. Instead, it adds a new optional transport based on gRPC, allowing the user to choose which protocol to use according to the test environment. In practice, when the address configured in the application uses http:// or https://, Beerus maintains the traditional flow. When the address uses the grpc:// prefix, operations switch to the new gRPC client.

This choice matters because Memory Dump and Sandbox Exfiltration typically produce large .tar.gz files. Memory dumps can easily reach hundreds of megabytes, while a sandbox exfiltration can package a large number of internal files from the target application. In these scenarios, a streaming-oriented transport tends to be more suitable than treating the transfer as a single regular HTTP request.

On the Android side, v1.1 adds a dedicated gRPC client for uploading these artifacts. After Beerus generates the .tar.gz, the framework checks the configured server type: if it is grpc://, the file is sent via BeerusGrpcUploader; otherwise, the existing HTTP upload continues to be used. This preserves compatibility with older environments while offering a more robust alternative for heavy operations.

The gRPC protocol was defined in a .proto file shared between client and server. It exposes a BeerusTransfer service with two main operations: Check, used to validate whether the gRPC server is available, and Upload, which receives a stream of UploadChunk. The first chunk carries the artifact’s metadata, such as collection type, filename, related package, and whether the .tar.gz should be extracted. Subsequent chunks carry the file’s binary data.

syntax = "proto3";

package beerus.transfer.v1;

option java_package = "io.hakaisecurity.beerusframework.grpc";
option java_multiple_files = true;
option java_outer_classname = "BeerusTransferProto";

service BeerusTransfer {
  rpc Check(CheckRequest) returns (CheckResponse);
  rpc Upload(stream UploadChunk) returns (UploadResult);
}

message CheckRequest {}

message CheckResponse {
  string app = 1;
  string version = 2;
}

enum ArtifactKind {
  ARTIFACT_KIND_UNSPECIFIED = 0;
  SANDBOX_EXFILTRATION_TAR_GZ = 1;
  MEMORY_DUMP_TAR_GZ = 2;
}

message UploadMetadata {
  ArtifactKind kind = 1;
  string filename = 2;
  string package_name = 3;
  string device_id = 4;
  bool extract_tar_gz = 5;
}

message UploadChunk {
  int64 seq = 1;
  oneof payload {
    UploadMetadata meta = 2;
    bytes data = 3;
  }
}

message UploadResult {
  bool success = 1;
  string message = 2;
  string saved_path = 3;
  string sha256_hex = 4;
  int64 bytes_received = 5;
}

This model brings some practical advantages. The server can receive the file incrementally, validate chunk ordering through a sequence number, calculate the SHA256 hash during reception, and return information to the client such as operation success, saved path, bytes received, and the artifact’s final hash. Furthermore, since the contract between client and server is described in Protocol Buffers, future transport evolution becomes more predictable and less dependent on ad-hoc formats.

On the Beerus Server, HTTP also continues to exist as the default mode. The new version adds a separate gRPC mode, started with --grpc, typically using a dedicated port. In this mode, the server brings up the BeerusTransfer service, receives uploads via streaming, saves artifacts to the output directory, and, when requested by the client, performs safe extraction of the received .tar.gz files.

With this, Beerus now offers two communication paths: the traditional HTTP/HTTPS flow, simple and compatible, and the new gRPC flow, more suitable for larger transfers and operations that benefit from streaming, typed contracts, and better control over the artifact reception lifecycle.


Fix: Root Modules Freezing on Startup

In some situations, the interface could freeze or get stuck during the module list initialization, especially when opening the application and loading the modules installed by the root manager.

The problem was in how the module state was loaded and updated by the UI. In the previous implementation, module enumeration was only initiated when the Root Modules screen was opened. Since this operation depended on executing commands with root privileges, the interface composition could become blocked while the list was being discovered. Additionally, some queries, such as checking a module’s state, used synchronous logic based on wait()/notify(), contributing to interface freezes.

The main change was moving module enumeration to the application initialization. Now, MainActivity performs module discovery during onCreate(), storing the result in a shared state (RootModulesState) that is then observed by Compose screens:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    RootModules.getAllModules { modules ->
        Handler(Looper.getMainLooper()).post {
            RootModulesState.modules = modules
        }
    }

    setContent {
        BeerusTheme {
            ...
        }
    }
}

The function responsible for enumeration also stopped using a synchronous flow, now returning the result through an asynchronous callback:

fun getAllModules(callback: (List<String>) -> Unit) {
    runSuCommand("find /data/adb/modules -mindepth 1 -maxdepth 1 -type d") {
        callback(it.lines().filter { it.isNotBlank() })
    }
}

The same pattern was applied to querying each module’s state. Instead of blocking the thread waiting for the root command to finish, the response is now delivered directly via the callback:

fun isModuleEnabled(module: String, callback: (Boolean) -> Unit) {
    runSuCommand(
        "test -f /data/adb/modules/$module/disable && echo false || echo true"
    ) {
        callback(it.trim() == "true")
    }
}

With this change, module enumeration now occurs only once during application initialization, while the interface simply observes an already-loaded state. Besides eliminating blocking operations based on wait()/notify(), this reduces repeated filesystem queries and makes the Root Modules screen significantly more responsive, in both Magisk and KernelSU environments.


Fix: Frida Auto Injector – Code Editor, Console and Fixes

The Frida Auto Injector allows writing and injecting Frida scripts directly on the device, without depending on a connection to an external host. In v1.1, the built-in editor was reworked to fix usability and reliability issues when editing larger or multi-line scripts.

The previous implementation used a Compose BasicTextField combined with a custom VisualTransformation for JavaScript highlighting (JsSyntaxHighlighter). This approach worked for simple cases but was limited for larger code. The text field had to handle scroll, cursor, keyboard, selection, and syntax highlighting rendering on its own, making the experience unstable as the script grew.

The fix replaced this manual editor with an integration with Sora Editor, rendered inside Compose through AndroidView. The commit adds io.github.Rosemoe.sora-editor dependencies, enables Java 17/desugaring, includes TextMate grammars and theme in the assets (javascript.tmLanguage.json, languages.json, and darcula.json), and creates a dedicated initializer to load the JavaScript theme and language.

private object TextMateInitializer {
    private var initialized = false

    fun init(context: Context) {
        if (initialized) return

        synchronized(this) {
            if (initialized) return

            FileProviderRegistry.getInstance().addFileProvider(
                AssetsFileResolver(context.assets)
            )

            val themeInputStream = context.assets.open("textmate/darcula.json")
            val themeSource = IThemeSource.fromInputStream(themeInputStream, "darcula.json", null)
            val themeModel = ThemeModel(themeSource, "darcula")

            ThemeRegistry.getInstance().loadTheme(themeModel)
            ThemeRegistry.getInstance().setTheme("darcula")
            GrammarRegistry.getInstance().loadGrammars("textmate/languages.json")

            initialized = true
        }
    }
}

With this change, the screen now uses a native code editor for script editing, with line numbering, better scroll, keyboard, and syntax highlighting support via TextMate. When saving, Beerus also stops relying solely on the old Compose-maintained state and now retrieves the content directly from the editor instance (editorRef?.text?.toString()), normalizing line breaks before persisting the file.

Button(
    onClick = {
        val toPersist = (editorRef?.text?.toString() ?: selectedScriptContent.text)
            .replace("\r\n", "\n")

        saveScript(activity, selectedScript, toPersist)
        refreshScripts()
        Toast.makeText(activity, "Script saved successfully", Toast.LENGTH_SHORT).show()
    }
) {
    Text("Save", fontSize = 11.sp, color = Color.Red, fontFamily = ibmFont)
}

Beyond the editor, v1.1 fixes a critical segfault in the fridaCore binary (the frida-devkit C wrapper). The crash occurred in the on_message callback when Frida emitted a message that failed JSON parsing — json_parser_get_root() returned NULL and the subsequent call to json_node_get_object(NULL) accessed offset 0x10 of a null pointer, causing an immediate SIGSEGV on the frida-main-loop thread.

The fix adds validation of the parser’s return before accessing the object. When the message is not valid JSON, fridaCore simply prints the raw content and returns, avoiding the null pointer dereference:

static void on_message(
    FridaScript * script,
    const gchar * message,
    GBytes * data,
    gpointer user_data
) {
    JsonParser * parser;
    JsonObject * root;
    const gchar * type;

    parser = json_parser_new();

    if (!json_parser_load_from_data(parser, message, -1, NULL) ||
        json_parser_get_root(parser) == NULL) {
        g_print("on_message (raw): %s\n", message);
        g_object_unref(parser);
        return;
    }

    root = json_node_get_object(json_parser_get_root(parser));
    type = json_object_get_string_member(root, "type");

    if (strcmp(type, "log") == 0) {
        const gchar * log_message;
        log_message = json_object_get_string_member(root, "payload");
        g_print("%s\n", log_message);
    } else {
        g_print("on_message: %s\n", message);
    }

    g_object_unref(parser);
}

The Android.mk was also updated to include -latomic in LOCAL_LDLIBS, a flag required by frida-devkit itself but that was missing, which could cause additional crashes in 64-bit atomic operations on ARM32 devices.

Previously, Beerus launched the target app via startActivity, waited 5 seconds with sleep 5, and only then attached by PID, which meant that any hook on onCreate or early in the Activity lifecycle simply did not work because the code had already executed.

Now fridaCore receives the package name instead of PID and uses frida_device_spawn_sync to start the process suspended, attaches and loads the script while the app is still paused, and only then calls frida_device_resume_sync.

g_print("[*] Spawning %s...\n", argv[1]);

spawn_options = frida_spawn_options_new();
target_pid = frida_device_spawn_sync(local_device, argv[1], spawn_options, NULL, &error);
g_object_unref(spawn_options);

if (error != NULL) {
    g_printerr("Failed to spawn: %s\n", error->message);
    g_error_free(error);
    goto cleanup;
}

g_print("[*] Spawned PID: %u (suspended)\n", target_pid);

session = frida_device_attach_sync(local_device, target_pid, NULL, NULL, &error);

After the session is attached and the script is created, the wrapper loads the script before releasing the target process execution:

g_signal_connect(script, "message", G_CALLBACK(on_message), NULL);
frida_script_load_sync(script, NULL, &error);

if (error != NULL) {
    g_printerr("Failed to load script: %s\n", error->message);
    g_error_free(error);
    frida_unref(script);
    frida_device_resume_sync(local_device, target_pid, NULL, NULL);
    frida_session_detach_sync(session, NULL, NULL);
    frida_unref(session);
    goto cleanup;
}

g_print("[*] Script loaded, resuming process...\n");
frida_device_resume_sync(local_device, target_pid, NULL, &error);

With this, hooks on onCreate and at any point during initialization now work because the script is already loaded before the process leaves the suspended state.

On the Kotlin side, injectFridaCore runs am force-stop before each injection to ensure a clean spawn, starts fridaCore passing the package name, and streams stdout and stderr on separate threads. This replaces the previous flow based on runSuCommand, which blocked until the process ended and prevented real-time feedback in the interface.

fun injectFridaCore(context: Context, packageName: String, script: String) {
    stopFridaCore()

    val scriptsFullPath = File(context.filesDir, "scripts").absolutePath + "/" + script
    isInjecting.value = true
    consoleLogs.clear()
    consoleLogs.add("[*] Starting injection: $packageName")

    Thread {
        try {
            val prep = Runtime.getRuntime().exec("su")
            val prepOut = DataOutputStream(prep.outputStream)
            prepOut.writeBytes("am force-stop $packageName\n")
            prepOut.writeBytes("exit\n")
            prepOut.flush()
            prep.waitFor()

            val process = Runtime.getRuntime().exec("su")
            fridaProcess = process

            val outputStream = DataOutputStream(process.outputStream)
            val inputStream = BufferedReader(InputStreamReader(process.inputStream))
            val errorStream = BufferedReader(InputStreamReader(process.errorStream))

            outputStream.writeBytes("fridaCore $packageName '$scriptsFullPath'\n")
            outputStream.flush()

            val stdoutThread = Thread {
                var line: String?
                while (inputStream.readLine().also { line = it } != null) {
                    line?.let { consoleLogs.add(it) }
                }
            }

            val stderrThread = Thread {
                var line: String?
                while (errorStream.readLine().also { line = it } != null) {
                    line?.let { consoleLogs.add("[err] $it") }
                }
            }

            stdoutThread.start()
            stderrThread.start()

            process.waitFor()
            stdoutThread.join()
            stderrThread.join()
            consoleLogs.add("[*] Process exited")
        } catch (e: Exception) {
            consoleLogs.add("[err] ${e.message}")
        } finally {
            isInjecting.value = false
            fridaProcess = null
        }
    }.start()
}

The stop flow was also adjusted. stopFridaCore first sends SIGINT, allowing graceful shutdown of the GLib main loop, script unload, and session detach. Only then does it fall back to SIGKILL. This avoids the previous problem where abruptly killing the process left an orphaned Frida agent inside the target app, and a second execution could cause instability or a device reboot.

fun stopFridaCore() {
    fridaProcess?.let { proc ->
        try {
            val kill = Runtime.getRuntime().exec("su")
            val out = DataOutputStream(kill.outputStream)

            // SIGINT triggers graceful shutdown (script unload + session detach)
            out.writeBytes("pkill -2 -f fridaCore\n")
            out.writeBytes("sleep 2\n")

            // force kill if still alive
            out.writeBytes("pkill -9 -f fridaCore\n")
            out.writeBytes("exit\n")
            out.flush()
            kill.waitFor()
            proc.destroy()
        } catch (_: Exception) {}

        fridaProcess = null
        isInjecting.value = false
        consoleLogs.add("[*] Stopped")
    }
}

Finally, the editor now includes an integrated log console. A terminal icon in the upper right corner of the screen indicates the console state (white when inactive, red when active). When clicking “Run”, the console opens automatically and displays in real time the stdout and stderr from fridaCore — status messages in green, errors in red, and script output in gray.

@Composable
fun FridaConsoleView(modifier: Modifier = Modifier) {
    val listState = rememberLazyListState()

    LaunchedEffect(consoleLogs.size) {
        if (consoleLogs.isNotEmpty()) {
            listState.animateScrollToItem(consoleLogs.size - 1)
        }
    }

    LazyColumn(
        state = listState,
        modifier = modifier.fillMaxSize().padding(10.dp)
    ) {
        items(consoleLogs.size) { index ->
            val line = consoleLogs[index]
            val color = when {
                line.startsWith("[err]") -> Color(0xFFFF6B6B)
                line.startsWith("[*]") -> Color(0xFF69DB7C)
                else -> Color(0xFFD4D4D4)
            }

            Text(
                text = line,
                color = color,
                fontSize = 12.sp,
                fontFamily = ibmFont,
                modifier = Modifier.padding(vertical = 1.dp)
            )
        }
    }
}

In practice, the fix makes the Frida Auto Injector more reliable for running, writing, editing, and saving Frida scripts directly on the device, especially longer multi-line scripts, while also greatly improving the editor’s operational and visual experience.


Integrated Auto Update

v1.1 adds an Auto Update flow directly within Beerus. When launching the application, the framework queries the latest release from the official GitHub repository and compares the published version with the locally installed version.

This logic was centralized in a new UpdateManager, responsible for obtaining the current APK version via PackageManager, querying the GitHub releases endpoint, extracting the tag_name from the latest release, and locating, among the published assets, the .apk file available for download. Version comparison removes prefixes like v/V and compares numeric version components to decide whether a newer update is available.

When a new version is available, the app can display an automatic dialog at startup asking the user if they want to update at that moment. Additionally, the navigation gained a new Update screen, where it is possible to view the current version, the latest version, check status, error messages, download progress, and manually trigger the update check or installation.

The update process downloads the APK to the application’s internal cache as update.apk, tracks download progress, and when the release provides a SHA256 digest in the asset, validates file integrity before initiating installation. To deliver the APK to the Android installer, the commit adds the REQUEST_INSTALL_PACKAGES permission, configures a FileProvider, and declares the file_paths.xml file, allowing the downloaded APK to be shared via a secure URI.

In practice, this reduces the friction of keeping Beerus updated in lab environments — the user doesn’t need to open the browser, manually search for the release, or download the APK externally. The app itself checks for a new version, downloads the correct asset, and forwards the installation through the standard Android flow.


IPTables as an Alternative to Proxy Profiles

Traditionally, the framework configured a global proxy through Android settings, changing the system’s http_proxy parameter to redirect traffic to tools like Burp Suite or mitmproxy. This approach remains available and works well for applications that respect the system proxy settings.

In practice, however, not all applications follow this behavior. Some clients implement their own network stacks, completely ignore global proxy settings, or make connections that do not go through the components responsible for consulting the http_proxy configured on Android. A recurring example is applications developed in Flutter, which use the engine’s network stack based on BoringSSL. Depending on the implementation and configuration used by the application, connections may not automatically respect Android’s global proxy definitions, making interception through http_proxy configuration alone difficult. In these scenarios, the traditional Proxy Profiles is no longer sufficient to guarantee traffic interception.

To work around this limitation, v1.1 adds an alternative based on iptables. When enabled, Beerus creates redirect rules in the nat table, intercepting outgoing connections and forwarding traffic to the configured proxy. Unlike the approach based solely on Android’s global settings, the redirect occurs at a level closer to the operating system’s network stack.

The new implementation was integrated directly into the Proxy Profiles interface, allowing switching between the traditional mode and the iptables-based mode according to the test scenario. This offers greater flexibility for dealing with applications that ignore global proxies, manufacturer-modified environments, or specific behaviors of certain network libraries.

First, profiles now have an explicit mode, allowing each configuration to be saved as HTTP or IPTABLES:

enum class ProxyMode { HTTP, IPTABLES }

data class ProxyData(
    val name: String,
    val conString: String,
    val selected: Boolean,
    val mode: ProxyMode = ProxyMode.HTTP
)

When the mode is HTTP, Beerus applies Android’s global proxy. When the mode is IPTABLES, it disables the global proxy and creates redirect rules for ports 80 and 443:

when (mode) {
    ProxyMode.HTTP -> {
        runSuCommand("iptables -t nat -F") { }
        runSuCommand("runcon u:r:shell:s0 sh -c 'settings put global http_proxy $conString'") { }
    }

    ProxyMode.IPTABLES -> {
        runSuCommand("runcon u:r:shell:s0 sh -c 'settings put global http_proxy :0'") { }
        runSuCommand(
            "iptables -t nat -F" +
            " && iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination $conString" +
            " && iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination $conString" +
            " && iptables -t nat -A POSTROUTING -p tcp --dport 443 -j MASQUERADE" +
            " && iptables -t nat -A POSTROUTING -p tcp --dport 80 -j MASQUERADE"
        ) { }
    }
}

Finally, the interface now exposes this choice directly in the profile card. When switching between HTTP Proxy and iptables, the app saves the new mode, reapplies the profile if it is already selected, and syncs the value with the root module:

ProxyModeSelector(
    currentMode = proxy.mode,
    onModeChange = { newMode ->
        updateProfileMode(context, proxy.name, newMode)
        if (isSelected) {
            selectProfile(context, proxy.conString, newMode)
            if (hasModule) changeProperties("proxyMode", newMode.name)
        }
        refreshProxies()
    }
)

Another important detail is that this choice is no longer just an interface configuration. The selected mode is also persisted in the root module through the proxyMode property and automatically reapplied during device startup. So, when restarting Android, Beerus restores both the traditional http_proxy-based mode and the iptables rules, preserving the user-configured behavior without requiring manual reconfiguration.

proxyModeProp=$(grep '^proxyMode=' "$STATUS_FILE" | cut -d'=' -f2)

if [ "$proxyModeProp" = "IPTABLES" ] && [ ! -z "$proxyProp" ] && [ "$proxyProp" != ":0" ]; then
    settings put global http_proxy ":0"
    iptables -t nat -F
    iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination "$proxyProp"
    iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination "$proxyProp"
    iptables -t nat -A POSTROUTING -p tcp --dport 443 -j MASQUERADE
    iptables -t nat -A POSTROUTING -p tcp --dport 80 -j MASQUERADE
else
    settings put global http_proxy "$proxyProp"
fi

In practice, Beerus now provides two complementary interception strategies: the traditional method based on Android proxy settings and an alternative mechanism using iptables, increasing compatibility with applications that would normally escape traffic instrumentation during a mobile test.


UX/UI Refactor

v1.1 also received a visual refactor of the Beerus Framework interface, focused on improving navigation, separating mixed flows, and providing clearer feedback during interactive operations.

The first change adds a new visual identity to the application, with new backgrounds, animated images, and navigation adjustments to support screen transitions. For this, the project updates Compose image usage with Glide and Landscapist, in addition to adapting screens for this new visual behavior.

Another important adjustment was the separation between Frida Setup and Frida Scripts. Previously, Frida server configuration and script management were on the same screen. Now, server setup has its own dedicated screen, while the new Frida Scripts screen concentrates script creation, upload, search, editing, and removal, with dedicated cards, content preview, and confirmation before deletion.

The ADB Over Network screen was also redesigned. The previous static image was replaced by an animation representing connection states: idle, connecting, connected, and error. Additionally, the action of starting or stopping ADB over Network is now executed off the main UI thread, avoiding freezes while the root command is being processed.

With this, v1.1 makes the Beerus interface more organized, responsive, and beautiful.


Honorable Mention: Magisk Detection by Our First External Contributor

This version also marks an important moment for the project: we received our first external contribution to the Beerus Framework. The contribution came from dapsvi, who fixed the Magisk detection logic in the initialization flow. Previously, Beerus relied on hardcoded paths such as /system/bin/magisk, /sbin/magisk, and Zygisk-related libraries to infer whether Magisk was present on the device. This approach could fail in environments where the binary was not at these paths or where the installation followed a different structure. The fix now validates Magisk‘s presence by executing magisk -v, looking for the :MAGISK: signature in the command output, and combining this result with verification of the module directory at /data/adb/modules/beerusMagiskModule.

The PR changes this validation to use Magisk‘s own binary as the source of truth. Instead of looking for specific files in the system, Beerus executes magisk -v and checks whether the output contains the :MAGISK: signature:

fun detectMagisk(callback: (Boolean) -> Unit) {
    val cmd = """
        output=$(magisk -v 2>/dev/null)
        case "$output" in
          *:MAGISK:*) echo true ;;
          *) echo false ;;
        esac
    """.trimIndent()

    runSuCommand(cmd) {
        callback(it.trim() == "true")
    }
}

Module detection was also adjusted to validate the correct directory at /data/adb/modules/beerusMagiskModule and combine this result with the actual presence of Magisk. With this, Beerus only considers the module active when the directory exists and the installed Magisk responds correctly:

fun detectRootModuleInstalled(callback: (Boolean) -> Unit) {
    val cmd = """
        if [ -d /data/adb/modules/beerusMagiskModule ]; then
            echo true
        else
            echo false
        fi
    """.trimIndent()

    runSuCommand(cmd) { dirResult ->
        val moduleDirExists = dirResult.trim() == "true"

        detectMagisk { magiskPresent ->
            callback(moduleDirExists && magiskPresent)
        }
    }
}

With this, detection becomes less dependent on specific system paths and more aligned with the actual behavior of the Magisk installed on the device. We extend our gratitude to dapsvi for this initiative as the project’s first external contributor.


Full Changelog

PRRepositoryTypeDescription
#33beerus-androidFixFrida Auto Inject – Segfault fix, spawn mode injection, and real-time log console
#32beerus-androidFixInconsistent beerusRootModule name during detection
#31beerus-androidFeatureNew background, screen animations, and Frida screen separation
#30beerus-androidFixMagisk detection fix by dapsvi
#29beerus-androidFeatureIPTables as an alternative to Proxy Profiles
#28beerus-androidRefactorGeneral UX/UI refactor
#27beerus-androidFeatureApp Auto Update
#26beerus-androidFeaturegRPC transport for Memory Dump and Sandbox Exfiltration
#7beerus-serverFeaturegRPC service implementation for receiving dumps and exfiltrations
#25beerus-androidFixFrida Auto Inject – Code Editor
#24beerus-androidFixRoot Modules UI freezing on startup
#23beerus-androidFixMemory Dump performance optimization
#20beerus-androidFixTrusted System Certificates + Root Manager icons
#19beerus-androidFeatureKernelSU support

How to Get It

Beerus Framework v1.1 is available for download at the official repository: github.com/hakaioffsec/beerus-android

Requirements:

  • Android device rooted via Magisk or KernelSU
  • Android 10 or higher recommended

Starting from the v1.1 update installed, future updates can be applied directly through the app with the new auto-update system.


Contribution

Issues, pull requests, and bug reports are welcome on the repository. The project is open for community contributions.

In the coming months we’ll have plenty of news! d(^o^)b

Logo da Hakai.