Add floating log output menu and fix several issues

This commit is contained in:
Mahdi Karzari
2026-07-27 14:04:49 +03:30
parent e4b4f6e350
commit 44cf537d89
12 changed files with 568 additions and 14 deletions
@@ -420,7 +420,7 @@ public class BActivityThread extends IBActivityThread.Stub {
Slog.w(TAG, "Unable to set guest context ClassLoader: " + error.getMessage());
}
}
FridaGadgetLoader.loadIfEnabled();
FridaGadgetLoader.loadAtProcessBind();
AppBindData bindData = new AppBindData();
bindData.appInfo = applicationInfo;
@@ -15,19 +15,36 @@ public final class FridaGadgetLoader {
private FridaGadgetLoader() {
}
/** Listener mode starts immediately; autonomous scripts wait for the guest lifecycle. */
public static boolean loadAtProcessBind() {
String packageName = GuestRuntimeRegistry.getGuestPackageName();
String mode = InstrumentationSettings.getModeForPackage(packageName);
if (InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)) {
Log.i(TAG, "Deferring on-device agent until the guest application is ready");
return false;
}
return loadIfEnabled();
}
public static boolean loadIfEnabled() {
if (!GuestRuntimeRegistry.isInstrumentationEnabled()) {
Log.i(TAG, "Instrumentation disabled for this guest process");
return false;
}
String packageName = GuestRuntimeRegistry.getGuestPackageName();
String mode = InstrumentationSettings.getModeForPackage(packageName);
if (InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)
&& !GuestRuntimeRegistry.isPrimaryProcess()) {
Log.i(TAG, "Skipping on-device agent in secondary process "
+ GuestRuntimeRegistry.getGuestProcessName());
return false;
}
if (loaded) return true;
synchronized (LOAD_LOCK) {
if (loaded) return true;
if (!ATTEMPTED.compareAndSet(false, true)) return false;
try {
InstrumentationStatusStore.recordBinding();
String packageName = GuestRuntimeRegistry.getGuestPackageName();
String mode = InstrumentationSettings.getModeForPackage(packageName);
if (InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)) {
String scriptPath = InstrumentationSettings.getScriptPathForPackage(packageName);
File runtime = LocalScriptGadgetRuntime.prepare(packageName, scriptPath);
@@ -56,6 +56,11 @@ public final class GuestRuntimeRegistry {
public static ClassLoader getGuestClassLoader() { return guestClassLoader; }
public static String getGuestSourceDir() { return guestSourceDir; }
public static boolean isInstrumentationEnabled() { return instrumentationEnabled; }
public static boolean isPrimaryProcess() {
String primaryProcess = guestApplicationInfo == null ? null : guestApplicationInfo.processName;
if (primaryProcess == null || primaryProcess.isEmpty()) primaryProcess = guestPackageName;
return primaryProcess != null && primaryProcess.equals(guestProcessName);
}
public static String getLastError() { return lastError; }
public static long getInitializationTimestamp() { return initializationTimestamp; }
@@ -81,6 +86,7 @@ public final class GuestRuntimeRegistry {
"\"virtualProcessId\":" + virtualProcessId + ',' +
"\"sourceDir\":" + quote(guestSourceDir) + ',' +
"\"instrumentationEnabled\":" + instrumentationEnabled + ',' +
"\"primaryProcess\":" + isPrimaryProcess() + ',' +
"\"lastError\":" + quote(lastError) + ',' +
"\"initializedAt\":" + initializationTimestamp +
'}';
@@ -16,6 +16,7 @@ public final class InstrumentationStatusStore {
}
public static void recordBinding() {
if (!GuestRuntimeRegistry.isPrimaryProcess()) return;
String packageName = GuestRuntimeRegistry.getGuestPackageName();
String mode = InstrumentationSettings.getModeForPackage(packageName);
String state;
@@ -49,6 +50,7 @@ public final class InstrumentationStatusStore {
}
public static void recordLoaded() {
if (!GuestRuntimeRegistry.isPrimaryProcess()) return;
String mode = preferences().getString("runtime_mode", InstrumentationSettings.MODE_COMPUTER);
String state = InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)
? "local_script_active" : "computer_attached";
@@ -56,6 +58,7 @@ public final class InstrumentationStatusStore {
}
public static void recordError(String error) {
if (!GuestRuntimeRegistry.isPrimaryProcess()) return;
preferences().edit().putString("runtime_state", "failed").putString("runtime_error", error).commit();
}
}
@@ -3,6 +3,7 @@ package top.niunaijun.blackbox.instrumentation;
import android.content.Context;
import java.io.File;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
@@ -14,6 +15,8 @@ import top.niunaijun.blackbox.BlackBoxCore;
final class LocalScriptGadgetRuntime {
private static final String AGENT_ROOT = "fridabox-agents";
private static final String AGENT_NAME = "agent.js";
private static final String INSTRUMENTED_AGENT_NAME = "fridabox-runtime.js";
private static final String LOG_NAME = "runtime.jsonl";
private static final String RUNTIME_NAME = "libfridabox-agent.so";
private static final String CONFIG_NAME = "libfridabox-agent.config.so";
@@ -35,6 +38,14 @@ final class LocalScriptGadgetRuntime {
}
File directory = script.getParentFile();
File log = new File(directory, LOG_NAME);
writeUtf8Atomically(log, "");
File instrumentedAgent = new File(directory, INSTRUMENTED_AGENT_NAME);
writeInstrumentedAgentAtomically(instrumentedAgent, script, buildLogBridge(log));
if (!instrumentedAgent.setReadable(true, true) || !instrumentedAgent.setWritable(false, false)) {
throw new IOException("Unable to secure the instrumented JavaScript agent");
}
File source = new File(context.getApplicationInfo().nativeLibraryDir, "libfrida-gadget.so");
if (!source.isFile()) throw new IOException("Packaged Frida Gadget is missing");
@@ -49,23 +60,66 @@ final class LocalScriptGadgetRuntime {
}
File config = new File(directory, CONFIG_NAME);
writeUtf8Atomically(config, buildConfig(packageName));
writeUtf8Atomically(config, buildConfig(packageName, instrumentedAgent.getAbsolutePath()));
return runtime;
}
static String buildConfig(String packageName) {
static String buildConfig(String packageName, String agentPath) {
return "{\n" +
" \"interaction\": {\n" +
" \"type\": \"script\",\n" +
" \"path\": \"" + AGENT_NAME + "\",\n" +
" \"on_change\": \"reload\",\n" +
" \"path\": \"" + json(agentPath) + "\",\n" +
" \"on_change\": \"ignore\",\n" +
" \"parameters\": { \"package\": \"" + json(packageName) + "\" }\n" +
" },\n" +
" \"runtime\": \"qjs\",\n" +
" \"runtime\": \"v8\",\n" +
" \"teardown\": \"minimal\"\n" +
"}\n";
}
static String buildLogBridge(File log) {
return "(function () {\n" +
" try {\n" +
" const logPath = \"" + json(log.getAbsolutePath()) + "\";\n" +
" const maxBytes = 512 * 1024;\n" +
" const original = { log: console.log, warn: console.warn, error: console.error, send: globalThis.send };\n" +
" function render(value) {\n" +
" if (typeof value === 'string') return value;\n" +
" try { return JSON.stringify(value); } catch (_) { return String(value); }\n" +
" }\n" +
" function append(level, values) {\n" +
" try {\n" +
" const message = Array.prototype.map.call(values, render).join(' ');\n" +
" let line = JSON.stringify({ time: Date.now(), level: level, message: message }) + '\\n';\n" +
" let existing = '';\n" +
" try { existing = File.readAllText(logPath); } catch (_) {}\n" +
" if (existing.length + line.length > maxBytes) {\n" +
" const notice = JSON.stringify({ time: Date.now(), level: 'system', message: 'Earlier logs were truncated' }) + '\\n';\n" +
" const keep = Math.max(0, maxBytes - notice.length - line.length);\n" +
" existing = existing.slice(Math.max(0, existing.length - keep));\n" +
" line = notice + line;\n" +
" File.writeAllText(logPath, existing + line);\n" +
" return;\n" +
" }\n" +
" const stream = new File(logPath, 'a');\n" +
" try { stream.write(line); stream.flush(); } finally { stream.close(); }\n" +
" } catch (error) { try { original.error.call(console, '[FridaBox log bridge]', error.stack || error); } catch (_) {} }\n" +
" }\n" +
" console.log = function () { append('log', arguments); return original.log.apply(console, arguments); };\n" +
" console.warn = function () { append('warn', arguments); return original.warn.apply(console, arguments); };\n" +
" console.error = function () { append('error', arguments); return original.error.apply(console, arguments); };\n" +
" const wrappedSend = function (payload, data) { append('send', [payload]); return original.send(payload, data); };\n" +
" Object.defineProperty(globalThis, '__fridaboxSend', { value: wrappedSend, configurable: true });\n" +
" try {\n" +
" const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'send');\n" +
" if (!descriptor || descriptor.writable) globalThis.send = wrappedSend;\n" +
" else if (descriptor.configurable) Object.defineProperty(globalThis, 'send', Object.assign({}, descriptor, { value: wrappedSend }));\n" +
" } catch (error) { append('system', ['send() capture unavailable: ' + error]); }\n" +
" append('system', ['On-device agent started']);\n" +
" } catch (error) { try { console.error('[FridaBox log bridge]', error.stack || error); } catch (_) {} }\n" +
"})();\n";
}
static boolean isInside(File root, File child) {
String rootPath = root.getAbsolutePath();
String childPath = child.getAbsolutePath();
@@ -107,6 +161,101 @@ final class LocalScriptGadgetRuntime {
replace(temporary, destination);
}
private static void writeInstrumentedAgentAtomically(
File destination, File source, String bridge) throws IOException {
File temporary = new File(destination.getParentFile(), destination.getName() + ".partial");
if (temporary.exists() && !temporary.delete()) {
throw new IOException("Unable to replace temporary JavaScript agent");
}
try (FileInputStream input = new FileInputStream(source);
ByteArrayOutputStream sourceBytes = new ByteArrayOutputStream((int) source.length())) {
byte[] buffer = new byte[64 * 1024];
int count;
while ((count = input.read(buffer)) >= 0) sourceBytes.write(buffer, 0, count);
byte[] instrumented = instrumentAgent(sourceBytes.toByteArray(), bridge);
try (FileOutputStream output = new FileOutputStream(temporary)) {
output.write(instrumented);
output.getFD().sync();
}
}
replace(temporary, destination);
}
static byte[] instrumentAgent(byte[] source, String bridge) throws IOException {
int offset = injectionOffset(source);
if (offset < 0) throw new IOException("Invalid Frida bundle header");
byte[] prefix = (bridge + "(function (send) {\n").getBytes(StandardCharsets.UTF_8);
byte[] suffix = "\n})(globalThis.__fridaboxSend);\n".getBytes(StandardCharsets.UTF_8);
if (offset > 0) return instrumentBundle(source, prefix, suffix, offset);
ByteArrayOutputStream output = new ByteArrayOutputStream(source.length + prefix.length + suffix.length);
output.write(prefix, 0, prefix.length);
output.write(source, 0, source.length);
output.write(suffix, 0, suffix.length);
return output.toByteArray();
}
private static byte[] instrumentBundle(
byte[] source, byte[] prefix, byte[] suffix, int bodyOffset) throws IOException {
int lengthStart = 0;
while (lengthStart < bodyOffset && source[lengthStart] != '\n') lengthStart++;
lengthStart++;
int lengthEnd = lengthStart;
long declaredLength = 0;
while (lengthEnd < bodyOffset && source[lengthEnd] >= '0' && source[lengthEnd] <= '9') {
declaredLength = declaredLength * 10 + (source[lengthEnd] - '0');
lengthEnd++;
}
if (lengthStart >= bodyOffset || lengthEnd == lengthStart
|| lengthEnd >= bodyOffset || source[lengthEnd] != ' ') {
throw new IOException("Invalid Frida bundle module length");
}
long bodyEndLong = bodyOffset + declaredLength;
if (bodyEndLong > source.length) throw new IOException("Invalid Frida bundle module length");
int bodyEnd = (int) bodyEndLong;
byte[] updatedLength = Long.toString(declaredLength + prefix.length + suffix.length)
.getBytes(StandardCharsets.US_ASCII);
ByteArrayOutputStream output = new ByteArrayOutputStream(
source.length + prefix.length + suffix.length
+ updatedLength.length - (lengthEnd - lengthStart));
output.write(source, 0, lengthStart);
output.write(updatedLength, 0, updatedLength.length);
output.write(source, lengthEnd, bodyOffset - lengthEnd);
output.write(prefix, 0, prefix.length);
output.write(source, bodyOffset, bodyEnd - bodyOffset);
output.write(suffix, 0, suffix.length);
output.write(source, bodyEnd, source.length - bodyEnd);
return output.toByteArray();
}
static int injectionOffset(byte[] source) {
byte[] magic = "📦".getBytes(StandardCharsets.UTF_8);
if (!startsWith(source, magic)) return 0;
byte[] unixMarker = "\n✄\n".getBytes(StandardCharsets.UTF_8);
int unix = indexOf(source, unixMarker);
if (unix >= 0) return unix + unixMarker.length;
byte[] windowsMarker = "\r\n✄\r\n".getBytes(StandardCharsets.UTF_8);
int windows = indexOf(source, windowsMarker);
return windows < 0 ? -1 : windows + windowsMarker.length;
}
private static boolean startsWith(byte[] value, byte[] prefix) {
if (value.length < prefix.length) return false;
for (int i = 0; i < prefix.length; i++) {
if (value[i] != prefix[i]) return false;
}
return true;
}
private static int indexOf(byte[] value, byte[] needle) {
for (int i = 0; i <= value.length - needle.length; i++) {
int j = 0;
while (j < needle.length && value[i + j] == needle[j]) j++;
if (j == needle.length) return i;
}
return -1;
}
private static void replace(File temporary, File destination) throws IOException {
if (destination.exists() && !destination.delete()) throw new IOException("Unable to replace " + destination.getName());
if (!temporary.renameTo(destination)) throw new IOException("Unable to install " + destination.getName());
@@ -20,6 +20,7 @@ public class GuestRuntimeRegistryTest {
public void initializeReplacesProcessLocalSnapshot() {
ApplicationInfo info = new ApplicationInfo();
info.sourceDir = "/private/original.apk";
info.processName = "sample.one";
ClassLoader loader = getClass().getClassLoader();
GuestRuntimeRegistry.initialize("sample.one", "sample.one:remote", 3, 7, info, loader, true);
@@ -30,9 +31,19 @@ public class GuestRuntimeRegistryTest {
assertSame(loader, GuestRuntimeRegistry.getGuestClassLoader());
assertEquals("/private/original.apk", GuestRuntimeRegistry.getGuestSourceDir());
assertTrue(GuestRuntimeRegistry.isInstrumentationEnabled());
assertFalse(GuestRuntimeRegistry.isPrimaryProcess());
assertTrue(GuestRuntimeRegistry.describe().contains("\"package\":\"sample.one\""));
}
@Test
public void primaryProcessUsesApplicationProcessName() {
ApplicationInfo info = new ApplicationInfo();
info.processName = "sample.custom";
GuestRuntimeRegistry.initialize("sample", "sample.custom", 0, 1, info, null, true);
assertTrue(GuestRuntimeRegistry.isPrimaryProcess());
}
@Test
public void clearRemovesPriorGuest() {
GuestRuntimeRegistry.initialize("sample", "sample", 0, 1, null, null, true);
@@ -6,16 +6,54 @@ import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.io.File;
import java.nio.charset.StandardCharsets;
public class LocalScriptGadgetRuntimeTest {
@Test
public void configUsesAutonomousScriptInteraction() {
String config = LocalScriptGadgetRuntime.buildConfig("sample.\"guest");
String config = LocalScriptGadgetRuntime.buildConfig(
"sample.\"guest", "/private/agents/fridabox-runtime.js");
assertTrue(config.contains("\"type\": \"script\""));
assertTrue(config.contains("\"path\": \"agent.js\""));
assertTrue(config.contains("\"path\": \"/private/agents/fridabox-runtime.js\""));
assertTrue(config.contains("\"on_change\": \"ignore\""));
assertTrue(config.contains("\"runtime\": \"v8\""));
assertTrue(config.contains("sample.\\\"guest"));
}
@Test
public void logBridgeCapturesConsoleAndSendWithoutChangingTheSourceAgent() {
String bridge = LocalScriptGadgetRuntime.buildLogBridge(new File("runtime.jsonl"));
assertTrue(bridge.contains("console.log = function"));
assertTrue(bridge.contains("console.error = function"));
assertTrue(bridge.contains("Object.getOwnPropertyDescriptor(globalThis, 'send')"));
assertTrue(bridge.contains("__fridaboxSend"));
assertTrue(bridge.contains("runtime.jsonl"));
assertTrue(bridge.contains("new File(logPath, 'a')"));
}
@Test
public void compiledBundleKeepsItsHeaderBeforeTheInjectedBridge() throws Exception {
String source = "📦\n16 /scripts/sample-hook.js\n✄\nvar hook = true;";
String bridge = "console.log('bridge');\n";
String result = new String(LocalScriptGadgetRuntime.instrumentAgent(
source.getBytes(StandardCharsets.UTF_8), bridge), StandardCharsets.UTF_8);
assertTrue(result.contains("✄\n" + bridge + "(function (send) {\nvar hook = true;"));
assertTrue(result.contains("})(globalThis.__fridaboxSend);"));
}
@Test
public void plainScriptReceivesBridgeAtTheBeginning() throws Exception {
String source = "console.log('agent');";
String bridge = "console.log('bridge');\n";
String result = new String(LocalScriptGadgetRuntime.instrumentAgent(
source.getBytes(StandardCharsets.UTF_8), bridge), StandardCharsets.UTF_8);
assertTrue(result.startsWith(bridge));
assertTrue(result.contains("(function (send) {\n" + source));
assertTrue(result.endsWith("})(globalThis.__fridaboxSend);\n"));
}
@Test
public void privatePathCheckRejectsSiblingPrefix() {
File root = new File("/data/user/0/host/files/fridabox-agents");