Hardening a rooted Android device against app detection
September 14, 2026 · 22 min

Debugging a PayPal startup crash: a device-number gap and an App Zygote errno

A real Android investigation using offline A/B/A, boot traces, bounded eBPF, an independent APK, and kernel compatibility checks—with a black-screen incident along the way. Two changes to existing components restored startup without another module.

androiddebuggingmagiskselinuxebpfkernel
Cover illustration for “Debugging a PayPal startup crash: a device-number gap and an App Zygote errno”
Contents
  1. Separate the current failure from its historical trigger
  2. Turn the aggregate decision into an observable check
  3. Establish causality with a one-field offline A/B/A
  4. Change the boot sequence that creates the first signal
  5. A black screen changed the observation strategy
  6. The second signal ran in App Zygote
  7. Reproduce the execution context as well as the bytes
  8. Keep the denial and normalize a specific error path
  9. A successful build is only the start of kernel compatibility
  10. Test the kernel separately before combining changes
  11. Acceptance includes the rest of the device
  12. A runbook for the next recurrence
  13. Attachments and provenance

PayPal used to work on this phone. Magisk’s Root hiding and an application-list hiding tool had been enough. Later, opening the app caused it to exit.

That symptom invites configuration roulette: another hiding module, another scope change, another version, another reboot. Each experiment changes the environment, but it may add little knowledge. The useful turning point was to stop editing settings and ask a narrower question: what does this particular PayPal build read, and which result makes it reject the environment?

The investigation identified two independent local signals. One came from Magisk’s early-boot allocation of anonymous filesystem device numbers. The other came from different SELinux errors returned to an App Zygote attempting context transitions. After combining two changes to existing components, PayPal reached its system fingerprint prompt. The phone’s operator completed authentication and confirmed that the app opened and remained usable on the resulting page. No additional persistent module, PayPal downgrade, or app-data reset was needed.

The path included a black screen during an ART probe experiment and a misleading kernel-symbol compatibility report. Those failures belong in the record alongside the successful changes.

Separate the current failure from its historical trigger

The test environment was a Xiaomi 13, codename fuxi, running Android 16 / SDK 36, kernel 5.15.207-g03e6e48a5b4e, Magisk 30.7, ZygiskNext 1.5.0, and PayPal 10.12.0. Vector, NoHello, and an existing iFAST compatibility component were also present. The recollection that Magisk and application-list hiding had originally been sufficient does not mean those were the only components in the current baseline.

After removing this task’s diagnostic APKs, a cold start still produced this exception structure:

RootDetectionSecurityException: Security policy violation: s=root

That directed the investigation toward the app’s Root-detection decision. It did not identify the individual check, and it did not establish a remedy for every device with the same exception.

There were no equivalent low-level captures from the period when PayPal worked. Consequently, an app, Magisk, or kernel update could not be named as the historical trigger just from its date. This investigation establishes a causal explanation for the current failure. The event that first exposed it remains undetermined.

Turn the aggregate decision into an observable check

