Isolated-process mount-view detection - #24
Merged
Merged
Conversation
The CI signs the release build itself with the repository secrets, so the release build type must not attach the debug signing config, which named the output app-release.apk and broke the rename-then-sign step.
Kernel-side mount hiders (e.g. KernelSU's mount_hide) erase module mount
records from /proc/<pid>/{mountinfo,mounts,mountstats} at the seq_file layer,
keyed on the reader, so every mount view an app reads is consistently scrubbed
-- defeating the marker scan, the cross-view differential, cross-file and
self-vs-init checks. The stat syscall family is not on that path, so it is
ground truth.
New shared core (recon.cpp / librecon.so), run identically in the main process
(libdemo), the native isolated probe (libmain) and the classic isolated Java
probe (ProcScanner via JNI):
- Reconciliation: statx(STATX_ATTR_MOUNT_ROOT) + statfs vs mountinfo. A path the
kernel confirms as a mount root, or a single file backed by the userdata fs
where a read-only partition is expected, that mountinfo omits is a hidden
record. Immune to the filter and needs no other process.
- Structural (mountinfo-only, fire even where the hidden path is unreachable):
orphaned parent id, and a gap in the peer-group id run collected as
shared:N u master:N so it is dense in both a global and an app slave view.
Fix mount-trace false positives on clean devices:
- native mount.cpp: drop the mount-id arithmetic heuristics (root.id==parent+1,
file-order-consecutive ids) that flagged clean phones and varied run to run;
keep the root-tool source/path scan and move the peer-group check into recon.
- classic differential: normalize out the per-process app-data isolation tmpfs
before comparing views, and only flag distinct > classes (injection adds a
view; fewer is benign).
- markers: drop data_mirror (stock AOSP tree) and the generic overlay-option
words; anchor the modules marker to /adb/modules so it no longer matches OEM
paths such as com.oplus.moduleservices.
UI: split the main-process integrity checks into two families -- Injection
detections and Mount traces -- driven by a new per-check "type" field, with the
detailed reconciliation card (findings + probes) under the mount family.
dex2oat binds are intentionally not probed: SELinux denies apps getattr on
dex2oat_exec so stat cannot reach them, and they never enter the app namespace.
…c probe
Differential panel: each mount-view group now carries the processes it compares
-- pid, uid, propagation, and the full cmdline -- and the panel renders them as a
per-view table so a distinct-views-vs-classes mismatch shows WHICH processes
diverge, not just raw pids. The differing-records list is labelled A (largest
view) / B (2nd largest).
Full command line: display /proc/pid/cmdline (argv[0]) instead of the kernel's
15-char-truncated comm, in the differential samples and the per-process mount
cards; MonoBlock scrolls so long names are not cut.
Speed up the classic isolated probe (~2.4s -> ~0.8s on ~840 visible pids),
without changing any verdict input (recon / markerHits / differential / crossFile
/ selfVsInit all come from readable-mountinfo pids, still fully read):
- read comm/cmdline only for pids with a readable mount view (the only consumers);
the hundreds of unreadable pids skip those two dead reads.
- uid from stat("/proc/pid").st_uid instead of reading and parsing status.
- read mountstats for self only (every other pid is a guaranteed EACCES and its
differential is always a single view).
- sample 40 pids in the fileAccess aggregate (a hidepid-leak diagnostic, not a
verdict input) instead of testing every pid -- tens of thousands of open() calls
removed; result carries tested/visiblePids and a sampled flag.
- do not format+log a line for every unreadable pid.
The Privisolated differential was normalizing benign per-process mounts out BEFORE counting views, so a genuine two-view split (e.g. a WebView sandboxed process lacking the /storage FUSE+tmpfs view a permitted app has) was reported as a single view. That baked our judgment into the raw data the UI shows. Now the raw grouping keeps every mount -- distinctViews is the true count and the panel shows both groups and the exact differing records. The benign judgment is a separate layer: interpretedViews is computed by discounting the mounts Android customises per process (app-data isolation tmpfs and the external-storage subsystem -- /storage and the storage /mnt binds, matched by mountpoint since their fs type varies tmpfs/fuse/sdcardfs), and ONLY that interpreted count drives the verdict (interpreted > classes). A raw difference that collapses once those are removed is flagged benignDifference, not a mismatch. - FileView carries normalized (raw) and normalizedInterpreted. - differentialByFile groups on raw for display, counts interpreted for the verdict, and tags each differing record benign via isBenignKey. - UI shows views as raw->interpreted, a benign result state, and per-record [benign]/[ LEAK ] tags, so the raw truth and our reading are both visible; a non-benign record keeps interpreted > classes and still fires as a LEAK.
…lass pair 7c817ae keyed the verdict on interpretedViews -- raw views minus the storage/data mounts Android customises per process -- on the theory that a same-class view difference is benign per-process variation. That is unsound: Privisolated's invariant is that processes in one propagation class have an identical view, so ANY same-class difference IS the inconsistency, and a module can mount under the very /storage, /mnt, /data/user paths the interpreted layer strips, so discounting them by mountpoint just deletes the evidence. On a stock device two views arise only from two classes (Chrome's isolated services shared:1 vs the WebView zygote master:1); a third view within one class does not. The verdict now fires on rawMismatch = (classes > 0 && distinctViews != classes) -- Privisolated's actual rule, both directions, with classes==0 (no shared:/master: root seen) treated as not-applicable rather than an unconditional mismatch. The interpreted / mismatch / benignDifference machinery and isPerProcessBenignMount are gone; the storage/data predicate survives only to tag a differing record's shape for display. The surfaced records now come from a SAME-class pair: the diff buckets the distinct views by class and, when a class holds more than one view, diffs two of them, so the panel shows the master:1-vs-master:1 leak instead of the normal shared-vs-master delta the two-largest heuristic used to surface. View rows render largest-first and the two diffed views are tagged (A)/(B), so the A-only / B-only records are attributable to a process group. The verdict reason lists per-file counts, e.g. [mountinfo(3/2), mounts(3/2)], and no longer names a cause -- that stays in comments.
JingMatrix
added a commit
to JingMatrix/NeoZygisk
that referenced
this pull request
Aug 24, 2026
The WebView app-zygote forks isolated sandboxed processes that inherit its mount namespace directly, outside the per-process specialization path that handles everything else. webview_zygote itself is not on the denylist, so it keeps the root mount view while a regular isolated process gets the clean one, and the two isolated processes end up presenting different mount content. Treat webview_zygote as if it were on the denylist so it follows the same revert-unmount path, and the isolated children it forks share the same clean namespace as every other isolated process. Fix the Privisolated type detection in JingMatrix/Demo#24.
HSSkyBoy
pushed a commit
to HSSkyBoy/NyaZygisk
that referenced
this pull request
Aug 26, 2026
The WebView app-zygote forks isolated sandboxed processes that inherit its mount namespace directly, outside the per-process specialization path that handles everything else. webview_zygote itself is not on the denylist, so it keeps the root mount view while a regular isolated process gets the clean one, and the two isolated processes end up presenting different mount content. Treat webview_zygote as if it were on the denylist so it follows the same revert-unmount path, and the isolated children it forks share the same clean namespace as every other isolated process. Fix the Privisolated type detection in JingMatrix/Demo#24.
The solist detector was dead on Android 17. It resolves ProtectedDataGuard before anything else and hard-fails if that lookup misses, and the C2/D2 mangled variants of its constructor are gone from the 17 linker, so Initialize() returned false and both "Solist injection" and "Module counter" reported clean without having run. A detector never writes to the linker's protected pages, so requiring that symbol at all was the bug; it is gone. The rest of the walk is rebuilt on offsetof over a real AOSP struct soinfo (include/linker_soinfo.h) instead of per-ABI magic numbers, discovers size, next and constructors_called at runtime the way NeoZygisk does, and keeps every symbol but the list head optional so a hostile build degrades the walk instead of silently disabling it. SandHook::ElfImg is replaced by ElfParser throughout. None of that would have caught NeoZygisk, which unloads through the real linker and then subtracts equally from both module counters so the books still balance. dlphdr.cpp asks a different question, and asks it over a public ABI with no linker symbols at all: dl_iterate_phdr hands over dlpi_adds and dlpi_subs directly, and DT_DEBUG reaches r_debug, whose link_map nodes ARE soinfo::link_map_head -- so the chain leaks the address of every live soinfo, and soinfo comes from a fixed-stride block allocator. On a clean device those addresses rise in lockstep with the enumeration, 360 deltas of exactly one stride with no exceptions. A hole means blocks are on the free list; a block below its predecessor was reclaimed from it. Neither is a verdict alone -- what is conclusive is that the linker counts the same events itself, so free + reclaimed must not exceed dlpi_subs. NeoZygisk v2.4 reports zero unloads while this app's own library sits on a reclaimed block. Measured against two baselines that stay clean: a Pixel 7a on 16 with KernelSU and no modules, and a stock Pixel 7 on 17 whose zygote genuinely unloads seven libraries. - Module counter is informational now. It reads 7 on the stock Pixel 7, so the raw count was never evidence; it becomes evidence only reconciled against the free list. - vmap and the unclaimed-page check no longer key on a mapping's name or its contents. prctl(PR_SET_VMA_ANON_NAME) lets any process call its anonymous memory ".bss" or "dalvik-jit-code-cache", and manually mapped code need not keep an ELF header. What a process cannot forge is whether a mapping is file-backed and whether it is shared, which is what separates ART's oat files and code caches from an injected library. - The linker check rides in librecon, so all three contexts run it; the classic probe's verdict now consumes it, and the report renders it.
41a7b77 claimed the sharing bit and file-backing were facts a process cannot forge about itself, and split unclaimed executable mappings on them. Both halves are wrong. MAP_SHARED is an argument to mmap, and memfd_create supplies a pathname, so changing one token in the loader's replacement mapping -- MAP_PRIVATE to MAP_SHARED, or an anonymous buffer to a memfd -- moves its text straight out of the flagged class and into the excused one, at no cost to the loader. The property the caller does not pick is the device the mapping is backed by, which the filesystem assigns. Anonymous memory, shmem and memfd are all major 0, and no unprivileged process can place its memory on a real block device. Measured on an app process: 380 executable mappings, every one on fe:xx or 07:xx except [vdso] and ART's two code caches on 00:01. That leaves the code caches indistinguishable from an injected memfd by any key that is not chosen by whoever made it -- the memfd name, the sharing bit, an ELF header. A writable view of the same inode looked promising and is measurably false: /memfd:jit- zygote-cache is mapped r--s and r-xs only, so that rule would have flagged a clean device. So executable memory on major 0 is now reported in full with its device, inode and name, and the verdict keeps to the one shape with no legitimate instance at all, private and anonymous. Separately: a solist walk that could not start is no longer reported as a clean walk. That distinction is not academic -- the detector failed open on Android 17 and read as clean for months.
…oinfo blocks DetectInjection() returned `&info` from a `for (auto &info : MapInfo::Scan())` loop. Scan() returns by value, so the vector is a temporary destroyed at the end of the loop and every caller dereferenced freed memory. The symptom was a finding that rendered as "Virtual-map injection:" with nothing after it -- the one case where the detail matters most, because the mapping that fires this check is anonymous and its path is empty by definition. It now returns std::optional<MapInfo>, and Describe() renders the range, permissions, private/shared bit, dev:inode, size, path or <anonymous>, and whether an ELF header sits at the base. A spoofed module library reads as `r-xp 0:0 ino 0 3530752 bytes [anon:dalvik-DEX data] [ELF image]`, which is enough to recognise without going back to /proc. Reclaimed soinfo blocks now count. The verdict used to rest only on the arithmetic contradiction -- more free-list activity than dlpi_subs admits -- which a loader closes by not editing the counter. The reclaim itself cannot be closed that way: the block came back off the linker's free list, and the allocator's memory of that is not the loader's to edit. It is the weaker of the two, since an ordinary dlopen/dlclose/dlopen produces one as well, so the detail distinguishes them: "N freed block(s) the unload counter does not account for, which no unmodified linker can produce" against "the count matches dlpi_subs, but a block was still handed back out of load order". A stock Pixel 7 has seven real unloads and still reports zero reclaimed, so the clean baseline is unaffected. Comments trimmed throughout; the reasoning that earned its place stayed.
The release build turns R8 on with `keepRules.includeDefault = false`, which drops the stock rule that protects native methods. R8 renamed ProcScanner to `yd0` and its two native methods to `k()`/`l()`, and MainActivity's runIntegrityChecks to `i()`; the C symbols are still spelled Java_org_matrix_demo_ProcScanner_nativeReconcile, so in the shipped APK every native call threw UnsatisfiedLinkError. Each caller swallows it, so the release build lost the main-process integrity section, the mount reconciliation and the linker ledger while still rendering CLEAN. proguard-rules.pro (until now the unused AGP template) carries the keep rule and the release build points at it. The rest, in the order they would bite: - probe.cpp accumulated its JSON with `o += snprintf(buf + o, cap - o, ...)`. snprintf returns what it WOULD have written, so the first truncation walks the cursor past the end and every later call gets `cap - o` as a huge size_t -- a heap overflow on exactly the devices this probe targets, since a rooted phone carries the most mount records. Appends now clamp, and the two variable-length arrays reserve room for the fixed tail so a full buffer still closes as valid JSON instead of being thrown away by JSONObject(raw). - solist.cpp only reached its realpath step after the constructors_called heuristic had matched, so on a build where that never matches the get_realpath symbol went unnoticed and the walk fell back to a compile-time offset -- calling c_str() on whatever soinfo word sits there. Resolve realpath from the symbol before the loop, and where neither the symbol nor the heuristic can confirm it, report the walk as unavailable rather than segfaulting in the app's own main process for findings it could not have named anyway. get_name() falls back to the path when only the soname is out of reach. - selfVsInitCard read "onlySelf"; ProcScanner emits "onlyInSelf", so the in-self-not-in-init half of the diff never rendered. - recon.cpp's st_dev fallback bailed out on any path whose last slash is at index 0, which is every depth-1 probe target -- "/system", the one a systemless overlay actually re-hosts -- leaving it with no fallback when statx cannot answer. - ElfImage::findLoadedLibraryInfo passed a string_view's data() to strstr, which reads to the first NUL the view does not promise. Also: include guards on mount.hpp/vmap.hpp/smap.h (mount.hpp additionally relied on transitive includes for unique_ptr, vector, FILE and PATH_MAX), the 8s bind timeout is cancelled in onDestroy so a stale activity cannot flip the new one's flags, and the exec-memory policy string no longer opens with a comma when the first mmap fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYoixQh9gppQoUiuSwqgi4
Findings only, no behaviour change except two deletions of code the comments were describing: - recon.cpp claimed to be linked into libmain and guarded LOGD/LOGI behind #ifndef in case logging.h was absent. It is compiled into librecon.so, which libmain links as a .so, and logging.h is included unconditionally eleven lines above -- the fallbacks could never fire. Both are gone. - DlPhdr::Object::region was documented as the allocator run a block belongs to, but nothing ever assigns it; the run only exists as a local in Run(). The rest are wording: - solist.hpp still said every symbol but the list head is optional; Initialize now also requires a readable realpath, which is why it does. - recon.cpp's Finding::high was documented as the hidden/structural switch. It is always true; the check id carries that split. recon.hpp's Result::structural named only orphans (peer-group gaps count too) and its json shape omitted "probes". - ProcScanner: DATA_ISOLATION_POINTS is matched by whole path segment, so "/data/user" does not cover "/data/user_de" -- which is why that entry is listed separately; markerSummary dedupes on field as well, and the Java catalogue has no LOW entries at all. - integrity.cpp check 2 described only a shadowing map, while the virtual-map scan also reports a non-absolute ELF path, a shared /dev/zero map and a duplicated JIT-cache name. - logging.h called the capture optional; every log() appends to it. - dlphdr.hpp: a benign failed dlopen leaves the residual at +1, which the same header explains further up; and probe.cpp is C++, it just exposes no C++ API. - probe.cpp's word-boundary example named "ksu", which is in the Java catalogue and not this one. - MainActivity: reconcileCard's blurb was stranded above linkerCard. - elf_parser.hpp had a stray leading "-" left by a patch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYoixQh9gppQoUiuSwqgi4
JingMatrix
added a commit
that referenced
this pull request
Sep 3, 2026
Detect root and module mounts from isolated processes, where a normal app
cannot look. Two techniques, each credited to its original proof of concept:
- a classic isolated service, after LSPosed/Privisolated: isolated processes
inherit AID_READPROC, so it reads every peer's /proc/<pid>/{mountinfo,mounts,
mountstats}, scans each record for module markers, and runs the
propagation-class differential;
- a native zygote_next service, after XiaoTong6666/ZygoteNextProbe: forked into
init's global mount namespace on Android 17, where module mounts are still
listed in its own /proc/self/mountinfo.
Two detection cores are shared by all three contexts, the main process and both
probes. Mount reconciliation asks the kernel via statx/statfs what is really
mounted, so it survives kernel-side mountinfo filtering such as KernelSU's
mount_hide. The linker check is built only on dl_iterate_phdr: the
dlpi_adds/dlpi_subs ledger, soinfo free-list gaps, and reconciliation of the
linker's PT_LOAD extents against /proc/self/maps. It needs no linker symbols
and holds on every Android release.
The dashboard is rebuilt in Jetpack Compose, with the existing native toolkit
folded in as a main-process integrity section and export of the report or logs.
The release build enables R8 with keepRules.includeDefault = false, which drops
the stock rule protecting native methods; proguard-rules.pro keeps them, so the
JNI entry points still resolve in the shipped APK.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This variant detects root and module mounts from isolated processes, where a normal app cannot look. It ports two techniques, each credited to its original proof-of-concept:
LSPosed/Privisolated): isolated processes inherit theAID_READPROCgroup, so it reads the/proc/<pid>/{mountinfo,mounts,mountstats}of every peer it is permitted to, scans each record for module markers, and runs the propagation-class differential Privisolated relies on;zygote_nextservice, after ZygoteNextProbe (xiaotong6666/ZygoteNextProbe): forked into init's global mount namespace on Android 17, it reads its own/proc/self/mountinfo, where module mounts remain visible.The dashboard is rebuilt in Jetpack Compose — foldable sections, a raw-data report and a logs panel, and export of either. The existing native toolkit (solist/vmap injection,
/systemremount, mountinfo) is folded in as a main-process integrity section, so every check reads from one screen. A file-access sweep over all visible pids records which sensitive/proc/<pid>entries an isolated reader can actually open, confirming SELinux confines the reach to a handful of same-domain peers.