Skip to content

Conform the invoker and the legacy-API guard to the interface - #907

Merged
JingMatrix merged 4 commits into
masterfrom
libxposed-conformance
Aug 11, 2026
Merged

Conform the invoker and the legacy-API guard to the interface#907
JingMatrix merged 4 commits into
masterfrom
libxposed-conformance

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Aug 11, 2026

Copy link
Copy Markdown
Owner

XposedInterface promises, on Invoker and again on both getInvoker overloads, that invocations through an invoker bypass access checks. Ours only did so once the executable carried a hook: for anything unhooked, invokeOriginalMethod fell back to Method.invoke on 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 calls setAccessible on the backup it hands out.

invokeSpecial and newInstanceSpecial never read Invoker.Type at 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.Origin included. The argument handling beside it bore little relation to Method#invoke, which is what the invoker is documented against: any Number was 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 reaching CallNonvirtual or a Character unboxed through a Number method 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 an InvocationTargetException. The chain boundary also stopped collapsing a level of wrapping, so a target throwing an InvocationTargetException of its own now surfaces as ITE(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::BackupTo makes private, so ART dispatches it directly and hooking a method quietly turned invoke into a non-virtual call: a Method taken from a superclass ran the superclass body where Method#invoke runs 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.Origin deliberately does not resolve — skipping all hooks cannot mean entering one, and resolving there would send a hooker's own super call back into its hook.

The last is a question of scope. The behaviour change for modules targeting 102 names one package, and legacyApiPrefixes refused four, adding AndroidAppHelper and the XResources family; 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.h bound the two ResStringPool::stringAt overloads to each other's signatures, so on any device exporting the modern one the call went out through an ABI-incompatible signature; resources_hook.cpp still read ResXMLTree at Android 9 field offsets; and XResources.isXmlCached compared an asset cookie against a resource id, so a shared XmlBlock was rewritten again on every inflation.

The conformance module this was checked against is on api102-harness. external/lsplant is rebased onto upstream in the same branch.

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.
@JingMatrix
JingMatrix merged commit 97126d8 into master Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant