Skip to content

Zygisk doesn't inject on Meta Quest 3 (partitioned zygote64_stub32 / stub_zygote): native.bridge cleared before the untrusted-app partition spawns #9940

Description

@AbandonedAccount1111

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.so3rd-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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions