commit 17ad43ee92450409c90287b29f1e486ce355c1e3 parent c8195c5250dd633c177e70a6d8d48a3de642b06e Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 21 Apr 2024 23:25:45 +0200 feat(experimental): composer hooks Diffstat:
12 files changed, 470 insertions(+), 12 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -124,6 +124,13 @@ class RemoteScriptManager( } override fun getScriptContent(moduleName: String): String? { + if (moduleName.startsWith("composer/")) { + return runCatching { + context.androidContext.assets.open("composer/${moduleName.removePrefix("composer/")}").use { + it.bufferedReader().readText() + } + }.getOrNull() + } return getScriptInputStream(moduleName) { it?.bufferedReader()?.readText() } } diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -770,6 +770,24 @@ "name": "Native Hooks", "description": "Unsafe Features that hook into Snapchat's native code", "properties": { + "composer_hooks": { + "name": "Composer Hooks", + "description": "Injects code into the Composer cross-platform UI framework (arm64 only)", + "properties": { + "bypass_camera_roll_limit": { + "name": "Bypass Camera Roll Limit", + "description": "Increases the maximum amount of media you can send from the camera roll" + }, + "composer_console": { + "name": "Composer Console", + "description": "Allows you to execute JavaScript code in Composer" + }, + "composer_logs": { + "name": "Composer Logs", + "description": "Redirects console logs of Composer to SnapEnhance" + } + } + }, "disable_bitmoji": { "name": "Disable Bitmoji", "description": "Disables Friends Profile Bitmoji" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -9,7 +9,14 @@ class Experimental : ConfigContainer() { val allowRunningInBackground = boolean("allow_running_in_background", true) } + class ComposerHooksConfig: ConfigContainer(hasGlobalState = true) { + val bypassCameraRollLimit = boolean("bypass_camera_roll_limit") + val composerConsole = boolean("composer_console") + val composerLogs = boolean("composer_logs") + } + class NativeHooks : ConfigContainer(hasGlobalState = true) { + val composerHooks = container("composer_hooks", ComposerHooksConfig()) { requireRestart() } val disableBitmoji = boolean("disable_bitmoji") } diff --git a/core/src/main/assets/composer/loader.js b/core/src/main/assets/composer/loader.js @@ -0,0 +1,44 @@ +const deviceBridge = require('composer_core/src/DeviceBridge'); + +if (LOADER_CONFIG.logPrefix) { + function internalLog(logLevel, args) { + deviceBridge.copyToClipBoard(LOADER_CONFIG.logPrefix + "|" + logLevel + "|" + Array.from(args).join(" ")); + } + + console.log = function() { + internalLog("info", arguments); + } + + console.error = function() { + internalLog("error", arguments); + } + + console.warn = function() { + internalLog("warn", arguments); + } + + console.info = function() { + internalLog("info", arguments); + } + + console.debug = function() { + internalLog("debug", arguments); + } + + console.stacktrace = function() { + return new Error().stack; + } +} + +if (LOADER_CONFIG.bypassCameraRollLimit) { + ((module) => { + module.MultiSelectClickHandler = new Proxy(module.MultiSelectClickHandler, { + construct: function(target, args) { + args[1].selectionLimit = 9999999; + return new target(...args); + }, + }); + })(require('memories_ui/src/clickhandlers/MultiSelectClickHandler')) +} + +console.info("loader.js loaded");+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -156,6 +156,7 @@ class ModContext( disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(), disableMetrics = config.global.disableMetrics.get(), hookAssetOpen = config.experimental.disableComposerModules.get().isNotEmpty(), + composerHooks = config.experimental.nativeHooks.composerHooks.globalState == true ) ) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt @@ -4,11 +4,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.core.ModContext -import me.rhunk.snapenhance.core.features.impl.COFOverride -import me.rhunk.snapenhance.core.features.impl.ConfigurationOverride -import me.rhunk.snapenhance.core.features.impl.MixerStories -import me.rhunk.snapenhance.core.features.impl.OperaViewerParamsOverride -import me.rhunk.snapenhance.core.features.impl.ScopeSync +import me.rhunk.snapenhance.core.features.impl.* import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.core.features.impl.downloader.ProfilePictureDownloader import me.rhunk.snapenhance.core.features.impl.experiments.* @@ -16,7 +12,6 @@ import me.rhunk.snapenhance.core.features.impl.global.* import me.rhunk.snapenhance.core.features.impl.messaging.* import me.rhunk.snapenhance.core.features.impl.spying.HalfSwipeNotifier import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.core.features.impl.FriendMutationObserver import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.features.impl.tweaks.* import me.rhunk.snapenhance.core.features.impl.ui.* @@ -131,6 +126,7 @@ class FeatureManager( HideActiveMusic(), AutoOpenSnaps(), CustomStreaksExpirationFormat(), + ComposerHooks(), ) initializeFeatures() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ComposerHooks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ComposerHooks.kt @@ -0,0 +1,189 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.content.ClipData +import android.content.ClipboardManager +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material3.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.gson.JsonObject +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.ui.AppMaterialTheme +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.nativelib.NativeLib +import kotlin.random.Random +import kotlin.random.nextInt + +class ComposerHooks: Feature("ComposerHooks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val config by lazy { context.config.experimental.nativeHooks.composerHooks } + + private val composerConsole by lazy { + createComposeAlertDialog(context.mainActivity!!) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + var result by remember { mutableStateOf("") } + var codeContent by remember { mutableStateOf("") } + + Text("Composer Console", fontSize = 18.sp, fontWeight = FontWeight.Bold) + + TextField( + modifier = Modifier.fillMaxWidth(), + textStyle = TextStyle.Default.copy(fontSize = 12.sp), + value = codeContent, + placeholder = { Text("Enter your JS code here:") }, + onValueChange = { + codeContent = it + } + ) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + context.log.verbose("input: $codeContent", "ComposerConsole") + result = "Running..." + context.coroutineScope.launch { + result = (context.native.composerEval(""" + (() => { + try { + $codeContent + } catch (e) { + return e.toString() + } + })() + """.trimIndent()) ?: "(no result)").also { + context.log.verbose("result: $it", "ComposerConsole") + } + } + } + ) { + Text("Run") + } + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Text(result) + } + } + } + } + + private fun injectConsole() { + val root = context.mainActivity!!.findViewById<FrameLayout>(android.R.id.content) + root.post { + root.addView(createComposeView(root.context) { + AppMaterialTheme { + FilledIconButton( + onClick = { + composerConsole.show() + }, + modifier = Modifier.padding(top = 100.dp, end = 16.dp) + ) { + Icon(Icons.Default.BugReport, contentDescription = "Debug Console") + } + } + }.apply { + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply { + gravity = android.view.Gravity.TOP or android.view.Gravity.END + } + }) + } + } + + private fun loadHooks() { + val loaderConfig = JsonObject() + + if (config.composerLogs.get()) { + val logPrefix = Random.nextInt(100000..999999).toString() + val logTag = "ComposerLogs" + + ClipboardManager::class.java.hook("setPrimaryClip", HookStage.BEFORE) { param -> + val clipData = param.arg<ClipData>(0).takeIf { it.itemCount == 1 } ?: return@hook + val logText = clipData.getItemAt(0).text ?: return@hook + if (!logText.startsWith("$logPrefix|")) return@hook + + val logContainer = logText.removePrefix("$logPrefix|").toString() + val logType = logContainer.substringBefore("|") + val content = logContainer.substringAfter("|") + + when (logType) { + "verbose" -> context.log.verbose(content, logTag) + "info" -> context.log.info(content, logTag) + "debug" -> context.log.debug(content, logTag) + "warn" -> context.log.warn(content, logTag) + "error" -> context.log.error(content, logTag) + else -> context.log.info(logContainer, logTag) + } + param.setResult(null) + } + loaderConfig.addProperty("logPrefix", logPrefix) + } + + if (config.bypassCameraRollLimit.get()) { + loaderConfig.addProperty("bypassCameraRollLimit", true) + } + + val loaderScript = context.scriptRuntime.scripting.getScriptContent("composer/loader.js") ?: run { + context.log.error("Failed to load composer loader script") + return + } + + val hookResult = context.native.composerEval(""" + (() => { try { const LOADER_CONFIG = $loaderConfig; $loaderScript + } catch (e) { + return e.toString() + "\n" + e.stack; + } + return "success"; + })() + """.trimIndent().trim()) + + if (hookResult != "success") { + context.shortToast(("Composer loader failed : $hookResult").also { + context.log.error(it) + }) + } + + if (config.composerConsole.get()) { + injectConsole() + } + } + + override fun onActivityCreate() { + if (!NativeLib.initialized || config.globalState != true) return + var composerThreadTask: (() -> Unit)? = null + + findClass("com.snap.composer.callable.ComposerFunctionNative").hook("nativePerform", HookStage.BEFORE) { + composerThreadTask?.invoke() + composerThreadTask = null + } + + context.coroutineScope.launch { + context.native.waitForComposer() + composerThreadTask = ::loadHooks + } + } +}+ \ No newline at end of file diff --git a/native/jni/src/common.h b/native/jni/src/common.h @@ -13,6 +13,7 @@ typedef struct { bool disable_bitmoji; bool disable_metrics; bool hook_asset_open; + bool composer_hooks; } native_config_t; namespace common { diff --git a/native/jni/src/hooks/composer_hook.h b/native/jni/src/hooks/composer_hook.h @@ -0,0 +1,173 @@ +#pragma once +#include <stdio.h> + +namespace ComposerHook { + enum { + JS_TAG_FIRST = -11, + JS_TAG_BIG_DECIMAL = -11, + JS_TAG_BIG_INT = -10, + JS_TAG_BIG_FLOAT = -9, + JS_TAG_SYMBOL = -8, + JS_TAG_STRING = -7, + JS_TAG_MODULE = -3, + JS_TAG_FUNCTION_BYTECODE = -2, + JS_TAG_OBJECT = -1, + + JS_TAG_INT = 0, + JS_TAG_BOOL = 1, + JS_TAG_NULL = 2, + JS_TAG_UNDEFINED = 3, + JS_TAG_UNINITIALIZED = 4, + JS_TAG_CATCH_OFFSET = 5, + JS_TAG_EXCEPTION = 6, + JS_TAG_FLOAT64 = 7, + }; + + typedef struct JSRefCountHeader { + int ref_count; + } JSRefCountHeader; + + struct JSString { + JSRefCountHeader header; + uint32_t len : 31; + uint8_t is_wide_char : 1; + uint32_t hash : 30; + uint8_t atom_type : 2; + uint32_t hash_next; + + union { + uint8_t str8[0]; + uint16_t str16[0]; + } u; + }; + + typedef union JSValueUnion { + int32_t int32; + double float64; + void *ptr; + } JSValueUnion; + + typedef struct JSValue { + JSValueUnion u; + int64_t tag; + } JSValue; + + typedef struct list_head { + struct list_head *next, *prev; + } list_head; + + struct JSGCObjectHeader { + int ref_count; + uint8_t gc_obj_type : 4; + uint8_t mark : 4; + uint8_t dummy1; + uint16_t dummy2; + struct list_head link; + }; + + struct JSContext { + JSGCObjectHeader header; + void *rt; + struct list_head link; + + uint16_t binary_object_count; + int binary_object_size; + + JSValue *array_shape; + JSValue *class_proto; + JSValue function_proto; + JSValue function_ctor; + JSValue array_ctor; + JSValue regexp_ctor; + JSValue promise_ctor; + JSValue native_error_proto[8]; + JSValue iterator_proto; + JSValue async_iterator_proto; + JSValue array_proto_values; + JSValue throw_type_error; + JSValue eval_obj; + + JSValue global_obj; + JSValue global_var_obj; + }; + + static uintptr_t global_instance; + static JSContext *global_ctx; + + HOOK_DEF(JSValue, js_eval, uintptr_t instance, JSContext *ctx, uintptr_t this_obj, uint8_t *input, uintptr_t input_len, const char *filename, unsigned int flags, unsigned int scope_idx) { + if (global_instance == 0 || global_ctx == nullptr) { + global_instance = instance; + global_ctx = ctx; + } + + return js_eval_original(instance, ctx, this_obj, input, input_len, filename, flags, scope_idx); + } + + void waitForComposer(JNIEnv *, jobject) { + while (global_instance == 0 || global_ctx == nullptr) { + usleep(10000); + } + } + + jstring composerEval(JNIEnv *env, jobject, jstring script) { + if (!ARM64) return env->NewStringUTF("Architecture not supported"); + if (global_instance == 0 || global_ctx == nullptr) { + return env->NewStringUTF("Composer not ready"); + } + + auto script_str = env->GetStringUTFChars(script, nullptr); + auto length = env->GetStringUTFLength(script); + auto jsvalue = js_eval_original(global_instance, global_ctx, (uintptr_t) &global_ctx->global_obj, (uint8_t *) script_str, length, "<input>", 0, 0); + env->ReleaseStringUTFChars(script, script_str); + + if (jsvalue.tag == JS_TAG_STRING) { + auto str = (JSString *) jsvalue.u.ptr; + return env->NewStringUTF((const char *) str->u.str8); + } + + std::string result; + switch (jsvalue.tag) { + case JS_TAG_INT: + result = std::to_string(jsvalue.u.int32); + break; + case JS_TAG_BOOL: + result = jsvalue.u.int32 ? "true" : "false"; + break; + case JS_TAG_NULL: + result = "null"; + break; + case JS_TAG_UNDEFINED: + result = "undefined"; + break; + case JS_TAG_OBJECT: + result = "[object Object]"; + break; + case JS_TAG_EXCEPTION: + result = "Failed to evaluate script"; + break; + case JS_TAG_FLOAT64: + result = std::to_string(jsvalue.u.float64); + break; + default: + result = "[unknown tag " + std::to_string(jsvalue.tag) + "]"; + break; + } + + return env->NewStringUTF(result.c_str()); + } + + void init() { + if (!ARM64) return; + auto js_eval_ptr = util::find_signature( + common::client_module.base, + common::client_module.size, + "00 E4 00 6F 29 00 80 52 76 00 04 8B", + -0x28 + ); + if (js_eval_ptr == 0) { + LOGE("js_eval_ptr signature not found"); + return; + } + DobbyHook((void*) js_eval_ptr, (void *) js_eval, (void **) &js_eval_original); + } +}+ \ No newline at end of file diff --git a/native/jni/src/library.cpp b/native/jni/src/library.cpp @@ -2,6 +2,7 @@ #include <string> #include <dobby.h> #include <vector> +#include <thread> #include "logger.h" #include "common.h" @@ -10,6 +11,7 @@ #include "hooks/fstat_hook.h" #include "hooks/sqlite_mutex.h" #include "hooks/duplex_hook.h" +#include "hooks/composer_hook.h" bool JNICALL init(JNIEnv *env, jobject clazz) { LOGD("Initializing native"); @@ -29,13 +31,24 @@ bool JNICALL init(JNIEnv *env, jobject clazz) { LOGD("client_module offset=0x%lx, size=0x%zx", client_module.base, client_module.size); - AssetHook::init(env); - UnaryCallHook::init(env); - FstatHook::init(); - SqliteMutexHook::init(); - DuplexHook::init(env); + auto threads = std::vector<std::thread>(); - util::remap_sections(BUILD_PACKAGE); + #define RUN(body) \ + threads.push_back(std::thread([&] { body; })) + + RUN(UnaryCallHook::init(env)); + RUN(AssetHook::init(env)); + RUN(FstatHook::init()); + RUN(SqliteMutexHook::init()); + RUN(DuplexHook::init(env)); + if (common::native_config->composer_hooks) { + RUN(ComposerHook::init()); + } + RUN(util::remap_sections(BUILD_PACKAGE)); + + for (auto &thread : threads) { + thread.join(); + } LOGD("Native initialized"); return true; @@ -49,6 +62,7 @@ void JNICALL load_config(JNIEnv *env, jobject, jobject config_object) { native_config->disable_bitmoji = GET_CONFIG_BOOL("disableBitmoji"); native_config->disable_metrics = GET_CONFIG_BOOL("disableMetrics"); native_config->hook_asset_open = GET_CONFIG_BOOL("hookAssetOpen"); + native_config->composer_hooks = GET_CONFIG_BOOL("composerHooks"); } void JNICALL lock_database(JNIEnv *env, jobject, jstring database_name, jobject runnable) { @@ -80,6 +94,8 @@ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) { methods.push_back({"init", "()Z", (void *)init}); methods.push_back({"loadConfig", "(L" BUILD_NAMESPACE "/NativeConfig;)V", (void *)load_config}); methods.push_back({"lockDatabase", "(Ljava/lang/String;Ljava/lang/Runnable;)V", (void *)lock_database}); + methods.push_back({"waitForComposer", "()V", (void *) ComposerHook::waitForComposer}); + methods.push_back({"composerEval", "(Ljava/lang/String;)Ljava/lang/String;",(void *) ComposerHook::composerEval}); env->RegisterNatives(env->FindClass(std::string(BUILD_NAMESPACE "/NativeLib").c_str()), methods.data(), methods.size()); return JNI_VERSION_1_6; diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt @@ -4,4 +4,5 @@ data class NativeConfig( val disableBitmoji: Boolean = false, val disableMetrics: Boolean = false, val hookAssetOpen: Boolean = false, + val composerHooks: Boolean = false, ) \ No newline at end of file diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt @@ -62,4 +62,6 @@ class NativeLib { private external fun init(): Boolean private external fun loadConfig(config: NativeConfig) private external fun lockDatabase(name: String, callback: Runnable) + external fun waitForComposer() + external fun composerEval(code: String): String? } \ No newline at end of file