diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/app/BActivityThread.java b/Bcore/src/main/java/top/niunaijun/blackbox/app/BActivityThread.java index 13d7a2b..f294b9c 100644 --- a/Bcore/src/main/java/top/niunaijun/blackbox/app/BActivityThread.java +++ b/Bcore/src/main/java/top/niunaijun/blackbox/app/BActivityThread.java @@ -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; diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java index e4a3fe3..4c3d3aa 100644 --- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java +++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java @@ -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); diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/GuestRuntimeRegistry.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/GuestRuntimeRegistry.java index ae78306..0b542a5 100644 --- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/GuestRuntimeRegistry.java +++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/GuestRuntimeRegistry.java @@ -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 + '}'; diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationStatusStore.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationStatusStore.java index 0506504..91b6fe4 100644 --- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationStatusStore.java +++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationStatusStore.java @@ -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(); } } diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java index c4b0acd..68d7224 100644 --- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java +++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java @@ -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()); diff --git a/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/GuestRuntimeRegistryTest.java b/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/GuestRuntimeRegistryTest.java index 63d950d..515fb0c 100644 --- a/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/GuestRuntimeRegistryTest.java +++ b/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/GuestRuntimeRegistryTest.java @@ -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); diff --git a/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntimeTest.java b/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntimeTest.java index 3f10096..a0ae05e 100644 --- a/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntimeTest.java +++ b/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntimeTest.java @@ -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"); diff --git a/app/src/main/java/com/qm4rs/fridabox/FridaBoxActivity.kt b/app/src/main/java/com/qm4rs/fridabox/FridaBoxActivity.kt index 995a11c..b52c5e1 100644 --- a/app/src/main/java/com/qm4rs/fridabox/FridaBoxActivity.kt +++ b/app/src/main/java/com/qm4rs/fridabox/FridaBoxActivity.kt @@ -377,6 +377,16 @@ class FridaBoxActivity : AppCompatActivity() { } private fun launch(packageName: String, mode: String) { + settings.edit() + .putString("runtime_package", packageName) + .putString("runtime_mode", mode) + .putString("runtime_state", when (mode) { + InstrumentationSettings.MODE_LOCAL_SCRIPT -> "loading_local_script" + InstrumentationSettings.MODE_COMPUTER -> "waiting_for_attach" + else -> "idle" + }) + .putString("runtime_error", null) + .apply() setLoading(true) worker.execute { val result = runCatching { @@ -625,7 +635,7 @@ class FridaBoxActivity : AppCompatActivity() { binding.toolbar.title = getString(R.string.fb_runtime_title) binding.toolbar.subtitle = getString(R.string.fb_runtime_subtitle) - val packageName = settings.getString("runtime_package", packageHint) ?: packageHint + val packageName = packageHint ?: settings.getString("runtime_package", null) val state = settings.getString("runtime_state", "idle") ?: "idle" val mode = settings.getString("runtime_mode", packageName?.let { InstrumentationSettings.getModeForPackage(it) @@ -664,6 +674,17 @@ class FridaBoxActivity : AppCompatActivity() { append("\n\nThe private agent is loaded autonomously by Frida Gadget.") } )) + if (packageName != null) { + binding.content.addView(outlineButton("Open agent logs") { + if (!FridaBoxGuestLogOverlay.show(this, packageName)) { + toast("No agent log is available for $packageName") + } + }.apply { + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(50)).apply { + topMargin = dp(14) + } + }) + } } else { binding.content.addView(infoCard("Clean launch", getString(R.string.fb_mode_clean_body))) } diff --git a/app/src/main/java/com/qm4rs/fridabox/FridaBoxGuestLogOverlay.kt b/app/src/main/java/com/qm4rs/fridabox/FridaBoxGuestLogOverlay.kt new file mode 100644 index 0000000..1b919da --- /dev/null +++ b/app/src/main/java/com/qm4rs/fridabox/FridaBoxGuestLogOverlay.kt @@ -0,0 +1,284 @@ +package com.qm4rs.fridabox + +import android.app.Activity +import android.app.Dialog +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import org.json.JSONObject +import top.niunaijun.blackbox.instrumentation.InstrumentationSettings +import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale + +/** Package-scoped floating log console shown inside a virtual guest Activity. */ +object FridaBoxGuestLogOverlay { + private const val OVERLAY_TAG = "fridabox.guest.log.overlay" + private const val LOG_NAME = "runtime.jsonl" + private const val REFRESH_INTERVAL_MS = 750L + + private val backgroundColor = Color.rgb(8, 13, 18) + private val surfaceColor = Color.rgb(18, 26, 34) + private val surfaceHighColor = Color.rgb(29, 40, 51) + private val primaryColor = Color.rgb(110, 231, 216) + private val textColor = Color.rgb(231, 238, 244) + private val secondaryTextColor = Color.rgb(148, 164, 178) + private val outlineColor = Color.rgb(49, 65, 80) + + fun attach(activity: Activity, packageName: String) { + val decor = activity.window?.decorView as? ViewGroup ?: return + val existing = decor.findViewWithTag(OVERLAY_TAG) + val localMode = InstrumentationSettings.MODE_LOCAL_SCRIPT == + InstrumentationSettings.getModeForPackage(packageName) + val logFile = logFileFor(packageName) + if (!localMode || logFile == null) { + if (existing != null) decor.removeView(existing) + return + } + if (existing != null) { + existing.bringToFront() + return + } + + val bubble = TextView(activity).apply { + tag = OVERLAY_TAG + text = ">_" + contentDescription = "Open Frida logs for $packageName" + gravity = Gravity.CENTER + setTextColor(Color.BLACK) + textSize = 15f + setTypeface(Typeface.MONOSPACE, Typeface.BOLD) + background = rounded(primaryColor, dp(activity, 18)) + elevation = dp(activity, 10).toFloat() + setOnClickListener { showConsole(activity, packageName, logFile) } + } + val margin = dp(activity, 18) + decor.addView(bubble, FrameLayout.LayoutParams(dp(activity, 54), dp(activity, 54)).apply { + gravity = Gravity.END or Gravity.BOTTOM + marginEnd = margin + bottomMargin = dp(activity, 30) + }) + makeDraggable(bubble, decor) + bubble.bringToFront() + } + + fun show(activity: Activity, packageName: String): Boolean { + val logFile = logFileFor(packageName) ?: return false + showConsole(activity, packageName, logFile) + return true + } + + private fun makeDraggable(view: View, parent: ViewGroup) { + val touchSlop = dp(view.context, 8).toFloat() + var downX = 0f + var downY = 0f + var startTranslationX = 0f + var startTranslationY = 0f + var dragged = false + view.setOnTouchListener { target, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = event.rawX + downY = event.rawY + startTranslationX = target.translationX + startTranslationY = target.translationY + dragged = false + true + } + MotionEvent.ACTION_MOVE -> { + val dx = event.rawX - downX + val dy = event.rawY - downY + dragged = dragged || kotlin.math.abs(dx) > touchSlop || kotlin.math.abs(dy) > touchSlop + val minX = -target.left.toFloat() + val maxX = (parent.width - target.right).toFloat() + val minY = -target.top.toFloat() + val maxY = (parent.height - target.bottom).toFloat() + target.translationX = (startTranslationX + dx).coerceIn(minX, maxX) + target.translationY = (startTranslationY + dy).coerceIn(minY, maxY) + true + } + MotionEvent.ACTION_UP -> { + if (!dragged) target.performClick() + true + } + MotionEvent.ACTION_CANCEL -> true + else -> false + } + } + } + + private fun showConsole(activity: Activity, packageName: String, logFile: File) { + if (activity.isFinishing || activity.isDestroyed) return + val handler = Handler(Looper.getMainLooper()) + val root = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(activity, 20), dp(activity, 20), dp(activity, 20), dp(activity, 16)) + background = rounded(surfaceColor, dp(activity, 22)) + } + root.addView(TextView(activity).apply { + text = "On-device agent logs" + textSize = 20f + setTypeface(typeface, Typeface.BOLD) + setTextColor(textColor) + }) + val status = TextView(activity).apply { + text = packageName + textSize = 11.5f + setTextColor(secondaryTextColor) + setPadding(0, dp(activity, 4), 0, 0) + } + root.addView(status) + + val output = TextView(activity).apply { + text = "No logs yet." + typeface = Typeface.MONOSPACE + textSize = 12f + setTextColor(textColor) + setTextIsSelectable(true) + setPadding(dp(activity, 14), dp(activity, 14), dp(activity, 14), dp(activity, 14)) + background = rounded(backgroundColor, dp(activity, 12)) + } + val scroll = ScrollView(activity).apply { + isFillViewport = true + addView(output, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )) + } + root.addView(scroll, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1f + ).apply { + topMargin = dp(activity, 14) + bottomMargin = dp(activity, 14) + }) + + val actions = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + } + var hiddenBefore = 0L + var fileSignature = Long.MIN_VALUE + val clear = button(activity, "Clear view", false) { + hiddenBefore = System.currentTimeMillis() + fileSignature = Long.MIN_VALUE + output.text = "No logs yet." + } + val copy = button(activity, "Copy", false) { + val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("$packageName Frida logs", output.text)) + Toast.makeText(activity, "Agent logs copied", Toast.LENGTH_SHORT).show() + } + lateinit var dialog: Dialog + val close = button(activity, "Close", true) { dialog.dismiss() } + actions.addView(clear, LinearLayout.LayoutParams(0, dp(activity, 44), 1f)) + actions.addView(copy, LinearLayout.LayoutParams(0, dp(activity, 44), 1f).apply { + marginStart = dp(activity, 8) + }) + actions.addView(close, LinearLayout.LayoutParams(0, dp(activity, 44), 1f).apply { + marginStart = dp(activity, 8) + }) + root.addView(actions) + + dialog = Dialog(activity).apply { + setContentView(root) + setCanceledOnTouchOutside(true) + window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + attributes = attributes.apply { dimAmount = 0.58f } + } + } + var snapshot = "" + lateinit var refresh: Runnable + refresh = Runnable { + if (!dialog.isShowing) return@Runnable + val length = logFile.length() + val nextSignature = length xor logFile.lastModified() + if (nextSignature != fileSignature) { + fileSignature = nextSignature + val next = formatLogs(logFile, hiddenBefore) + if (next != snapshot) { + val stayAtBottom = scroll.scrollY + scroll.height >= output.height - dp(activity, 24) + snapshot = next + output.text = next.ifBlank { "No logs yet." } + if (stayAtBottom) scroll.post { scroll.fullScroll(View.FOCUS_DOWN) } + } + val size = if (length in 1..1023) "<1 KiB" else "${length / 1024} KiB" + status.text = "$packageName ยท $size" + } + handler.postDelayed(refresh, REFRESH_INTERVAL_MS) + } + dialog.setOnShowListener { + val metrics = activity.resources.displayMetrics + dialog.window?.setLayout((metrics.widthPixels * 0.92f).toInt(), (metrics.heightPixels * 0.76f).toInt()) + handler.post(refresh) + } + dialog.setOnDismissListener { handler.removeCallbacks(refresh) } + dialog.show() + } + + private fun logFileFor(packageName: String): File? { + val scriptPath = InstrumentationSettings.getScriptPathForPackage(packageName) ?: return null + val directory = File(scriptPath).parentFile ?: return null + return File(directory, LOG_NAME) + } + + private fun formatLogs(file: File, hiddenBefore: Long): String { + if (!file.isFile || file.length() == 0L) return "" + val formatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.ROOT) + return runCatching { + file.readLines(Charsets.UTF_8).mapNotNull { line -> + runCatching { + val item = JSONObject(line) + val time = item.optLong("time") + if (time <= hiddenBefore) return@runCatching null + val timestamp = formatter.format(java.util.Date(time)) + val level = item.optString("level", "log").uppercase(Locale.ROOT) + "[$timestamp] ${level.padEnd(6)} ${item.optString("message")}" + }.getOrElse { if (hiddenBefore == 0L) line else null } + }.joinToString("\n") + }.getOrElse { "Unable to read agent logs: ${it.message}" } + } + + private fun button(context: Context, label: String, primary: Boolean, action: () -> Unit): TextView { + return TextView(context).apply { + text = label + gravity = Gravity.CENTER + textSize = 12f + setTypeface(typeface, Typeface.BOLD) + setTextColor(if (primary) Color.BLACK else textColor) + background = rounded(if (primary) primaryColor else surfaceHighColor, dp(context, 12), + if (primary) primaryColor else outlineColor) + setOnClickListener { action() } + } + } + + private fun rounded(fill: Int, radius: Int, stroke: Int? = null): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + setColor(fill) + cornerRadius = radius.toFloat() + if (stroke != null) setStroke(1, stroke) + } + } + + private fun dp(context: Context, value: Int): Int = + (value * context.resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/com/qm4rs/fridabox/FridaBoxRuntime.kt b/app/src/main/java/com/qm4rs/fridabox/FridaBoxRuntime.kt index 5b0c081..f237e87 100644 --- a/app/src/main/java/com/qm4rs/fridabox/FridaBoxRuntime.kt +++ b/app/src/main/java/com/qm4rs/fridabox/FridaBoxRuntime.kt @@ -1,16 +1,22 @@ package com.qm4rs.fridabox +import android.app.Activity import android.app.Application import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Log import java.io.File import top.niunaijun.blackbox.BlackBoxCore import top.niunaijun.blackbox.app.BActivityThread import top.niunaijun.blackbox.app.configuration.AppLifecycleCallback import top.niunaijun.blackbox.app.configuration.ClientConfiguration +import top.niunaijun.blackbox.instrumentation.FridaGadgetLoader +import top.niunaijun.blackbox.instrumentation.GuestRuntimeRegistry object FridaBoxRuntime { private const val TAG = "FridaBox.Runtime" + private const val AGENT_READY_FALLBACK_MS = 5_000L fun attach(context: Context) { BlackBoxCore.get().doAttachBaseContext(context, object : ClientConfiguration() { @@ -48,6 +54,19 @@ object FridaBoxRuntime { userId: Int ) { Log.d(TAG, "afterApplicationOnCreate: package=$packageName process=$processName") + if (GuestRuntimeRegistry.isPrimaryProcess()) { + Handler(Looper.getMainLooper()).postDelayed({ + FridaGadgetLoader.loadIfEnabled() + }, AGENT_READY_FALLBACK_MS) + } + } + + override fun onActivityResumed(activity: Activity) { + val packageName = runCatching { BActivityThread.getAppPackageName() }.getOrNull() + if (!packageName.isNullOrBlank()) { + FridaGadgetLoader.loadIfEnabled() + FridaBoxGuestLogOverlay.attach(activity, packageName) + } } override fun onStoragePermissionNeeded(packageName: String?, userId: Int): Boolean { diff --git a/sample-guest/build.gradle b/sample-guest/build.gradle index 625e135..c715031 100644 --- a/sample-guest/build.gradle +++ b/sample-guest/build.gradle @@ -10,8 +10,8 @@ android { applicationId "com.qm4rs.fridabox.sample" minSdk rootProject.ext.minSdk targetSdk rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "1.1" } compileOptions { diff --git a/sample-guest/src/main/java/com/qm4rs/fridabox/sample/MainActivity.java b/sample-guest/src/main/java/com/qm4rs/fridabox/sample/MainActivity.java index b4d1f59..450d79e 100644 --- a/sample-guest/src/main/java/com/qm4rs/fridabox/sample/MainActivity.java +++ b/sample-guest/src/main/java/com/qm4rs/fridabox/sample/MainActivity.java @@ -3,6 +3,7 @@ package com.qm4rs.fridabox.sample; import android.app.Activity; import android.os.Bundle; import android.view.Gravity; +import android.view.View; import android.widget.Button; import android.widget.LinearLayout; import android.widget.TextView; @@ -16,7 +17,12 @@ public final class MainActivity extends Activity { output.setText("Press the button to call Target.add(2, 3)"); Button button = new Button(this); button.setText("Call Target.add(2, 3)"); - button.setOnClickListener(view -> output.setText("Result: " + Target.add(2, 3))); + button.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + output.setText("Result: " + Target.add(2, 3)); + } + }); LinearLayout root = new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); root.setGravity(Gravity.CENTER);