Skip to content

Commit 8336afa

Browse files
committed
Guard the hook dispatch against re-entering itself
Hooking Object.getClass() made the dispatch call itself. R8 compiles Kotlin's parameter null checks into obj.getClass(), and one of those is the first instruction of the trampoline callback, so the first getClass the process ran after the hook landed re-entered the trampoline, and re-entered again from its own prologue, until the stack was gone and before any hooker had run. Nothing recovers from there: lsplant marks a hooked method non-compilable, so the call site can never be inlined away afterwards, and the framework dex is loaded from memory and never gets an oat file, so the interpreter reaches the trampoline every time. The lowering is new. Built from the same source, 3046 has 16 Object.getClass() call sites in the framework dex and 3047 has 207; the difference between them is #792, which moved the build from AGP 8.13.1 to 9.3.1. Reported as #798, where a module that hooks every method named getClass over Class.getMethods() - a list that always contains the one inherited from Object - crash-looped com.miui.home through six process starts. The build it was compared against turned out to be a branch artefact from before the migration; the version code counts commits on master rather than on the branch being built, which is why it read as 3047. The trampoline entry point is native now, so the re-entrancy check runs ahead of anything a compiler can put in front of it. A Kotlin body cannot promise that, which is the whole of the bug. While the guard is raised, a hooked method entered from the framework's own frames runs its original instead of dispatching again. Where the guard comes down took several attempts on a device to get right, and the shape that finally works comes from writing VectorChain in Java. The chain is the surface the API hands to modules, so every method on it is entered from module code with the guard down, and the guard cannot cover a callee's prologue - in Kotlin, R8 opens each with a parameter null check compiled into Object.getClass(), a hookable call ahead of any statement of ours. A hooker that rebuilds its arguments then re-enters twice per frame and the nesting cap bounds a tree rather than a chain. The parameter types come from a Java @nonnull interface and cannot be made nullable, so the only way not to emit the checks is not to write this in Kotlin; javac emits none. Being Java, the chain's own bookkeeping calls nothing a module can hook - a constructor, an array read, two field writes. So it needs no guard of its own: one lower per node covers both calls that leave the framework, the hooker and the terminal, and nothing raises. The guard comes down for those two, and for the original run through an Invoker; it is raised only by the native trampoline and, for its own bookkeeping, by the legacy bridge. Because a site that gets this wrong is invisible until a device hangs, the two sides live in DispatchGuard.kt as callIntoModule and enterFramework, the raw primitives are named nowhere else, and checkDispatchGuard fails the build if they are. That check is not sufficient on its own: the Java-facing wrappers in that same file were at one point recursing into themselves through a SAM conversion, which reads correctly in source and only shows in the bytecode. A hooker that calls the method it hooks recurses in module code, where the guard does not reach, and hooking Object.getClass has the compiler write such calls on the module's behalf. Past a nesting of thirty-two the thread latches into serving originals until it unwinds, and names the method once. Latching rather than re-arming per frame matters for the same reason as above: a hooker that re-enters more than once per frame would otherwise branch at every level. Three things follow from the entry point being native. A registration that fails now refuses the hook, because the alternative is UnsatisfiedLinkError thrown out of whatever the application was calling, which is far harder to trace back. The two invoke bridges take the argument array rather than a vararg, since the spread copied it on every dispatch and the JVM descriptor is the same either way. And the trampoline's package joins the ones the daemon renames as it loads the dex: keeping a native method keeps its class name, so R8 stopped renaming it, and it would otherwise stand as a fixed string in every injected process. tests/dispatch-guard asserts five properties of dispatch, of which the fourth is the one that is easy to lose and hard to see: the dispatch must not dispatch its own internal calls. A dispatch that re-enters itself still returns the right answers, at a multiple of the cost, until a real workload turns that into an ANR - so it is asserted by the cap staying silent rather than by any result. Its checks are chosen for the shapes that reach different parts of the framework rather than for what any one module does, since three defects here survived a reading of the bytecode and were only caught by running something shaped differently. Verified on a Pixel 7a running Android 16, against builds of this branch and of master without it. Without: the target hangs on the first dispatch after the hook lands and is killed by an ANR whose trace holds no main thread, which is the signature reported in #798. With, and with that hook live throughout: 33 results, one process id, no ANR, and the cap firing once, for the hooker written to recurse into itself. That covers ordinary, static and constructor hooks, a class initializer, both Invoker types, a hooker that rebuilds its arguments, a legacy de.robv hook, a hooker seeing another module's hook, and an Invoker whose original calls a method hooked elsewhere. An empty pass-through hook measured about ten percent slower per dispatch than master, from the added native round trip. Fixes #798.
1 parent 3d8090f commit 8336afa