Runtime analysis located a call from o.APMf to the native method o.endFlow.a([[Ljava/lang/String;)J. In this exact app build, the function was at offset 0x6e628 in libob98.so. These obfuscated names and offsets are sample-specific navigation aids.

The caller transformed the return through integer arithmetic; the raw value was not a Boolean. Decoding it as the actual caller did produced 1, and the branch used 0x11c to update a marker. A -1 observed elsewhere was a control-flow state, illustrating why neither negative values nor suspicious strings are sufficient evidence of a positive detection.

The function issued AArch64 newfstatat directly, examining four paths alongside kernel-release and SDK inputs:

PathObserved st_dev
/dev17
/dev/pts18
/proc19
/sys21

These are filesystem device identifiers, distinct from disk partition numbers and mountinfo mount IDs. The gap was interesting: PID 1’s mount information associated 0:20 with a retained tmpfs mounted at /debug_ramdisk, with source magisk.

Removing an entry from an application’s visible mount tree does not renumber other existing filesystems. Hiding a name and changing the allocation history are different operations. This explains why editing the hidden-app list did not directly address the observed input.

Establish causality with a one-field offline A/B/A

A suspicious number was not enough reason to replace a boot image. First, I executed the captured native function in Unicorn on the host, using the four complete stat buffers collected from the device. SDK, kernel release, and all other fields remained fixed. Only /sys’s st_dev changed:

ExperimentInput changeDecoded result
AOriginal /sys st_dev = 211
BOnly /sys st_dev = 200
A restored/sys st_dev = 21 again1

Within that function and captured input set, the field was sufficient to flip the check.

This was an offline A/B/A. JNI object handling, some libc operations, and parts of the environment used explicit substitutes; unknown calls stopped execution. It was neither a whole-device A/B/A nor evidence that PayPal had recovered. Keeping those states separate matters throughout the investigation.

Change the boot sequence that creates the first signal

The evidence pointed to Magisk’s first-stage startup. I followed the exact version’s first_stage and prepare_data implementation, then built a diagnostic magiskinit to record allocation and handoff on the actual device.

The early temporary environment and Android’s final environment must be kept distinct:

AllocationOriginal sequenceModified sequence
Early temporary /proc, /sys18, 1918, 19
Additional temporary /dev, /dev/ptsAbsent20, 21; released before handoff
Retained Magisk tmpfs2022
Final Android /dev, /dev/pts, /proc, /sys17, 18, 19, 2117, 18, 19, 20

The change creates temporary /dev and /dev/pts before prepare_data() allocates the retained tmpfs. It registers both mounts with the existing reverse-order cleanup, releasing them before the original Android init takes over and creates its filesystems.

The entry point becomes:

fn first_stage(&mut self) {
    info!("First Stage Init");
    self.prepare_first_stage_devices().log_ok();
    self.prepare_data();
    // Existing continuation.
}

The complete helper is in the installed Magisk source patch. It changes the interleaving of temporary and persistent allocations. It does not hard-code 20 or 21, override stat, or match PayPal. The numbers in the table are observations from this build, not universal Android constants.

The candidate booted normally. Device numbers matched the prediction, and the actual endFlow result decoded from 1 to 0. PayPal still exited with its Root-detection exception. One signal was fixed; the aggregate outcome required more investigation.

A black screen changed the observation strategy

To identify the remaining branch, the investigation tried to capture fixed detector arguments at ART reflection entry points. ArtMethod::Invoke did not yield the target event. During a subsequent uprobe experiment at art_quick_invoke_static_stub, opening the app was followed by an ADB disconnect and a black or frozen display.

No panic log established the mechanism, and pstore was empty after recovery. The supported statement is that the failure occurred during that probe experiment. It would be unjustified to blame the Magisk mount change, or to present a specific ART, CFI, or kernel explanation as proven.

Observation stopped. The operator entered Fastboot, the verified original init_boot was restored, and boot images, Root, and configuration were checked. The operator confirmed the phone worked again. The ART probe script received a runtime guard and was retired from subsequent work.

A read-only probe can still disrupt the system it observes. The next design therefore used narrowly scoped syscall tracepoints and an independent reproduction APK, with an exit path that did not depend on the host connection. Recovery also restored a trustworthy baseline before another hypothesis was tested.

The second signal ran in App Zygote

Static analysis exposed another path involving an isolated service and SELinux labels. Calling it an isolated_app check would have been premature. The real trace placed the three writes in App Zygote, targeting /proc/thread-self/attr/current.

The app opened, wrote, and closed the file with these exact bytes, without a trailing NUL:

Written labelBytesOriginal kernel write returnerrno
u:r:kp:s09-22EINVAL
u:object_r:ksu_file:s022-22EINVAL
u:r:magisk:s013-1EPERM

Negative values here are raw kernel syscall returns. The APK tables below use positive errno values; -22 and errno 22 describe the same failure at different layers.

The eBPF observer used sys_enter / sys_exit, filtering the application’s UID and narrowly named child processes, plus two exact paths:

/proc/thread-self/attr/current
/sys/fs/selinux/context

It paired entry and exit by thread and associated subsequent I/O with process and fd, preserving input length and return value. Collection stopped 15 seconds after the first target call. A separate device watchdog cleaned up after 35 seconds, and the host used finally to remove its own pins and maps. It did not override return values or attach ART probes. The baseline contained 39 related events; exported events and counters agreed without reaching the configured truncation limit.

These bounds reduced this experiment’s exposure. They do not make eBPF universally harmless or this collector a general tracing tool: a later run must review UID selection, process names, fd lifetime, capacity, and paths again.

In the captured sequence, /sys/fs/selinux/context served normal Android context setup. The three detector writes used procfs. Changing policy for the former interface would not directly address the observed calls.

Reproduce the execution context as well as the bytes

An independent APK requested no permissions and contained no networking or Xposed injection. It used zygotePreloadName and a service configured with isolatedProcess=true and useAppZygote=true to create a real App Zygote and isolated child. The Android service manifest reference describes those process options.

Running identical writes in three actual environments produced a useful distinction:

EnvironmentOriginal errno valuesModified-kernel errno values
Ordinary untrusted_app13, 13, 1313, 13, 13
app_zygote22, 22, 122, 22, 22
Child isolated_app13, 13, 1313, 13, 13

Testing only an ordinary application or the final isolated child would have missed the discriminating row. App Zygote needs a different permission path to specialize its children. UID, SELinux domain, thread state, and lifecycle stage all belong in the experiment’s inputs.

The first APK revision accidentally appended a NUL. It returned the same results, but was still corrected to the real byte lengths, rebuilt, and retested. The earlier output remained separately labeled. A coincidentally identical result does not excuse a mismatched reproduction.

Static decision logic explained the distinction: EINVAL was excluded from a hit-label list, while EPERM was included. A nonempty list, with preceding service state normal, could generate 0x122. The final aggregate code was not captured directly from the live app. That number is a static derivation, supported by observed inputs and independent reproduction, rather than a runtime measurement.

Keep the denial and normalize a specific error path

The exact kernel’s selinux_setprocattr() separates the initial permission check, target-context resolution, and later transition checks. An unknown label can fail resolution with EINVAL; a known label can pass resolution and fail a later condition with a different error.

The app did not successfully acquire a Root context. The rejected operation exposed evidence of a label’s existence. DirtySepolicy is an independent implementation of related App Zygote probing; it is a mechanism reference, not proof of what this PayPal instance executed.

Configuration changes had not supplied a verified remedy. Removing App Zygote’s setcurrent would interfere with normal isolated-child specialization, while restricting only the final isolated_app would miss the caller. Repeatedly renaming a Root domain also creates compatibility work. The scope therefore expanded to the existing kernel after explicit authorization for review and testing.

The actual patch adds 20 lines after abort_creds(new) at abort_change:. Its core is reproduced below; the patch download preserves the placement:

if (!strcmp(name, "current") &&
    (error == -EPERM || error == -EACCES)) {
    char *context = NULL;
    u32 context_len = 0;
    static const char prefix[] = "u:r:app_zygote:";

    if (!security_sid_to_context(&selinux_state, mysid,
                                &context, &context_len)) {
        if (context_len >= sizeof(prefix) - 1 &&
            !memcmp(context, prefix, sizeof(prefix) - 1))
            error = -EINVAL;
        kfree(context);
    }
}

The denied operation remains denied. Existing permission checks and auditing execute, and successful specialization follows its original path. No allow rule or permissive mode is introduced. Earlier returns, including the initial setcurrent denial, do not traverse this block. Other callers, attributes, and unmatched errors retain their results.

There is still an interface change: every matching App Zygote caller on this failure path sees a normalized errno, which could affect code that depends on that distinction. The patch is independent of PayPal’s package, UID, and specific Root label. Its scope also does not cover every SELinux query interface.

A successful build is only the start of kernel compatibility

Twenty changed lines can require substantial validation because unrelated build inputs affect bootability. The build used the precise source revision and runtime configuration:

InputRecorded value
Kernel base03e6e48a5b4ed606dfdd48bc782a57f9c778938b
Magisk basee8a58776f1d7bdf852072ad0baa6eceb9a1e4aac
LLVM source revision5e96669f06077099aa41290cdb4c5e6fa0f59349
CompilerClang 21.0.0; matching source revision, different distribution description
Kernel featuresFull LTO and CFI retained; CFI permissive disabled
ModulesOriginal vendor modules and kernel-release string retained

A matching LLVM source commit does not imply an identical compiler binary or kernel output. Configuration differences, symbol CRCs, certificate checks, and an actual boot were still necessary.

First, the candidate retained the original kernel’s public module-verification certificate. The resulting certificate matched byte for byte. No original private key was used and vendor modules were not all resigned. The original configuration did not universally force signatures for every module; that setting remained unchanged. This work did not introduce stricter signature enforcement. The kernel signing documentation explains the roles of public keys, signatures, and build settings.

Second, compatibility checking initially made a false assumption: it compared every vendor-module import only with candidate vmlinux. Some imports were supplied by other vendor modules. Including their actual ELF __ksymtab_* and __crc_* providers resolved the false missing-symbol report. Across 400 module files, all 4543 imports matched: 2984 were provided by the kernel and 1559 by unchanged modules, with no CRC mismatch.

After boot, the same set of 388 modules was loaded as in the baseline. The file count and loaded count measure different things.

Fifteen host tests also compiled the actual before-and-after function bodies against controlled kernel API substitutes, with AddressSanitizer and UndefinedBehaviorSanitizer. They checked denied transitions, permitted child specialization, unrelated callers, similar prefixes, other attributes, allocation failures, and identical credential commit/abort behavior. These were control-flow tests, not substitutes for runtime policy, ABI, or boot validation.

Test the kernel separately before combining changes

The first device test used a temporary Fastboot boot of the candidate kernel with the original Magisk image. Rereading the partitions verified that original boot and init_boot contents had not changed. Boot, Root, HMA, Enforcing, and the loaded module set were healthy. The independent APK matched the expected table, and the bounded trace of real PayPal confirmed three -22 writes.

PayPal still exited. The original mount-allocation signal was expected to remain with the original Magisk image, so this did not invalidate the kernel result.

Only then were both changes installed: the validated kernel plus magiskinit with temporary diagnostic logging removed. Packaging preserved boot headers and unrelated contents. This device’s boot ramdisk was empty. In init_boot, only the ramdisk’s init content changed; other entries and their metadata were preserved. vendor_boot stayed untouched.

This table distinguishes what was measured in each stage:

StageDevice-number evidenceSELinux evidenceActual app result
Original baseline17, 18, 19, 21; native result 1Real returns -22, -22, -1Root rejection
Magisk change only17, 18, 19, 20; real native result 0Not separately collected in that stageStill rejected
Kernel change onlyOriginal Magisk image retainedIndependent APK and real calls verifiedStill rejected
Combined changesFinal values 17, 18, 19, 20Same verified kernel; no probe reattached during acceptanceOpened and stayed after fingerprint authentication

This was not a fully remeasured factorial experiment. Nor were every proposed whole-device A/B/A and repeated cold-start cycle completed. The recorded evidence supports the result for this build while preserving the actual test scope.

Acceptance includes the rest of the device

The combined image reached PayPal’s normal system fingerprint prompt. The operator authenticated and confirmed entry and a stable page. A later check found the same PayPal process alive and an empty crash buffer.

The surrounding checks were recorded separately:

  • Magisk Root and the existing Zygisk components worked; the HMA configuration hash was unchanged.
  • SELinux remained Enforcing, with the live policy byte-identical to the original.
  • The baseline set of 388 loaded kernel modules was preserved.
  • The diagnostic APK, task-owned BPF programs, maps, pins, probes, and device temporary files were removed.
  • An iFAST cold start showed no Root or jailbreak warning. It did show a developer-options risk warning, so this run did not validate functionality beyond that screen.

PayPal acceptance covered authenticated entry and remaining on the page. No payment or transfer was executed. The result does not establish a change to hardware attestation or backend risk decisions.

A runbook for the next recurrence

Start from a fresh baseline if startup breaks again: app, OS, kernel, Magisk, and ZygiskNext versions; slot; module list; policy state; and the new exception. Similar symptoms do not justify automatically reapplying these changes.

  1. Classify the failure. Check whether it is still s=root, or instead a native crash, ANR, or system reboot. Preserve the original evidence.
  2. Check whether the changes survived. OS, kernel, and Magisk updates can replace boot or init_boot. Compare source and image records with actual signals; the Magisk UI version alone is insufficient.
  3. Measure in the relevant execution context. Device numbers are an initial clue. Critical behavior belongs in the real app, App Zygote, or a faithful reproduction—not just a shell session.
  4. Change one hypothesis at a time. Write down the input to change, the observation expected to move, and other reasons the app may still fail.
  5. Give temporary observation an exit path. Bound the target, time, count, and cleanup ownership. Do not reactivate the retired ART entry probe.
  6. Accept the result without diagnostic instrumentation. Recheck the actual application, Root, policy, and existing functionality. Record untested areas explicitly.

Prepare recovery before a boot-image test: read the current original partitions, hash them, retain offline host copies, verify device and slot, and establish a working Fastboot path. The final repair changed both boot and init_boot, so complete rollback needs both corresponding originals. A normal reboot ends a temporary boot; it does not undo persistent flashing.

After an update, review the changes against the new source and repeat compatibility checks. An old image also contains the old boot structure; matching the phone model is not enough to make cross-version flashing appropriate. Patches, exact baselines, captured inputs, and acceptance records are more useful maintenance artifacts than an image labeled “worked once.”

Attachments and provenance

The attachment README, sanitized evidence summary, 15 host-test results, and checksum manifest accompany the two installed source patches. No flashable image is included.

The public summary selects technical fields from local records, omitting device identifiers, personal paths, and account UI. It is neither a raw-log archive nor independent third-party reproduction. Original images, captures, and recovery materials remain local. The repository’s docs/SOURCES.md records code and evidence anchors for future revisions.

The final repair consists of two local changes to existing Magisk and kernel code. They still carry maintenance cost. The next update should prompt a fresh check of whether the differences exist and whether the changes remain appropriate, rather than an automatic stack of old patches.

Comments