Beerus Framework: v1.1 – KernelSU, gRPC Transport e Correções
Alguns meses após o lançamento inicial do Beerus Framework, estamos publicando a versão v1.1, uma atualização minor que traz suporte a novos ambientes de root, uma mudança arquitetural relevante no transporte de dados, auto-atualização integrada e um conjunto de correções acumuladas desde o primeiro release.
Neste post, detalhamos cada mudança incluída nessa versão, o contexto técnico por trás de cada decisão e o que isso significa na prática para quem usa o Beerus no dia a dia de testes em Android.
TL;DR: O Beerus Framework v1.1 amplia a compatibilidade e melhora a experiência operacional do pentest Android, com suporte a KernelSU, correções no fluxo de Trusted System Certificates, otimizações de performance no Memory Dump e uma nova opção de transporte via gRPC para dumps e sandbox exfiltration. A atualização também resolve travamentos em Root Modules, evolui o Frida Auto Injector com editor, console e injeção antecipada, adiciona auto-update integrado, oferece iptables como alternativa aos Proxy Profiles e traz uma refatoração de UX/UI. Por fim, destacamos a primeira contribuição externa ao projeto, que aprimorou a detecção do Magisk, além do changelog completo e das instruções de instalação e contribuição.
Contexto
O Beerus Framework foi criado para consolidar as principais operações de pentest mobile em uma única interface instalada diretamente no dispositivo alvo. Ao invés de depender de múltiplas ferramentas externas, scripts avulsos e configurações manuais repetitivas, o Beerus expõe essas capacidades de forma centralizada, desde injeção de Frida até dump de memória e extração de sandbox.
A versão 1.0 estabeleceu essa base. A v1.1 corrige o bugs, expande compatibilidade, remodela interfaces e prepara o terreno para os próximos desenvolvimentos.
Novidades
KernelSU Support
O Beerus assumia o Magisk como backend principal de root. Isso funcionava bem para a maioria dos dispositivos de laboratório, mas limitava o framework a um modelo específico de root manager. Na prática, usuários com KernelSU conseguiam ter root no dispositivo, mas o Beerus ainda tratava o ambiente como se ele precisasse ser obrigatoriamente baseado em Magisk.
A v1.1 muda esse comportamento adicionando uma validação específica para KernelSU no fluxo de inicialização. Agora, o aplicativo tenta detectar primeiro o Magisk. Caso ele não esteja presente, o Beerus executa uma segunda validação utilizando o ksud, daemon responsável pelo gerenciamento do KernelSU. Se essa validação retornar uma versão válida, o framework considera que existe um ambiente de root compatível e habilita normalmente todos os recursos que dependem de privilégios elevados.
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)
}
A principal diferença entre Magisk e KernelSU está na arquitetura. O Magisk atua durante o processo de boot, modificando o initramfs para criar um ambiente systemless capaz de fornecer acesso root, módulos e alterações no sistema sem modificar diretamente as partições originais. Já o KernelSU implementa o gerenciamento de privilégios diretamente no kernel, utilizando o ksud para controlar a concessão de permissões root aos aplicativos autorizados.
Por isso, o Beerus deixa de depender exclusivamente da detecção do Magisk. O fluxo de inicialização foi ajustado para tratar Magisk e KernelSU como dois root managers suportados. Quando o Magisk é encontrado, o comportamento permanece inalterado. Caso contrário, o aplicativo tenta detectar o KernelSU e, se ele estiver disponível, habilita o restante do fluxo normalmente, permitindo que os recursos dependentes de root sejam utilizados independentemente do gerenciador instalado.
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)
}
}
}
}
}
}
}
Para o usuário, a principal diferença prática é que o Beerus Framework deixa de ser exclusivo para ambientes baseados em Magisk. Em dispositivos com KernelSU, basta conceder ao aplicativo a permissão de Root Access by UID através do próprio gerenciador. Após essa autorização, o Beerus passa a ter acesso aos privilégios necessários para executar normalmente suas funcionalidades.
Com essa mudança, recursos dependentes de root, como Frida Core, manipulação de propriedades do sistema, Boot Options, Trusted System Certificates e gerenciamento de módulos passam a funcionar também em ambientes baseados em KernelSU, ampliando a compatibilidade do framework sem alterar a experiência de uso para quem continua utilizando Magisk.
Fix: Trusted System Certificates
Um dos recursos do Beerus Framework é a promoção de certificados instalados pelo usuário para a trust store utilizada pelo sistema, permitindo a interceptação TLS em aplicativos que não aceitam certificados presentes apenas na trust store de usuário, comportamento comum em aplicações com configurações restritivas de segurança de rede ou compiladas para API level 24+.
Na v1.0, esse fluxo seguia uma abordagem mais simples: os certificados presentes em /data/misc/user/0/cacerts-added eram copiados para /system/etc/security/cacerts. Embora essa técnica tenha funcionado por muitos anos, versões mais recentes do Android passaram a utilizar a trust store disponibilizada pelo módulo Conscrypt através do APEX /apex/com.android.conscrypt/cacerts, tornando insuficiente modificar apenas o caminho tradicional de certificados do sistema.
Na prática, o certificado promovido pelo Beerus podia estar presente em /system/etc/security/cacerts, mas o mecanismo responsável pela validação TLS continuava consultando a trust store exposta pelo Conscrypt, fazendo com que o certificado não fosse reconhecido como uma âncora de confiança válida.
A correção refez completamente o processo de promoção dos certificados. Agora, quando a opção Trusted System Certificates está ativa, o Beerus cria uma trust store temporária contendo os certificados originais do sistema e os certificados instalados pelo usuário. Após aplicar as permissões e o contexto SELinux adequados (u:object_r:system_security_cacerts_file:s0), o framework utiliza bind mounts para expor essa trust store consolidada nos caminhos efetivamente utilizados pelo dispositivo, incluindo /apex/com.android.conscrypt/cacerts e, quando aplicável, /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
...
Com isso, o Beerus passa a disponibilizar uma visão unificada da trust store do sistema, preservando os certificados originais do Android e adicionando os certificados instalados pelo usuário nos locais efetivamente consultados durante o processo de validação TLS.


