Skip to content

Add standalone mode to trace command for late zygote injection - #107

Open
JingMatrix wants to merge 9 commits into
masterfrom
init
Open

Add standalone mode to trace command for late zygote injection#107
JingMatrix wants to merge 9 commits into
masterfrom
init

Conversation

@JingMatrix

Copy link
Copy Markdown
Owner

This commit introduces the --standalone flag to the zygisk-ptrace64 binary, enabling the initialization of NeoZygisk after the zygote process has already started. This provides an alternative to relying strictly on Magisk's init phase injection.

To support injecting into an active, running process, several structural adjustments were made to the injection flow:

CLI and Daemon Initialization:

  • The trace command now parses the --standalone flag, which is mutually exclusive with --restart.
  • In standalone mode, the tracer resolves and changes the working directory to the module directory, then initializes the daemon controller prior to injection. This ensures the UNIX domain sockets are listening before the injected library attempts to connect.
  • Added SIGINT and SIGTERM handlers to safely detach and send SIGCONT to zygote if the tracer is aborted by the user, preventing system lockups.

Ptrace Flow Adjustments:

  • Omitted PTRACE_O_EXITKILL in standalone mode to ensure the zygote process is not terminated if the tracer exits unexpectedly.
  • Updated the PTRACE_SEIZE strategy to explicitly issue PTRACE_INTERRUPT. Unlike standard Magisk mode where zygote is already suspended via SIGSTOP, a running process must be manually interrupted.
  • Handled the resulting SIGTRAP stop and bypassed the SIGCONT wake-up sequence during detach, as a clean detach is sufficient to resume a process stopped via PTRACE_INTERRUPT.

Injection Logic:

  • Extracted the remote code execution sequence (dlopen, dlsym, entry) into a shared function to maintain DRY principles.
  • Created inject_standalone to bypass the AT_ENTRY stack parsing and memory hijacking. Since the dynamic linker is already initialized, the payload is invoked immediately.
  • Implemented a cross-architecture syscall restart mitigation. Interrupting a tracee inside a blocking system call (e.g., epoll_wait) and altering the instruction pointer triggers the kernel's ERESTARTSYS rewind logic, causing the instruction pointer to jump into invalid padding and throw a SIGTRAP. This is resolved by clearing orig_rax (and architecture equivalents) in the temporary registers before the remote call, while preserving the actual system call state in the backup registers for a seamless resumption.

JingMatrix added a commit to JingMatrix/Vector that referenced this pull request Mar 10, 2026
In JingMatrix/NeoZygisk#107, NeoZygisk is modified to support Zygisk initialization without relying on the early init phase hooks of Magisk. This commit adds support for Vector to operate under this late injection model.

Modifications include:
- Daemon: Added parsing for the --late-inject flag in ServiceManager. When active, the daemon uses "serial_vector" as the proxy service name and LSPosedService manually dispatches the boot completed event.
- IPC Bridge: Updated RequestSystemServerBinder to accept a dynamic rendezvous service name instead of hardcoding it.
- Native Module: VectorModule now reads RuntimeFlags::LATE_INJECT during server specialization, adjusts the bridge service name accordingly, and passes the state to the Java payload.
- Framework: Updated Main.forkCommon to manually bootstrap the system_server environment during late injection. This extracts the ClassLoader from the live activity service, deoptimizes system server methods, and synchronously fires Xposed and LibXposed load callbacks.
@huynhbaman

Copy link
Copy Markdown

All good, but still detect ptrace, Do u have plan to hide its , bro

@JingMatrix

Copy link
Copy Markdown
Owner Author

@huynhbaman ptrace is not detectable as you may imagine, and the purpose of this pull-request is not about hiding. This pull-request adds support for AOSP debug builds without installing any root solution, an advanced scenario that is out of the scope of most users.

Current hiding mechinism is already good. Please avoid commenting about something you don't truly understand. If you want to ask questions, open a thread in Discussions.

JingMatrix added a commit to JingMatrix/Vector that referenced this pull request Mar 11, 2026
In JingMatrix/NeoZygisk#107, NeoZygisk is modified to support Zygisk initialization without relying on the early init phase hooks of Magisk. This commit adds support for Vector to operate under this late injection model.

