Skip to content

Isolated-process mount-view detection - #24

Merged
JingMatrix merged 12 commits into
masterfrom
isolated
Sep 3, 2026
Merged

Isolated-process mount-view detection#24
JingMatrix merged 12 commits into
masterfrom
isolated

Conversation

@JingMatrix

Copy link
Copy Markdown
Owner

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:

  • a classic isolated service, after Privisolated (LSPosed/Privisolated): isolated processes inherit the AID_READPROC group, 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;
  • a native zygote_next service, 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, /system remount, 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.

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.
JingMatrix and others added 5 commits September 3, 2026 15:11
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
JingMatrix merged commit 616ebed into master Sep 3, 2026
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.
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.

1 participant