Fix: Memory Dump Performance Optimization
Além da mudança de transporte, o processo de Memory Dump também recebeu uma otimização direta no fluxo de coleta de strings da memória do processo alvo.
Na implementação anterior, o Beerus percorria todas as regiões legíveis presentes em /proc/<pid>/maps. Para cada região, o código calculava o intervalo de memória e executava a leitura em /proc/<pid>/mem usando dd com bs=1, ou seja, byte a byte. Em processos com muitas regiões mapeadas ou regiões muito grandes, essa abordagem podia tornar o dump extremamente lento, motivo pelo qual a interface exibia um aviso de que a operação poderia levar entre 20 e 30 minutos.
A correção mudou essa estratégia para reduzir o volume de memória analisado e tornar a leitura mais eficiente. Agora, antes de executar o dump, o Beerus filtra o /proc/<pid>/maps buscando apenas regiões com permissão de leitura e escrita relacionadas a áreas mais úteis para extração de strings, como heap, regiões anônimas e artefatos do runtime Android (.dex, .odex e .oat).
Além disso, regiões vazias ou inválidas são ignoradas, e regiões maiores que 20MB deixam de ser processadas para evitar que uma única área de memória comprometa o tempo total da operação. A leitura também deixou de ser feita byte a byte: o dd passou a utilizar páginas de 4096 bytes, calculando skip e count com base no tamanho da página, o que reduz significativamente o overhead de leitura em /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")
}
}
}
}
}
}
}
O resultado é um Memory Dump mais rápido e previsível. Em vez de tentar extrair strings de todo e qualquer mapping legível do processo, o Beerus passa a focar em regiões com maior chance de conter dados úteis para análise, evitando leituras excessivamente grandes e reduzindo o custo operacional da coleta.
gRPC Transport for Memory Dump and Sandbox Exfiltration
As duas operações mais pesadas do framework, Memory Dump e Sandbox Exfiltration. Até então, essas funcionalidades já conseguiam enviar os artefatos coletados para o Beerus Server através do fluxo HTTP/HTTPS existente, que continua disponível e segue como o modo padrão de comunicação.
A mudança da v1.1 não remove esse caminho anterior. Em vez disso, ela adiciona um novo transporte opcional baseado em gRPC, permitindo que o usuário escolha qual protocolo utilizar de acordo com o ambiente de teste. Na prática, quando o endereço configurado no aplicativo utiliza http:// ou https://, o Beerus mantém o fluxo tradicional. Quando o endereço utiliza o prefixo grpc://, as operações passam a usar o novo cliente gRPC.
Essa escolha é importante porque Memory Dump e Sandbox Exfiltration normalmente produzem arquivos grandes em formato .tar.gz. Dumps de memória podem chegar facilmente a centenas de megabytes, enquanto uma exfiltração de sandbox pode empacotar uma grande quantidade de arquivos internos do aplicativo alvo. Nesses cenários, um transporte orientado a streaming tende a ser mais adequado do que tratar o envio como uma única requisição HTTP comum.
No lado do Android, a v1.1 adiciona um cliente gRPC dedicado para upload desses artefatos. Após o Beerus gerar o .tar.gz, o framework verifica o tipo de servidor configurado: se for grpc://, o arquivo é enviado pelo BeerusGrpcUploader; caso contrário, o envio HTTP existente continua sendo utilizado. Isso preserva compatibilidade com ambientes antigos e, ao mesmo tempo, oferece uma alternativa mais robusta para operações pesadas.
O protocolo gRPC foi definido em um arquivo .proto compartilhado entre cliente e servidor. Ele expõe um serviço BeerusTransfer com duas operações principais: Check, usada para validar se o servidor gRPC está disponível, e Upload, que recebe um stream de UploadChunk. O primeiro chunk carrega os metadados do artefato, como tipo da coleta, nome do arquivo, pacote relacionado e se o .tar.gz deve ser extraído. Os chunks seguintes carregam os dados binários do arquivo.
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;
}
Esse modelo traz algumas vantagens práticas. O servidor consegue receber o arquivo de forma incremental, validar a ordem dos chunks através de um número de sequência, calcular o hash SHA256 durante o recebimento e retornar ao cliente informações como sucesso da operação, caminho salvo, quantidade de bytes recebidos e hash final do artefato. Além disso, como o contrato entre cliente e servidor fica descrito em Protocol Buffers, a evolução futura do transporte passa a ser mais previsível e menos dependente de formatos ad-hoc.
No Beerus Server, o HTTP também continua existindo como modo padrão. A nova versão adiciona um modo gRPC separado, iniciado com --grpc, normalmente utilizando uma porta dedicada. Nesse modo, o servidor sobe o serviço BeerusTransfer, recebe os uploads em streaming, salva os artefatos no diretório de saída e, quando solicitado pelo cliente, realiza a extração segura dos arquivos .tar.gz recebidos.
Com isso, o Beerus passa a oferecer dois caminhos de comunicação: o fluxo HTTP/HTTPS tradicional, simples e compatível, e o novo fluxo gRPC, mais adequado para transferências maiores e operações que se beneficiam de streaming, contrato tipado e melhor controle sobre o ciclo de recebimento dos artefatos.
Fix: Root Modules Freezing on Startup
Em algumas situações, a interface podia travar ou ficar presa durante a inicialização da lista de módulos, especialmente ao abrir o aplicativo e carregar os módulos instalados pelo root manager.
O problema estava na forma como o estado dos módulos era carregado e atualizado pela UI. Na implementação anterior, a enumeração dos módulos era iniciada apenas quando a tela de Root Modules era aberta. Como essa operação dependia da execução de comandos com privilégios de root, a composição da interface podia ficar bloqueada enquanto a lista era descoberta. Além disso, algumas consultas, como a verificação do estado de um módulo, utilizavam uma lógica síncrona baseada em wait()/notify(), contribuindo para congelamentos da interface.
A principal mudança foi mover a enumeração dos módulos para a inicialização do aplicativo. Agora, a MainActivity realiza a descoberta dos módulos durante o onCreate(), armazenando o resultado em um estado compartilhado (RootModulesState) que passa a ser observado pelas telas do Compose:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
RootModules.getAllModules { modules ->
Handler(Looper.getMainLooper()).post {
RootModulesState.modules = modules
}
}
setContent {
BeerusTheme {
...
}
}
}
A própria função responsável pela enumeração também deixou de utilizar um fluxo síncrono, passando a retornar o resultado através de um callback assíncrono:
fun getAllModules(callback: (List<String>) -> Unit) {
runSuCommand("find /data/adb/modules -mindepth 1 -maxdepth 1 -type d") {
callback(it.lines().filter { it.isNotBlank() })
}
}
O mesmo padrão foi aplicado à consulta do estado de cada módulo. Em vez de bloquear a thread aguardando o término do comando root, a resposta passa a ser entregue diretamente pelo callback:
fun isModuleEnabled(module: String, callback: (Boolean) -> Unit) {
runSuCommand(
"test -f /data/adb/modules/$module/disable && echo false || echo true"
) {
callback(it.trim() == "true")
}
}
Com essa mudança, a enumeração dos módulos passa a ocorrer apenas uma vez durante a inicialização do aplicativo, enquanto a interface apenas observa um estado já carregado. Além de eliminar operações bloqueantes baseadas em wait()/notify(), isso reduz consultas repetidas ao sistema de arquivos e torna a tela de Root Modules significativamente mais responsiva, tanto em ambientes Magisk quanto KernelSU.
Fix: Frida Auto Injector – Code Editor, Console and Fixes
O Frida Auto Injector permite escrever e injetar scripts Frida diretamente no dispositivo, sem depender de conexão com um host externo. Na v1.1, o editor embutido foi refeito para resolver problemas de usabilidade e confiabilidade ao editar scripts maiores ou com múltiplas linhas.
A implementação anterior usava um BasicTextField do Compose combinado com um VisualTransformation próprio para highlight de JavaScript (JsSyntaxHighlighter). Essa abordagem funcionava para casos simples, mas era limitada para códigos maiores. O campo de texto precisava lidar sozinho com scroll, cursor, teclado, seleção e renderização de syntax highlighting, o que tornava a experiência instável conforme o script crescia.
A correção substituiu esse editor manual por uma integração com o Sora Editor, renderizada dentro do Compose através de AndroidView. O commit adiciona as dependências io.github.Rosemoe.sora-editor, habilita Java 17/desugaring, inclui gramáticas e tema TextMate nos assets (javascript.tmLanguage.json, languages.json e darcula.json) e cria um inicializador dedicado para carregar o tema e a linguagem JavaScript.
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
}
}
}
Com essa mudança, a tela passa a usar um editor de código nativo para edição de scripts, com numeração de linhas, auto-complete, suporte melhor a scroll, teclado e syntax highlighting via TextMate. Na hora de salvar, o Beerus também deixa de depender apenas do estado antigo mantido pelo Compose e passa a recuperar o conteúdo diretamente da instância do editor (editorRef?.text?.toString()), normalizando quebras de linha antes de persistir o arquivo.
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)
}
Além do editor, a v1.1 corrige um segfault crítico no binário fridaCore (o wrapper C do frida-devkit). O crash ocorria no callback on_message, quando o Frida emitia uma mensagem que falhava no parse JSON, json_parser_get_root() retornava NULL e a chamada seguinte a json_node_get_object(NULL) acessava o offset 0x10 de um ponteiro nulo, SIGSEGV imediato na thread frida-main-loop.
A correção adiciona validação do retorno do parser antes de acessar o objeto. Quando a mensagem não é um JSON válido, o fridaCore apenas imprime o conteúdo bruto e retorna, evitando o dereference de ponteiro nulo:
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);
}
O Android.mk também passou a incluir -latomic nos LOCAL_LDLIBS, flag exigida pelo próprio frida-devkit mas que estava ausente, o que poderia causar crashes adicionais em operações atômicas de 64 bits em dispositivos ARM32.
Antes, o Beerus iniciava o app alvo via startActivity, esperava 5 segundos com sleep 5 e só então fazia attach pelo PID, o que significava que qualquer hook em onCreate ou no início do ciclo de vida da Activity simplesmente não funcionava, porque o código já tinha executado.
Agora o fridaCore recebe o package name em vez do PID e usa frida_device_spawn_sync para iniciar o processo suspenso, faz attach e carrega o script enquanto o app ainda está parado, e só então chama 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);
Depois que a sessão está anexada e o script foi criado, o wrapper carrega o script antes de liberar a execução do processo alvo:
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);
Com isso, hooks em onCreate e em qualquer ponto da inicialização passam a funcionar, porque o script já está carregado antes do processo sair do estado suspenso.
No lado Kotlin, o injectFridaCore executa am force-stop antes de cada injeção para garantir um spawn limpo, inicia o fridaCore passando o package name e faz streaming de stdout e stderr em threads separadas. Isso substitui o fluxo anterior baseado em runSuCommand, que bloqueava até o fim do processo e impedia feedback em tempo real na 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()
}
O fluxo de parada também foi ajustado. O stopFridaCore primeiro envia SIGINT, permitindo o shutdown graceful do GLib main loop, unload do script e detach da sessão. Só depois ele recorre ao SIGKILL como fallback. Isso evita o problema anterior em que matar o processo abruptamente deixava um agente Frida órfão dentro do app alvo, e uma segunda execução podia causar instabilidade ou reboot do dispositivo.
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")
}
}
Por fim, o editor agora inclui um console de logs integrado. Um ícone de terminal no canto superior direito da tela indica o estado do console (branco inativo, vermelho quando ativo). Ao clicar em “Run”, o console abre automaticamente e exibe em tempo real o stdout e stderr do fridaCore, mensagens de status em verde, erros em vermelho, e output do script em cinza.
@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)
)
}
}
}
Na prática, o fix torna o Frida Auto Injector mais confiável para executar, escrever, editar e salvar scripts Frida diretamente no dispositivo, especialmente scripts mais longos e multiline, além de melhorar bastante a experiência operacional e visual do editor.
Integrated Auto Update
A v1.1 adiciona um fluxo de Auto Update diretamente dentro do Beerus. Ao iniciar o aplicativo, o framework consulta a release mais recente do repositório oficial no GitHub e compara a versão publicada com a versão instalada localmente.
Essa lógica foi centralizada em um novo UpdateManager, responsável por obter a versão atual do APK via PackageManager, consultar o endpoint de releases do GitHub, extrair o tag_name da última release e localizar, entre os assets publicados, o arquivo .apk disponível para download. A comparação de versões remove prefixos como v/V e compara os componentes numéricos da versão para decidir se existe uma atualização mais nova.
Quando uma nova versão está disponível, o app pode exibir um diálogo automático na inicialização perguntando se o usuário deseja atualizar naquele momento. Além disso, a navegação ganhou uma nova tela Update, onde é possível visualizar a versão atual, a versão mais recente, o status da checagem, mensagens de erro, progresso de download e disparar manualmente a verificação ou instalação da atualização.
O processo de atualização baixa o APK para o cache interno do aplicativo como update.apk, acompanha o progresso do download e, quando a release fornece um digest SHA256 no asset, valida a integridade do arquivo antes de iniciar a instalação. Para entregar o APK ao instalador do Android, o commit adiciona a permissão REQUEST_INSTALL_PACKAGES, configura um FileProvider e declara o arquivo file_paths.xml, permitindo compartilhar o APK baixado via URI segura.
Na prática, isso reduz a fricção de manter o Beerus atualizado em ambientes de laboratório, o usuário não precisa abrir o navegador, procurar a release manualmente ou baixar o APK por fora. O próprio app verifica se existe uma versão nova, baixa o asset correto e encaminha a instalação pelo fluxo padrão do Android.
IPTables as an Alternative to Proxy Profiles
Tradicionalmente, o framework configurava um proxy global através das configurações do Android, alterando o parâmetro http_proxy do sistema para redirecionar o tráfego para ferramentas como Burp Suite ou mitmproxy. Essa abordagem continua disponível e funciona bem para aplicações que respeitam as configurações de proxy do sistema.
Na prática, entretanto, nem todos os aplicativos seguem esse comportamento. Alguns clientes implementam suas próprias stacks de rede, ignoram completamente as configurações globais de proxy ou realizam conexões que não passam pelos componentes responsáveis por consultar o http_proxy configurado no Android. Um exemplo recorrente são aplicações desenvolvidas em Flutter, que utilizam a stack de rede da engine baseada em BoringSSL. Dependendo da implementação e da configuração utilizada pelo aplicativo, as conexões podem não respeitar automaticamente as definições de proxy global do Android, dificultando a interceptação apenas através da configuração de http_proxy. Nesses cenários, o Proxy Profiles tradicional deixa de ser suficiente para garantir a interceptação do tráfego.
Para contornar essa limitação, a v1.1 adiciona uma alternativa baseada em iptables. Quando habilitada, o Beerus passa a criar regras de redirecionamento na tabela nat, interceptando conexões de saída e encaminhando o tráfego para o proxy configurado. Diferentemente da abordagem baseada apenas em configurações globais do Android, o redirecionamento ocorre em um nível mais próximo da pilha de rede do sistema operacional.
A nova implementação foi integrada diretamente à interface de Proxy Profiles, permitindo alternar entre o modo tradicional e o modo baseado em iptables de acordo com o cenário de teste. Isso oferece maior flexibilidade para lidar com aplicações que ignoram proxies globais, ambientes modificados por fabricantes ou comportamentos específicos de determinadas bibliotecas de rede.
Primeiro, os perfis agora possuem um modo explícito, permitindo que cada configuração seja salva como HTTP ou IPTABLES:
enum class ProxyMode { HTTP, IPTABLES }
data class ProxyData(
val name: String,
val conString: String,
val selected: Boolean,
val mode: ProxyMode = ProxyMode.HTTP
)
Quando o modo é HTTP, o Beerus aplica o proxy global do Android. Quando o modo é IPTABLES, ele desativa o proxy global e cria regras de redirecionamento para as portas 80 e 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"
) { }
}
}
Por fim, a interface passou a expor essa escolha diretamente no card do perfil. Ao alternar entre HTTP Proxy e iptables, o app salva o novo modo, reaplica o perfil caso ele já esteja selecionado e sincroniza o valor com o módulo root:
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()
}
)
Outro detalhe importante é que essa escolha deixa de ser apenas uma configuração da interface. O modo selecionado também é persistido no módulo root através da propriedade proxyMode e reaplicado automaticamente durante a inicialização do dispositivo. Assim, ao reiniciar o Android, o Beerus restaura tanto o modo tradicional baseado em http_proxy quanto as regras de iptables, preservando o comportamento configurado pelo usuário sem necessidade de reconfiguração manual.
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
Na prática, o Beerus passa a disponibilizar duas estratégias complementares de interceptação: o método tradicional baseado em configurações de proxy do Android e um mecanismo alternativo utilizando iptables, aumentando a compatibilidade com aplicações que normalmente escapariam da instrumentação de tráfego durante um teste mobile.
UX/UI Refactor
A v1.1 também recebeu uma refatoração visual na interface do Beerus Framework, focada em melhorar a navegação, separar fluxos que estavam misturados e dar feedback mais claro durante operações interativas.
A primeira mudança adiciona uma nova identidade visual ao aplicativo, com novos backgrounds, imagens animadas e ajustes na navegação para suportar transições entre telas. Para isso, o projeto atualiza o uso de imagens no Compose com Glide e Landscapist, além de adaptar telas para o esse novo comportamento visual.
Outro ajuste importante foi a separação entre Frida Setup e Frida Scripts. Antes, a configuração do servidor Frida e o gerenciamento de scripts ficavam na mesma tela. Agora, o setup do servidor fica em uma tela dedicada, enquanto a nova tela Frida Scripts concentra criação, upload, busca, edição e remoção de scripts, com cards próprios, preview do conteúdo e confirmação antes de deletar.
A tela ADB Over Network também foi redesenhada. A imagem estática anterior foi substituída por uma animação que representa os estados de conexão: idle, conectando, conectado e erro. Além disso, a ação de iniciar ou parar o ADB over Network passou a ser executada fora do fluxo principal da UI, evitando travamentos enquanto o comando root é processado.
Com isso, a v1.1 deixa a interface do Beerus mais organizada, responsiva e bela.



