Conform the invoker and the legacy-API guard to the interface - #907
Merged
Conversation
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.
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.
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 Call<Kind>MethodA, 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
XposedInterface promises, on
Invokerand again on bothgetInvokeroverloads, that invocations through an invoker bypass access checks. Ours only did so once the executable carried a hook: for anything unhooked,invokeOriginalMethodfell back toMethod.invokeon the module's own reflected object, where ART runs the check it always runs and refuses every non-public member. The hooked case escaped only by accident, because LSPlant callssetAccessibleon the backup it hands out.invokeSpecialandnewInstanceSpecialnever readInvoker.Typeat all, dispatching the live hooked ArtMethod whose entry point is LSPlant's trampoline, so the full chain ran whatever type the module had set,Type.Originincluded. The argument handling beside it bore little relation toMethod#invoke, which is what the invoker is documented against: anyNumberwas taken for any numeric parameter and narrowed silently, reference arguments were never compared against the declared type, the receiver was never checked against the declaring class, and a static executable reachingCallNonvirtualor aCharacterunboxed through aNumbermethod id took the process down rather than throwing. Both paths are now one primitive that dispatches through JNI, which performs no access control, after applying the receiver and argument rules reflection applies — the widening conversions of JLS 5.1.2 and nothing else, checked before the chain is entered so that a refusal of ours arrives unwrapped rather than inside anInvocationTargetException. The chain boundary also stopped collapsing a level of wrapping, so a target throwing anInvocationTargetExceptionof its own now surfaces asITE(ITE(x)).One more followed from the same backup. An unhooked executable already dispatched through the receiver's vtable, but a hooked one is reached through a backup that
ArtMethod::BackupTomakes private, so ART dispatches it directly and hooking a method quietly turnedinvokeinto a non-virtual call: aMethodtaken from a superclass ran the superclass body whereMethod#invokeruns the override. The override is now resolved before dispatch, walking classes top-down so overriding stays transitive, comparing return types as well as parameters so a covariant override's bridge is not skipped, and confining package-private overriding to the same runtime package.Type.Origindeliberately does not resolve — skipping all hooks cannot mean entering one, and resolving there would send a hooker's ownsupercall back into its hook.The last is a question of scope. The behaviour change for modules targeting 102 names one package, and
legacyApiPrefixesrefused four, addingAndroidAppHelperand theXResourcesfamily; since API 102 carries no resource API of its own, that left such a module unable to touch resources at all rather than pointed at a modern route. Narrowing the guard made the path reachable and exposed three defects behind it:android_types.hbound the twoResStringPool::stringAtoverloads to each other's signatures, so on any device exporting the modern one the call went out through an ABI-incompatible signature;resources_hook.cppstill readResXMLTreeat Android 9 field offsets; andXResources.isXmlCachedcompared an asset cookie against a resource id, so a sharedXmlBlockwas rewritten again on every inflation.The conformance module this was checked against is on api102-harness.
external/lsplantis rebased onto upstream in the same branch.