diff --git a/external/lsplant b/external/lsplant index a0990196c..49d2e5641 160000 --- a/external/lsplant +++ b/external/lsplant @@ -1 +1 @@ -Subproject commit a0990196c26e3fad57213a03af22dbf993396c8a +Subproject commit 49d2e5641dfb222e24cd1fc0e9968a5a00496baa diff --git a/legacy/src/main/java/android/content/res/XResources.java b/legacy/src/main/java/android/content/res/XResources.java index d1e53636f..2a5850ebe 100644 --- a/legacy/src/main/java/android/content/res/XResources.java +++ b/legacy/src/main/java/android/content/res/XResources.java @@ -73,6 +73,8 @@ public class XResources extends XResourcesSuperClass { private static final SparseArray>> sLayoutCallbacks = new SparseArray<>(); private static final WeakHashMap sXmlInstanceDetails = new WeakHashMap<>(); + // The XML blocks whose native tree has already had its module IDs rewritten. See [rewriteXmlReferences]. + private static final WeakHashMap sRewrittenXmlBlocks = new WeakHashMap<>(); private static final String EXTRA_XML_INSTANCE_DETAILS = "xmlInstanceDetails"; // No lambda, and no anonymous ThreadLocal either. See the note above [includedLayouts]. @@ -669,14 +671,8 @@ public XmlResourceParser getAnimation(int id) throws NotFoundException { Resources repRes = ((XResForwarder) replacement).getResources(); int repId = ((XResForwarder) replacement).getId(); - boolean loadedFromCache = isXmlCached(repRes, repId); XmlResourceParser result = repRes.getAnimation(repId); - - if (!loadedFromCache) { - long parseState = getLongField(result, "mParseState"); - rewriteXmlReferencesNative(parseState, this, repRes); - } - + rewriteXmlReferences(result, repRes); return result; } return super.getAnimation(id); @@ -958,13 +954,8 @@ public XmlResourceParser getLayout(int id) throws NotFoundException { Resources repRes = ((XResForwarder) replacement).getResources(); int repId = ((XResForwarder) replacement).getId(); - boolean loadedFromCache = isXmlCached(repRes, repId); result = repRes.getLayout(repId); - - if (!loadedFromCache) { - long parseState = getLongField(result, "mParseState"); - rewriteXmlReferencesNative(parseState, this, repRes); - } + rewriteXmlReferences(result, repRes); } else { result = super.getLayout(id); } @@ -1144,28 +1135,46 @@ public XmlResourceParser getXml(int id) throws NotFoundException { Resources repRes = ((XResForwarder) replacement).getResources(); int repId = ((XResForwarder) replacement).getId(); - boolean loadedFromCache = isXmlCached(repRes, repId); XmlResourceParser result = repRes.getXml(repId); - - if (!loadedFromCache) { - long parseState = getLongField(result, "mParseState"); - rewriteXmlReferencesNative(parseState, this, repRes); - } - + rewriteXmlReferences(result, repRes); return result; } return super.getXml(id); } - private static boolean isXmlCached(Resources res, int id) { - int[] mCachedXmlBlockIds = (int[]) getObjectField(getObjectField(res, "mResourcesImpl"), "mCachedXmlBlockCookies"); - synchronized (mCachedXmlBlockIds) { - for (int cachedId : mCachedXmlBlockIds) { - if (cachedId == id) - return true; + /** + * Rewrites the module's IDs in a replacement document, unless that has already happened. + * + * The rewrite mutates the native tree in place, and that tree belongs to the {@code XmlBlock}, + * not to the parser: {@code ResourcesImpl} keeps a small cache of blocks and hands out a fresh + * parser over the same block for every later request, so "already rewritten" is a property of + * the block and nothing else. That is why the answer is kept per block here. + * + * Getting it wrong is not merely wasted work. A second pass sees the host IDs the first one + * wrote and feeds them back through {@link #translateResId}, which resolves them against the + * module's table — where the same 0x7f package ID makes an unrelated entry a plausible match — + * and then installs a replacement for whatever it found. + */ + private void rewriteXmlReferences(XmlResourceParser parser, Resources repRes) { + Object block; + try { + block = getObjectField(parser, "mBlock"); + } catch (Throwable ignored) { + // A ROM that renamed the field costs the deduplication, not the rewrite. + block = null; + } + + if (block != null) { + synchronized (sRewrittenXmlBlocks) { + // Marked before the rewrite rather than after: one that throws part of the way + // through leaves a partly translated tree, and a retry would translate that part + // a second time. + if (sRewrittenXmlBlocks.put(block, Boolean.TRUE) != null) + return; } } - return false; + + rewriteXmlReferencesNative(getLongField(parser, "mParseState"), this, repRes); } /** diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java b/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java index 7b53cdac1..d2c37bd07 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java @@ -7,11 +7,11 @@ import org.matrix.vector.util.Utils; import org.matrix.vector.impl.hooks.VectorNativeHooker; +import org.matrix.vector.impl.hooks.VectorInvocation; import org.matrix.vector.impl.hooks.VectorLegacyCallback; import org.matrix.vector.nativebridge.HookBridge; import org.matrix.vector.nativebridge.ResourcesHook; -import java.lang.reflect.AccessibleObject; import java.lang.reflect.Executable; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Member; @@ -298,7 +298,6 @@ public static void hookInitPackageResources(XC_InitPackageResources callback) { * @param args Arguments for the method call as Object[] array. * @return The result returned from the invoked method. * @throws NullPointerException if {@code receiver == null} for a non-static method - * @throws IllegalAccessException if this method is not accessible (see {@link AccessibleObject}) * @throws IllegalArgumentException if the number of arguments doesn't match the number of parameters, the receiver * is incompatible with the declaring class, or an argument could not be unboxed * or converted by a widening conversion to the corresponding parameter type @@ -314,7 +313,7 @@ public static Object invokeOriginalMethod(Member method, Object thisObject, Obje throw new IllegalArgumentException("method must be of type Method or Constructor"); } - return HookBridge.invokeOriginalMethod((Executable) method, thisObject, args); + return VectorInvocation.invokeOriginal((Executable) method, thisObject, args); } /** diff --git a/native/include/framework/android_types.h b/native/include/framework/android_types.h index 1e2df2e92..77d0a797e 100644 --- a/native/include/framework/android_types.h +++ b/native/include/framework/android_types.h @@ -82,8 +82,10 @@ struct ResXMLTree_node { void *comment; }; -class ResXMLTree; - +// Only the four words below are mirrored, and only because mCurExt is where the attributes of the +// current tag live. Everything further in - the tree the parser walks, its string pool, its +// attribute name map - is reached through the exported accessors instead: those members have moved +// in almost every release since Pie, and a struct that guesses at them fails silently. class ResXMLParser { public: enum event_code_t { @@ -100,37 +102,32 @@ class ResXMLParser { TEXT = RES_XML_CDATA_TYPE }; - const ResXMLTree &mTree; + const void *mTree; event_code_t mEventCode; const ResXMLTree_node *mCurNode; const void *mCurExt; }; +// Handled only through pointers the framework hands out, so none of its fields are mirrored: the +// class gained a vtable in Android 11 and a lookup cache in Android 15, and each of those moved +// everything behind it. class ResStringPool { public: - status_t mError; - void *mOwnedData; - const void *mHeader; - size_t mSize; - mutable pthread_mutex_t mDecodeLock; - const uint32_t *mEntries; - const uint32_t *mEntryStyles; - const void *mStrings; - char16_t mutable **mCache; - uint32_t mStringPoolSize; // number of uint16_t - const uint32_t *mStyles; - uint32_t mStylePoolSize; // number of uint32_t - using stringAtRet = expected; - inline static auto stringAtS_ = ("_ZNK7android13ResStringPool8stringAtEjPj"_sym | - "_ZNK7android13ResStringPool8stringAtEmPm"_sym) - .as; - - inline static auto stringAt_ = ("_ZNK7android13ResStringPool8stringAtEj"_sym | - "_ZNK7android13ResStringPool8stringAtEm"_sym) + // The two overloads are told apart by the arity in their mangled names, and binding one to the + // other's signature links cleanly and then breaks the ABI. EjPj/EmPm is stringAt(idx, outLen) + // returning a raw pointer, which is what Android 11 and older export; Ej/Em is stringAt(idx) + // returning the expected, which replaced it in Android 12 and whose result comes back through + // the indirect-result register a raw-pointer caller never sets. + inline static auto stringAt_ = ("_ZNK7android13ResStringPool8stringAtEjPj"_sym | + "_ZNK7android13ResStringPool8stringAtEmPm"_sym) .as; + inline static auto stringAtS_ = ("_ZNK7android13ResStringPool8stringAtEj"_sym | + "_ZNK7android13ResStringPool8stringAtEm"_sym) + .as; + StringPiece16 stringAt(size_t idx) const { if (stringAt_) { size_t len; @@ -150,22 +147,6 @@ class ResStringPool { } }; -class ResXMLTree : public ResXMLParser { -public: - void *mDynamicRefTable; - status_t mError; - void *mOwnedData; - const void *mHeader; - size_t mSize; - const uint8_t *mDataEnd; - ResStringPool mStrings; - const uint32_t *mResIds; - size_t mNumResIds; - const ResXMLTree_node *mRootNode; - const void *mRootExt; - event_code_t mRootCode; -}; - struct ResStringPool_ref { // Index into the string pool table at which // to find the location of the string data in the pool. diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index c6ad2745d..3ddf7e048 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -91,6 +91,212 @@ SharedHashMap> hooked_methods; // Cached JNI method and field IDs for performance. jmethodID invoke = nullptr; +/** + * @struct PrimitiveWrapper + * @brief One boxed primitive type, with its own accessor and its own valueOf. + * + * The accessor has to be the wrapper's own. java.lang.Character is not a java.lang.Number, so + * calling Number.intValue() on a Character reads Number's vtable index out of Character's vtable, + * which lands on an unrelated method or past the end of it. + */ +struct PrimitiveWrapper { + char shorty; + jclass clazz; + jmethodID unbox; + jmethodID box; +}; + +constexpr size_t kWrapperCount = 8; + +// The eight wrappers, resolved once and held as global references. +struct WrapperTable { + PrimitiveWrapper entries[kWrapperCount]; + + explicit WrapperTable(JNIEnv *env) { + // Ordered by how often an argument turns out to be one: identifying an argument's wrapper + // is a walk of this table comparing its class against each entry's. + static constexpr struct { + char shorty; + const char *name; + const char *accessor; + const char *accessor_signature; + const char *box_signature; + } kSpecs[kWrapperCount] = { + {'I', "java/lang/Integer", "intValue", "()I", "(I)Ljava/lang/Integer;"}, + {'Z', "java/lang/Boolean", "booleanValue", "()Z", "(Z)Ljava/lang/Boolean;"}, + {'J', "java/lang/Long", "longValue", "()J", "(J)Ljava/lang/Long;"}, + {'D', "java/lang/Double", "doubleValue", "()D", "(D)Ljava/lang/Double;"}, + {'F', "java/lang/Float", "floatValue", "()F", "(F)Ljava/lang/Float;"}, + {'C', "java/lang/Character", "charValue", "()C", "(C)Ljava/lang/Character;"}, + {'B', "java/lang/Byte", "byteValue", "()B", "(B)Ljava/lang/Byte;"}, + {'S', "java/lang/Short", "shortValue", "()S", "(S)Ljava/lang/Short;"}, + }; + + for (size_t i = 0; i < kWrapperCount; ++i) { + jclass local = env->FindClass(kSpecs[i].name); + entries[i].shorty = kSpecs[i].shorty; + entries[i].clazz = static_cast(env->NewGlobalRef(local)); + entries[i].unbox = + env->GetMethodID(local, kSpecs[i].accessor, kSpecs[i].accessor_signature); + entries[i].box = env->GetStaticMethodID(local, "valueOf", kSpecs[i].box_signature); + env->DeleteLocalRef(local); + } + } +}; + +const WrapperTable &Wrappers(JNIEnv *env) { + static const WrapperTable table(env); + return table; +} + +// The wrapper an argument actually is, which is what decides whether the conversion the parameter +// asks for is a widening one. Null for anything that is not a boxed primitive. +// +// Exact class identity, not IsInstanceOf: one JNI call and then pointer comparisons, instead of up +// to eight round trips per argument on the framework's own invocation path. It is also what the +// widening matrix means. No class can extend a wrapper - all eight are final - but plenty extend +// java.lang.Number, and reflection converts none of them. +const PrimitiveWrapper *WrapperOf(JNIEnv *env, jobject value) { + jclass value_class = env->GetObjectClass(value); + const PrimitiveWrapper *found = nullptr; + for (const auto &entry : Wrappers(env).entries) { + if (env->IsSameObject(value_class, entry.clazz) == JNI_TRUE) { + found = &entry; + break; + } + } + env->DeleteLocalRef(value_class); + return found; +} + +/** + * @brief The name ART puts in a reflective refusal. + * + * Class#getTypeName is the Java side of ART's PrettyDescriptor: dotted, and "int[]" rather than + * "[I". Only reached on the way to throwing, so what it costs does not matter. + */ +std::string PrettyName(JNIEnv *env, jclass cls) { + static jclass cls_Class = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Class")); + static auto *const get_type_name = + env->GetMethodID(cls_Class, "getTypeName", "()Ljava/lang/String;"); + + // Only an allocation failure can fail either step, and the caller is on its way to throwing a + // refusal that says more than an OutOfMemoryError would - so the pending one is cleared rather + // than left for the next JNI call to trip over. + auto name = (jstring)env->CallObjectMethod(cls, get_type_name); + if (name == nullptr) { + env->ExceptionClear(); + return "?"; + } + std::string result = "?"; + if (const char *chars = env->GetStringUTFChars(name, nullptr); chars != nullptr) { + result = chars; + env->ReleaseStringUTFChars(name, chars); + } else { + env->ExceptionClear(); + } + env->DeleteLocalRef(name); + return result; +} + +// The wrapper that boxes the primitive `shorty` names. +const PrimitiveWrapper *WrapperFor(JNIEnv *env, char shorty) { + for (const auto &entry : Wrappers(env).entries) { + if (entry.shorty == shorty) return &entry; + } + return nullptr; +} + +/** + * @brief Whether a value of the primitive `from` may be passed where `to` is declared. + * + * The identity conversion plus the widening primitive conversions of JLS 5.1.2, which is all + * java.lang.reflect performs on an argument. Every other pair is an IllegalArgumentException there, + * rather than the silent truncation an unchecked unboxing would produce. + */ +constexpr bool Widens(char from, char to) { + if (from == to) return true; + switch (from) { + case 'B': + return to == 'S' || to == 'I' || to == 'J' || to == 'F' || to == 'D'; + case 'S': + case 'C': + return to == 'I' || to == 'J' || to == 'F' || to == 'D'; + case 'I': + return to == 'J' || to == 'F' || to == 'D'; + case 'J': + return to == 'F' || to == 'D'; + case 'F': + return to == 'D'; + default: + return false; + } +} + +/** + * @brief Unboxes `value` with its own accessor and stores it as the primitive `to` names. + * + * Widens() has already refused every pair that is not a widening conversion, so none of the casts + * below narrows anything. + */ +void StoreWidened(JNIEnv *env, const PrimitiveWrapper &from, char to, jobject value, jvalue &out) { + if (from.shorty == 'Z') { + out.z = env->CallBooleanMethod(value, from.unbox); + return; + } + + jlong integral = 0; + jdouble floating = 0; + switch (from.shorty) { + case 'B': + integral = env->CallByteMethod(value, from.unbox); + break; + case 'C': + integral = env->CallCharMethod(value, from.unbox); + break; + case 'S': + integral = env->CallShortMethod(value, from.unbox); + break; + case 'I': + integral = env->CallIntMethod(value, from.unbox); + break; + case 'J': + integral = env->CallLongMethod(value, from.unbox); + break; + case 'F': + floating = env->CallFloatMethod(value, from.unbox); + break; + default: + floating = env->CallDoubleMethod(value, from.unbox); + break; + } + + const bool from_floating = from.shorty == 'F' || from.shorty == 'D'; + switch (to) { + case 'B': + out.b = static_cast(integral); + break; + case 'C': + out.c = static_cast(integral); + break; + case 'S': + out.s = static_cast(integral); + break; + case 'I': + out.i = static_cast(integral); + break; + case 'J': + out.j = integral; + break; + case 'F': + out.f = from_floating ? static_cast(floating) : static_cast(integral); + break; + default: + out.d = from_floating ? floating : static_cast(integral); + break; + } +} + } // namespace namespace vector::native::jni { @@ -268,6 +474,17 @@ VECTOR_DEF_NATIVE_METHOD(jboolean, HookBridge, deoptimizeMethod, jobject hookMet /** * @brief JNI method to invoke the original, un-hooked method. + * + * The trampoline's terminal, and only that: it is reached from inside a hook callback, so on every + * call that matters the hook item exists and its backup is the original body. Everything that + * dispatches an executable which may carry no hook at all goes through invokeOriginal instead, + * which does not depend on the reflected object being accessible. + * + * The two fallbacks below are what a hook whose installation failed leaves behind: no hook item at + * all, and the FAILED sentinel. lsplant replaced no entry point in either case, so the executable + * still carries its own body - but the first reaches it through the caller's own reflected object, + * where ART does run an access check, and the second reports it as a null return the caller cannot + * tell from a method that returned null. Neither is reachable from the trampoline. */ VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, invokeOriginalMethod, jobject hookMethod, jobject thiz, jobjectArray args) { @@ -287,262 +504,251 @@ VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, invokeOriginalMethod, jobject hook } /** - * @brief JNI wrapper around AllocObject. + * @brief JNI wrapper around AllocObject, refusing what AllocObject has no answer for. + * + * AllocObject is only defined for an instantiable non-array class. CheckJNI aborts the process on + * anything else, and without it ART allocates from a class whose instance size means nothing. + * Constructor#newInstance reports that as InstantiationException, which is what CtorInvoker + * documents and what this method has always declared. */ VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, allocateObject, jclass cls) { + static jclass cls_Class = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Class")); + static auto *const is_interface = env->GetMethodID(cls_Class, "isInterface", "()Z"); + static auto *const is_array = env->GetMethodID(cls_Class, "isArray", "()Z"); + static auto *const is_primitive = env->GetMethodID(cls_Class, "isPrimitive", "()Z"); + static auto *const get_modifiers = env->GetMethodID(cls_Class, "getModifiers", "()I"); + constexpr jint kAccAbstract = 0x0400; + + if (cls == nullptr || env->CallBooleanMethod(cls, is_interface) == JNI_TRUE || + env->CallBooleanMethod(cls, is_array) == JNI_TRUE || + env->CallBooleanMethod(cls, is_primitive) == JNI_TRUE || + (env->CallIntMethod(cls, get_modifiers) & kAccAbstract) != 0) { + jclass error = env->FindClass("java/lang/InstantiationException"); + env->ThrowNew(error, "no instance of this class can be allocated"); + env->DeleteLocalRef(error); + return nullptr; + } return env->AllocObject(cls); } /** - * Core JNI backend for non-virtual method invocation and special object initialization. + * @brief Runs an executable's own body: the one dispatch primitive behind every invoker. * - * Implementation details: - * 1. Dispatches using JNI CallNonvirtualMethodA. - * 2. Employs stack allocation (alloca) for JNI argument mapping. - * 3. Safely mirrors standard Java reflection (NPEs on null primitives/receivers). - * 4. Prevents JNI Type Confusion and memory leaks by caching primitive wrappers globally, - * while leveraging java.lang.Number for fast implicit widening/narrowing. - * 5. Accurately catches and wraps target method exceptions into InvocationTargetException. + * The invoker family and the legacy bridge both land here, and everything they need to differ on is + * a parameter. `is_static` and `non_virtual` pick the JNI call form, `declaring_class` is the class + * to dispatch against - the superclass, for a newInstanceSpecial - and `parameter_types` is what an + * argument has to match, which the shorty cannot say because every reference type is 'L'. + * + * JNI performs no access control, which is what makes an invocation through an invoker bypass + * access checks as the interface promises. The other way round, java.lang.reflect.Method.invoke on + * the caller's own Executable, runs ART's check with this class as the caller and so refuses every + * member that is not public in a public class. + * + * It also performs no argument or receiver check, and a violation is not reported but executed, so + * everything reflection would refuse is refused here first. */ -VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, invokeSpecialMethod, jobject method, - jcharArray shorty, jclass cls, jobject thiz, jobjectArray args) { - // --- JNI Global Reference Caching --- - // Cached once per process lifecycle to maintain extreme performance and prevent JNI aborts. - static jclass cls_Number = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Number")); - static jclass cls_Boolean = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Boolean")); - static jclass cls_Character = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Character")); - - // Globally cache primitive wrapper classes for safe return value boxing - static jclass cls_Integer = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Integer")); - static jclass cls_Double = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Double")); - static jclass cls_Long = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Long")); - static jclass cls_Float = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Float")); - static jclass cls_Short = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Short")); - static jclass cls_Byte = (jclass)env->NewGlobalRef(env->FindClass("java/lang/Byte")); - +VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, invokeOriginal, jobject executable, jcharArray shorty, + jobjectArray parameter_types, jclass declaring_class, jboolean is_static, + jboolean non_virtual, jobject thiz, jobjectArray args) { static jclass cls_ITE = (jclass)env->NewGlobalRef(env->FindClass("java/lang/reflect/InvocationTargetException")); - static auto *const ctor_ite = env->GetMethodID(cls_ITE, "", "(Ljava/lang/Throwable;)V"); - static auto *const get_int = env->GetMethodID(cls_Number, "intValue", "()I"); - static auto *const get_double = env->GetMethodID(cls_Number, "doubleValue", "()D"); - static auto *const get_long = env->GetMethodID(cls_Number, "longValue", "()J"); - static auto *const get_float = env->GetMethodID(cls_Number, "floatValue", "()F"); - static auto *const get_short = env->GetMethodID(cls_Number, "shortValue", "()S"); - static auto *const get_byte = env->GetMethodID(cls_Number, "byteValue", "()B"); - - static auto *const get_char = env->GetMethodID(cls_Character, "charValue", "()C"); - static auto *const get_boolean = env->GetMethodID(cls_Boolean, "booleanValue", "()Z"); - - static auto *const set_int = - env->GetStaticMethodID(cls_Integer, "valueOf", "(I)Ljava/lang/Integer;"); - static auto *const set_double = - env->GetStaticMethodID(cls_Double, "valueOf", "(D)Ljava/lang/Double;"); - static auto *const set_long = - env->GetStaticMethodID(cls_Long, "valueOf", "(J)Ljava/lang/Long;"); - static auto *const set_float = - env->GetStaticMethodID(cls_Float, "valueOf", "(F)Ljava/lang/Float;"); - static auto *const set_short = - env->GetStaticMethodID(cls_Short, "valueOf", "(S)Ljava/lang/Short;"); - static auto *const set_byte = - env->GetStaticMethodID(cls_Byte, "valueOf", "(B)Ljava/lang/Byte;"); - static auto *const set_char = - env->GetStaticMethodID(cls_Character, "valueOf", "(C)Ljava/lang/Character;"); - static auto *const set_boolean = - env->GetStaticMethodID(cls_Boolean, "valueOf", "(Z)Ljava/lang/Boolean;"); + // Everything raised here is the caller's own mistake rather than something the call produced, + // and Method#invoke reports exactly those unwrapped. The wording is ART's own where this side + // knows what ART would have printed; the per-argument refusals below cannot have it, because + // ART's form names the resolved method and nothing here carries that name. + const auto raise = [env](const char *type, const char *message) -> jobject { + jclass cls = env->FindClass(type); + env->ThrowNew(cls, message); + env->DeleteLocalRef(cls); + return nullptr; + }; - auto target = env->FromReflectedMethod(method); - auto param_len = env->GetArrayLength(shorty) - 1; + auto target = env->FromReflectedMethod(executable); + HookItem *hook_item = nullptr; + hooked_methods.if_contains(target, + [&hook_item](const auto &it) { hook_item = it.second.get(); }); - // --- Argument & Receiver Validation --- - auto args_len = args != nullptr ? env->GetArrayLength(args) : 0; - if (args_len != param_len) { - env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), - "args.length does not match parameter count"); - return nullptr; + if (hook_item) { + // lsplant hooks by rewriting this ArtMethod's entry point, and CallNonvirtual only skips + // the vtable lookup rather than the entry point, so the original body is reachable through + // the backup alone. No jmethodID can name the backup either - lsplant rewrites a backup's + // id to its target's, which is how it keeps the index based ids of a debuggable process + // meaningful - so it is invoked the one way that reads the ArtMethod off the reflected + // object instead: Method.invoke. lsplant made the backup accessible, and private when it is + // not static, so that call bypasses access checks and is direct whichever form was asked + // for, and reflection's own conversions apply to arguments this side has already coerced. + if (jobject backup = hook_item->GetBackup(); backup) { + return env->CallObjectMethod(backup, invoke, thiz, args); + } + // A null backup is the failed-hook sentinel. lsplant never replaced the entry point, so + // the executable still carries its own body and dispatching it is what runs the original. } - if (thiz == nullptr) { - env->ThrowNew(env->FindClass("java/lang/NullPointerException"), "null receiver"); - return nullptr; + // Method#invoke ignores the receiver of a static executable, and refuses a missing or a foreign + // one rather than letting the callee read another layout's fields at this class's offsets. + if (is_static) { + thiz = nullptr; + } else if (thiz == nullptr) { + return raise("java/lang/NullPointerException", "null receiver"); + } else if (env->IsInstanceOf(thiz, declaring_class) != JNI_TRUE) { + jclass actual = env->GetObjectClass(thiz); + auto message = fmt::format("Expected receiver of type {}, but got {}", + PrettyName(env, declaring_class), PrettyName(env, actual)); + env->DeleteLocalRef(actual); + return raise("java/lang/IllegalArgumentException", message.c_str()); + } + + const jint param_len = parameter_types != nullptr ? env->GetArrayLength(parameter_types) : 0; + // A null argument array is how Method#invoke spells "no arguments", so it is one here too. + const jint args_len = args != nullptr ? env->GetArrayLength(args) : 0; + if (args_len != param_len) { + return raise( + "java/lang/IllegalArgumentException", + fmt::format("Wrong number of arguments; expected {}, got {}", param_len, args_len) + .c_str()); + } + // No executable declares more than 255 parameters, so anything above that is a caller that + // built its own arrays wrong - and the stack allocation below has to be bounded by something. + if (param_len > 255 || env->GetArrayLength(shorty) != param_len + 1) { + return raise("java/lang/IllegalArgumentException", + "parameter types and shorty do not describe the same executable"); } - // Allocate jvalue array on the stack jvalue *a = param_len > 0 ? static_cast(alloca(param_len * sizeof(jvalue))) : nullptr; auto *const shorty_char = env->GetCharArrayElements(shorty, nullptr); if (shorty_char == nullptr) { return nullptr; // JVM already threw OutOfMemoryError } - - // RAII/Helper for clean JNI array exits - auto abort_and_return = [&]() { - env->ReleaseCharArrayElements(shorty, shorty_char, JNI_ABORT); - return nullptr; - }; + const auto release = [&] { env->ReleaseCharArrayElements(shorty, shorty_char, JNI_ABORT); }; // --- Safe Unboxing --- for (jint i = 0; i != param_len; ++i) { jobject element = env->GetObjectArrayElement(args, i); - if (env->ExceptionCheck()) return abort_and_return(); - - char type = shorty_char[i + 1]; - - if (element == nullptr) { - if (type != 'L' && type != '[') { - env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), - "null primitive argument"); - return abort_and_return(); - } - a[i].l = nullptr; - } else { - if (type == 'Z') { - if (!env->IsInstanceOf(element, cls_Boolean)) { - env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), - "Expected Boolean"); - return abort_and_return(); - } - a[i].z = env->CallBooleanMethod(element, get_boolean); - } else if (type == 'C') { - if (!env->IsInstanceOf(element, cls_Character)) { - env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), - "Expected Character"); - return abort_and_return(); - } - a[i].c = env->CallCharMethod(element, get_char); - } else if (type != 'L' && type != '[') { - bool is_number = env->IsInstanceOf(element, cls_Number) == JNI_TRUE; - bool is_character = - !is_number && (env->IsInstanceOf(element, cls_Character) == JNI_TRUE); - - if (!is_number && !is_character) { - env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), - "Expected Number or Character"); - return abort_and_return(); - } - - // If a Character is passed to a numeric parameter, extract its value for widening - jchar c_val = 0; - if (is_character) { - c_val = env->CallCharMethod(element, get_char); - if (env->ExceptionCheck()) return abort_and_return(); - } + if (env->ExceptionCheck()) { + release(); + return nullptr; + } - switch (type) { - case 'I': - a[i].i = env->CallIntMethod(element, get_int); - break; - case 'D': - a[i].d = env->CallDoubleMethod(element, get_double); - break; - case 'J': - a[i].j = env->CallLongMethod(element, get_long); - break; - case 'F': - a[i].f = env->CallFloatMethod(element, get_float); - break; - case 'S': - a[i].s = env->CallShortMethod(element, get_short); - break; - case 'B': - a[i].b = env->CallByteMethod(element, get_byte); - break; + const char declared = shorty_char[i + 1]; + if (declared == 'L') { + if (element != nullptr) { + auto param = (jclass)env->GetObjectArrayElement(parameter_types, i); + const bool assignable = env->IsInstanceOf(element, param) == JNI_TRUE; + env->DeleteLocalRef(param); + if (!assignable) { + env->DeleteLocalRef(element); + release(); + return raise("java/lang/IllegalArgumentException", "argument type mismatch"); } - } else { - a[i].l = element; - element = - nullptr; // Transferred ownership to jvalue array; will be freed on return } + // The local reference lives until this frame returns, which is exactly as long as the + // jvalue holding it is read. + a[i].l = element; + continue; } - if (element) env->DeleteLocalRef(element); - if (env->ExceptionCheck()) return abort_and_return(); + if (element == nullptr) { + release(); + return raise("java/lang/IllegalArgumentException", "null primitive argument"); + } + + const PrimitiveWrapper *wrapper = WrapperOf(env, element); + if (wrapper == nullptr || !Widens(wrapper->shorty, declared)) { + env->DeleteLocalRef(element); + release(); + return raise("java/lang/IllegalArgumentException", "argument type mismatch"); + } + StoreWidened(env, *wrapper, declared, element, a[i]); + env->DeleteLocalRef(element); + if (env->ExceptionCheck()) { + release(); + return nullptr; + } } - // --- Non-virtual Invocation --- - jvalue ret_val; - switch (shorty_char[0]) { + // --- Invocation --- + jvalue ret_val{}; + const char returns = shorty_char[0]; + + // JNI spells the call form in the function name rather than taking it as a value, so the three + // ways to reach a body are three calls for every return kind. +#define VECTOR_DISPATCH(Kind, member) \ + ret_val.member = \ + is_static ? env->CallStatic##Kind##MethodA(declaring_class, target, a) \ + : non_virtual ? env->CallNonvirtual##Kind##MethodA(thiz, declaring_class, target, a) \ + : env->Call##Kind##MethodA(thiz, target, a) + + switch (returns) { case 'I': - ret_val.i = env->CallNonvirtualIntMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Int, i); break; case 'D': - ret_val.d = env->CallNonvirtualDoubleMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Double, d); break; case 'J': - ret_val.j = env->CallNonvirtualLongMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Long, j); break; case 'F': - ret_val.f = env->CallNonvirtualFloatMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Float, f); break; case 'S': - ret_val.s = env->CallNonvirtualShortMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Short, s); break; case 'B': - ret_val.b = env->CallNonvirtualByteMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Byte, b); break; case 'C': - ret_val.c = env->CallNonvirtualCharMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Char, c); break; case 'Z': - ret_val.z = env->CallNonvirtualBooleanMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Boolean, z); break; case 'L': - ret_val.l = env->CallNonvirtualObjectMethodA(thiz, cls, target, a); + VECTOR_DISPATCH(Object, l); break; default: - env->CallNonvirtualVoidMethodA(thiz, cls, target, a); + if (is_static) { + env->CallStaticVoidMethodA(declaring_class, target, a); + } else if (non_virtual) { + env->CallNonvirtualVoidMethodA(thiz, declaring_class, target, a); + } else { + env->CallVoidMethodA(thiz, target, a); + } break; } +#undef VECTOR_DISPATCH + + // The shorty is not read again, and releasing it first is what keeps every JNI call below out + // of the window in which the call's own exception is still pending. + release(); + // --- Exception Wrapping --- - jthrowable target_exception = env->ExceptionOccurred(); - if (target_exception) { + // Only what the call threw is wrapped; every refusal above is the caller's and stays raw. + if (jthrowable thrown = env->ExceptionOccurred(); thrown) { env->ExceptionClear(); - jobject ite = env->NewObject(cls_ITE, ctor_ite, target_exception); - // Ensure NewObject didn't fail due to OOM before throwing + jobject ite = env->NewObject(cls_ITE, ctor_ite, thrown); + // NewObject failing leaves its own OutOfMemoryError pending, which is the truer answer. if (ite) { env->Throw(static_cast(ite)); } - return abort_and_return(); + return nullptr; } // --- Box Return Value --- jobject value = nullptr; - switch (shorty_char[0]) { - case 'I': - value = env->CallStaticObjectMethod(cls_Integer, set_int, ret_val.i); - break; - case 'D': - value = env->CallStaticObjectMethod(cls_Double, set_double, ret_val.d); - break; - case 'J': - value = env->CallStaticObjectMethod(cls_Long, set_long, ret_val.j); - break; - case 'F': - value = env->CallStaticObjectMethod(cls_Float, set_float, ret_val.f); - break; - case 'S': - value = env->CallStaticObjectMethod(cls_Short, set_short, ret_val.s); - break; - case 'B': - value = env->CallStaticObjectMethod(cls_Byte, set_byte, ret_val.b); - break; - case 'C': - value = env->CallStaticObjectMethod(cls_Character, set_char, ret_val.c); - break; - case 'Z': - value = env->CallStaticObjectMethod(cls_Boolean, set_boolean, ret_val.z); - break; - case 'L': + if (returns == 'L') { value = ret_val.l; - break; - case 'V': - value = nullptr; - break; + } else if (returns != 'V') { + // valueOf reads the jvalue member its own shorty names, which is the one the call wrote. + if (const PrimitiveWrapper *wrapper = WrapperFor(env, returns); wrapper != nullptr) { + value = env->CallStaticObjectMethodA(wrapper->clazz, wrapper->box, &ret_val); + } } - env->ReleaseCharArrayElements(shorty, shorty_char, JNI_ABORT); return value; } @@ -659,17 +865,16 @@ VECTOR_DEF_NATIVE_METHOD(jobjectArray, HookBridge, callbackSnapshot, jclass call * boot. Resolving them through the same map the rest of the framework uses is what makes the guard * hold in both configurations. * - * The four entries are the whole legacy surface the obfuscation table covers: the package itself, - * AndroidAppHelper, and the XResources / XModuleResources family. Guarding only the package would - * leave the legacy resource API reachable. + * The one entry is the one package the spec names. The obfuscation table also covers + * AndroidAppHelper and the XResources / XModuleResources family, and guarding those too was the + * wider reading of the same sentence - but the interface says "legacy {@code de.robv.android.xposed} + * APIs" and names nothing else, and API 102 offers no resource API of its own, so the wider reading + * left a module targeting it with no way to touch resources at all. */ VECTOR_DEF_NATIVE_METHOD(jobjectArray, HookBridge, legacyApiPrefixes) { // In the dotted form the obfuscation map is served in - the same form loadClass receives. static constexpr const char *kLegacyKeys[] = { "de.robv.android.xposed.", - "android.app.AndroidApp", - "android.content.res.XRes", - "android.content.res.XModule", }; const auto count = static_cast(ArraySize(kLegacyKeys)); @@ -820,9 +1025,9 @@ static JNINativeMethod gMethods[] = { VECTOR_NATIVE_METHOD(HookBridge, invokeOriginalMethod, "(Ljava/lang/reflect/Executable;Ljava/lang/Object;[Ljava/" "lang/Object;)Ljava/lang/Object;"), - VECTOR_NATIVE_METHOD(HookBridge, invokeSpecialMethod, - "(Ljava/lang/reflect/Executable;[CLjava/lang/Class;Ljava/" - "lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;"), + VECTOR_NATIVE_METHOD(HookBridge, invokeOriginal, + "(Ljava/lang/reflect/Executable;[C[Ljava/lang/Class;Ljava/" + "lang/Class;ZZLjava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;"), VECTOR_NATIVE_METHOD(HookBridge, allocateObject, "(Ljava/lang/Class;)Ljava/lang/Object;"), VECTOR_NATIVE_METHOD(HookBridge, instanceOf, "(Ljava/lang/Object;Ljava/lang/Class;)Z"), VECTOR_NATIVE_METHOD(HookBridge, setTrusted, "(Ljava/lang/Object;)Z"), diff --git a/native/src/jni/resources_hook.cpp b/native/src/jni/resources_hook.cpp index da073b56f..39abd0973 100644 --- a/native/src/jni/resources_hook.cpp +++ b/native/src/jni/resources_hook.cpp @@ -1,6 +1,9 @@ #include +#include +#include #include +#include #include #include @@ -15,10 +18,12 @@ namespace vector::native::jni { // --- Type Aliases for Native Android Framework Functions --- -// Signature for android::ResXMLParser::getAttributeNameID(int) -using TYPE_GET_ATTR_NAME_ID = int32_t (*)(void *, int); -// Signature for android::ResStringPool::stringAt(int, size_t*) -using TYPE_STRING_AT = char16_t *(*)(const void *, int32_t, size_t *); +// Signature for android::ResXMLParser::getAttributeNameID(size_t) +using TYPE_GET_ATTR_NAME_ID = int32_t (*)(void *, size_t); +// Signature for android::ResXMLParser::getAttributeNameResID(size_t) +using TYPE_GET_ATTR_NAME_RES_ID = uint32_t (*)(void *, size_t); +// Signature for android::ResXMLParser::getStrings() +using TYPE_GET_STRINGS = const android::ResStringPool *(*)(void *); // Signature for android::ResXMLParser::restart() using TYPE_RESTART = void (*)(void *); // Signature for android::ResXMLParser::next() @@ -34,6 +39,8 @@ static jmethodID methodXResourcesTranslateResId; static TYPE_NEXT ResXMLParser_next = nullptr; static TYPE_RESTART ResXMLParser_restart = nullptr; static TYPE_GET_ATTR_NAME_ID ResXMLParser_getAttributeNameID = nullptr; +static TYPE_GET_ATTR_NAME_RES_ID ResXMLParser_getAttributeNameResID = nullptr; +static TYPE_GET_STRINGS ResXMLParser_getStrings = nullptr; /** * @brief Constructs the class name for the XResources class at runtime. @@ -97,6 +104,19 @@ static bool PrepareSymbols() { LOGE("Failed to find symbol: ResXMLParser::getAttributeNameID"); return false; } + // The next two are only needed for the attribute name half of the rewrite, so a library that + // does not export them costs that half rather than the whole resource hook. + // Find android::ResXMLParser::getAttributeNameResID(unsigned int/long) + if (!(ResXMLParser_getAttributeNameResID = fw.getSymbAddress( + LP_SELECT("_ZNK7android12ResXMLParser21getAttributeNameResIDEj", + "_ZNK7android12ResXMLParser21getAttributeNameResIDEm")))) { + LOGW("Failed to find symbol: ResXMLParser::getAttributeNameResID"); + } + // Find android::ResXMLParser::getStrings() + if (!(ResXMLParser_getStrings = + fw.getSymbAddress("_ZNK7android12ResXMLParser10getStringsEv"))) { + LOGW("Failed to find symbol: ResXMLParser::getStrings"); + } // Initialize another part of the resource framework that we depend on. return android::ResStringPool::setup(lsplant::InitInfo{ .art_symbol_resolver = [&](auto s) { return fw.template getSymbAddress<>(s); }}); @@ -209,6 +229,138 @@ VECTOR_DEF_NATIVE_METHOD(jobject, ResourcesHook, buildDummyClassLoader, jobject .release(); } +/** + * @brief Reports whether the pages spanning [addr, addr + len) are mapped. + * + * msync on an unmapped range fails with ENOMEM, which turns a read that would raise SIGSEGV into an + * answer. The search below walks off the end of a struct whose size it does not know, so it needs + * one. + * + * hook_bridge.cpp has the same helper, also with internal linkage. Folding the two into a shared + * header is a follow-up; both are being edited in this round. + */ +static bool IsMapped(uintptr_t addr, size_t len) { + static const size_t page = static_cast(sysconf(_SC_PAGESIZE)); + if (page == 0) return false; + const uintptr_t start = addr & ~(page - 1); + const size_t span = ((addr + len) - start + page - 1) & ~(page - 1); + return msync(reinterpret_cast(start), span, MS_ASYNC) == 0; +} + +// The attribute name map is the member behind the string pool, so the distance to it is +// sizeof(ResStringPool), and that grows with almost every release: 0x80 on Android 10 and 0x130 on +// Android 17 for LP64. The bracket has to be per ABI, because every member of that class is a word +// and its lock is a single int on ILP32, which puts the same layout at 0x30 there - below the LP64 +// floor, so a shared floor would start the search past what it is looking for. The ceilings are set +// so that the last slot the scan reads is still inside the ResXMLTree allocation on the newest +// release measured: msync answers for a page, not for a malloc chunk, and a process with heap +// tagging on faults on the word after the object rather than returning garbage. +static constexpr size_t kMinMapOffset = LP_SELECT(0x20, 0x40); +static constexpr size_t kMaxMapOffset = LP_SELECT(0xa0, 0x140); +// The map holds one id per string in the document's own pool, so a count this large is not a +// candidate but a coincidence. +static constexpr size_t kMaxMapEntries = 0x4000; +// Where the search landed: zero until it has run, kMapUnusable once it has given up. Inflation is +// not single threaded - RemoteViews and AsyncLayoutInflater both do it off the main thread - so the +// one variable that gates a raw write into framework memory is not left to luck. +static constexpr size_t kMapUnusable = ~static_cast(0); +static std::atomic attr_map_offset{0}; +// getAttributeNameResID() runs the raw id through the dynamic reference table, so a module built as +// a shared library answers differently from the map it is reading and fails the match below. One +// such document is not proof that the offset cannot be found, so give up only after a few. +static constexpr int kMaxMapSearches = 3; +static std::atomic attr_map_searches{0}; + +/** + * @brief Reports whether a candidate array is the map the parser is reading its attributes from. + * + * Every attribute the parser answers with a non-zero id has to come back out of the array at the + * index the parser names for it, which for a tag with several attributes leaves no room for a + * coincidence. + */ +static bool MapsCurrentAttributes(void *parser, const uint32_t *map, size_t count, + size_t attrCount) { + bool matched = false; + for (size_t idx = 0; idx < attrCount; idx++) { + auto resID = ResXMLParser_getAttributeNameResID(parser, idx); + if (resID == 0) continue; + auto nameID = ResXMLParser_getAttributeNameID(parser, idx); + if (nameID < 0 || static_cast(nameID) >= count) return false; + if (map[nameID] != resID) return false; + matched = true; + } + return matched; +} + +/** + * @brief Returns the distance from the string pool to the attribute name map, or zero. + */ +static size_t FindAttributeNameMap(void *parser, uintptr_t pool, size_t attrCount) { + for (size_t off = kMinMapOffset; off <= kMaxMapOffset; off += sizeof(void *)) { + if (!IsMapped(pool + off, sizeof(void *) + sizeof(size_t))) break; + auto candidate = *reinterpret_cast(pool + off); + auto count = *reinterpret_cast(pool + off + sizeof(void *)); + if (candidate == nullptr || count == 0 || count > kMaxMapEntries) continue; + if (reinterpret_cast(candidate) % alignof(uint32_t) != 0) continue; + if (!IsMapped(reinterpret_cast(candidate), count * sizeof(uint32_t))) continue; + if (!MapsCurrentAttributes(parser, candidate, count, attrCount)) continue; + return off; + } + return 0; +} + +/** + * @brief Returns the writable slot holding an attribute's mapped resource id, or nullptr. + * + * That map is what turns an attribute's string index into the resource id the inflater looks it up + * by, so rewriting it is the only way a replacement layout can carry attributes of its own. + * getAttributeNameResID() reads it but nothing exported writes it, so its address has to be found. + * Hard-coding the offset is what killed this rewrite in Android 10: the map is the member behind + * ResStringPool, and that class has since gained a vtable, a decode lock and a lookup cache. So + * look for the slot instead - once per process, the layout being the same for every document - and + * leave the attribute names alone if nothing matches, which is what happened on every release after + * Pie anyway. + * + * @param searchedHere Whether this document has already paid for a search, so a failing one costs + * the bracket once rather than once per attribute. + */ +static uint32_t *AttributeNameSlot(void *parser, const android::ResStringPool *strings, + size_t attrCount, int32_t nameID, uint32_t resID, + bool &searchedHere) { + const auto pool = reinterpret_cast(strings); + auto offset = attr_map_offset.load(std::memory_order_relaxed); + if (offset == kMapUnusable) return nullptr; + if (offset == 0) { + if (searchedHere) return nullptr; + searchedHere = true; + offset = FindAttributeNameMap(parser, pool, attrCount); + if (offset == 0) { + if (attr_map_searches.fetch_add(1, std::memory_order_relaxed) + 1 < kMaxMapSearches) + return nullptr; + // And give up only if nothing has found it meanwhile: a document that another thread + // matched is the answer, whatever this one failed to match. + size_t unset = 0; + if (!attr_map_offset.compare_exchange_strong(unset, kMapUnusable, + std::memory_order_relaxed)) + return nullptr; + LOGW("Could not locate the attribute name map, leaving attribute names untranslated."); + return nullptr; + } + attr_map_offset.store(offset, std::memory_order_relaxed); + } + + // The offset was matched against one document; every other one gets the cheap half of the same + // check, so a slot that has stopped being the map is skipped instead of written through. The + // count sits in the word behind the pointer, which answers exactly what a page probe could only + // approximate, and without a syscall per attribute. + auto map = *reinterpret_cast(pool + offset); + auto count = *reinterpret_cast(pool + offset + sizeof(void *)); + if (map == nullptr || count > kMaxMapEntries) return nullptr; + if (static_cast(nameID) >= count) return nullptr; + auto slot = map + static_cast(nameID); + return *slot == resID ? slot : nullptr; +} + /** * @brief The core resource rewriting function. * @@ -228,10 +380,17 @@ VECTOR_DEF_NATIVE_METHOD(void, ResourcesHook, rewriteXmlReferencesNative, jlong if (parser == nullptr) return; - const android::ResXMLTree &mTree = parser->mTree; - auto mResIds = (uint32_t *)mTree.mResIds; + // Everything behind the parser is reached through the framework's own accessors: the tree used + // to be read at fixed offsets, which stopped describing it in Android 10 and silently skipped + // the whole attribute name half of this rewrite from then on. Without those accessors only the + // values below are translated, which is all that happened on any release after Pie anyway. + auto strings = + ResXMLParser_getStrings != nullptr && ResXMLParser_getAttributeNameResID != nullptr + ? ResXMLParser_getStrings(parser) + : nullptr; android::ResXMLTree_attrExt *tag; - int attrCount; + size_t attrCount; + bool searchedHere = false; // This loop iterates through all tokens in the binary XML file. do { @@ -241,7 +400,7 @@ VECTOR_DEF_NATIVE_METHOD(void, ResourcesHook, rewriteXmlReferencesNative, jlong tag = (android::ResXMLTree_attrExt *)parser->mCurExt; attrCount = tag->attributeCount; // Loop through all attributes of the current XML tag. - for (int idx = 0; idx < attrCount; idx++) { + for (size_t idx = 0; idx < attrCount; idx++) { auto attr = (android::ResXMLTree_attribute *)(((const uint8_t *)tag) + tag->attributeStart + tag->attributeSize * idx); @@ -249,23 +408,34 @@ VECTOR_DEF_NATIVE_METHOD(void, ResourcesHook, rewriteXmlReferencesNative, jlong // Translate the attribute name's resource ID --- // e.g., for 'android:textColor', translate the ID for 'textColor'. int32_t attrNameID = ResXMLParser_getAttributeNameID(parser, idx); - - // Only replace IDs that belong to the app's package (0x7f...). - if (attrNameID >= 0 && (size_t)attrNameID < mTree.mNumResIds && - mResIds[attrNameID] >= 0x7f000000) { - auto attrName = mTree.mStrings.stringAt(attrNameID); - jstring attrNameStr = - env->NewString((const jchar *)attrName.data_, attrName.length_); - if (env->ExceptionCheck()) goto leave; // Critical check - - // Call back to Java: XResources.translateAttrId(String name, ...) - jint attrResID = env->CallStaticIntMethod( - classXResources, methodXResourcesTranslateAttrId, attrNameStr, origRes); - env->DeleteLocalRef(attrNameStr); - if (env->ExceptionCheck()) goto leave; - - // Directly modify the resource ID table in the parser's memory. - mResIds[attrNameID] = attrResID; + uint32_t oldAttrResID = + strings != nullptr ? ResXMLParser_getAttributeNameResID(parser, idx) : 0; + + // Only replace IDs that belong to the app's package (0x7f...), and only where the + // map can be written back: a slot that was found by searching is trusted for + // exactly as long as it keeps answering the same as the parser does. + uint32_t *nameSlot = attrNameID >= 0 && oldAttrResID >= 0x7f000000 + ? AttributeNameSlot(parser, strings, attrCount, attrNameID, + oldAttrResID, searchedHere) + : nullptr; + if (nameSlot != nullptr) { + auto attrName = strings->stringAt(attrNameID); + // An index the pool cannot decode comes back empty, and NewString(nullptr, 0) + // is a JNI misuse that aborts the process under CheckJNI. + if (attrName.data_ != nullptr) { + jstring attrNameStr = + env->NewString((const jchar *)attrName.data_, attrName.length_); + if (env->ExceptionCheck()) goto leave; // Critical check + + // Call back to Java: XResources.translateAttrId(String name, ...) + jint attrResID = env->CallStaticIntMethod( + classXResources, methodXResourcesTranslateAttrId, attrNameStr, origRes); + env->DeleteLocalRef(attrNameStr); + if (env->ExceptionCheck()) goto leave; + + // Directly modify the resource ID table in the parser's memory. + *nameSlot = attrResID; + } } // Translate the attribute's value if it's a reference --- diff --git a/xposed/src/main/java/org/matrix/vector/impl/hooks/InvokerEntry.java b/xposed/src/main/java/org/matrix/vector/impl/hooks/InvokerEntry.java new file mode 100644 index 000000000..323ca2d1a --- /dev/null +++ b/xposed/src/main/java/org/matrix/vector/impl/hooks/InvokerEntry.java @@ -0,0 +1,64 @@ +package org.matrix.vector.impl.hooks; + +import io.github.libxposed.api.XposedInterface.CtorInvoker; +import io.github.libxposed.api.XposedInterface.Invoker; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Method; + +/** + * The vararg doors of {@link Invoker}, in Java because Kotlin bars a null array from one. + * + *

{@code Object invoke(Object, Object...)} lets a Java module write + * {@code invoke(obj, (Object[]) null)}, and reflection reads that null array as no arguments at + * all: {@link Method#invoke} returns normally for a zero-parameter executable and reports + * IllegalArgumentException for any other. A Kotlin {@code vararg args: Any?} override compiles to + * a non-null {@code Object[]} parameter with an {@code Intrinsics.checkNotNullParameter(args, + * "args")} ahead of its first statement, so the same call is a NullPointerException before any of + * that can be decided. Java emits no such check. + * + *

That is the only reason these four methods are not in BaseInvoker.kt: every line of decision + * is still Kotlin's, behind the {@code ...With} methods. Turning the check off with + * {@code -Xno-param-assertions} is not the alternative - it is a module-wide flag, and + * VectorChain's own {@code proceed(Object[])} is declared @NonNull, where the NullPointerException + * is correct. + */ +public interface InvokerEntry, U extends Executable> extends Invoker { + + Object[] NO_ARGS = new Object[0]; + + @Override + default Object invoke(Object thisObject, Object... args) { + return invokeWith(thisObject, args == null ? NO_ARGS : args); + } + + @Override + default Object invokeSpecial(Object thisObject, Object... args) { + return invokeSpecialWith(thisObject, args == null ? NO_ARGS : args); + } + + Object invokeWith(Object thisObject, Object[] args); + + Object invokeSpecialWith(Object thisObject, Object[] args); + + /** The same doors for the two entry points {@link CtorInvoker} adds. */ + interface Ctor extends InvokerEntry, Constructor>, CtorInvoker { + + @Override + default T newInstance(Object... args) throws InstantiationException { + return newInstanceWith(args == null ? NO_ARGS : args); + } + + @Override + default V newInstanceSpecial(Class subClass, Object... args) + throws InstantiationException { + return newInstanceSpecialWith(subClass, args == null ? NO_ARGS : args); + } + + T newInstanceWith(Object[] args) throws InstantiationException; + + V newInstanceSpecialWith(Class subClass, Object[] args) + throws InstantiationException; + } +} diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt index a8f8e2564..8a1e834be 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt @@ -6,38 +6,84 @@ import java.lang.reflect.Constructor import java.lang.reflect.Executable import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method +import java.lang.reflect.Modifier import org.matrix.vector.impl.di.VectorBootstrap import org.matrix.vector.nativebridge.HookBridge +import org.matrix.vector.util.Utils /** * Base implementation of the Invoker system. Handles the resolution of [Invoker.Type] to determine * whether to execute the original method directly or to construct a partial interceptor chain. + * + * The vararg entry points the interface declares are not here but in [InvokerEntry], which is Java + * for the one reason given there; what arrives here is the array they normalised. */ internal abstract class BaseInvoker, U : Executable>( protected val executable: U -) : Invoker { +) : InvokerEntry { protected var type: Invoker.Type = Invoker.Type.Chain.FULL + // An invoker names one executable for its whole life, and each of these would otherwise be + // rebuilt per call: getParameterTypes clones its array every time it is asked, and the shorty + // is derived from that array. + protected val parameterTypes: Array> = executable.parameterTypes + private val shorty: CharArray = VectorInvocation.shortyOf(executable, parameterTypes) + protected val declaringClass: Class<*> = executable.declaringClass + private val isStatic: Boolean = Modifier.isStatic(executable.modifiers) + @Suppress("UNCHECKED_CAST") override fun setType(type: Invoker.Type): T { this.type = type return this as T } - /** Resolves the current [type] and executes the underlying method. */ - protected fun proceedInvocation(thisObject: Any?, args: Array): Any? { + /** + * Resolves [type] and runs the executable, non-virtually when [nonVirtual]. + * + * The receiver and the arguments are checked before the chain is entered, because Method#invoke + * reports its own refusals unwrapped and reserves InvocationTargetException for what the call + * threw - and everything thrown inside the chain is what the call threw. [onReceiver] reports + * the receiver each dispatch actually ran against, which a hooker may have redirected. + * + * [type] defaults to this invoker's own, and is a parameter for the one caller that has to + * decide it from outside: a virtual invocation that resolved to an override runs the override's + * chain, under the type asked of the invoker the module actually holds. + */ + protected fun proceedInvocation( + thisObject: Any?, + args: Array, + nonVirtual: Boolean, + type: Invoker.Type = this.type, + onReceiver: (Any?) -> Unit = {}, + ): Any? { + val receiver = VectorInvocation.checkReceiver(executable, isStatic, thisObject) + val actualArgs = VectorInvocation.coerceArguments(executable, parameterTypes, args) + + // Reaches the body this invoker names, never through the trampoline. + fun dispatch(tObj: Any?, tArgs: Array): Any? { + onReceiver(tObj) + return HookBridge.invokeOriginal( + executable, + shorty, + parameterTypes, + declaringClass, + isStatic, + nonVirtual, + tObj, + tArgs, + ) + } + return when (val currentType = type) { - // Both paths below already report a target exception wrapped and their own argument - // failures unwrapped, which is what Invoker#invoke documents. - is Invoker.Type.Origin -> dispatchOriginal(thisObject, args) + is Invoker.Type.Origin -> dispatch(receiver, actualArgs) is Invoker.Type.Chain -> { val snapshots = HookBridge.callbackSnapshot(VectorHookRecord::class.java, executable) // The executable carries no hooks, so there is no chain to enter. Invokers // default to Type.Chain.FULL, so this is the ordinary case for a module // that obtains an invoker for a method it has not hooked. - ?: return dispatchOriginal(thisObject, args) + ?: return dispatch(receiver, actualArgs) @Suppress("UNCHECKED_CAST") val allModernHooks = snapshots[0] as Array @@ -47,112 +93,123 @@ internal abstract class BaseInvoker, U : Executable>( val filteredHooks = allModernHooks.filter { it.priority <= currentType.maxPriority }.toTypedArray() + // Chain#proceed is documented to throw whatever the original executable threw, so + // the reflective wrapper comes off here rather than at the public boundary. + val runOriginal: (Any?, Array) -> Any? = { tObj, tArgs -> + try { + dispatch(tObj, tArgs) + } catch (e: InvocationTargetException) { + throw e.cause ?: e + } + } + val terminal: (Any?, Array) -> Any? = { tObj, tArgs -> val delegate = VectorBootstrap.delegate if (legacyHooks.isNotEmpty() && delegate != null) { delegate.processLegacyHook(executable, tObj, tArgs, legacyHooks) { - invokeOriginal(tObj, tArgs) + runOriginal(tObj, tArgs) } } else { - invokeOriginal(tObj, tArgs) + runOriginal(tObj, tArgs) } } val chain = - VectorChain(executable, thisObject, arrayOf(*args), filteredHooks, 0, terminal) - // Chain#proceed is documented to hand hookers the exception itself, while - // Invoker#invoke is documented against Method#invoke, which reports it wrapped, so - // the wrapping belongs at this boundary rather than inside the chain. The paths - // that skip the chain get this from the dispatch itself and are not re-wrapped. + VectorChain(executable, receiver, actualArgs, filteredHooks, 0, terminal) try { chain.proceed() - } catch (e: InvocationTargetException) { - throw e - } catch (e: Throwable) { - throw InvocationTargetException(e) + } catch (t: Throwable) { + // The terminal took the wrapper off, so whatever arrives here is what the call + // produced - the executable's exception or a hooker's - and Method#invoke + // reports that wrapped, including an InvocationTargetException of its own. + throw InvocationTargetException(t) } } } } +} - /** Invokes the original executable, reporting a target exception as Method#invoke does. */ - private fun dispatchOriginal(thisObject: Any?, args: Array): Any? { - // invokeOriginalMethod dispatches through a cached Method.invoke id. For a hooked - // executable that is applied to lsplant's backup Method, which is correct. For an - // executable with no hook item at all it is applied to the reflected object we passed in - // — and if that is a Constructor, the id belongs to a different class. Route those - // through the non-virtual path instead, which is what invokeSpecial already uses. - if ( - executable is Constructor<*> && - HookBridge.callbackSnapshot(VectorHookRecord::class.java, executable) == null - ) { - requireNotNull(thisObject) { - "A constructor invoked as a method needs a receiver: $executable" - } - return HookBridge.invokeSpecialMethod( - executable, - getExecutableShorty(), - executable.declaringClass, - thisObject, - *args, - ) - } - return HookBridge.invokeOriginalMethod(executable, thisObject, *args) - } +/** Invoker implementation specifically for [Method] types. */ +internal class VectorMethodInvoker(method: Method) : + BaseInvoker(method) { /** - * The chain terminal. Chain#proceed is documented to throw whatever the original executable - * threw, so the reflective wrapper comes off here rather than at the public boundary. + * One resolution, kept whole so that no reader can pair one call's class with another's target. */ - private fun invokeOriginal(thisObject: Any?, args: Array): Any? = - try { - dispatchOriginal(thisObject, args) - } catch (e: InvocationTargetException) { - throw e.cause ?: e - } + private class Resolution(val receiverClass: Class<*>, val target: VectorMethodInvoker) - /** Helper to generate the JNI shorty for non-virtual special invocations. */ - protected fun getExecutableShorty(): CharArray { - val parameterTypes = executable.parameterTypes - val shorty = CharArray(parameterTypes.size + 1) - shorty[0] = getTypeShorty(if (executable is Method) executable.returnType else Void.TYPE) - for (i in 1..shorty.lastIndex) { - shorty[i] = getTypeShorty(parameterTypes[i - 1]) - } - return shorty - } + // Whether any receiver can move this call elsewhere at all. A property of the executable alone, + // so it is settled once and short-circuits every call on a method nothing can override. + private val overridable: Boolean = VectorInvocation.canBeOverridden(method) - private fun getTypeShorty(type: Class<*>): Char = - when (type) { - Int::class.javaPrimitiveType -> 'I' - Long::class.javaPrimitiveType -> 'J' - Float::class.javaPrimitiveType -> 'F' - Double::class.javaPrimitiveType -> 'D' - Boolean::class.javaPrimitiveType -> 'Z' - Byte::class.javaPrimitiveType -> 'B' - Char::class.javaPrimitiveType -> 'C' - Short::class.javaPrimitiveType -> 'S' - Void.TYPE -> 'V' - else -> 'L' - } -} + // What the executable alone cannot settle is which override a call reaches, because that + // depends on the receiver's class - so that is what this is keyed by. One entry rather than a + // map: a call site sees one receiver class almost always, which is what makes an inline cache + // worth having, and a map would pin every class it ever saw - and through each, its whole + // loader - for as long as the module holds the invoker. + @Volatile private var resolved: Resolution? = null -/** Invoker implementation specifically for [Method] types. */ -internal class VectorMethodInvoker(method: Method) : - BaseInvoker(method) { + /** + * `invoke` is documented "@see Method#invoke", and Method#invoke dispatches virtually: a Method + * taken from a superclass reaches the receiver's override. That does not happen by itself here, + * because a hooked executable is reached through lsplant's backup, which is private and so + * dispatched directly - the override has to be resolved and entered explicitly. + * + * The override is a different executable carrying a chain of its own, and Method#invoke would + * run it hooks and all, so what is entered is that chain and not this one's. Its invoker is + * asked to run it directly rather than through this entry point, which is what makes the + * redirection exactly one hop deep whatever the hierarchy looks like. + */ + override fun invokeWith(thisObject: Any?, args: Array): Any? = + virtualTarget(thisObject).runChain(thisObject, args, type) - override fun invoke(thisObject: Any?, vararg args: Any?): Any? { - return proceedInvocation(thisObject, args) - } + override fun invokeSpecialWith(thisObject: Any?, args: Array): Any? = + proceedInvocation(thisObject, args, nonVirtual = true) + + /** Runs this invoker's own chain under a [type] its caller owns, resolving nothing further. */ + private fun runChain(thisObject: Any?, args: Array, type: Invoker.Type): Any? = + proceedInvocation(thisObject, args, nonVirtual = false, type = type) + + /** + * The invoker whose executable this call reaches, which is this one unless the receiver's class + * overrides. + * + * Type.Origin is answered with this invoker and never resolves, which is the one place the type + * decides the dispatch. "Invokes the original executable, skipping all hooks" reads most simply + * as the executable this invoker names, and the alternative breaks the idiom the whole type + * exists for: a hooker on `Base.name` asking for the original with an overriding receiver in + * hand would reach `Derived.name`, whose body calls `super.name()`, which is the hooked method + * again - the hook would call itself until the stack ran out. Skipping all hooks cannot mean + * entering one. + * + * A Chain type carries no such hazard, because the chain it enters is the override's own and + * the override's `super` call reaches this executable's body once, not its hook. + */ + private fun virtualTarget(thisObject: Any?): VectorMethodInvoker { + if (!overridable || thisObject == null || type is Invoker.Type.Origin) return this + val receiverClass = thisObject.javaClass + // The commonest receiver of all, and the one a walk could say nothing about. + if (receiverClass === declaringClass) return this + + val cached = resolved + if (cached != null && cached.receiverClass === receiverClass) return cached.target + + // Resolution reads declared members of classes this call would otherwise never touch, so a + // signature naming a class that is not there raises where the invocation would have + // succeeded. Only that is caught: anything else is a bug in the walk and has to surface. + // The answer is not cached either, because it is the wrong one - on a hooked executable it + // reinstates the very defect this resolves, so a later call has to be free to try again. + val override = + try { + VectorInvocation.virtualTargetOf(executable, parameterTypes, receiverClass) + } catch (e: LinkageError) { + Utils.logW("Cannot resolve the override of $executable for $receiverClass", e) + return this + } - override fun invokeSpecial(thisObject: Any, vararg args: Any?): Any? { - return HookBridge.invokeSpecialMethod( - executable, - getExecutableShorty(), - executable.declaringClass, - thisObject, - *args, - ) + val target = override?.let(::VectorMethodInvoker) ?: this + resolved = Resolution(receiverClass, target) + return target } } @@ -161,49 +218,48 @@ internal class VectorMethodInvoker(method: Method) : * initialize objects safely. */ internal class VectorCtorInvoker(constructor: Constructor) : - BaseInvoker, Constructor>(constructor), CtorInvoker { + BaseInvoker, Constructor>(constructor), InvokerEntry.Ctor { - override fun invoke(thisObject: Any?, vararg args: Any?): Any? { + // A constructor is a direct method: it has no vtable slot for a receiver's class to override, + // so every way of calling one is non-virtual. + override fun invokeWith(thisObject: Any?, args: Array): Any? { // Invoking a constructor as a method returns nothing (void/null) - proceedInvocation(thisObject, args) + proceedInvocation(thisObject, args, nonVirtual = true) return null } - override fun invokeSpecial(thisObject: Any, vararg args: Any?): Any? { - HookBridge.invokeSpecialMethod( - executable, - getExecutableShorty(), - executable.declaringClass, - thisObject, - *args, - ) + override fun invokeSpecialWith(thisObject: Any?, args: Array): Any? { + proceedInvocation(thisObject, args, nonVirtual = true) return null } @Suppress("UNCHECKED_CAST") - override fun newInstance(vararg args: Any?): T { + override fun newInstanceWith(args: Array): T { // Allocate memory without invoking - val obj = HookBridge.allocateObject(executable.declaringClass) - // Drive the invocation (origin or chain) utilizing the allocated object - proceedInvocation(obj, args) - return obj + val allocated = HookBridge.allocateObject(executable.declaringClass) + // A hooker may redirect the construction with Chain#proceedWith, and newInstance is + // documented to return the instance the constructor initialized, not the one allocated. + // Whichever object the chain settled on is a T: the declaring class here is the type asked + // for, and no receiver that is not an instance of it reaches the constructor. + var initialized: Any? = allocated + proceedInvocation(allocated, args, nonVirtual = true) { initialized = it } + return initialized as T } @Suppress("UNCHECKED_CAST") - override fun newInstanceSpecial(subClass: Class, vararg args: Any?): U { + override fun newInstanceSpecialWith(subClass: Class, args: Array): V { if (!executable.declaringClass.isAssignableFrom(subClass)) { throw IllegalArgumentException( "$subClass is not inherited from ${executable.declaringClass}" ) } - val obj = HookBridge.allocateObject(subClass) - HookBridge.invokeSpecialMethod( - executable, - getExecutableShorty(), - executable.declaringClass, - obj, - *args, - ) - return obj + val allocated = HookBridge.allocateObject(subClass) + var initialized: Any? = allocated + proceedInvocation(allocated, args, nonVirtual = true) { initialized = it } + // Here the type asked for is not the one the chain has to keep: a hooker's proceedWith only + // owes the constructor an instance of its declaring class, the parent. Handing that back + // would return something that is not a V, and the caller would find out at its own + // checkcast, nowhere near the hooker that caused it. + return (if (subClass.isInstance(initialized)) initialized else allocated) as V } } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorInvocation.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorInvocation.kt new file mode 100644 index 000000000..d308e0403 --- /dev/null +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorInvocation.kt @@ -0,0 +1,351 @@ +package org.matrix.vector.impl.hooks + +import java.lang.reflect.Constructor +import java.lang.reflect.Executable +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import org.matrix.vector.nativebridge.HookBridge + +/** + * What java.lang.reflect does to a receiver and an argument list before it calls anything, for the + * callers that reach the executable through JNI instead. + * + * JNI reports none of it. A foreign receiver or a mistyped argument is not refused, it is executed, + * with the callee reading fields at offsets that belong to a different layout; a boxed value of the + * wrong width is not refused either, it is silently truncated. Method#invoke reports all of that as + * IllegalArgumentException, and the invoker interface is specified against Method#invoke. + * + * The checks live here rather than in the JNI backend because a shorty cannot name the declared + * class of a reference parameter, and they run before the hook chain is entered because everything + * thrown inside the chain is reported wrapped - a refusal of ours is not something the call + * produced. + * + * The messages are ART's own, from `art/runtime/reflection.cc`, so a module that reads one - or a + * conformance suite that asserts on it - gets the same text here as from Method#invoke. + * `Class#getTypeName` is the Java side of ART's PrettyDescriptor: dotted, and `int[]` for an array. + * + * The receiver decides one more thing reflection settles before it calls anything, and that is + * *which* method it calls: Method#invoke dispatches virtually, so a Method taken from a superclass + * reaches the receiver's override. A caller that already holds the target - which is what every + * dispatch through a hook backup is - reaches no override on its own, so the answer is computed + * here instead. + */ +object VectorInvocation { + + /** + * The JNI shorty of [executable]: the return type first, then one character per parameter. + * Reference types and arrays are both 'L', which is ART's own convention. + */ + fun shortyOf(executable: Executable, parameterTypes: Array>): CharArray { + val shorty = CharArray(parameterTypes.size + 1) + shorty[0] = shortyOf(if (executable is Method) executable.returnType else Void.TYPE) + for (i in parameterTypes.indices) { + shorty[i + 1] = shortyOf(parameterTypes[i]) + } + return shorty + } + + private fun shortyOf(type: Class<*>): Char = + when (type) { + Int::class.javaPrimitiveType -> 'I' + Long::class.javaPrimitiveType -> 'J' + Float::class.javaPrimitiveType -> 'F' + Double::class.javaPrimitiveType -> 'D' + Boolean::class.javaPrimitiveType -> 'Z' + Byte::class.javaPrimitiveType -> 'B' + Char::class.javaPrimitiveType -> 'C' + Short::class.javaPrimitiveType -> 'S' + Void.TYPE -> 'V' + else -> 'L' + } + + /** + * The receiver Method#invoke would call with: a static executable ignores it, a missing one is + * a NullPointerException and one of a foreign class an IllegalArgumentException. + */ + fun checkReceiver(executable: Executable, isStatic: Boolean, thisObject: Any?): Any? { + if (isStatic) return null + if (thisObject == null) throw NullPointerException("null receiver") + if (!executable.declaringClass.isInstance(thisObject)) { + throw IllegalArgumentException( + "Expected receiver of type ${executable.declaringClass.typeName}, " + + "but got ${thisObject.javaClass.typeName}" + ) + } + return thisObject + } + + /** + * Whether any class could put another body in front of [method] for some receiver. + * + * A static or private method has no vtable slot to replace - the runtime dispatches both + * directly - and a final method, or any method of a final class, has one that nothing is + * allowed to replace. None of that depends on the receiver, so an invoker settles it once + * instead of walking a hierarchy per call. + */ + fun canBeOverridden(method: Method): Boolean { + val modifiers = method.modifiers + if (Modifier.isStatic(modifiers) || Modifier.isPrivate(modifiers)) return false + if (Modifier.isFinal(modifiers)) return false + // An interface is never final, and a method declared by one is overridden by definition. + return !Modifier.isFinal(method.declaringClass.modifiers) + } + + /** + * The declaration a virtual call on [receiverClass] enters, when that is not [method] itself. + * + * The runtime answers this out of the vtable, which Java cannot read, so the answer is rebuilt + * the way the vtable is: starting at the declaring class and coming down towards the receiver, + * each class's own declaration replacing the one it overrides. Coming down rather than up from + * the receiver is what makes overriding transitive, which it is - a package-private method may + * be overridden inside its own package by a declaration that widens it to public, and a + * subclass in another package then overrides that one, and so this one too, though it could + * never have overridden it directly. + * + * [parameterTypes] is the caller's copy of what `method.getParameterTypes()` returns, which is + * cloned on every call and does not change for the life of an invoker. + * + * Asking the same question again about the answer settles it: a second walk for the same + * receiver starts at the class the first one stopped in and covers exactly the classes it had + * already rejected, so it finds nothing. Dispatch resolves once and reaches a fixed point. + * + * @return the override, or null when [method] is what the call reaches. A receiver that is not + * an instance of the declaring class answers null too: its own hierarchy says nothing about a + * method it does not have, and the refusal is [checkReceiver]'s to report. + */ + fun virtualTargetOf( + method: Method, + parameterTypes: Array>, + receiverClass: Class<*>, + ): Method? { + val declaringClass = method.declaringClass + if (!declaringClass.isAssignableFrom(receiverClass)) return null + + // The receiver's superclasses down to the declaring class, most derived first. An interface + // is not on that chain, so a method declared by one ends the walk at Object instead, which + // finds the implementing class's declaration - what Method#invoke reaches for the shape that + // matters. It does not find a more specific default: where a sub-interface overrides a + // default and no class declares it, the walk answers nothing and the call reaches the + // declared interface's default. Resolving that needs a walk of the interface graph as well, + // and no caller has wanted one yet. + val classes = ArrayList>(4) + var clazz: Class<*>? = receiverClass + while (clazz != null && clazz !== declaringClass) { + classes.add(clazz) + clazz = clazz.superclass + } + + var current = method + for (i in classes.size - 1 downTo 0) { + current = declaredOverrideIn(classes[i], current, parameterTypes) ?: current + } + return if (current === method) null else current + } + + /** The declaration in [clazz] that takes [inherited]'s slot, or null when it has none. */ + private fun declaredOverrideIn( + clazz: Class<*>, + inherited: Method, + parameterTypes: Array>, + ): Method? { + for (candidate in clazz.declaredMethods) { + // The name is tested first because it is the only test that resolves nothing: reading a + // return or parameter type of a method this class merely happens to declare would load + // classes on a path that has no business loading any. + if (candidate.name != inherited.name) continue + // The return type is part of what the runtime matches on, and has to be here too. A + // covariant override compiles to two methods - the narrow one, plus a synthetic bridge + // carrying the inherited signature - and it is the bridge that takes the slot. Entering + // the narrow one instead would skip whatever the bridge does and any hook on it. + if (candidate.returnType !== inherited.returnType) continue + if (candidate.parameterCount != parameterTypes.size) continue + if (!candidate.parameterTypes.contentEquals(parameterTypes)) continue + + // No class can declare two methods agreeing on all three, so this one decides whether + // the class contributes an override or nothing at all - and a private or static + // declaration contributes nothing, because it is dispatched directly and leaves the + // inherited slot exactly where it was. + val modifiers = candidate.modifiers + if (Modifier.isStatic(modifiers) || Modifier.isPrivate(modifiers)) return null + return if (overrides(inherited, clazz)) candidate else null + } + return null + } + + /** + * Whether a declaration in [subclass] may replace [inherited], which for anything but a + * package-private method it always may. + * + * A package-private method is overridden only from inside its own runtime package, and a + * runtime package is the package name together with the defining class loader: two loaders each + * defining a `com.example.Foo` define two packages, and a method in one overrides nothing in + * the other. Reading this rule as the name alone would silently redirect every package-private + * call whose receiver was loaded somewhere else - which, in a process hosting an app, a module + * and the framework at once, is not a rare shape. + */ + private fun overrides(inherited: Method, subclass: Class<*>): Boolean { + val modifiers = inherited.modifiers + if (Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers)) return true + val owner = inherited.declaringClass + return owner.classLoader === subclass.classLoader && + packageNameOf(owner) == packageNameOf(subclass) + } + + /** + * The package a class belongs to, taken from its name because that is where the runtime takes + * it from - and because Class#getPackageName arrived in API 28, above what this supports. + */ + private fun packageNameOf(clazz: Class<*>): String { + val name = clazz.name + val lastDot = name.lastIndexOf('.') + return if (lastDot < 0) "" else name.substring(0, lastDot) + } + + /** + * The argument list Method#invoke would build: one identity or widening conversion per + * argument, and IllegalArgumentException for every other pair. + * + * Converting here rather than at the dispatch is what lets a hooker see through Chain#getArgs + * the values the executable will actually receive, which is what a hooked call arriving from + * real bytecode always carries. + */ + fun coerceArguments( + executable: Executable, + parameterTypes: Array>, + args: Array, + ): Array { + if (args.size != parameterTypes.size) { + throw IllegalArgumentException( + "Wrong number of arguments; expected ${parameterTypes.size}, got ${args.size}" + ) + } + return Array(args.size) { i -> coerce(executable, parameterTypes[i], args[i], i) } + } + + private fun coerce(executable: Executable, type: Class<*>, value: Any?, index: Int): Any? { + if (!type.isPrimitive) { + // Class#isInstance is the whole rule: it answers for interfaces, for arrays and their + // covariance, and false for null, which is why null is short-circuited first. + if (value != null && !type.isInstance(value)) { + throw mismatch(executable, index, type, value) + } + return value + } + // A null where a primitive is declared is refused with the same message: ART routes it + // through the same test as a mistyped reference and prints "null" for what it got. + if (value == null) throw mismatch(executable, index, type, null) + // Identity plus the widening primitive conversions of JLS 5.1.2, and nothing else. The + // tests below are exact-wrapper tests, which is what reflection does: a Number that is not + // one of the eight wrappers converts to nothing, and neither does a Character to a short. + val widened: Any? = + when (type) { + Boolean::class.javaPrimitiveType -> value as? Boolean + Char::class.javaPrimitiveType -> value as? Char + Byte::class.javaPrimitiveType -> value as? Byte + Short::class.javaPrimitiveType -> + when (value) { + is Byte -> value.toShort() + is Short -> value + else -> null + } + Int::class.javaPrimitiveType -> + when (value) { + is Byte -> value.toInt() + is Short -> value.toInt() + is Char -> value.code + is Int -> value + else -> null + } + Long::class.javaPrimitiveType -> + when (value) { + is Byte -> value.toLong() + is Short -> value.toLong() + is Char -> value.code.toLong() + is Int -> value.toLong() + is Long -> value + else -> null + } + Float::class.javaPrimitiveType -> + when (value) { + is Byte -> value.toFloat() + is Short -> value.toFloat() + is Char -> value.code.toFloat() + is Int -> value.toFloat() + is Long -> value.toFloat() + is Float -> value + else -> null + } + Double::class.javaPrimitiveType -> + when (value) { + is Byte -> value.toDouble() + is Short -> value.toDouble() + is Char -> value.code.toDouble() + is Int -> value.toDouble() + is Long -> value.toDouble() + is Float -> value.toDouble() + is Double -> value + else -> null + } + else -> null + } + return widened ?: throw mismatch(executable, index, type, value) + } + + private fun mismatch( + executable: Executable, + index: Int, + type: Class<*>, + value: Any?, + ): IllegalArgumentException = + IllegalArgumentException( + "method ${prettyMethod(executable)} argument ${index + 1} has type " + + "${type.typeName}, got ${value?.javaClass?.typeName ?: "null"}" + ) + + /** + * ART's PrettyMethod without the signature: the declaring class and the name the runtime knows + * the member by, which for a constructor is `` and not the class name Constructor#getName + * reports. Arguments are numbered from one for the same reason ART numbers them from one. + */ + private fun prettyMethod(executable: Executable): String { + val name = if (executable is Constructor<*>) "" else executable.name + return "${executable.declaringClass.typeName}.$name" + } + + /** + * The whole of the legacy bridge's invocation. `XposedBridge.invokeOriginalMethod` is + * documented as Method#invoke without the access check and is handed an Executable of either + * kind, so it needs what an invoker needs; it holds no invoker, so nothing here is cached. + * + * A constructor is dispatched non-virtually because it is a direct method either way. A method + * asks for a virtual dispatch, and gets one while it carries no hook; once it does, it is + * reached through lsplant's backup, which is private and so dispatched directly, and an + * overriding receiver reaches this executable's body rather than its override. The modern + * invoker resolves the override for itself rather than rely on the dispatch - see + * [VectorMethodInvoker.invokeWith] - and this bridge deliberately does not: `invokeOriginalMethod` + * is what a legacy hooker calls to run the method it hooked, so the executable it named is the + * one it means, and resolving an override here would send a hooker's own super call back into + * its hook. + * + * IllegalAccessException is not among the outcomes: neither branch of the dispatch runs an + * access check, which is the whole point of the legacy bridge's "access permissions are not + * checked". + */ + @JvmStatic + @Throws(IllegalArgumentException::class, InvocationTargetException::class) + fun invokeOriginal(executable: Executable, thisObject: Any?, args: Array): Any? { + val parameterTypes = executable.parameterTypes + val isStatic = Modifier.isStatic(executable.modifiers) + return HookBridge.invokeOriginal( + executable, + shortyOf(executable, parameterTypes), + parameterTypes, + executable.declaringClass, + isStatic, + executable is Constructor<*>, + checkReceiver(executable, isStatic, thisObject), + coerceArguments(executable, parameterTypes, args), + ) + } +} diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt index be64c7e4a..7b93af393 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt @@ -149,23 +149,19 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { /** * What the legacy API is called *here*, which is not what it is called in source: the - * daemon rewrites `de.robv.android.xposed`, `AndroidAppHelper` and the `XResources` family - * in the framework dex and in every module dex when dex obfuscation is on, so the names a - * module asks this loader for are a different random string on every boot. Matching the - * literal package would leave the 102 rule unenforced on exactly the builds that have - * obfuscation turned on. + * daemon rewrites `de.robv.android.xposed` in the framework dex and in every module dex + * when dex obfuscation is on, so the name a module asks this loader for is a different + * random string on every boot. Matching the literal package would leave the 102 rule + * unenforced on exactly the builds that have obfuscation turned on. + * + * Only that package is refused. `AndroidAppHelper` and the `XResources` family are rewritten + * by the same table and were once refused with it, but the interface names one package and + * gives modules targeting 102 no resource API to move to. */ private val LEGACY_API_PREFIXES: Array by lazy { runCatching { HookBridge.legacyApiPrefixes() } .onFailure { Log.w(TAG, "Cannot resolve the legacy API prefixes", it) } - .getOrElse { - arrayOf( - "de.robv.android.xposed.", - "android.app.AndroidApp", - "android.content.res.XRes", - "android.content.res.XModule", - ) - } + .getOrElse { arrayOf("de.robv.android.xposed.") } } private val SYSTEM_NATIVE_LIBRARY_DIRS = splitPaths(System.getProperty("java.library.path")) diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index 1bcbdc1a4..09ccb5d64 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -45,6 +45,14 @@ object HookBridge { @JvmStatic external fun deoptimizeMethod(method: Executable): Boolean + /** + * Allocates an instance of [clazz] without running any constructor. + * + * Throws InstantiationException for a class that has no instances to allocate - an interface, + * an array class, a primitive type or an abstract class - which is what + * `Constructor#newInstance` reports and what the runtime would otherwise only catch in a debug + * build. + */ @JvmStatic @Throws(InstantiationException::class) external fun allocateObject(clazz: Class): T @@ -57,18 +65,34 @@ object HookBridge { ) external fun invokeOriginalMethod(method: Executable, thisObject: Any?, vararg args: Any?): Any? + /** + * Runs [executable]'s own body, skipping the trampoline of every hook installed on it. + * + * The one dispatch primitive behind the whole invoker family, and behind the legacy bridge. + * JNI performs no access control, which is what lets an invocation through an invoker bypass + * access checks - and no receiver or argument check either, so this refuses against + * [parameterTypes] everything reflection would refuse. Callers refuse it earlier still, so that + * their own refusal is not reported as something the call produced. + * + * [shorty] carries the return type first, then one character per parameter. [declaringClass] is + * the class to dispatch against, which is the superclass for a `newInstanceSpecial`. + * [isStatic] and [nonVirtual] pick the JNI call form; a null [args] means no arguments. + * + * Only what the call itself throws is reported wrapped in [InvocationTargetException]; a + * refusal of the receiver or of an argument propagates raw, as `Method#invoke` reports it. + * IllegalAccessException is not among them, because neither branch runs an access check. + */ @JvmStatic - @Throws( - IllegalAccessException::class, - IllegalArgumentException::class, - InvocationTargetException::class, - ) - external fun invokeSpecialMethod( - method: Executable, + @Throws(IllegalArgumentException::class, InvocationTargetException::class) + external fun invokeOriginal( + executable: Executable, shorty: CharArray, - clazz: Class, + parameterTypes: Array>, + declaringClass: Class<*>, + isStatic: Boolean, + nonVirtual: Boolean, thisObject: Any?, - vararg args: Any?, + args: Array?, ): Any? @JvmStatic @FastNative external fun instanceOf(obj: Any?, clazz: Class<*>): Boolean diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/ResourcesHook.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/ResourcesHook.kt index a3f8ed057..4876580fb 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/ResourcesHook.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/ResourcesHook.kt @@ -1,7 +1,6 @@ package org.matrix.vector.nativebridge import android.content.res.Resources -import dalvik.annotation.optimization.FastNative object ResourcesHook { @JvmStatic external fun initXResourcesNative(): Boolean @@ -15,7 +14,9 @@ object ResourcesHook { typedArraySuperClass: String, ): ClassLoader + // Not @FastNative: the implementation walks a whole binary XML document and calls back into + // Java once per attribute, and a fast transition leaves the thread runnable for all of it, so + // anything waiting to suspend threads waits for the document. @JvmStatic - @FastNative external fun rewriteXmlReferencesNative(parserPtr: Long, origRes: Any, repRes: Resources) }