Skip to content

Commit 250199a

Browse files
committed
zygisk: make built-in Zygisk work on Meta Quest (lazy per-trust partition zygotes)
Meta Quest (Horizon OS) does not start the zygote the normal way: ro.zygote= zygote64_stub32 launches /system/bin/stub_zygote, which forks a separate app_process64 zygote PER security partition (system/trusted vs untrusted-app), driven by hzos_security_zygote_partitioning_policy. The untrusted-app partition zygote -- the one that forks 3rd-party apps and the Magisk manager -- is spawned LAZILY, after boot-complete. magiskd sets ro.dalvik.vm.native.bridge=libzygisk.so during boot but clears it again at boot-complete (ZygiskState::reset). On a normal device every zygote has already started by then, so clearing it is harmless. On Quest the untrusted-app partition zygote reads the (now cleared) property when it finally execve's app_process64, never loads libzygisk.so, so 3rd-party apps + the Magisk manager are never injected -> "Zygisk: N/A". Fix (daemon.rs): on the boot-complete reset(restore=true) path, reset the crash counter but KEEP native.bridge set (set_prop) instead of clearing it (restore_prop); only the >3-crash rollback path still clears. The lazily-spawned partition zygotes then load the loader when they start. Also (hook.cpp), robustness fixes the loader needs on this device: - Wrap hook_zygote_jni()'s JNI locals in PushLocalFrame(64)/PopLocalFrame so leaking locals can't trip ART's "non-empty local reference table" abort, and bail if GetEnv returns no env. - Make the strdup(ZygoteInit) trigger a substring match + add an idempotency guard so the zygote hooks install exactly once per process. Verified on Quest 3 (Android 14): system_server + every app fork is intercepted and LSPosed loads end-to-end.
1 parent 6fc2854 commit 250199a

2 files changed

Lines changed: 43 additions & 10 deletions

File tree

native/src/core/zygisk/daemon.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,21 @@ impl ZygiskState {
107107
Ok(())
108108
}
109109

110-
pub fn reset(&mut self, mut restore: bool) {
110+
pub fn reset(&mut self, restore: bool) {
111111
if restore {
112+
// boot-complete: reset the crash counter but KEEP the native bridge prop set, so that
113+
// zygote partitions that spawn lazily AFTER boot-complete (e.g. Meta Quest's
114+
// per-trust-level partition zygotes, which fork untrusted apps) still load the zygisk
115+
// loader. Clearing it here is why untrusted apps were never injected on such devices.
112116
self.start_count = 1;
113-
} else {
114-
self.sockets = (None, None);
115-
self.start_count += 1;
116-
if self.start_count > 3 {
117-
warn!("zygote crashed too many times, rolling-back");
118-
restore = true;
119-
}
117+
self.set_prop();
118+
return;
120119
}
121120

122-
if restore {
121+
self.sockets = (None, None);
122+
self.start_count += 1;
123+
if self.start_count > 3 {
124+
warn!("zygote crashed too many times, rolling-back");
123125
self.restore_prop();
124126
} else {
125127
self.set_prop();

native/src/core/zygisk/hook.cpp

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ struct HookContext : JniHookDefinitions {
103103
const NativeBridgeRuntimeCallbacks *runtime_callbacks = nullptr;
104104
void *self_handle = nullptr;
105105
bool should_unmap = false;
106+
// Guards against hooking the zygote JNI methods more than once. On lazy-native-bridge
107+
// devices both the post_native_bridge_load path and the strdup("ZygoteInit") trigger can
108+
// fire; a second pass corrupts the JNI registration (nulls fnPtrs) and unregisters
109+
// nativeForkSystemServer -> UnsatisfiedLinkError -> zygote dies.
110+
bool jni_hooked = false;
106111

107112
void hook_plt();
108113
void hook_unloader();
@@ -142,7 +147,11 @@ ret (*old_##func)(__VA_ARGS__); \
142147
ret new_##func(__VA_ARGS__)
143148

144149
DCL_HOOK_FUNC(static char *, strdup, const char * str) {
145-
if (strcmp(kZygoteInit, str) == 0) {
150+
// The runtime hands the "com.android.internal.os.ZygoteInit" class name to strdup at the correct
151+
// point (after the Zygote natives are (re)registered, before ZygoteInit#main forks), which is when
152+
// hook_zygote_jni() must arm. Match as a substring (rather than exact) so a wrapped/prefixed name
153+
// still triggers — harmless on standard devices, and it is what fires reliably on Meta Quest.
154+
if (str && strstr(str, kZygoteInit)) {
146155
g_hook->hook_zygote_jni();
147156
}
148157
return old_strdup(str);
@@ -378,6 +387,11 @@ void HookContext::post_native_bridge_load(void *handle) {
378387
arg.load_native_bridge(nb.c_str() + len, arg.callbacks);
379388
}
380389
runtime_callbacks = arg.callbacks;
390+
// NOTE: do NOT hook the zygote JNI methods here. The native bridge loads before the runtime
391+
// finishes registering (and later re-registers) the Zygote natives, so a hook installed now is
392+
// overwritten by the runtime and never takes effect. The strdup("com.android.internal.os.ZygoteInit")
393+
// PLT hook fires at the correct time (after registration, before ZygoteInit#main forks), and it
394+
// does fire on Meta Quest too, so let it arm hook_zygote_jni().
381395
}
382396

383397
// -----------------------------------------------------------------
@@ -532,6 +546,10 @@ void HookContext::hook_jni_methods(JNIEnv *env, const char *clz, JNIMethods meth
532546
}
533547

534548
void HookContext::hook_zygote_jni() {
549+
// Idempotent: only replace the zygote JNI methods once per process.
550+
if (jni_hooked) {
551+
return;
552+
}
535553
using method_sig = jint(*)(JavaVM **, jsize, jsize *);
536554
auto get_created_vms = reinterpret_cast<method_sig>(
537555
dlsym(RTLD_DEFAULT, "JNI_GetCreatedJavaVMs"));
@@ -564,8 +582,14 @@ void HookContext::hook_zygote_jni() {
564582
res = vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6);
565583
if (res != JNI_OK || env == nullptr) {
566584
ZLOGW("JNIEnv not found\n");
585+
return;
567586
}
568587

588+
// Contain every JNI local reference we create (FindClass, ExceptionOccurred, ...) in an
589+
// explicit frame. Depending on the exact caller/timing this may run outside a managed JNI
590+
// transition, and leaking locals trips ART's "non-empty local reference table" check -> abort.
591+
bool local_frame = env->PushLocalFrame(64) == JNI_OK;
592+
569593
JNINativeMethod missing_method{};
570594
bool replaced_fork_app = false;
571595
bool replaced_specialize_app = false;
@@ -606,6 +630,13 @@ void HookContext::hook_zygote_jni() {
606630
ranges::for_each(specialize_app_methods, [](auto &m) { m.fnPtr = nullptr; });
607631
ranges::for_each(fork_server_methods, [](auto &m) { m.fnPtr = nullptr; });
608632
}
633+
// Only mark as hooked when the full set was replaced cleanly, so that a premature/failed
634+
// call does not permanently block a later well-timed trigger from installing the hooks.
635+
if (missing_method.name == nullptr && replaced_fork_app && replaced_specialize_app &&
636+
replaced_fork_server) {
637+
jni_hooked = true;
638+
}
639+
if (local_frame) env->PopLocalFrame(nullptr);
609640
}
610641

611642
void HookContext::restore_zygote_hook(JNIEnv *env) {

0 commit comments

Comments
 (0)