mirror of
https://github.com/QM4RS/FridaBox.git
synced 2026-10-16 04:42:13 +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");
|
||||
|
||||
Reference in New Issue
Block a user