Honorable Mention: Magisk Detection by Our First External Contributor
Esta versão também marca um momento importante para o projeto: recebemos nossa primeira contribuição externa no Beerus Framework. A contribuição veio de dapsvi, que corrigiu a lógica de detecção do Magisk no fluxo de inicialização. Antes, o Beerus dependia de caminhos hardcoded, como /system/bin/magisk, /sbin/magisk e bibliotecas relacionadas ao Zygisk, para inferir se o Magisk estava presente no dispositivo. Essa abordagem podia falhar em ambientes onde o binário não estava nesses caminhos ou onde a instalação seguia uma estrutura diferente. A correção passou a validar a presença do Magisk através da execução de magisk -v, procurando a assinatura :MAGISK: na saída do comando, e combinando esse resultado com a verificação do diretório do módulo em /data/adb/modules/beerusMagiskModule.
O PR altera essa validação para usar o próprio binário do Magisk como fonte de verdade. Em vez de procurar arquivos específicos no sistema, o Beerus executa magisk -v e verifica se a saída contém a assinatura :MAGISK::
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")
}
}
A detecção do módulo também foi ajustada para validar o diretório correto em /data/adb/modules/beerusMagiskModule e combinar esse resultado com a presença real do Magisk. Com isso, o Beerus só considera o módulo ativo quando o diretório existe e o Magisk instalado responde corretamente:
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)
}
}
}
Com isso, a detecção fica menos dependente de caminhos específicos do sistema e mais alinhada ao comportamento real do Magisk instalado no dispositivo. Fica aqui nosso agradecimento ao dapsvi por essa iniciativa como primeiro colaborador externo do projeto.
Full Changelog
| PR | Repositório | Tipo | Descrição |
|---|---|---|---|
| #33 | beerus-android | Fix | Frida Auto Inject – Correção de segfault, injeção via spawn mode e console de logs em tempo real |
| #32 | beerus-android | Fix | Nome do beerusRootModule inconsistente durante a detecção |
| #31 | beerus-android | Feature | Novo background, animações de tela e separação das telas do Frida |
| #30 | beerus-android | Fix | Correção na detecção do Magisk por dapsvi |
| #29 | beerus-android | Feature | IPTables como alternativa aos Proxy Profiles |
| #28 | beerus-android | Refactor | Refatoração geral da UX/UI |
| #27 | beerus-android | Feature | App Auto Update |
| #26 | beerus-android | Feature | gRPC transport para Memory Dump e Sandbox Exfiltration |
| #7 | beerus-server | Feature | Implementação dos serviços gRPC para recebimento de dumps e exfiltrações |
| #25 | beerus-android | Fix | Frida Auto Inject – Code Editor |
| #24 | beerus-android | Fix | UI do Root Modules travando na inicialização |
| #23 | beerus-android | Fix | Otimização de performance no Memory Dump |
| #20 | beerus-android | Fix | Trusted System Certificates + ícones de Root Manager |
| #19 | beerus-android | Feature | Suporte a KernelSU |
How to Get It
O Beerus Framework v1.1 está disponível para download no repositório oficial: github.com/hakaioffsec/beerus-android
Requisitos:
- Dispositivo Android com root via Magisk ou KernelSU
- Android 10 ou superior recomendado
A partir da atualização v1.1 instalada, poderá ser atualizado diretamente pelo app com o novo sistema de auto-update.
Contribution
Issues, pull requests e reports de bugs são bem-vindos no repositório. O projeto está aberto para contribuições da comunidade.
Nos próximos meses teremos bastante novidades! d(^o^)b