mirror of
https://github.com/QM4RS/FridaBox.git
synced 2026-09-14 13:12:10 +02:00
Add floating log output menu and fix several issues
This commit is contained in:
@@ -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 +
|
||||
'}';
|
||||
|
||||
+3
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+154
-5
@@ -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());
|
||||
|
||||
+11
@@ -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);
|
||||
|
||||
+40
-2
@@ -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");
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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<View>(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<String?> {
|
||||
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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user