Device: Meta Quest 3 (ro.product.model=Quest 3, Horizon OS, build UP1A.231005.007.A1)
Android version: 14 (SDK 34)
Magisk version name: debug build from latest source (HEAD 14ea5cf)
Magisk version code: 30700
>
Summary
Zygisk never becomes active on Meta Quest 3: the Magisk app shows Zygisk: N/A, and Zygisk modules (e.g. LSPosed) do not load. NeoZygisk (ptrace) works, which is why most Quest users flash it — but the goal here is to make built-in Zygisk work, and it turns out it can with a small change.
Root cause is the Quest's non‑standard zygote topology plus the point at which magiskd clears the native-bridge property.
Root cause
Quest 3 does not start the zygote the normal way:
ro.zygote = zygote64_stub32
- init starts
/system/bin/stub_zygote, which is Meta's "zygote partitioning" launcher (vendor/oculus/services/stub_zygote/). It owns ANDROID_SOCKET_zygote and fork()+execve()s a separate app_process64 zygote per security partition, driven by persist.device_config.oculus_shared_os_services_sessionless.hzos_security_zygote_partitioning_policy=untrusted_app;.
- So there is a system/trusted partition zygote and a separate untrusted-app partition zygote (the one that forks 3rd‑party apps, including the Magisk manager). The partitions are fresh
app_process64 exec's, not forks of each other.
The native-bridge injection mechanism itself works — the strdup("com.android.internal.os.ZygoteInit") PLT trigger does fire on this device and hook_zygote_jni() replaces the fork/specialize methods correctly in a partition that loaded libzygisk.so. The problem is which partitions load it:
magiskd sets ro.dalvik.vm.native.bridge=libzygisk.so during boot, but at boot-complete it clears it back:
// native/src/core/zygisk/daemon.rs ZygiskState::reset()
if restore {
self.start_count = 1;
}
...
if restore {
self.restore_prop(); // <- sets ro.dalvik.vm.native.bridge back to "0" at boot-complete
} else {
self.set_prop();
}
On a normal device every zygote has already started by boot-complete, so clearing the prop is harmless. On Quest, the untrusted-app partition zygote is spawned lazily, after boot-complete, so by the time it execves app_process64 and reads ro.dalvik.vm.native.bridge, it is already 0 → it never loads libzygisk.so → 3rd-party apps and the Magisk manager are never injected → Zygisk: N/A. (The trusted partition, spawned during boot, does get injected — system_server-side modules like LSPosed's daemon can even come up.)
Fix
Keep the native-bridge prop set at boot-complete (only reset the crash counter); still roll back on the >3-crash path:
pub fn reset(&mut self, restore: bool) {
if restore {
// boot-complete: reset the crash counter but KEEP native.bridge set, so zygote
// partitions that spawn lazily AFTER boot-complete (e.g. Quest's per-trust-level
// partition zygotes) still load the zygisk loader.
self.start_count = 1;
self.set_prop();
return;
}
self.sockets = (None, None);
self.start_count += 1;
if self.start_count > 3 {
warn!("zygote crashed too many times, rolling-back");
self.restore_prop();
} else {
self.set_prop();
}
}
With this change ro.dalvik.vm.native.bridge stays libzygisk.so, the untrusted-app partition zygote loads the loader when it spawns, its strdup(ZygoteInit) trigger arms hook_zygote_jni(), and injection works for all apps.
Verified
On a fork with the above change I confirmed (via probes in ZygiskContext::nativeForkAndSpecialize_pre/nativeForkSystemServer_pre) that zygisk's fork/specialize runs for system_server and every app fork, and LSPosed loads end-to-end (its lspd daemon runs). Multiple partition zygotes now map libzygisk.so.
Possibly also needed
hook_zygote_jni() creates JNI local refs (FindClass, exception checks). Depending on the exact caller context on this device I hit an ART abort Check failed: ... non-empty local reference table; wrapping the JNI work in env->PushLocalFrame(64) / PopLocalFrame(nullptr) made it robust. This may or may not be strictly required on a clean stock build, but seems like cheap insurance.
Happy to open a PR. (Filing per the maintainer-independent goal of getting this upstream; I understand the template asks for debug builds / no modules — this is a root-cause + patch report for a device-class Zygisk incompatibility rather than a runtime crash report.)
#9231 <- should be implemented as well for cve-based root access to detect if the root is a bootloader based one or a vulnerability one and make magisk safe for root in these headsets.
Device: Meta Quest 3 (
>ro.product.model=Quest 3, Horizon OS, buildUP1A.231005.007.A1)Android version: 14 (SDK 34)
Magisk version name: debug build from latest source (HEAD
14ea5cf)Magisk version code: 30700
Summary
Zygisk never becomes active on Meta Quest 3: the Magisk app shows Zygisk: N/A, and Zygisk modules (e.g. LSPosed) do not load. NeoZygisk (ptrace) works, which is why most Quest users flash it — but the goal here is to make built-in Zygisk work, and it turns out it can with a small change.
Root cause is the Quest's non‑standard zygote topology plus the point at which magiskd clears the native-bridge property.
Root cause
Quest 3 does not start the zygote the normal way:
ro.zygote = zygote64_stub32/system/bin/stub_zygote, which is Meta's "zygote partitioning" launcher (vendor/oculus/services/stub_zygote/). It ownsANDROID_SOCKET_zygoteandfork()+execve()s a separateapp_process64zygote per security partition, driven bypersist.device_config.oculus_shared_os_services_sessionless.hzos_security_zygote_partitioning_policy=untrusted_app;.app_process64exec's, not forks of each other.The native-bridge injection mechanism itself works — the
strdup("com.android.internal.os.ZygoteInit")PLT trigger does fire on this device andhook_zygote_jni()replaces the fork/specialize methods correctly in a partition that loadedlibzygisk.so. The problem is which partitions load it:magiskd sets
ro.dalvik.vm.native.bridge=libzygisk.soduring boot, but at boot-complete it clears it back:On a normal device every zygote has already started by boot-complete, so clearing the prop is harmless. On Quest, the untrusted-app partition zygote is spawned lazily, after boot-complete, so by the time it
execvesapp_process64and readsro.dalvik.vm.native.bridge, it is already0→ it never loadslibzygisk.so→ 3rd-party apps and the Magisk manager are never injected → Zygisk: N/A. (The trusted partition, spawned during boot, does get injected — system_server-side modules like LSPosed's daemon can even come up.)Fix
Keep the native-bridge prop set at boot-complete (only reset the crash counter); still roll back on the >3-crash path:
With this change
ro.dalvik.vm.native.bridgestayslibzygisk.so, the untrusted-app partition zygote loads the loader when it spawns, itsstrdup(ZygoteInit)trigger armshook_zygote_jni(), and injection works for all apps.Verified
On a fork with the above change I confirmed (via probes in
ZygiskContext::nativeForkAndSpecialize_pre/nativeForkSystemServer_pre) that zygisk's fork/specialize runs forsystem_serverand every app fork, and LSPosed loads end-to-end (itslspddaemon runs). Multiple partition zygotes now maplibzygisk.so.Possibly also needed
hook_zygote_jni()creates JNI local refs (FindClass, exception checks). Depending on the exact caller context on this device I hit an ART abortCheck failed: ... non-empty local reference table; wrapping the JNI work inenv->PushLocalFrame(64)/PopLocalFrame(nullptr)made it robust. This may or may not be strictly required on a clean stock build, but seems like cheap insurance.Happy to open a PR. (Filing per the maintainer-independent goal of getting this upstream; I understand the template asks for debug builds / no modules — this is a root-cause + patch report for a device-class Zygisk incompatibility rather than a runtime crash report.)
#9231 <- should be implemented as well for cve-based root access to detect if the root is a bootloader based one or a vulnerability one and make magisk safe for root in these headsets.