31 files changed

Lines changed: 1628 additions & 192 deletions

File tree

daemon/src/main/jni/obfuscation.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@ namespace {
2222

2323
std::once_flag init_flag;
2424

25+
// The trampoline's callback is a native method, so R8 keeps its class under its real name the way
26+
// it already keeps the nativebridge classes - a stable string in every injected process for anyone
27+
// looking for one. Renaming the package here restores what R8 used to do for it. Nothing resolves
28+
// this package by name: hook_bridge.cpp receives the hooker class as a jclass and reads its members
29+
// off that, and every other reference is a type descriptor in the same dex, rewritten with it.
2530
std::map<std::string, std::string> signatures = {
2631
{"Lde/robv/android/xposed/", ""}, {"Landroid/app/AndroidApp", ""},
2732
{"Landroid/content/res/XRes", ""}, {"Landroid/content/res/XModule", ""},
2833
{"Lio/github/libxposed/api/Xposed", ""}, {"Lorg/matrix/vector/core/", ""},
29-
{"Lorg/matrix/vector/nativebridge/", ""}, {"Lorg/matrix/vector/service/", ""},
34+
{"Lorg/matrix/vector/impl/hooks/", ""}, {"Lorg/matrix/vector/nativebridge/", ""},
35+
{"Lorg/matrix/vector/service/", ""},
3036
};
3137

3238
jclass class_file_descriptor = nullptr;

legacy/src/main/java/org/matrix/vector/legacy/LegacyDelegateImpl.java

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.matrix.vector.impl.di.LegacyPackageInfo;
99
import org.matrix.vector.impl.di.OriginalInvoker;
1010
import org.matrix.vector.impl.hooks.VectorLegacyCallback;
11+
import org.matrix.vector.impl.hooks.DispatchGuard;
1112
import org.matrix.vector.impl.utils.VectorMetaDataReader;
1213

1314
import java.io.File;
@@ -64,26 +65,40 @@ public void onSystemServerLoaded(ClassLoader classLoader) {
6465

6566
@Override
6667
public Object processLegacyHook(Executable executable, Object thisObject, Object[] args, Object[] legacyHooks, OriginalInvoker invokeOriginal) {
67-
VectorLegacyCallback<Executable> callback = new VectorLegacyCallback<>(executable, thisObject, args);
68-
XposedBridge.LegacyApiSupport<Executable> legacy = new XposedBridge.LegacyApiSupport<>(callback, legacyHooks);
69-
70-
legacy.handleBefore();
68+
// The bookkeeping here is framework code reached from a hooker, so the guard has to go back
69+
// up; it comes down only for the three calls that leave again — the two module callbacks and
70+
// the original. Left unguarded, a module hooking anything this path touches makes it call
71+
// itself. See #798.
72+
return DispatchGuard.intoFramework(() -> {
73+
VectorLegacyCallback<Executable> callback = new VectorLegacyCallback<>(executable, thisObject, args);
74+
XposedBridge.LegacyApiSupport<Executable> legacy = new XposedBridge.LegacyApiSupport<>(callback, legacyHooks);
75+
76+
DispatchGuard.intoModule(() -> {
77+
legacy.handleBefore();
78+
return null;
79+
});
7180

72-
if (!callback.isSkipped()) {
73-
try {
74-
Object result = invokeOriginal.invoke();
75-
callback.setResult(result);
76-
} catch (Throwable t) {
77-
callback.setThrowable(t);
81+
if (!callback.isSkipped()) {
82+
DispatchGuard.intoModule(() -> {
83+
try {
84+
callback.setResult(invokeOriginal.invoke());
85+
} catch (Throwable t) {
86+
callback.setThrowable(t);
87+
}
88+
return null;
89+
});
7890
}
79-
}
8091

81-
legacy.handleAfter();
92+
DispatchGuard.intoModule(() -> {
93+
legacy.handleAfter();
94+
return null;
95+
});
8296

83-
if (callback.getThrowable() != null) {
84-
sneakyThrow(callback.getThrowable());
85-
}
86-
return callback.getResult();
97+
if (callback.getThrowable() != null) {
98+
sneakyThrow(callback.getThrowable());
99+
}
100+
return callback.getResult();
101+
});
87102
}
88103

89104
@Override

native/src/jni/hook_bridge.cpp

Lines changed: 252 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <lsplant.hpp>
88
#include <limits>
99
#include <memory>
10+
#include <mutex>
1011
#include <shared_mutex>
1112
#include <vector>
1213

@@ -89,6 +90,183 @@ SharedHashMap<jmethodID, std::unique_ptr<HookItem>> hooked_methods;
8990

9091
// Cached JNI method and field IDs for performance.
9192
jmethodID invoke = nullptr;
93+
94+
/**
95+
* Depth of the framework's own hook dispatch on this thread.
96+
*
97+
* Non-zero means a hooked method was entered from inside the dispatch itself, which must run the
98+
* original rather than start a second dispatch. The dispatch cannot promise to avoid hookable
99+
* methods, because it does not get to choose which ones it calls: AGP 9's R8 compiles Kotlin's
100+
* parameter null checks into Object.getClass(), and one of those lands as the first instruction of
101+
* the callback. A module hooking such a method would otherwise make the dispatch re-enter itself
102+
* until the stack is gone, before any hooker runs. See #798.
103+
*
104+
* The counter is read before any Java frame exists, which is why the trampoline entry point is a
105+
* native method: nothing a compiler emits can run ahead of it.
106+
*/
107+
thread_local uint32_t dispatch_depth = 0;
108+
109+
/**
110+
* How deep hook dispatch is nested on this thread, counting only dispatches that actually entered
111+
* Java. Unlike dispatch_depth this is never suspended, because it exists to bound the one thing the
112+
* guard cannot reach: a hooker that calls the method it hooks, directly or through a call the
113+
* compiler generated for it. That recursion lives entirely in module code, runs with the guard
114+
* down by design, and would otherwise take the process out with it.
115+
*
116+
* Thirty-two is deep enough that no chain of one hook calling into another will reach it, and
117+
* shallow enough that a runaway costs a small constant instead of thousands of frames. A method
118+
* whose hooks genuinely nest deeper than this - a hooked recursive method, say - stops being hooked
119+
* past the limit, which is why hitting it is reported.
120+
*/
121+
thread_local uint32_t dispatch_nesting = 0;
122+
constexpr uint32_t kMaxDispatchNesting = 32;
123+
124+
/**
125+
* Set when the cap is reached, cleared when the thread unwinds back out of dispatch entirely.
126+
*
127+
* Without it the cap bounds depth but not work. A hooker that re-enters more than once per frame -
128+
* two calls, or one call the compiler wrote twice - branches at every level, so re-arming the cap on
129+
* the way back down turns one call into a tree of that many dispatches. Latching instead means the
130+
* thread serves originals from the moment it is in trouble until it is out of it.
131+
*/
132+
thread_local bool dispatch_degraded = false;
133+
134+
// Bounded so a runaway cannot itself flood the log. Eight is enough to name several distinct
135+
// offenders in one boot.
136+
std::atomic<int> runaway_reports{0};
137+
constexpr int kMaxRunawayReports = 8;
138+
139+
// Resolved from the hooker class the first time a hook is installed. That call is the only place
140+
// the class is available: the framework dex is repackaged per build, so its name cannot be
141+
// looked up, and it is also the earliest moment anything can reach the trampoline.
142+
std::once_flag hooker_init;
143+
bool hooker_ready = false;
144+
jmethodID hooker_dispatch = nullptr;
145+
jfieldID hooker_method = nullptr;
146+
jfieldID hooker_is_static = nullptr;
147+
jclass object_class = nullptr;
148+
jmethodID to_string = nullptr;
149+
jclass invocation_target_exception = nullptr;
150+
jmethodID get_cause = nullptr;
151+
152+
/**
153+
* @brief Invokes a hooked method's original implementation, or the method itself if unhooked.
154+
*/
155+
jobject InvokeBackup(JNIEnv *env, jobject executable, jobject thiz, jobjectArray args) {
156+
auto target = env->FromReflectedMethod(executable);
157+
HookItem *hook_item = nullptr;
158+
hooked_methods.if_contains(target,
159+
[&hook_item](const auto &it) { hook_item = it.second.get(); });
160+
161+
// If a hook item exists, invoke its backup. Otherwise, invoke the method directly
162+
// (though this case should be rare if called from a hook callback).
163+
jobject method_to_invoke = hook_item ? hook_item->GetBackup() : executable;
164+
if (!method_to_invoke) {
165+
// Hooking might have failed or is not complete.
166+
return nullptr;
167+
}
168+
return env->CallObjectMethod(method_to_invoke, invoke, thiz, args);
169+
}
170+
171+
/**
172+
* @brief Serves a trampoline hit that arrived from inside the dispatch, without entering Java.
173+
*
174+
* Runs the original and hands its exception back unwrapped, the way the call site that the
175+
* compiler generated would have seen it. Nothing here may call back into the framework: this path
176+
* exists precisely because the framework is already on the stack below it.
177+
*/
178+
jobject InvokeOriginalReentrant(JNIEnv *env, jobject hooker, jobjectArray args) {
179+
jobject executable = env->GetObjectField(hooker, hooker_method);
180+
jobject receiver = nullptr;
181+
jobjectArray actual = args;
182+
183+
if (!env->GetBooleanField(hooker, hooker_is_static)) {
184+
receiver = env->GetObjectArrayElement(args, 0);
185+
const jsize count = env->GetArrayLength(args) - 1;
186+
actual = env->NewObjectArray(count, object_class, nullptr);
187+
for (jsize i = 0; i < count; ++i) {
188+
jobject arg = env->GetObjectArrayElement(args, i + 1);
189+
env->SetObjectArrayElement(actual, i, arg);
190+
env->DeleteLocalRef(arg);
191+
}
192+
}
193+
194+
jobject result = InvokeBackup(env, executable, receiver, actual);
195+
196+
// Method.invoke reports whatever the target threw wrapped; the caller here is ordinary code
197+
// that never went through reflection, so hand it the cause instead.
198+
if (jthrowable thrown = env->ExceptionOccurred(); thrown) {
199+
env->ExceptionClear();
200+
jthrowable to_throw = thrown;
201+
if (env->IsInstanceOf(thrown, invocation_target_exception)) {
202+
if (auto cause = env->CallObjectMethod(thrown, get_cause); cause) {
203+
to_throw = static_cast<jthrowable>(cause);
204+
}
205+
}
206+
env->Throw(to_throw);
207+
return nullptr;
208+
}
209+
return result;
210+
}
211+
212+
/**
213+
* @brief Names the method whose hooks stopped nesting, at most a few times per process.
214+
*
215+
* Reads the executable and asks it for its name only here, on the rare path, so the dispatch does
216+
* not pay for a diagnostic it almost never emits. The guard is raised for the duration because
217+
* Executable.toString() is ordinary Java and may itself touch whatever the module hooked.
218+
*/
219+
void ReportRunaway(JNIEnv *env, jobject hooker) {
220+
if (runaway_reports.fetch_add(1, std::memory_order_relaxed) >= kMaxRunawayReports) return;
221+
222+
++dispatch_depth;
223+
jobject executable = env->GetObjectField(hooker, hooker_method);
224+
auto name = static_cast<jstring>(env->CallObjectMethod(executable, to_string));
225+
if (env->ExceptionCheck()) {
226+
env->ExceptionClear();
227+
} else if (name) {
228+
if (const char *chars = env->GetStringUTFChars(name, nullptr); chars) {
229+
LOGE(
230+
"Hook dispatch nested past {} for {}; running the original instead. A hooker that "
231+
"calls the method it hooks recurses into itself, and the compiler emits such calls "
232+
"on its behalf - a null check becomes Object.getClass(), for one.",
233+
kMaxDispatchNesting, chars);
234+
env->ReleaseStringUTFChars(name, chars);
235+
}
236+
}
237+
if (name) env->DeleteLocalRef(name);
238+
env->DeleteLocalRef(executable);
239+
--dispatch_depth;
240+
}
241+
242+
/**
243+
* @brief The trampoline entry point, registered onto VectorNativeHooker.callback.
244+
*
245+
* Native so that the re-entrancy check is genuinely first: a Kotlin body would let the compiler
246+
* put its own prologue ahead of it, which is the whole of #798.
247+
*/
248+
jobject HookerCallback(JNIEnv *env, jobject hooker, jobjectArray args) {
249+
if (dispatch_depth != 0) {
250+
return InvokeOriginalReentrant(env, hooker, args);
251+
}
252+
253+
if (dispatch_degraded || dispatch_nesting >= kMaxDispatchNesting) {
254+
if (!dispatch_degraded) {
255+
dispatch_degraded = true;
256+
ReportRunaway(env, hooker);
257+
}
258+
return InvokeOriginalReentrant(env, hooker, args);
259+
}
260+
261+
++dispatch_nesting;
262+
++dispatch_depth;
263+
jobject result = env->CallObjectMethod(hooker, hooker_dispatch, args);
264+
// Restored on the exception path too: CallObjectMethod returns with the exception pending, and
265+
// leaving either counter raised would silently disable every hook on this thread from here on.
266+
--dispatch_depth;
267+
if (--dispatch_nesting == 0) dispatch_degraded = false;
268+
return result;
269+
}
92270
} // namespace
93271

94272
namespace vector::native::jni {
@@ -121,6 +299,45 @@ VECTOR_DEF_NATIVE_METHOD(jboolean, HookBridge, hookMethod, jboolean useModernApi
121299
} finally{.newHook = newHook};
122300
#endif
123301

302+
// The hooker class arrives only here, and this call is also the earliest moment anything can
303+
// reach its trampoline, so bind the native entry point and the members it reads now.
304+
std::call_once(hooker_init, [env, hooker] {
305+
static const JNINativeMethod entry[] = {
306+
{"callback", "([Ljava/lang/Object;)Ljava/lang/Object;",
307+
VECTOR_JNI_CAST(void *)(HookerCallback)},
308+
};
309+
if (env->RegisterNatives(hooker, entry, 1) != JNI_OK) {
310+
LOGF("Cannot register the hook trampoline entry point");
311+
return;
312+
}
313+
hooker_dispatch =
314+
env->GetMethodID(hooker, "dispatch", "([Ljava/lang/Object;)Ljava/lang/Object;");
315+
hooker_method = env->GetFieldID(hooker, "method", "Ljava/lang/reflect/Executable;");
316+
hooker_is_static = env->GetFieldID(hooker, "isStatic", "Z");
317+
318+
auto object = env->FindClass("java/lang/Object");
319+
object_class = static_cast<jclass>(env->NewGlobalRef(object));
320+
to_string = env->GetMethodID(object, "toString", "()Ljava/lang/String;");
321+
env->DeleteLocalRef(object);
322+
323+
auto ite = env->FindClass("java/lang/reflect/InvocationTargetException");
324+
invocation_target_exception = static_cast<jclass>(env->NewGlobalRef(ite));
325+
env->DeleteLocalRef(ite);
326+
327+
auto throwable = env->FindClass("java/lang/Throwable");
328+
get_cause = env->GetMethodID(throwable, "getCause", "()Ljava/lang/Throwable;");
329+
env->DeleteLocalRef(throwable);
330+
331+
hooker_ready = hooker_dispatch && hooker_method && hooker_is_static && object_class &&
332+
to_string && invocation_target_exception && get_cause;
333+
if (!hooker_ready) LOGF("Cannot resolve the hook trampoline members");
334+
});
335+
336+
// Refusing the hook is the only honest outcome: the trampoline would land on an unregistered
337+
// native method and throw UnsatisfiedLinkError out of whatever the app was calling, which is
338+
// a great deal harder to trace back than a module being told its hook did not take.
339+
if (!hooker_ready) return JNI_FALSE;
340+
124341
auto target = env->FromReflectedMethod(hookMethod);
125342
HookItem *hook_item = nullptr;
126343

@@ -215,19 +432,39 @@ VECTOR_DEF_NATIVE_METHOD(jboolean, HookBridge, deoptimizeMethod, jobject hookMet
215432
*/
216433
VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, invokeOriginalMethod, jobject hookMethod,
217434
jobject thiz, jobjectArray args) {
218-
auto target = env->FromReflectedMethod(hookMethod);
219-
HookItem *hook_item = nullptr;
220-
hooked_methods.if_contains(target,
221-
[&hook_item](const auto &it) { hook_item = it.second.get(); });
435+
return InvokeBackup(env, hookMethod, thiz, args);
436+
}
222437

223-
// If a hook item exists, invoke its backup. Otherwise, invoke the method directly
224-
// (though this case should be rare if called from a hook callback).
225-
jobject method_to_invoke = hook_item ? hook_item->GetBackup() : hookMethod;
226-
if (!method_to_invoke) {
227-
// Hooking might have failed or is not complete.
228-
return nullptr;
229-
}
230-
return env->CallObjectMethod(method_to_invoke, invoke, thiz, args);
438+
/**
439+
* @brief Lowers the dispatch guard for the duration of a call into module code.
440+
*
441+
* A hooker, and the original method the chain ends in, are entitled to a full dispatch of whatever
442+
* they call; only the framework's own bookkeeping must not re-enter. The previous depth is
443+
* returned rather than assumed to be one, so nesting restores exactly what it found.
444+
*/
445+
VECTOR_DEF_NATIVE_METHOD(jint, HookBridge, suspendDispatch) {
446+
const auto saved = dispatch_depth;
447+
dispatch_depth = 0;
448+
return static_cast<jint>(saved);
449+
}
450+
451+
/** @brief Restores the depth returned by suspendDispatch. */
452+
VECTOR_DEF_NATIVE_METHOD(void, HookBridge, resumeDispatch, jint depth) {
453+
dispatch_depth = static_cast<uint32_t>(depth);
454+
}
455+
456+
/**
457+
* @brief Raises the guard over a stretch of framework code, returning the depth to restore.
458+
*
459+
* The chain is re-entered from module code, with the guard already down, so it has to raise the
460+
* guard for its own bookkeeping rather than assume it holds. Allocating one chain node calls
461+
* getClass twice - R8's null checks on the constructor's parameters - and without this each of
462+
* those would dispatch, allocate another node, and call getClass again.
463+
*/
464+
VECTOR_DEF_NATIVE_METHOD(jint, HookBridge, raiseDispatch) {
465+
const auto saved = dispatch_depth;
466+
dispatch_depth = 1;
467+
return static_cast<jint>(saved);
231468
}
232469

233470
/**
@@ -691,6 +928,9 @@ static JNINativeMethod gMethods[] = {
691928
"Executable;)[[Ljava/lang/Object;"),
692929
VECTOR_NATIVE_METHOD(HookBridge, findStaticInitializer,
693930
"(Ljava/lang/Class;[JJ)Ljava/lang/reflect/Executable;"),
931+
VECTOR_NATIVE_METHOD(HookBridge, suspendDispatch, "()I"),
932+
VECTOR_NATIVE_METHOD(HookBridge, resumeDispatch, "(I)V"),
933+
VECTOR_NATIVE_METHOD(HookBridge, raiseDispatch, "()I"),
694934
};
695935

696936
/**

tests/dispatch-guard/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
build/
2+
.gradle/
3+
local.properties

0 commit comments

Comments
 (0)