From fb63816ec484e3a95e1d6157312d0108004156b9 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Tue, 11 Aug 2026 02:50:51 +0200 Subject: [PATCH 1/4] Invoke through the invoker the way reflection does A third-party conformance suite reported sixteen failures against the libxposed API 102 surface, ten of them in the invoker. They share one cause: there were two unrelated dispatch paths, Method.invoke on one side and a hand-written CallNonvirtual on the other, and each was wrong where the other was right. Method.invoke ran an access check, because the reflected object it was handed for an unhooked executable is the module's own and carries no accessible flag - so ART named the framework as the calling class and refused every non-public member, which is the opposite of "invocations through invokers will bypass access checks". The JNI side checked nothing at all: a receiver of a foreign class, a reference argument of the wrong type and a static method were each handed to CallNonvirtual, where they are an abort rather than an exception, and every numeric argument was unboxed through a java.lang.Number method id chosen by the parameter - so a Long silently truncated into an int, and a Character, which is not a Number, was read through Number's vtable slot. Both are replaced by one native primitive that dispatches with JNI, which performs no access control, after doing what reflection does first: the receiver and the arguments are checked, and only the widening conversions of JLS 5.1.2 are performed. The checks run before the hook chain is entered, because a refusal of ours is not something the call produced and must not arrive wrapped. invokeSpecial and newInstanceSpecial now honour the invoker's type. They used to call the live hooked ArtMethod, whose entry point is the trampoline, so every one of them replayed the whole hook chain whatever type was asked for. An InvocationTargetException coming out of the chain is now wrapped rather than passed through, so a target that throws one is reported as the interface says Method.invoke reports it, and allocateObject refuses a class that cannot be instantiated instead of letting AllocObject abort. The resource path is fixed alongside it: the two ResStringPool::stringAt overloads were bound to each other's signatures, the attribute-name half of the binary XML rewrite had been reading Android 8 field offsets since Android 10, and the cache test that decides whether a replacement document still needs rewriting compared an asset cookie against a resource id, so a shared XmlBlock was rewritten again on every inflation. --- .../java/android/content/res/XResources.java | 63 +- .../de/robv/android/xposed/XposedBridge.java | 5 +- native/include/framework/android_types.h | 57 +- native/src/jni/hook_bridge.cpp | 586 ++++++++++++------ native/src/jni/resources_hook.cpp | 220 ++++++- .../vector/impl/hooks/InvokerEntry.java | 64 ++ .../matrix/vector/impl/hooks/BaseInvoker.kt | 211 +++---- .../vector/impl/hooks/VectorInvocation.kt | 212 +++++++ .../matrix/vector/nativebridge/HookBridge.kt | 42 +- .../vector/nativebridge/ResourcesHook.kt | 5 +- 10 files changed, 1054 insertions(+), 411 deletions(-) create mode 100644 xposed/src/main/java/org/matrix/vector/impl/hooks/InvokerEntry.java create mode 100644 xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorInvocation.kt 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..c3f27bec0 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; } @@ -820,9 +1026,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..e4c649bfc 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,78 @@ 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 /** * 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. + private val parameterTypes: Array> = executable.parameterTypes + private val shorty: CharArray = VectorInvocation.shortyOf(executable, parameterTypes) + private 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 the current [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. + */ + protected fun proceedInvocation( + thisObject: Any?, + args: Array, + nonVirtual: Boolean, + 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,113 +87,51 @@ 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) - } - - /** - * 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. - */ - private fun invokeOriginal(thisObject: Any?, args: Array): Any? = - try { - dispatchOriginal(thisObject, args) - } catch (e: InvocationTargetException) { - throw e.cause ?: e - } - - /** 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 - } - - 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' - } } /** Invoker implementation specifically for [Method] types. */ internal class VectorMethodInvoker(method: Method) : BaseInvoker(method) { - override fun invoke(thisObject: Any?, vararg args: Any?): Any? { - return proceedInvocation(thisObject, args) - } + override fun invokeWith(thisObject: Any?, args: Array): Any? = + proceedInvocation(thisObject, args, nonVirtual = false) - override fun invokeSpecial(thisObject: Any, vararg args: Any?): Any? { - return HookBridge.invokeSpecialMethod( - executable, - getExecutableShorty(), - executable.declaringClass, - thisObject, - *args, - ) - } + override fun invokeSpecialWith(thisObject: Any?, args: Array): Any? = + proceedInvocation(thisObject, args, nonVirtual = true) } /** @@ -161,49 +139,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..27a876ccc --- /dev/null +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorInvocation.kt @@ -0,0 +1,212 @@ +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. + */ +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 + } + + /** + * 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, and a + * method virtually because that is what the Method#invoke it is documented against does. + * + * 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/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) } From 921d77df9ca0fd03d722521aca4a70ca5f66c59f Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Tue, 11 Aug 2026 08:40:02 +0200 Subject: [PATCH 2/4] Refuse a 102 module only the package the interface names The API 102 behaviour change is one sentence, and it names one package: "Libxposed modules can not call legacy de.robv.android.xposed APIs". We also refused android.app.AndroidAppHelper and the android.content.res.XResources family, which is the wider reading of the same sentence. Two things settle it against the wider reading. The interface names that package and nothing else, and API 102 carries no resource API of its own - so a module targeting it had no way to touch resources at all, rather than a modern route to prefer. A conformance module could not so much as resolve XResources, and every defect behind it was unreachable rather than fixed. Both enforcement points move together: the prefix list the class loader is handed, and the fallback it uses when the native side cannot answer. The obfuscation table still rewrites all four prefixes, because that is about hiding the framework rather than about what a module may call. --- native/src/jni/hook_bridge.cpp | 11 +++++----- .../impl/utils/VectorModuleClassLoader.kt | 22 ++++++++----------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index c3f27bec0..3ddf7e048 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -865,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)); 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")) From 14f97a157db072d1b419c3ec23d43a3c89eae19d Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Tue, 11 Aug 2026 10:29:14 +0200 Subject: [PATCH 3/4] Reach the override when the invoker is asked to invoke Invoker#invoke is documented against Method#invoke, which dispatches virtually, and the interface offers invokeSpecial separately for "bypassing any overridden methods in subclasses". The contrast only means something if invoke does not bypass them - and ours did, once the executable carried a hook. The dispatch was never the problem. An unhooked executable already went out through CallMethodA, which consults the receiver's vtable. A hooked one has to be reached through lsplant's backup, and lsplant makes a non-static backup private, so ART dispatches it directly. Hooking a method quietly turned invoke into a non-virtual call: a Method taken from a superclass ran the superclass body while reflection on the same Method ran the override. So the override is resolved before anything is dispatched, and its own invoker is asked to run it - the override is a different executable carrying its own chain, and Method#invoke would run it hooks and all. Resolution follows the language's rules rather than a shortcut: classes are walked top-down so overriding stays transitive, the return type is compared as well as the parameters so a covariant override's bridge is not skipped, a static or private declaration overrides nothing, and a package-private one is only overridden inside the same runtime package - same package name and same loader. Type.Origin is answered without resolving, which is the one place the type decides the dispatch. "Skipping all hooks" cannot mean entering one, and the alternative breaks the idiom the type exists for: a hooker on the superclass method asking for the original with an overriding receiver would reach the override, whose body calls super and arrives back at its own hook. The legacy bridge does not resolve either, and for the same reason. Found by the conformance harness on `api102-harness`, which is also where the assertion that found it lives. --- .../matrix/vector/impl/hooks/BaseInvoker.kt | 87 ++++++++++- .../vector/impl/hooks/VectorInvocation.kt | 143 +++++++++++++++++- 2 files changed, 224 insertions(+), 6 deletions(-) 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 e4c649bfc..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 @@ -9,6 +9,7 @@ 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 @@ -26,9 +27,9 @@ internal abstract class BaseInvoker, U : Executable>( // 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. - private val parameterTypes: Array> = executable.parameterTypes + protected val parameterTypes: Array> = executable.parameterTypes private val shorty: CharArray = VectorInvocation.shortyOf(executable, parameterTypes) - private val declaringClass: Class<*> = executable.declaringClass + protected val declaringClass: Class<*> = executable.declaringClass private val isStatic: Boolean = Modifier.isStatic(executable.modifiers) @Suppress("UNCHECKED_CAST") @@ -38,17 +39,22 @@ internal abstract class BaseInvoker, U : Executable>( } /** - * Resolves the current [type] and runs the executable, non-virtually when [nonVirtual]. + * 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) @@ -127,11 +133,84 @@ internal abstract class BaseInvoker, U : Executable>( internal class VectorMethodInvoker(method: Method) : BaseInvoker(method) { + /** + * One resolution, kept whole so that no reader can pair one call's class with another's target. + */ + private class Resolution(val receiverClass: Class<*>, val target: VectorMethodInvoker) + + // 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) + + // 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 + + /** + * `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? = - proceedInvocation(thisObject, args, nonVirtual = false) + virtualTarget(thisObject).runChain(thisObject, args, type) 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 + } + + val target = override?.let(::VectorMethodInvoker) ?: this + resolved = Resolution(receiverClass, target) + return target + } } /** 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 index 27a876ccc..d308e0403 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorInvocation.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorInvocation.kt @@ -24,6 +24,12 @@ import org.matrix.vector.nativebridge.HookBridge * 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 { @@ -70,6 +76,132 @@ object VectorInvocation { 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. @@ -186,8 +318,15 @@ object VectorInvocation { * 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, and a - * method virtually because that is what the Method#invoke it is documented against does. + * 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 From c7bc6d82617d78f56b4e1909837db24714cacd6b Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Tue, 11 Aug 2026 10:48:01 +0200 Subject: [PATCH 4/4] Rebase LSPlant onto upstream Upstream has thirty-six commits we did not have, including an x86 naked bridge for FixupStaticTrampolines, memfd-backed executable memory, and a fix for the JIT crash that hooking an intrinsic in the bootclasspath caused from Android 15 on. Three of our four patches survive the rebase. The fourth avoided a "ClassLoader referenced unknown path" warning by passing /proc/self/task as the dex path, and upstream now passes "." for the same reason, so it is dropped rather than carried into a conflict on every future rebase. The FixupStaticTrampolines patch is re-applied onto the shape upstream gave that function. It still reports when neither entry point could be hooked, which is worth keeping: Android 17 exports neither symbol. --- external/lsplant | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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