Merge pull request #12 from QM4RS/agent/gadget-manager-liquid-glass

Agent/gadget manager liquid glass
This commit is contained in:
Mr.Karzari
2026-07-27 16:56:35 +03:30
committed by GitHub
47 changed files with 2648 additions and 368 deletions
@@ -0,0 +1,169 @@
package top.niunaijun.blackbox.instrumentation;
import android.content.Context;
import android.os.Build;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import top.niunaijun.blackbox.BlackBoxCore;
/** Resolves a selected, downloaded Gadget and prepares per-guest runtime copies. */
final class DownloadedGadgetRuntime {
private static final String DOWNLOAD_ROOT = "fridabox-gadgets";
private static final String RUNTIME_ROOT = "fridabox-gadget-runtimes";
private static final String RUNTIME_NAME = "libpayload.so";
private static final String CONFIG_NAME = "libpayload.config.so";
private static final String SOURCE_NAME = "payload.source";
private DownloadedGadgetRuntime() {
}
static File prepareListener(String packageName) throws IOException {
Context context = BlackBoxCore.getContext();
File source = requireSelected(context);
File directory = new File(context.getFilesDir(),
RUNTIME_ROOT + File.separator + safeName(packageName));
if (!directory.isDirectory() && !directory.mkdirs()) {
throw new IOException("Unable to create the Gadget runtime directory");
}
File runtime = new File(directory, RUNTIME_NAME);
syncSelected(source, runtime);
writeUtf8Atomically(new File(directory, CONFIG_NAME), listenerConfig());
return runtime;
}
static File requireSelected(Context context) throws IOException {
String selectedPath = InstrumentationSettings.getSelectedGadgetPath();
String selectedAbi = InstrumentationSettings.getSelectedGadgetAbi();
if (selectedPath == null || selectedPath.trim().isEmpty()) {
throw new IOException("No downloaded Frida Gadget is selected");
}
File root = new File(context.getFilesDir(), DOWNLOAD_ROOT).getCanonicalFile();
File selected = new File(selectedPath).getCanonicalFile();
if (!isInside(root, selected) || !selected.isFile()) {
throw new IOException("The selected Frida Gadget is missing or outside private storage");
}
if (!supportsAbi(selectedAbi)) {
throw new IOException("The selected Frida Gadget ABI is not supported by this device");
}
validateElf(selected, selectedAbi);
return selected;
}
static void syncSelected(File source, File destination) throws IOException {
String identity = source.getCanonicalPath() + "\n"
+ String.valueOf(InstrumentationSettings.getSelectedGadgetSha256()) + "\n";
File marker = new File(destination.getParentFile(), SOURCE_NAME);
if (!destination.isFile() || destination.length() != source.length()
|| !identity.equals(readUtf8(marker))) {
copyAtomically(source, destination);
writeUtf8Atomically(marker, identity);
}
if (!destination.setReadable(true, false)
|| !destination.setExecutable(true, false)
|| !destination.setWritable(false, false)) {
throw new IOException("Unable to secure the private Frida Gadget runtime");
}
}
private static String listenerConfig() {
return "{\n" +
" \"interaction\": {\n" +
" \"type\": \"listen\",\n" +
" \"address\": \"127.0.0.1\",\n" +
" \"port\": " + InstrumentationSettings.getBasePort() + ",\n" +
" \"on_port_conflict\": \"pick-next\",\n" +
" \"on_load\": \"wait\"\n" +
" },\n" +
" \"runtime\": \"qjs\",\n" +
" \"teardown\": \"minimal\"\n" +
"}\n";
}
private static void validateElf(File file, String abi) throws IOException {
byte[] header = new byte[20];
try (FileInputStream input = new FileInputStream(file)) {
if (input.read(header) != header.length) throw new IOException("Downloaded Gadget is truncated");
}
if ((header[0] & 0xff) != 0x7f || header[1] != 'E' || header[2] != 'L' || header[3] != 'F') {
throw new IOException("Downloaded Gadget is not an ELF library");
}
int machine = (header[18] & 0xff) | ((header[19] & 0xff) << 8);
int expected = expectedMachine(abi);
if (expected < 0 || machine != expected) {
throw new IOException("Downloaded Gadget ELF architecture does not match " + abi);
}
}
private static int expectedMachine(String abi) {
if ("arm64-v8a".equals(abi)) return 183;
if ("armeabi-v7a".equals(abi)) return 40;
if ("x86".equals(abi)) return 3;
if ("x86_64".equals(abi)) return 62;
return -1;
}
private static boolean supportsAbi(String abi) {
if (abi == null) return false;
for (String supported : Build.SUPPORTED_ABIS) {
if (abi.equals(supported)) return true;
}
return false;
}
private static boolean isInside(File root, File child) {
return child.getAbsolutePath().startsWith(root.getAbsolutePath() + File.separator);
}
private static String safeName(String value) {
return value == null ? "unknown" : value.replaceAll("[^A-Za-z0-9._-]", "_");
}
private static void copyAtomically(File source, File destination) throws IOException {
File temporary = new File(destination.getParentFile(), destination.getName() + ".partial");
if (temporary.exists() && !temporary.delete()) throw new IOException("Unable to replace temporary Gadget");
try (FileInputStream input = new FileInputStream(source);
FileOutputStream output = new FileOutputStream(temporary)) {
byte[] buffer = new byte[128 * 1024];
int count;
while ((count = input.read(buffer)) >= 0) output.write(buffer, 0, count);
output.getFD().sync();
}
replace(temporary, destination);
}
private static String readUtf8(File file) {
if (!file.isFile() || file.length() > 4096) return null;
try (FileInputStream input = new FileInputStream(file);
ByteArrayOutputStream output = new ByteArrayOutputStream((int) file.length())) {
byte[] buffer = new byte[512];
int count;
while ((count = input.read(buffer)) >= 0) output.write(buffer, 0, count);
return output.toString(StandardCharsets.UTF_8.name());
} catch (IOException ignored) {
return null;
}
}
static void writeUtf8Atomically(File destination, String value) throws IOException {
byte[] expected = value.getBytes(StandardCharsets.UTF_8);
File temporary = new File(destination.getParentFile(), destination.getName() + ".partial");
try (FileOutputStream output = new FileOutputStream(temporary)) {
output.write(expected);
output.getFD().sync();
}
replace(temporary, destination);
}
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());
}
}
@@ -52,7 +52,8 @@ public final class FridaGadgetLoader {
System.load(runtime.getAbsolutePath());
} else {
Log.i(TAG, "Loading Frida Gadget listener for " + GuestRuntimeRegistry.getGuestProcessName());
System.loadLibrary("frida-gadget");
File runtime = DownloadedGadgetRuntime.prepareListener(packageName);
System.load(runtime.getAbsolutePath());
}
loaded = true;
InstrumentationStatusStore.recordLoaded();
@@ -12,6 +12,11 @@ public final class InstrumentationSettings {
public static final String KEY_BASE_PORT = "frida_base_port";
public static final String KEY_SCAN_COUNT = "frida_port_scan_count";
public static final String KEY_ADVANCED_LOGS = "show_advanced_logs";
public static final String KEY_GADGET_PATH = "selected_gadget_path";
public static final String KEY_GADGET_SOURCE = "selected_gadget_source";
public static final String KEY_GADGET_VERSION = "selected_gadget_version";
public static final String KEY_GADGET_ABI = "selected_gadget_abi";
public static final String KEY_GADGET_SHA256 = "selected_gadget_sha256";
private static final String PACKAGE_PREFIX = "package_enabled_";
private static final String PACKAGE_MODE_PREFIX = "package_mode_";
private static final String PACKAGE_SCRIPT_PREFIX = "package_script_";
@@ -97,6 +102,54 @@ public final class InstrumentationSettings {
return preferences().getBoolean(KEY_ADVANCED_LOGS, false);
}
public static String getSelectedGadgetPath() {
return preferences().getString(KEY_GADGET_PATH, null);
}
public static String getSelectedGadgetSource() {
return preferences().getString(KEY_GADGET_SOURCE, null);
}
public static String getSelectedGadgetVersion() {
return preferences().getString(KEY_GADGET_VERSION, null);
}
public static String getSelectedGadgetAbi() {
return preferences().getString(KEY_GADGET_ABI, null);
}
public static String getSelectedGadgetSha256() {
return preferences().getString(KEY_GADGET_SHA256, null);
}
public static boolean setSelectedGadget(
String path, String source, String version, String abi, String sha256) {
if (path == null || path.trim().isEmpty()
|| source == null || source.trim().isEmpty()
|| version == null || version.trim().isEmpty()
|| abi == null || abi.trim().isEmpty()
|| sha256 == null || sha256.trim().isEmpty()) {
return false;
}
return preferences().edit()
.putString(KEY_GADGET_PATH, path)
.putString(KEY_GADGET_SOURCE, source)
.putString(KEY_GADGET_VERSION, version)
.putString(KEY_GADGET_ABI, abi)
.putString(KEY_GADGET_SHA256, sha256)
.commit();
}
public static boolean clearSelectedGadget() {
return preferences().edit()
.remove(KEY_GADGET_PATH)
.remove(KEY_GADGET_SOURCE)
.remove(KEY_GADGET_VERSION)
.remove(KEY_GADGET_ABI)
.remove(KEY_GADGET_SHA256)
.commit();
}
static int clamp(int value, int minimum, int maximum, int fallback) {
return value < minimum || value > maximum ? fallback : value;
}
@@ -46,18 +46,10 @@ final class LocalScriptGadgetRuntime {
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");
File source = DownloadedGadgetRuntime.requireSelected(context);
File runtime = new File(directory, RUNTIME_NAME);
if (!runtime.isFile() || runtime.length() != source.length()) {
copyAtomically(source, runtime);
}
if (!runtime.setReadable(true, false)
|| !runtime.setExecutable(true, false)
|| !runtime.setWritable(false, false)) {
throw new IOException("Unable to secure private Frida Gadget permissions");
}
DownloadedGadgetRuntime.syncSelected(source, runtime);
File config = new File(directory, CONFIG_NAME);
writeUtf8Atomically(config, buildConfig(packageName, instrumentedAgent.getAbsolutePath()));
+11 -8
View File
@@ -182,12 +182,12 @@ in On-device, Computer, and Clean modes. Evidence and exact commands are in
- JDK 21;
- Android SDK 35 and NDK 29.0.14206865;
- Python 3.10+;
- Node.js 20+ and npm when rebuilding the bundled Frida 17 agents.
- Node.js 20+ and npm for the pinned Frida agent toolchain and official Tabler
Icons source package.
### Build
```text
python tools/fetch_frida_gadget.py
npm ci
python tools/build_frida_agents.py
./gradlew :app:assembleDebug :Bcore:testDebugUnitTest :app:testDebugUnitTest :app:check
@@ -200,10 +200,12 @@ documented in [docs/BUILDING.md](docs/BUILDING.md). The output is written under
### Use
1. Install and open FridaBox.
2. Tap **Import APK** and select an ARM64 base APK.
3. Choose **On-device**, **Computer**, or **Clean** on that guest's card.
4. Select an agent for On-device mode, or attach normally in Computer mode.
5. Inspect live state in the **Runtime** tab.
2. Open **Gadgets** and download a compatible Official Frida version.
3. Select one downloaded Gadget as active.
4. Tap the import icon and choose an installed app or an ARM64 base APK file.
5. Tap the guest icon and choose **On-device**, **Computer**, or **Clean** in the launch panel.
6. Select an agent for On-device mode, or attach normally in Computer mode.
7. Inspect live state in the **Runtime** tab.
See [docs/USAGE.md](docs/USAGE.md) and
[docs/FRIDA_CONNECTION.md](docs/FRIDA_CONNECTION.md) for the complete workflow.
@@ -246,8 +248,9 @@ docs/ Build, usage, limitations and device evidence
## Scope and limitations
FridaBox currently targets single-file ARM64 APKs. Split APK sets, privileged or
system applications, full Google Play Services, Play Integrity, hardware-backed
FridaBox targets ARM64 APKs and can clone the active base/split set of a
launchable installed app. Standalone split archives, privileged system
applications, full Google Play Services, Play Integrity, hardware-backed
attestation, and perfect anti-instrumentation resistance are outside the current
scope. Vendor behavior and hidden Android APIs can still affect compatibility.
+27
View File
@@ -42,6 +42,33 @@ FridaBox gratefully acknowledges the Frida maintainers and contributors for the
instrumentation engine, GumJS runtime, Java bridge, Gadget deployment model, and
controller APIs that make this research possible.
## Tabler Icons
FridaBox UI control icons are generated from the official `@tabler/icons`
package version 3.45.0, sourced from `https://github.com/tabler/tabler-icons`.
MIT License
Copyright (c) 2020-2026 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Frida Java bridge and agent compiler
The compiled JavaScript agents under `scripts/dist/` include
+21 -34
View File
@@ -77,7 +77,6 @@ android {
jniLibs {
excludes.add("**/libandroidx.graphics.path.so")
useLegacyPackaging true
keepDebugSymbols.add("**/libfrida-gadget.config.so")
}
resources {
excludes.add("**/libandroidx.graphics.path.so")
@@ -104,40 +103,15 @@ tasks.matching { it.name == "mergeDebugAssets" }.configureEach { dependsOn("copy
tasks.matching { it.name.toLowerCase().contains("debug") && it.name.toLowerCase().contains("lint") }
.configureEach { dependsOn("copyDemoGuestDebug") }
def fridaGadget = file("src/main/jniLibs/arm64-v8a/libfrida-gadget.so")
def fridaGadgetConfig = file("src/main/jniLibs/arm64-v8a/libfrida-gadget.config.so")
tasks.register("verifyFridaGadget") {
inputs.files(fridaGadget, fridaGadgetConfig)
doLast {
if (!fridaGadget.isFile()) {
throw new GradleException("Frida Gadget 17.16.0 is missing. Run: python tools/fetch_frida_gadget.py")
}
if (!fridaGadgetConfig.isFile()) {
throw new GradleException("Frida Gadget configuration is missing: ${fridaGadgetConfig}")
}
def digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(fridaGadget.bytes).collect { String.format("%02x", it & 0xff) }.join()
if (digest != "6bf149e5d1c5ec701e7b822cab57bb243f1c2a03318fa974fe373ee711a9ed9e") {
throw new GradleException("Frida Gadget 17.16.0 SHA-256 mismatch: ${digest}")
}
def config = new groovy.json.JsonSlurper().parse(fridaGadgetConfig)
if (config.interaction?.address != "127.0.0.1" || config.interaction?.on_load != "wait") {
throw new GradleException("Unsafe or invalid Frida Gadget configuration")
}
}
}
tasks.matching { it.name == "preBuild" }.configureEach { dependsOn("verifyFridaGadget") }
tasks.register("verifyDebugApkFridaPackaging") {
tasks.register("verifyDebugApkHasNoGadget") {
dependsOn("assembleDebug")
doLast {
def apk = fileTree("$buildDir/outputs/apk/debug").matching { include("*.apk") }.singleFile
def entries = zipTree(apk).matching {
include("lib/arm64-v8a/libfrida-gadget.so")
include("lib/arm64-v8a/libfrida-gadget.config.so")
}.files.collect { it.name }.toSet()
if (!entries.contains("libfrida-gadget.so") || !entries.contains("libfrida-gadget.config.so")) {
throw new GradleException("Debug APK does not contain both Frida Gadget files")
def entries = zipTree(apk).files.findAll {
it.name.toLowerCase().contains("gadget") && it.name.toLowerCase().endsWith(".so")
}
if (!entries.isEmpty()) {
throw new GradleException("APK must not bundle Frida Gadget: ${entries*.name}")
}
}
}
@@ -145,7 +119,7 @@ tasks.register("verifyDebugApkFridaPackaging") {
tasks.register("verifyInstrumentationOrdering") {
doLast {
def source = project(":Bcore").file("src/main/java/top/niunaijun/blackbox/app/BActivityThread.java").text
def loader = source.indexOf("FridaGadgetLoader.loadIfEnabled()")
def loader = source.indexOf("FridaGadgetLoader.loadAtProcessBind()")
def makeApplication = source.indexOf("makeApplication(", loader)
if (loader < 0 || makeApplication < 0 || loader >= makeApplication) {
throw new GradleException("Frida Gadget loader must execute before guest makeApplication")
@@ -153,6 +127,17 @@ tasks.register("verifyInstrumentationOrdering") {
}
}
tasks.register("verifyTablerAndroidIcons", Exec) {
workingDir(rootProject.projectDir)
def nodeExecutable = System.getProperty("os.name").toLowerCase().contains("windows") ? "node.exe" : "node"
commandLine(nodeExecutable, "tools/generate_tabler_android_icons.mjs", "--check")
doFirst {
if (!rootProject.file("node_modules/@tabler/icons/package.json").isFile()) {
throw new GradleException("Missing @tabler/icons. Run npm ci before building FridaBox.")
}
}
}
tasks.register("verifyDemoGuestUnmodified") {
dependsOn("copyDemoGuestDebug")
doLast {
@@ -166,7 +151,8 @@ tasks.register("verifyDemoGuestUnmodified") {
}
tasks.named("check").configure {
dependsOn("verifyDebugApkFridaPackaging", "verifyInstrumentationOrdering", "verifyDemoGuestUnmodified")
dependsOn("verifyDebugApkHasNoGadget", "verifyInstrumentationOrdering",
"verifyDemoGuestUnmodified", "verifyTablerAndroidIcons")
}
dependencies {
@@ -176,6 +162,7 @@ dependencies {
implementation libs.material
implementation libs.constraintlayout
implementation libs.core.ktx
implementation "org.tukaani:xz:1.10"
testImplementation libs.junit
androidTestImplementation libs.ext.junit
androidTestImplementation libs.espresso.core
+1
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@@ -2,6 +2,7 @@ package com.qm4rs.fridabox;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -13,23 +14,36 @@ public final class ApkInspector {
}
public static Result inspect(File apk) throws IOException {
return inspect(Collections.singletonList(apk));
}
public static Result inspect(File... apks) throws IOException {
return inspect(Arrays.asList(apks));
}
public static Result inspect(Iterable<File> apks) throws IOException {
Set<String> abis = new LinkedHashSet<>();
boolean hasNativeLibraries = false;
try (ZipFile archive = new ZipFile(apk)) {
if (archive.getEntry("AndroidManifest.xml") == null) {
throw new IOException("The selected file has no AndroidManifest.xml");
}
for (ZipEntry entry : Collections.list(archive.entries())) {
String name = entry.getName();
if (!entry.isDirectory() && name.startsWith("lib/") && name.endsWith(".so")) {
String[] parts = name.split("/", 3);
if (parts.length == 3) {
hasNativeLibraries = true;
abis.add(parts[1]);
boolean inspectedAny = false;
for (File apk : apks) {
inspectedAny = true;
try (ZipFile archive = new ZipFile(apk)) {
if (archive.getEntry("AndroidManifest.xml") == null) {
throw new IOException("The selected file has no AndroidManifest.xml");
}
for (ZipEntry entry : Collections.list(archive.entries())) {
String name = entry.getName();
if (!entry.isDirectory() && name.startsWith("lib/") && name.endsWith(".so")) {
String[] parts = name.split("/", 3);
if (parts.length == 3) {
hasNativeLibraries = true;
abis.add(parts[1]);
}
}
}
}
}
if (!inspectedAny) throw new IOException("No APK files were provided");
boolean supported = !hasNativeLibraries || abis.contains("arm64-v8a");
return new Result(hasNativeLibraries, supported, abis);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
package com.qm4rs.fridabox
import android.content.Context
import android.os.Build
import org.json.JSONArray
import org.json.JSONObject
import org.tukaani.xz.XZInputStream
import top.niunaijun.blackbox.instrumentation.InstrumentationSettings
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.Locale
import java.util.zip.GZIPInputStream
enum class GadgetSource(
val id: String,
val title: String,
val repository: String
) {
OFFICIAL("official", "Official Frida", "frida/frida");
fun expectedAsset(version: String, architecture: String): String =
"frida-gadget-$version-android-$architecture.so.xz"
companion object {
fun fromId(value: String?): GadgetSource? = entries.firstOrNull { it.id == value }
}
}
data class GadgetAbi(val androidName: String, val releaseName: String) {
companion object {
private val supported = listOf(
GadgetAbi("arm64-v8a", "arm64"),
GadgetAbi("armeabi-v7a", "arm"),
GadgetAbi("x86_64", "x86_64"),
GadgetAbi("x86", "x86")
)
fun detect(): GadgetAbi? = Build.SUPPORTED_ABIS
.firstNotNullOfOrNull { deviceAbi -> supported.firstOrNull { it.androidName == deviceAbi } }
}
}
data class GadgetRelease(
val source: GadgetSource,
val version: String,
val abi: GadgetAbi,
val assetName: String,
val downloadUrl: String,
val compressedSize: Long,
val publishedAt: String
)
data class InstalledGadget(
val source: GadgetSource,
val version: String,
val abi: GadgetAbi,
val file: File,
val sha256: String,
val size: Long
) {
val identity: String get() = "${source.id}:$version:${abi.androidName}"
}
data class GadgetCatalog(
val releases: List<GadgetRelease>,
val fromCache: Boolean,
val hasMore: Boolean
)
class GadgetManager(private val context: Context) {
private val root = File(context.filesDir, DOWNLOAD_ROOT)
private val cacheRoot = File(context.cacheDir, "gadget-catalogs")
init {
if (InstrumentationSettings.getSelectedGadgetSource() != null
&& InstrumentationSettings.getSelectedGadgetSource() != GadgetSource.OFFICIAL.id) {
InstrumentationSettings.clearSelectedGadget()
}
}
fun detectedAbi(): GadgetAbi? = GadgetAbi.detect()
fun loadCatalog(source: GadgetSource, abi: GadgetAbi, page: Int): GadgetCatalog {
val cache = File(cacheRoot, "${source.id}-${abi.androidName}-$page.json")
return try {
val json = requestText(releaseCatalogUrl(source, page))
val releases = parseCatalog(json, source, abi)
writeAtomically(cache, json.toByteArray(Charsets.UTF_8))
GadgetCatalog(releases, false, JSONArray(json).length() == CATALOG_PAGE_SIZE)
} catch (networkError: Exception) {
if (!cache.isFile) throw networkError
val cached = cache.readText(Charsets.UTF_8)
val releases = parseCatalog(cached, source, abi)
GadgetCatalog(releases, true, JSONArray(cached).length() == CATALOG_PAGE_SIZE)
}
}
fun download(release: GadgetRelease): InstalledGadget {
val currentAbi = detectedAbi() ?: throw IOException("This device ABI is not supported")
if (release.abi != currentAbi) throw IOException("The selected release does not match this device ABI")
val directory = installDirectory(release.source, release.version, release.abi).apply {
if (!isDirectory && !mkdirs()) throw IOException("Unable to create Gadget storage")
}
val temporary = File(directory, "$PAYLOAD_NAME.partial")
val destination = File(directory, PAYLOAD_NAME)
val metadata = File(directory, METADATA_NAME)
if (temporary.exists() && !temporary.delete()) throw IOException("Unable to replace partial download")
val connection = openConnection(release.downloadUrl, "application/octet-stream")
try {
if (connection.responseCode !in 200..299) {
throw IOException("GitHub download failed (HTTP ${connection.responseCode})")
}
val compressedLength = connection.contentLengthLong
if (compressedLength > MAX_COMPRESSED_BYTES) throw IOException("Gadget download is unexpectedly large")
val digest = MessageDigest.getInstance("SHA-256")
var total = 0L
decompressed(connection.inputStream, release.assetName).use { input ->
FileOutputStream(temporary).use { output ->
val buffer = ByteArray(128 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
total += count
if (total > MAX_DECOMPRESSED_BYTES) throw IOException("Expanded Gadget is unexpectedly large")
digest.update(buffer, 0, count)
output.write(buffer, 0, count)
}
output.fd.sync()
}
}
if (total == 0L) throw IOException("Downloaded Gadget is empty")
validateElf(temporary, release.abi)
replace(temporary, destination)
if (!destination.setReadable(true, true) || !destination.setWritable(false, false)) {
throw IOException("Unable to secure downloaded Gadget")
}
val sha256 = digest.digest().joinToString("") { "%02x".format(it) }
val record = InstalledGadget(
release.source, release.version, release.abi, destination, sha256, total
)
writeMetadata(metadata, record, release.assetName)
return record
} catch (error: Exception) {
temporary.delete()
throw error
} finally {
connection.disconnect()
}
}
fun installed(): List<InstalledGadget> {
val abi = detectedAbi() ?: return emptyList()
if (!root.isDirectory) return emptyList()
return root.walkTopDown()
.filter { it.isFile && it.name == METADATA_NAME }
.mapNotNull { metadata -> runCatching { readMetadata(metadata) }.getOrNull() }
.filter { it.abi == abi && it.file.isFile }
.filter { runCatching { validateElf(it.file, it.abi) }.isSuccess }
.sortedWith(compareByDescending<InstalledGadget> { versionParts(it.version) }
.thenBy { it.source.ordinal })
.toList()
}
fun selected(): InstalledGadget? {
val path = InstrumentationSettings.getSelectedGadgetPath() ?: return null
val selected = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
return installed().firstOrNull { runCatching { it.file.canonicalFile == selected }.getOrDefault(false) }
}
fun select(gadget: InstalledGadget) {
val currentAbi = detectedAbi() ?: throw IOException("This device ABI is not supported")
if (gadget.abi != currentAbi || !gadget.file.isFile) {
throw IOException("The selected Gadget is not available for this device")
}
validateElf(gadget.file, gadget.abi)
if (!InstrumentationSettings.setSelectedGadget(
gadget.file.canonicalPath,
gadget.source.id,
gadget.version,
gadget.abi.androidName,
gadget.sha256
)) {
throw IOException("Unable to save the selected Gadget")
}
}
fun findInstalled(release: GadgetRelease): InstalledGadget? {
val expected = runCatching {
File(installDirectory(release.source, release.version, release.abi), METADATA_NAME)
}.getOrNull() ?: return null
return runCatching { readMetadata(expected) }.getOrNull()
?.takeIf { it.file.isFile && runCatching { validateElf(it.file, it.abi) }.isSuccess }
}
private fun parseCatalog(json: String, source: GadgetSource, abi: GadgetAbi): List<GadgetRelease> {
val releases = JSONArray(json)
val result = ArrayList<GadgetRelease>()
for (index in 0 until releases.length()) {
val release = releases.optJSONObject(index) ?: continue
if (release.optBoolean("draft") || release.optBoolean("prerelease")) continue
val version = release.optString("tag_name").trim()
if (!isSafeSegment(version)) continue
val expected = source.expectedAsset(version, abi.releaseName)
val assets = release.optJSONArray("assets") ?: continue
for (assetIndex in 0 until assets.length()) {
val asset = assets.optJSONObject(assetIndex) ?: continue
val name = asset.optString("name")
if (name != expected) continue
val url = asset.optString("browser_download_url")
if (!isTrustedUrl(url)) continue
result += GadgetRelease(
source = source,
version = version,
abi = abi,
assetName = name,
downloadUrl = url,
compressedSize = asset.optLong("size", -1L),
publishedAt = release.optString("published_at")
)
break
}
}
return result
}
private fun installDirectory(source: GadgetSource, version: String, abi: GadgetAbi): File {
if (!isSafeSegment(version)) throw IOException("Invalid Gadget version")
return File(root, "${source.id}${File.separator}$version${File.separator}${abi.androidName}")
}
private fun writeMetadata(file: File, gadget: InstalledGadget, assetName: String) {
val json = JSONObject()
.put("source", gadget.source.id)
.put("version", gadget.version)
.put("abi", gadget.abi.androidName)
.put("releaseAbi", gadget.abi.releaseName)
.put("sha256", gadget.sha256)
.put("size", gadget.size)
.put("asset", assetName)
.toString()
writeAtomically(file, json.toByteArray(Charsets.UTF_8))
}
private fun readMetadata(file: File): InstalledGadget {
if (!file.isFile || file.length() > MAX_METADATA_BYTES) throw IOException("Invalid Gadget metadata")
val json = JSONObject(file.readText(Charsets.UTF_8))
val source = GadgetSource.fromId(json.getString("source")) ?: throw IOException("Unknown Gadget source")
val version = json.getString("version")
if (!isSafeSegment(version)) throw IOException("Invalid Gadget version")
val androidAbi = json.getString("abi")
val releaseAbi = json.getString("releaseAbi")
val abi = GadgetAbi(androidAbi, releaseAbi)
val payload = File(file.parentFile, PAYLOAD_NAME).canonicalFile
val canonicalRoot = root.canonicalFile
if (!payload.path.startsWith(canonicalRoot.path + File.separator)) throw IOException("Invalid Gadget path")
return InstalledGadget(
source, version, abi, payload, json.getString("sha256"), json.getLong("size")
)
}
private fun requestText(url: String): String {
val connection = openConnection(url, "application/vnd.github+json")
try {
if (connection.responseCode !in 200..299) {
throw IOException("GitHub release lookup failed (HTTP ${connection.responseCode})")
}
val bytes = readLimited(connection.inputStream, MAX_CATALOG_BYTES)
return bytes.toString(Charsets.UTF_8)
} finally {
connection.disconnect()
}
}
private fun openConnection(value: String, accept: String): HttpURLConnection {
if (!isTrustedUrl(value)) throw IOException("Untrusted Gadget download URL")
return (URL(value).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 45_000
instanceFollowRedirects = true
setRequestProperty("Accept", accept)
setRequestProperty("User-Agent", "FridaBox/${BuildConfig.VERSION_NAME}")
setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
}
}
private fun decompressed(input: InputStream, assetName: String): InputStream = when {
assetName.endsWith(".xz") -> XZInputStream(input)
assetName.endsWith(".gz") -> GZIPInputStream(input, 128 * 1024)
else -> throw IOException("Unsupported Gadget archive format")
}
companion object {
private const val DOWNLOAD_ROOT = "fridabox-gadgets"
internal const val CATALOG_PAGE_SIZE = 10
private const val PAYLOAD_NAME = "payload.so"
private const val METADATA_NAME = "metadata.json"
private const val MAX_METADATA_BYTES = 16L * 1024L
private const val MAX_CATALOG_BYTES = 24L * 1024L * 1024L
private const val MAX_COMPRESSED_BYTES = 96L * 1024L * 1024L
private const val MAX_DECOMPRESSED_BYTES = 192L * 1024L * 1024L
internal fun releaseCatalogUrl(source: GadgetSource, page: Int): String {
require(page > 0) { "Catalog page must be positive" }
return "https://api.github.com/repos/${source.repository}/releases" +
"?per_page=$CATALOG_PAGE_SIZE&page=$page"
}
internal fun validateElf(file: File, abi: GadgetAbi) {
val header = ByteArray(20)
FileInputStream(file).use { input ->
if (input.read(header) != header.size) throw IOException("Downloaded Gadget is truncated")
}
if (header[0].toInt() and 0xff != 0x7f || header[1] != 'E'.code.toByte()
|| header[2] != 'L'.code.toByte() || header[3] != 'F'.code.toByte()) {
throw IOException("Downloaded Gadget is not an ELF library")
}
if (header[5].toInt() != 1) throw IOException("Only little-endian Android ELF libraries are supported")
val machine = (header[18].toInt() and 0xff) or ((header[19].toInt() and 0xff) shl 8)
val expected = when (abi.androidName) {
"arm64-v8a" -> 183
"armeabi-v7a" -> 40
"x86" -> 3
"x86_64" -> 62
else -> -1
}
if (machine != expected) throw IOException("Gadget ELF architecture does not match ${abi.androidName}")
}
private fun readLimited(input: InputStream, maximum: Long): ByteArray {
input.use {
val output = ByteArrayOutputStream()
val buffer = ByteArray(64 * 1024)
var total = 0L
while (true) {
val count = it.read(buffer)
if (count < 0) break
total += count
if (total > maximum) throw IOException("GitHub response is unexpectedly large")
output.write(buffer, 0, count)
}
return output.toByteArray()
}
}
private fun writeAtomically(destination: File, bytes: ByteArray) {
destination.parentFile?.let { if (!it.isDirectory && !it.mkdirs()) throw IOException("Unable to create storage") }
val temporary = File(destination.parentFile, destination.name + ".partial")
FileOutputStream(temporary).use { output ->
output.write(bytes)
output.fd.sync()
}
replace(temporary, destination)
}
private fun replace(temporary: File, destination: File) {
if (destination.exists() && !destination.delete()) throw IOException("Unable to replace ${destination.name}")
if (!temporary.renameTo(destination)) throw IOException("Unable to install ${destination.name}")
}
private fun isSafeSegment(value: String): Boolean =
value.isNotBlank() && value.length <= 64 && value.matches(Regex("[A-Za-z0-9._-]+"))
private fun isTrustedUrl(value: String): Boolean = runCatching {
val url = URL(value)
val host = url.host.lowercase(Locale.ROOT)
url.protocol == "https" && (host == "api.github.com" || host == "github.com"
|| host.endsWith(".githubusercontent.com"))
}.getOrDefault(false)
private fun versionParts(value: String): String = value.split('.')
.joinToString(".") { it.toIntOrNull()?.toString()?.padStart(8, '0') ?: it }
}
}
@@ -1,11 +0,0 @@
{
"interaction": {
"type": "listen",
"address": "127.0.0.1",
"port": 27042,
"on_port_conflict": "pick-next",
"on_load": "wait"
},
"runtime": "qjs",
"teardown": "minimal"
}
Binary file not shown.
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:angle="315" android:startColor="#1A2942" android:centerColor="#141D30" android:endColor="#111827" />
<corners android:radius="28dp" />
<stroke android:width="1dp" android:color="#334862" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="28dp" />
<gradient
android:angle="90"
android:startColor="#E0253850"
android:centerColor="#C91A2B42"
android:endColor="#B3122136" />
<stroke android:width="1dp" android:color="@color/fb_glass_outline" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="22dp" />
<gradient
android:angle="135"
android:startColor="#F02A3E58"
android:centerColor="#E01C2D45"
android:endColor="#D916263C" />
<stroke android:width="1dp" android:color="@color/fb_glass_outline" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="24dp" />
<gradient
android:angle="135"
android:startColor="#D9253850"
android:centerColor="#B51B2B42"
android:endColor="#9915253B" />
<stroke android:width="1dp" android:color="@color/fb_glass_outline" />
</shape>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<corners android:radius="22dp" />
<gradient android:angle="135" android:startColor="#D564E8E8" android:endColor="#A329415D" />
<stroke android:width="1dp" android:color="#D9FFFFFF" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<corners android:radius="22dp" />
<gradient android:angle="135" android:startColor="#A638516D" android:endColor="#7315263B" />
<stroke android:width="1dp" android:color="@color/fb_glass_outline" />
</shape>
</item>
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="22dp" />
<gradient android:angle="135" android:startColor="#59364E68" android:endColor="#33152235" />
<stroke android:width="1dp" android:color="@color/fb_glass_outline_soft" />
</shape>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="135"
android:startColor="#07111D"
android:centerColor="#11162D"
android:endColor="#080C18" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0.88"
android:centerY="0.08"
android:gradientRadius="420dp"
android:startColor="#5964E8E8"
android:centerColor="#2464E8E8"
android:endColor="#0064E8E8" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0.08"
android:centerY="0.92"
android:gradientRadius="460dp"
android:startColor="#4D9B87F5"
android:centerColor="#1F9B87F5"
android:endColor="#009B87F5" />
</shape>
</item>
</layer-list>
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF05070B" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
</vector>
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from @tabler/icons 3.45.0: icons/outline/apps.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M14 7l6 0" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M17 4l0 6" />
</vector>
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from @tabler/icons 3.45.0: icons/outline/database-off.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M3 3l18 18" />
</vector>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from @tabler/icons 3.45.0: icons/outline/x.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M18 6l-12 12" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6 6l12 12" />
</vector>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from @tabler/icons 3.45.0: icons/outline/info-circle.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M12 9h.01" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M11 12h1v4h1" />
</vector>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from @tabler/icons 3.45.0: icons/outline/file.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M14 3v4a1 1 0 0 0 1 1h4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
</vector>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from @tabler/icons 3.45.0: icons/outline/package.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M12 12l8 -4.5" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M12 12l0 9" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M12 12l-8 -4.5" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M16 5.25l-8 4.5" />
</vector>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from @tabler/icons 3.45.0: icons/outline/file-import.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M14 3v4a1 1 0 0 0 1 1h4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M5 13v-8a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-5.5m-9.5 -2h7m-3 -3l3 3l-3 3" />
</vector>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from @tabler/icons 3.45.0: icons/outline/trash.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M4 7l16 0" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M10 11l0 6" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M14 11l0 6" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</vector>
+13 -2
View File
@@ -1,4 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M3,13h4l2.4,-7.2L14,20l3,-7h4v-2h-5.3l-1.5,3.5L9.6,1L5.5,11H3z" />
<!-- Generated from @tabler/icons 3.45.0: icons/outline/activity.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M3 12h4l3 8l4 -16l3 8h4" />
</vector>
+20 -2
View File
@@ -1,4 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M19.4,13a7.7,7.7 0,0 0,0.1 -1,7.7 7.7,0 0,0 -0.1,-1l2.1,-1.7 -2,-3.4 -2.5,1a8,8 0,0 0,-1.7 -1L15,3h-4l-0.4,2.8a8,8 0,0 0,-1.7 1l-2.5,-1 -2,3.4L6.6,11a7.7,7.7 0,0 0,-0.1 1,7.7 7.7,0 0,0 0.1,1l-2.1,1.7 2,3.4 2.5,-1a8,8 0,0 0,1.7 1L11,21h4l0.4,-2.8a8,8 0,0 0,1.7 -1l2.5,1 2,-3.4zM13,15.5a3.5,3.5 0,1 1,0 -7,3.5 3.5,0 0,1 0,7z" />
<!-- Generated from @tabler/icons 3.45.0: icons/outline/settings.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</vector>
+34 -2
View File
@@ -1,4 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M3,3h8v8H3zM13,3h8v5h-8zM13,10h8v11h-8zM3,13h8v8H3z" />
<!-- Generated from @tabler/icons 3.45.0: icons/outline/layout-grid.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
</vector>
+29 -22
View File
@@ -4,15 +4,18 @@
android:id="@+id/fridabox_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/fb_background"
android:background="@drawable/bg_liquid_glass_app"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/fb_background"
android:elevation="8dp"
android:layout_marginStart="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:background="@android:color/transparent"
android:elevation="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@@ -20,8 +23,9 @@
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="72dp"
android:background="@color/fb_background"
android:layout_height="64dp"
android:background="@drawable/bg_glass_toolbar"
android:elevation="10dp"
android:contentInsetStart="0dp"
android:contentInsetEnd="0dp">
@@ -31,12 +35,12 @@
android:gravity="center_vertical"
android:layoutDirection="ltr"
android:orientation="horizontal"
android:paddingStart="18dp"
android:paddingEnd="18dp">
android:paddingStart="14dp"
android:paddingEnd="14dp">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_width="44dp"
android:layout_height="44dp"
android:contentDescription="@string/fb_brand"
android:scaleType="centerInside"
android:src="@drawable/logo" />
@@ -118,29 +122,32 @@
app:layout_constraintTop_toBottomOf="@id/app_bar"
app:trackColor="@color/fb_surface" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/import_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/import_fab_icon"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginEnd="18dp"
android:layout_marginBottom="18dp"
android:letterSpacing="0"
android:text="@string/fb_import_apk"
android:textAllCaps="false"
android:textColor="@color/fb_black"
android:layout_marginBottom="14dp"
android:contentDescription="@string/fb_import"
app:backgroundTint="@color/fb_primary"
app:icon="@drawable/ic_fb_add"
app:iconTint="@color/fb_black"
app:elevation="12dp"
app:srcCompat="@drawable/ic_fb_import"
app:tint="@color/fb_black"
app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="0dp"
android:layout_height="72dp"
android:background="@color/fb_surface"
android:layout_height="64dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_glass_navigation"
android:elevation="14dp"
app:itemActiveIndicatorStyle="@style/Widget.MaterialComponents.BottomNavigationView.Colored"
app:itemIconTint="@color/fb_text_primary"
app:itemRippleColor="@color/fb_glass_highlight"
app:itemTextColor="@color/fb_text_primary"
app:labelVisibilityMode="labeled"
app:layout_constraintBottom_toBottomOf="parent"
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/nav_workspace" android:icon="@drawable/ic_fb_workspace" android:title="@string/fb_nav_workspace" />
<item android:id="@+id/nav_gadgets" android:icon="@drawable/ic_fb_gadget" android:title="@string/fb_nav_gadgets" />
<item android:id="@+id/nav_runtime" android:icon="@drawable/ic_fb_runtime" android:title="@string/fb_nav_runtime" />
<item android:id="@+id/nav_settings" android:icon="@drawable/ic_fb_settings" android:title="@string/fb_nav_settings" />
</menu>
+11 -5
View File
@@ -1,15 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="fb_background">#080C14</color>
<color name="fb_surface">#111827</color>
<color name="fb_surface_high">#182235</color>
<color name="fb_surface_tint">#1D2940</color>
<color name="fb_background">#07111D</color>
<color name="fb_surface">#18263A</color>
<color name="fb_surface_high">#22344D</color>
<color name="fb_surface_tint">#29415D</color>
<color name="fb_glass_surface">#B31A2940</color>
<color name="fb_glass_surface_strong">#E0203047</color>
<color name="fb_glass_surface_soft">#731C3049</color>
<color name="fb_glass_outline">#66FFFFFF</color>
<color name="fb_glass_outline_soft">#2EFFFFFF</color>
<color name="fb_glass_highlight">#38FFFFFF</color>
<color name="fb_primary">#64E8E8</color>
<color name="fb_primary_dark">#1AAFB5</color>
<color name="fb_secondary">#9B87F5</color>
<color name="fb_text_primary">#F8FAFC</color>
<color name="fb_text_secondary">#9CAEC5</color>
<color name="fb_outline">#2B3A52</color>
<color name="fb_outline">#52708F</color>
<color name="fb_success">#42D39A</color>
<color name="fb_warning">#F5BF5B</color>
<color name="fb_error">#FB7185</color>
+30 -8
View File
@@ -3,16 +3,17 @@
<string name="fb_brand">FridaBox</string>
<string name="fb_brand_tagline">Mobile instrumentation workspace</string>
<string name="fb_nav_workspace">Workspace</string>
<string name="fb_nav_gadgets">Gadgets</string>
<string name="fb_nav_runtime">Runtime</string>
<string name="fb_nav_settings">Settings</string>
<string name="fb_import_apk">Import APK</string>
<string name="fb_hero_eyebrow">PRIVATE · NON-ROOT · ARM64</string>
<string name="fb_hero_title">Instrument apps. Keep control.</string>
<string name="fb_hero_body">Run trusted JavaScript agents directly on the device, connect from a computer, or launch a clean guest—per app.</string>
<string name="fb_guest_workspace">Guest workspace</string>
<string name="fb_guest_workspace_hint">Each guest keeps its own launch mode and agent.</string>
<string name="fb_no_guests">Your workspace is empty</string>
<string name="fb_no_guests_body">Import one ARM64 base APK to create a private FridaBox guest.</string>
<string name="fb_import">Import</string>
<string name="fb_import_from_apps">Import from apps</string>
<string name="fb_import_from_file">Import from file</string>
<string name="fb_installed_apps">Installed apps</string>
<string name="fb_import_app">Import</string>
<string name="fb_app_imported">Imported</string>
<string name="fb_loading_apps">Loading apps…</string>
<string name="fb_no_importable_apps">No importable apps found</string>
<string name="fb_mode_local">On-device</string>
<string name="fb_mode_computer">Computer</string>
<string name="fb_mode_clean">Clean</string>
@@ -56,4 +57,25 @@
<string name="fb_remove_body">The virtual app, its virtual data, and its saved on-device agent will be removed from FridaBox. The Android PackageManager installation is not changed.</string>
<string name="fb_remove">Remove</string>
<string name="fb_details">App details</string>
<string name="fb_gadgets_title">Gadget manager</string>
<string name="fb_active_gadget">Active Gadget</string>
<string name="fb_no_active_gadget_short">Not selected</string>
<string name="fb_installed_gadgets">Installed</string>
<string name="fb_browse_gadgets">Browse versions</string>
<string name="fb_close">Close</string>
<string name="fb_loading_versions">Loading versions…</string>
<string name="fb_all_versions_loaded">All available versions loaded</string>
<string name="fb_cached_versions">Showing cached versions</string>
<string name="fb_use_gadget">Use this Gadget</string>
<string name="fb_gadget_active">Active</string>
<string name="fb_download">Download</string>
<string name="fb_gadget_required">Download and select a Gadget before using an instrumented launch mode.</string>
<string name="fb_gadget_selected">Gadget %1$s selected</string>
<string name="fb_gadget_select_failed">Unable to select Gadget</string>
<string name="fb_gadget_downloading">Downloading Gadget %1$s…</string>
<string name="fb_gadget_downloaded">Gadget %1$s downloaded. Choose it from Installed when you want to use it.</string>
<string name="fb_gadget_downloaded_selected">Gadget %1$s downloaded and selected.</string>
<string name="fb_gadget_download_failed">Gadget download failed: %1$s</string>
<string name="fb_catalog_failed_body">Unable to load compatible Gadget releases from GitHub.</string>
<string name="fb_gadget_unsupported_title">Unsupported device ABI</string>
</resources>
+3 -3
View File
@@ -11,8 +11,8 @@
<item name="colorOnSurface">@color/fb_text_primary</item>
<item name="colorError">@color/fb_error</item>
<item name="android:colorAccent">@color/fb_primary</item>
<item name="android:windowBackground">@color/fb_background</item>
<item name="android:navigationBarColor">@color/fb_surface</item>
<item name="android:windowBackground">@drawable/bg_liquid_glass_app</item>
<item name="android:navigationBarColor">@color/fb_background</item>
<item name="android:statusBarColor">@color/fb_background</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">false</item>
@@ -22,7 +22,7 @@
</style>
<style name="ThemeOverlay.FridaBox.Dialog" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog">
<item name="colorSurface">@color/fb_surface_high</item>
<item name="colorSurface">@color/fb_glass_surface_strong</item>
<item name="colorOnSurface">@color/fb_text_primary</item>
<item name="colorPrimary">@color/fb_primary</item>
</style>
@@ -37,6 +37,15 @@ public class ApkInspectorTest {
assertFalse(result.supported);
}
@Test
public void splitNativeLibrariesAreInspectedTogether() throws Exception {
File base = apkWith("classes.dex");
File arm64Split = apkWith("lib/arm64-v8a/libsample.so");
ApkInspector.Result result = ApkInspector.inspect(base, arm64Split);
assertTrue(result.hasNativeLibraries);
assertTrue(result.supported);
}
private File apkWith(String entry) throws Exception {
File file = temporary.newFile("test-" + System.nanoTime() + ".apk");
try (ZipOutputStream output = new ZipOutputStream(new FileOutputStream(file))) {
@@ -0,0 +1,56 @@
package com.qm4rs.fridabox
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.IOException
class GadgetManagerTest {
@get:Rule
val temporary = TemporaryFolder()
@Test
fun releaseAssetNameMatchesOfficialRepository() {
assertEquals(
"frida-gadget-17.9.11-android-arm64.so.xz",
GadgetSource.OFFICIAL.expectedAsset("17.9.11", "arm64")
)
}
@Test
fun releaseCatalogUsesTenItemPages() {
assertEquals(
"https://api.github.com/repos/frida/frida/releases?per_page=10&page=3",
GadgetManager.releaseCatalogUrl(GadgetSource.OFFICIAL, 3)
)
}
@Test
fun elfValidationAcceptsMatchingArchitecture() {
val file = temporary.newFile("payload.so")
file.writeBytes(elfHeader(183))
GadgetManager.validateElf(file, GadgetAbi("arm64-v8a", "arm64"))
}
@Test
fun elfValidationRejectsWrongArchitecture() {
val file = temporary.newFile("payload.so")
file.writeBytes(elfHeader(40))
assertThrows(IOException::class.java) {
GadgetManager.validateElf(file, GadgetAbi("arm64-v8a", "arm64"))
}
}
private fun elfHeader(machine: Int): ByteArray = ByteArray(20).also {
it[0] = 0x7f
it[1] = 'E'.code.toByte()
it[2] = 'L'.code.toByte()
it[3] = 'F'.code.toByte()
it[4] = 2
it[5] = 1
it[18] = (machine and 0xff).toByte()
it[19] = (machine shr 8).toByte()
}
}
+13 -11
View File
@@ -7,23 +7,19 @@
- Android NDK 29.0.13846066 when available. This build host only had the closely
related complete NDK 29.0.14206865, which is recorded in `docs/BASELINE.md`.
- Python 3.10 or newer.
- Node.js 20 or newer and npm for reproducibly building Frida 17 Java agents.
- Node.js 20 or newer and npm for reproducibly building Frida 17 Java agents
and generating Android vectors from the official `@tabler/icons` package.
Create `local.properties` with the SDK path, then fetch the pinned official
Gadget:
```powershell
python tools\fetch_frida_gadget.py
```
The script uses the GitHub Releases API and Python's standard `lzma` module. The
build fails with a direct instruction if the binary is absent. No XZ archive is
kept.
Create `local.properties` with the SDK path. Frida Gadget is intentionally not a
build input and no Gadget binary is packaged in the APK. After installation, the
user downloads and selects an ABI-compatible Official Frida build from the
in-app **Gadgets** screen.
Install the pinned Java bridge/compiler and build the controller agents:
```powershell
npm ci
npm run verify:android-icons
python tools\build_frida_agents.py
```
@@ -31,6 +27,10 @@ Frida 17 no longer bundles runtime bridges into API-loaded GumJS agents. The
compiled files under `scripts/dist/` are reproducible from the source scripts,
`package.json`, and `package-lock.json`.
UI control icons are generated from pinned `@tabler/icons` sources with
`npm run generate:android-icons`. The app `check` task runs the matching
verification script and fails if a committed Android vector is out of sync.
On Windows, if the repository path contains spaces, create a no-space junction
and set the Bcore-only native path override:
@@ -52,6 +52,8 @@ Build and test:
The debug host build depends on the sample build and copies its byte-identical
APK into generated debug assets. Generated sample APKs are not source-controlled.
The final host output is under `app/build/outputs/apk/debug/` and is ARM64-only.
`verifyDebugApkHasNoGadget` inspects the assembled APK and fails if a Gadget
library or configuration is accidentally bundled again.
## Production release
+11 -11
View File
@@ -1,6 +1,6 @@
# Test results
Validation date: 2026-07-19
Validation date: 2026-07-27
## Host validation
@@ -12,10 +12,10 @@ The following commands completed successfully on the build host:
.\gradlew.bat :app:assembleDebug
.\gradlew.bat test
.\gradlew.bat :app:check
.\gradlew.bat :app:verifyDebugApkFridaPackaging :app:verifyInstrumentationOrdering :app:verifyDemoGuestUnmodified
.\gradlew.bat :app:verifyDebugApkHasNoGadget :app:verifyInstrumentationOrdering :app:verifyDemoGuestUnmodified
npm ci
python tools\build_frida_agents.py
python -m py_compile tools\attach_guest.py tools\forward_frida_ports.py tools\fetch_frida_gadget.py tools\build_frida_agents.py
python -m py_compile tools\attach_guest.py tools\forward_frida_ports.py tools\build_frida_agents.py
```
For this workspace path, native builds used:
@@ -24,8 +24,12 @@ For this workspace path, native builds used:
$env:FRIDABOX_NDK_PROJECT_DIR='D:\FridaBoxBuild\Bcore'
```
Unit-test results: 16 executions passed, with zero failures, errors, or skips.
This is eight test methods run for both debug and release variants:
Unit tests passed with zero failures, including Gadget asset naming, ten-item
release pagination, ELF ABI validation, and combined base/split APK inspection.
The original suite also covers:
The app `check` task also verifies that all committed UI control vectors are
byte-for-byte reproducible from pinned `@tabler/icons` 3.45.0 sources.
- `ApkInspectorTest`: 3 per variant;
- `ApkIntegrityTest`: 1 per variant;
@@ -35,13 +39,9 @@ This is eight test methods run for both debug and release variants:
The final APK and custom verification tasks passed:
- output: `app/build/outputs/apk/debug/FridaBox_4.0.0_arm64-v8a-debug.apk`;
- size: 19,977,904 bytes;
- SHA-256: `a87af38ff7f4a54d2cdc0207ac68319a1a05ab8067b7acdeb057c6c967ed2573`;
- packaged ABIs: ARM64 only;
- packaged native files: `libblackbox.so`, `libfrida-gadget.so`, and
`libfrida-gadget.config.so` under `lib/arm64-v8a/`;
- Frida Gadget 17.16.0 SHA-256:
`6bf149e5d1c5ec701e7b822cab57bb243f1c2a03318fa974fe373ee711a9ed9e`;
- packaged native files do not include a Frida Gadget binary or
Gadget configuration;
- early-load ordering and byte-identical demo-asset checks passed.
- pinned Frida 17 registry/bootstrap/sample agents rebuilt successfully, and
controller help plus empty-range discovery completed successfully.
+25 -7
View File
@@ -5,12 +5,30 @@ Importing an APK does not install that package into Android's real PackageManage
## Import an application
1. Open **Workspace** and tap **Import APK**.
2. Select one ARM64 base `.apk` through Android's document picker.
3. FridaBox validates the APK, records its SHA-256, and creates a private guest.
1. Open **Workspace** and tap the import icon.
2. Choose **Import from apps** to clone a launchable app already installed on
the device, or **Import from file** to select one base `.apk` through
Android's document picker.
3. FridaBox validates the full installed base/split set or selected file,
records its SHA-256 fingerprint, and creates a private guest.
Split-only packages (`.apks`, `.xapk`, and `.apkm`) and unsupported native ABIs
are rejected.
Split-only files (`.apks`, `.xapk`, and `.apkm`) and unsupported native ABIs are
rejected. Installed split applications are imported through their system package
so their active base and split APKs remain available to the virtual runtime.
## Install a Gadget
1. Open **Gadgets**. FridaBox detects the device ABI and only offers matching
release assets.
2. Tap **Browse versions**. The minimal release browser loads Official builds
from `frida/frida` ten at a time as the list is scrolled.
3. Download any listed version. Downloads are decompressed in private storage,
checked as ELF libraries, and rejected when their architecture does not match.
4. Select any installed version as active. Downloading another version does not
replace the active selection unless no Gadget had been selected yet.
Instrumented launches require an active Gadget. Clean mode remains available
without one. Gadget binaries are never bundled in the FridaBox APK.
## Choose a launch mode
@@ -34,7 +52,7 @@ Switching modes always stops the previous guest process before the next launch.
## On-device agents
Select **On-device**, tap **Select JavaScript**, review the trust warning, and
Tap an app icon, select **On-device**, tap **Select JavaScript**, review the trust warning, and
choose a `.js` file up to 16 MiB. The original file is not modified. The private
copy and the private Gadget executable are made read-only before execution.
@@ -46,7 +64,7 @@ interceptors, and in-app overlays continue to run in the guest process.
The **Runtime** tab reports the latest package, process, virtual user ID, virtual
process slot, source APK, ClassLoader, selected mode, state, and latest error.
Use **Manage** on an app card for app details, runtime details, virtual data
Use **Manage** in an app's launch panel for app details, runtime details, virtual data
clearing, or removal from the private workspace.
Only run JavaScript agents you trust. An on-device agent executes with the same
+12
View File
@@ -9,9 +9,21 @@
"frida-java-bridge": "7.0.13"
},
"devDependencies": {
"@tabler/icons": "3.45.0",
"frida-compile": "19.0.5"
}
},
"node_modules/@tabler/icons": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.45.0.tgz",
"integrity": "sha512-jiATwV8+zGYLTZ7gMLGivCic+KtsMZXcDmufIG8umlLxoHhI6902hGYIEt0Oa9Y9SXblNzUlrisHm5jOFMxOQA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+5
View File
@@ -1,10 +1,15 @@
{
"name": "fridabox-agents",
"private": true,
"scripts": {
"generate:android-icons": "node tools/generate_tabler_android_icons.mjs",
"verify:android-icons": "node tools/generate_tabler_android_icons.mjs --check"
},
"dependencies": {
"frida-java-bridge": "7.0.13"
},
"devDependencies": {
"@tabler/icons": "3.45.0",
"frida-compile": "19.0.5"
}
}
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""Fetch and verify the pinned official Frida Gadget Android ARM64 binary."""
from __future__ import annotations
import argparse
import hashlib
import json
import lzma
import pathlib
import shutil
import tempfile
import urllib.request
VERSION = "17.16.0"
ASSET = f"frida-gadget-{VERSION}-android-arm64.so.xz"
API_URL = f"https://api.github.com/repos/frida/frida/releases/tags/{VERSION}"
ROOT = pathlib.Path(__file__).resolve().parents[1]
DEST = ROOT / "app/src/main/jniLibs/arm64-v8a/libfrida-gadget.so"
CHECKSUM = ROOT / "tools/frida-gadget-17.16.0.sha256"
EXPECTED_SHA256 = "6bf149e5d1c5ec701e7b822cab57bb243f1c2a03318fa974fe373ee711a9ed9e"
def sha256(path: pathlib.Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def release_asset_url() -> str:
request = urllib.request.Request(
API_URL,
headers={"Accept": "application/vnd.github+json", "User-Agent": "FridaBox-build"},
)
with urllib.request.urlopen(request, timeout=30) as response:
release = json.load(response)
if release.get("tag_name") != VERSION:
raise RuntimeError(f"GitHub returned unexpected release {release.get('tag_name')!r}")
for asset in release.get("assets", []):
if asset.get("name") == ASSET:
return str(asset["browser_download_url"])
raise RuntimeError(f"Release {VERSION} does not contain {ASSET}")
def fetch(force: bool = False) -> str:
if DEST.exists() and not force:
digest = sha256(DEST)
if digest != EXPECTED_SHA256:
raise RuntimeError(f"Existing Gadget SHA-256 mismatch: {digest}")
CHECKSUM.write_text(f"{digest} {DEST.name}\n", encoding="ascii")
return digest
DEST.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="fridabox-") as temp_dir:
archive = pathlib.Path(temp_dir) / ASSET
request = urllib.request.Request(release_asset_url(), headers={"User-Agent": "FridaBox-build"})
with urllib.request.urlopen(request, timeout=120) as response, archive.open("wb") as output:
shutil.copyfileobj(response, output)
temporary_output = pathlib.Path(temp_dir) / DEST.name
with lzma.open(archive, "rb") as source, temporary_output.open("wb") as output:
shutil.copyfileobj(source, output)
if temporary_output.stat().st_size < 1_000_000:
raise RuntimeError("Downloaded Gadget is unexpectedly small")
shutil.move(str(temporary_output), DEST)
digest = sha256(DEST)
if digest != EXPECTED_SHA256:
DEST.unlink(missing_ok=True)
raise RuntimeError(f"Downloaded Gadget SHA-256 mismatch: {digest}")
CHECKSUM.write_text(f"{digest} {DEST.name}\n", encoding="ascii")
return digest
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--force", action="store_true", help="replace an existing Gadget")
args = parser.parse_args()
digest = fetch(args.force)
print(f"Frida Gadget {VERSION}: {DEST}")
print(f"SHA-256: {digest}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-1
View File
@@ -1 +0,0 @@
6bf149e5d1c5ec701e7b822cab57bb243f1c2a03318fa974fe373ee711a9ed9e libfrida-gadget.so
+69
View File
@@ -0,0 +1,69 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const packageRoot = path.join(projectRoot, 'node_modules', '@tabler', 'icons');
const drawableRoot = path.join(projectRoot, 'app', 'src', 'main', 'res', 'drawable');
const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
const checkOnly = process.argv.includes('--check');
const icons = new Map([
['layout-grid', 'ic_fb_workspace.xml'],
['package', 'ic_fb_gadget.xml'],
['activity', 'ic_fb_runtime.xml'],
['settings', 'ic_fb_settings.xml'],
['file-import', 'ic_fb_import.xml'],
['apps', 'ic_fb_apps.xml'],
['file', 'ic_fb_file.xml'],
['x', 'ic_fb_close.xml'],
['info-circle', 'ic_fb_details.xml'],
['database-off', 'ic_fb_clear.xml'],
['trash', 'ic_fb_remove.xml'],
]);
let mismatches = 0;
for (const [iconName, outputName] of icons) {
const sourcePath = path.join(packageRoot, 'icons', 'outline', `${iconName}.svg`);
const svg = fs.readFileSync(sourcePath, 'utf8');
const paths = [...svg.matchAll(/<path\s+d="([^"]+)"\s*\/>/g)].map((match) => match[1]);
if (paths.length === 0) throw new Error(`No SVG paths found for ${iconName}`);
const pathXml = paths.map((pathData) => [
' <path',
' android:fillColor="@android:color/transparent"',
' android:strokeColor="@android:color/white"',
' android:strokeWidth="2"',
' android:strokeLineCap="round"',
' android:strokeLineJoin="round"',
` android:pathData="${pathData}" />`,
].join('\n')).join('\n');
const output = [
'<?xml version="1.0" encoding="utf-8"?>',
`<!-- Generated from @tabler/icons ${packageJson.version}: icons/outline/${iconName}.svg -->`,
'<vector xmlns:android="http://schemas.android.com/apk/res/android"',
' android:width="24dp"',
' android:height="24dp"',
' android:viewportWidth="24"',
' android:viewportHeight="24">',
pathXml,
'</vector>',
'',
].join('\n');
const outputPath = path.join(drawableRoot, outputName);
if (checkOnly) {
const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
if (current.replaceAll('\r\n', '\n') !== output) {
console.error(`${outputName} is not synchronized with @tabler/icons`);
mismatches += 1;
}
} else {
fs.writeFileSync(outputPath, output, 'utf8');
}
}
if (mismatches > 0) process.exitCode = 1;
else console.log(`${checkOnly ? 'Verified' : 'Generated'} ${icons.size} Android vectors from @tabler/icons ${packageJson.version}`);