Modifications include:
- Daemon: Added parsing for the --late-inject flag in ServiceManager. When active, the daemon uses "serial_vector" as the proxy service name and LSPosedService manually dispatches the boot completed event.
- IPC Bridge: Updated RequestSystemServerBinder to accept a dynamic rendezvous service name instead of hardcoding it.
- Native Module: VectorModule now reads RuntimeFlags::LATE_INJECT during server specialization, adjusts the bridge service name accordingly, and passes the state to the Java payload.
- Framework: Updated Main.forkCommon to manually bootstrap the system_server environment during late injection. This extracts the ClassLoader from the live activity service, deoptimizes system server methods, and synchronously fires Xposed and LibXposed load callbacks.
@JingMatrix

Copy link
Copy Markdown
Owner Author

This pull-request is not going to be merged for now, due to lack of test thus potentially vulnerable to attacks.

We nevertheless provide docs for the standalone setup: https://github.com/JingMatrix/NeoZygisk/blob/init/docs/late-injection.md

Enovale pushed a commit to Enovale/Vector that referenced this pull request Jul 13, 2026
In JingMatrix/NeoZygisk#107, NeoZygisk is modified to support Zygisk initialization without relying on the early init phase hooks of Magisk. This commit adds support for Vector to operate under this late injection model.

Modifications include:
- Daemon: Added parsing for the --late-inject flag in ServiceManager. When active, the daemon uses "serial_vector" as the proxy service name and LSPosedService manually dispatches the boot completed event.
- IPC Bridge: Updated RequestSystemServerBinder to accept a dynamic rendezvous service name instead of hardcoding it.
- Native Module: VectorModule now reads RuntimeFlags::LATE_INJECT during server specialization, adjusts the bridge service name accordingly, and passes the state to the Java payload.
- Framework: Updated Main.forkCommon to manually bootstrap the system_server environment during late injection. This extracts the ClassLoader from the live activity service, deoptimizes system server methods, and synchronously fires Xposed and LibXposed load callbacks.
This commit introduces the --standalone flag to the zygisk-ptrace64 binary, enabling the initialization of NeoZygisk after the zygote process has already started. This provides an alternative to relying strictly on Magisk's init phase injection.

To support injecting into an active, running process, several structural adjustments were made to the injection flow:

CLI and Daemon Initialization:
- The trace command now parses the --standalone flag, which is mutually exclusive with --restart.
- In standalone mode, the tracer resolves and changes the working directory to the module directory, then initializes the daemon controller prior to injection. This ensures the UNIX domain sockets are listening before the injected library attempts to connect.
- Added SIGINT and SIGTERM handlers to safely detach and send SIGCONT to zygote if the tracer is aborted by the user, preventing system lockups.

Ptrace Flow Adjustments:
- Omitted PTRACE_O_EXITKILL in standalone mode to ensure the zygote process is not terminated if the tracer exits unexpectedly.
- Updated the PTRACE_SEIZE strategy to explicitly issue PTRACE_INTERRUPT. Unlike standard Magisk mode where zygote is already suspended via SIGSTOP, a running process must be manually interrupted.
- Handled the resulting SIGTRAP stop and bypassed the SIGCONT wake-up sequence during detach, as a clean detach is sufficient to resume a process stopped via PTRACE_INTERRUPT.

Injection Logic:
- Extracted the remote code execution sequence (dlopen, dlsym, entry) into a shared function to maintain DRY principles.
- Created inject_standalone to bypass the AT_ENTRY stack parsing and memory hijacking. Since the dynamic linker is already initialized, the payload is invoked immediately.
- Implemented a cross-architecture syscall restart mitigation. Interrupting a tracee inside a blocking system call (e.g., epoll_wait) and altering the instruction pointer triggers the kernel's ERESTARTSYS rewind logic, causing the instruction pointer to jump into invalid padding and throw a SIGTRAP. This is resolved by clearing orig_rax (and architecture equivalents) in the temporary registers before the remote call, while preserving the actual system call state in the backup registers for a seamless resumption.
Complicated modules, such as LSPosed / Vector, need to be inform about the injection mode, since many of their functions could reply on the exact launching sequence of system services.
Gate the control socket with a kernel SO_PEERCRED check: the daemon now admits a
connection only if the peer uid is root (0) or system (1000), and rejects anything
else with a warning. This is sound because every connection the daemon legitimately
accepts is opened while the caller is still privileged -- zygote (uid 0) during
injection and the whole pre-specialization window, system_server (uid 1000) on the
late path, and the root tracer (uid 0). The loader makes no daemon connection after a
process drops to its app uid, and the app/companion channel is a handed-off fd that
never re-enters the accept path, so an app-uid connection is illegitimate by design.

Add docs/late-injection.md covering the --standalone and --system_server modes: the
permissive-SELinux requirement (NeoZygisk's sepolicy rules cannot be loaded in these
environments, and the /data/system relocation narrows but cannot remove the gap), the
emulated Magisk environment (magisk / resetprop / unshare shims and a crafted
magisk.db), the module-side RuntimeFlags::LATE_INJECT contract, the socket peer
admission and its residual risk, and a link to the reference implementation in
JingMatrix/Vector#564.

The 0777 socket mode is kept (it matches shipped releases) with a comment recording
that admission is enforced by SO_PEERCRED, not by the DAC mode.

Note: this branch is not intended to be merged. The residual risk under a permissive
policy (a process already running as root or system may still supply unverified
request data) is inherent and documented rather than papered over.
@JingMatrix
JingMatrix force-pushed the init branch 2 times, most recently from c20b64d to 24f3978 Compare August 8, 2026 15:23
The BTI workaround added in 133cd99 sat in inject_before_start, so the new
late-injection path jumped into BTI-protected bionic with whatever BTYPE the
interrupted tracee happened to carry. Move it into execute_remote_injection,
which both paths share; the callers still keep the original PSTATE in their
register backup for the final resume.
The previous wording called the relocation futile because it does not lift the
permissive-SELinux requirement. That is true but misses the actual reason for
it: /data/adb is 0700 root:root, so a late-injected system_server (uid 1000)
has no search permission on it. Being a DAC denial, setenforce 0 does not
relax it, and the chcon on the work directory relabels the child without
granting traversal of the parent.

Both halves of the system_server path fail there -- the dlopen of libzygisk.so
and the PingHeartbeat connect() to the control socket, the latter making the
0777 socket mode and the AID_SYSTEM branch of the SO_PEERCRED check moot.
/data/system fixes both, since post-fs-data.sh chowns it to system:system.
Zygote is uid 0 throughout injection, so --standalone never depended on this.
handle_trace's standalone branch declared `auto pid = fork()`, shadowing the
tracee PID it had just parsed, and reported a failed fork with `return false`
-- which is 0, i.e. EXIT_SUCCESS -- from a function returning int. The child
also left its failure paths on `return`, so instead of exiting it unwound back
into main; had monitor.run() ever returned, it would have fallen through and
attached to zygote a second time. Name the fork result, return EXIT_FAILURE,
and terminate the child with _exit() on every path. The child additionally
inherited handle_interrupt with g_traced_pid already armed, so a stray SIGTERM
made it detach a process it never attached to; clear it after the fork.

Scripts: quote the pidof substitutions -- an empty result silently shifted argv
and fed `trace --standalone` a missing pid -- pick the tracer binary once
instead of hardcoding zygisk-ptrace64 in service.sh, and bail with a log line
when no zygote or no tracer is found. The 32-bit branch looked for "zygote32",
but app_process only ever names itself "zygote64" or "zygote".

Dead code: drop the commented-out copy_to_temp call site along with the use_temp
plumbing that could never fire, and the commented-out libart lookup in
JniAttachment that RTLD_DEFAULT already covers. Keep copy_to_temp itself, marked
maybe_unused, documented as a debugging aid. Restore the DeleteLocalRef on the
gids array. Also fix the --spwan usage typo, `sucess`, "No moniter handler
actived", a missing newline, and replace the local PROP_VALUE_MAX define with
the bionic header that defines it.
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.

2 participants