Third audit pass: WebView as a probing channel, source-verified against AOSP

Three re-audits under new lenses -- the WebView/JS-bridge as an active probe for
hidden APIs and core logic (not just a config risk surface), environment blind
spots, and jvm-art internals -- each instructed to verify claims against the
cnb.cool AOSP mirror. The subagents' network was denied, so the mirror checks
were run in the main session: WebView/WebView.java and WebSettings.java javadocs,
platform.xml, art/runtime/hidden_api.cc, ActivityThread.java, and
libcore's DexPathList.java were all fetched and cited.

The probing lens rewrote system-ipc-audit.md section 3 around what the audit
found: the bridge-inventory hook over-reported (it printed every declared method;
since API 17 only @JavascriptInterface-annotated public methods are exposed --
javadoc-verified, with the targetSdk<17 legacy caveat), the risk table gained a
defaults column so absence stops being read as off (setAllowFileAccess defaults
true through targetSdk 29; the FileURLs pair has been false-and-deprecated since
API 16), and the DevTools entry was split into its two channels (chrome://inspect
self-discovers; the forward is for /json, socket found via /proc/net/unix) with
the process-wide and debuggable-auto-enable gates stated. New: the five-step
probing playbook -- global diff, dictionary probe (enumeration of the injected
object returns empty on modern WebView), arity/type oracle with the parallel
logcat watch (a Java exception stack can name the handler class without any
hook), return-oracle calibration, and the origin-gate read -- each routed back to
the Java class for an ordinary hook.

jvm-art: the hidden-API boundary sentence in dynamic-frida.md was directionally
right and mechanically wrong; it now states the real policy (targetSdk>=28
gating; greylist warn-only; blocklist and greylist-max-X deny -- verified against
hidden_api.cc), the caller-domain mechanism that explains why Frida's bridge
passes where app reflection fails, and the debuggable/policy exemptions. The
classloader walk (pathList -> dexElements -> Element.dexFile, field names
verified against current main) joins the frida-dexdump route as the hand-rolled
fallback that also says which loader owns a dex.

Environment: wireless debugging/pairing (Android 11+, pairing vs connect ports,
plaintext-TCP caveat), the -d downgrade gate (INSTALL_FAILED_VERSION_DOWNGRADE is
a build-type fact), --user N multi-user handling, and run-as as the cheapest
non-root inspection route. One auditor correction was REJECTED by verification:
current-main platform.xml assigns the shell uid only INTERNET, so the proposed
"shell holds INSTALL_PACKAGES/INJECT_EVENTS" rewrite was itself wrong; the
existing measured claim stays, now with the platform fact recorded beside it.

Gates re-run: routing coherent, 412 section references zero dangling, repo clean,
pytest 751 passed / 194 skipped / 25 xfailed.
This commit is contained in:
dabao1955
2026-10-19 21:52:42 +08:00
parent 2bb54a72fb
commit ce227a13c5
5 changed files with 114 additions and 35 deletions
@@ -277,6 +277,16 @@ an extraction shell it can only ever produce the **skeleton**, because the missi
bodies exist nowhere in memory until invoked. No amount of re-dumping fixes that;
only the invocation loop recovers them.
When the tool itself is refused, the walk it automates is short enough to hand-roll
— field names verified against current main (`libcore/dalvik/src/main/java/dalvik/system/DexPathList.java`,
cnb.cool mirror, 2026-10-19): every `BaseDexClassLoader` carries a `pathList`
(`DexPathList`) whose `dexElements` array holds one `Element` per source, each with
its `dexFile` (and the dex's `mCookie` the runtime actually maps). Enumerate
`Java.enumerateClassLoaders`, read `pathList.dexElements[i].dexFile` per loader, and
you have the loader-aware inventory `frida-dexdump` would have produced — useful both
as the fallback and to see *which loader* owns a suspicious dex, which the tool's
flat output does not tell you.
## Dumping when frida is refused
A hardened target can refuse instrumentation outright: the dump tool dies with
+10 -3
View File
@@ -199,9 +199,16 @@ Java.perform(() => {
- **Need a `Context` and have none?** This repository's own scripts already carry the idiom:
`Java.use('android.app.ActivityThread').currentApplication()` — see `scripts/rpc_template.js` and
`scripts/sig_probe.py`, both with a null-application retry the naive version lacks. Note the
boundary: on Android 9+ reflection into hidden/greylist APIs is restricted, which is why a
reflective route into `ServiceManager` or hidden AIDL sometimes fails where the bridge's own
handles still work. *(inferred — platform restriction, absorbed from milksu.org notes, 2026-10-19)*
hidden-API boundary, stated precisely *(verified against `art/runtime/hidden_api.cc` via the
cnb.cool AOSP mirror, 2026-10-19)*: enforcement gates apps by **targetSdk ≥ 28 (P)**; the
**greylist is warn-only** (a logcat `Accessing hidden method …` line, not a failure) — what
denies is the **blocklist**, and a greylist-max-X entry denies only when targetSdk > X. The
check derives the caller domain from the *calling frame's dex* (boot classpath ⇒ trusted), so
an app's reflective `Class.forName(...).getMethod(...)` hits the gate while Frida's bridge —
JNI called from an injected native thread with no app Java frame — resolves as trusted and
sails through. That asymmetry is why `Java.use` reaches what app reflection cannot. Cheapest
neutralization when you control the build: a **debuggable app disables the policy entirely**
(and on userdebug/eng, `settings put global hidden_api_policy`).
- **Dynamic proxy as a hook alternative.** When the target exposes an interface with many or
obfuscated implementations, `java.lang.Proxy.newProxyInstance` with an `InvocationHandler`
registers one interceptor for all of them — the Java-native counterpart of wrapping every
+20 -1
View File
@@ -29,6 +29,18 @@ Root:
adb -s <serial> shell "su -c id"
```
`pm grant` and `pm install` frequently require root on OEM builds; the `shell` user gets `SecurityException`.
One platform fact to keep the claim honest: in AOSP main, `data/etc/platform.xml` assigns the shell
uid only `INTERNET` — the non-root install path on stock devices goes through the adb/PackageInstaller
session route, not a shell permission grant *(verified via the cnb.cool mirror, 2026-10-19)*. Where a
build denies the shell, treat it as that build's policy and keep the `su` wrapper — do not report a
`pm` SecurityException as a target finding before checking it as a shell-user limitation.
**Wireless debugging** (Android 11+), for devices where USB is impractical or flaky: enable in
Developer options → pair once with `adb pair <ip>:<pair-port> <code>`, then `adb connect <ip>:<port>`.
The pairing port and connect port are different and both rotate on toggle; a stale `adb connect`
serial after a reboot is a `disconnect` + re-`connect` fix, not a device fault. Note it is plaintext
TCP on the LAN — fine for a bench, not for a hostile network. *(unverified — absorbed from milksu-era
platform knowledge, 2026-10-19; not exercised by this repo)*
**Always specify `-s <serial>` when more than one device is attached** — otherwise adb errors out or picks the wrong one.
@@ -177,9 +189,16 @@ adb -s <serial> shell "su -c 'pm uninstall <pkg>'"
adb push out.apk /data/local/tmp/x.apk
adb -s <serial> shell "su -c 'pm install -r -t -d /data/local/tmp/x.apk'"
```
- `-r` reinstall (keep data, same signing key) · `-t` allow test-only · `-d` allow downgrade
- `-r` reinstall (keep data, same signing key) · `-t` allow test-only · `-d` allow downgrade — **and `-d` only works on debuggable builds**: on retail user builds expect `INSTALL_FAILED_VERSION_DOWNGRADE`; that string is a build-type fact, not a patch failure
- Different signing key than the installed app → must uninstall first (data is lost)
- Vendor installers may reject `adb install`; pushing + `su -c pm install` usually works
- Multi-user: install lands per-user — `pm install --user N` (or `am start --user N`), data under
`/data/user/<N>/<pkg>` (0 = primary, 10+ = work/guest; adjust the chown recipe's path accordingly),
and `pm uninstall -k --user 0` removes per-user without wiping the APK. *(unverified — platform
knowledge, 2026-10-19)*
- **`run-as <pkg>`** works against any debuggable build without root — `run-as <pkg> ls files/`,
`run-as <pkg> sh` — the cheapest private-data inspection route on a userdebug device, and the one
`capabilities.py`'s root-hint should name alongside Magisk. *(unverified — same date)*
**After reinstalling:** if you restored a data directory, fix ownership, or the app crashes in a DB-init path:
```bash
+2 -1
View File
@@ -181,7 +181,8 @@ API names, `inferred`; their hit-rate as triage shortcuts is not measured here.)
| `Runtime.exec` / `ProcessBuilder` | shell-out behaviour, often config flips or su probes |
| `addJavascriptInterface` | a JS bridge — bridge method inventory next (`references/system-ipc-audit.md`) |
| `PendingIntent.get` without `FLAG_IMMUTABLE` nearby | a mutable capability token (Android 12+ forces a flag) |
| `loadUrl` / `setAllowFileAccess` / `setAllowUniversalAccess` | WebView width — how far web content can reach (`references/system-ipc-audit.md`) |
| `loadUrl` / `setAllowFileAccess` / `setAllowUniversalAccess` | WebView width — how far web content can reach; **absence is not "off"** (e.g. `setAllowFileAccess` defaults true up to targetSdk 29) — see the defaults table (`references/system-ipc-audit.md`) |
| `addWebMessageListener` / `postWebMessage` / `createWebMessageChannel` | a message-style bridge — never transits the `addJavascriptInterface` hook (`references/system-ipc-audit.md`) |
| `DexClassLoader` / `InMemoryDexClassLoader` / `PathClassLoader` on a download path | dynamic dex loading — plugin/hot-update surface (`references/packers.md`, `references/updates-and-forced-upgrade.md`) |
| `getParcelableExtra` on an exported component, result passed to `startActivity`/`sendBroadcast` | intent redirection (`references/system-ipc-audit.md`) |
| `MODE_WORLD_READABLE` / `MODE_WORLD_WRITABLE` | world-open file storage — legacy API, still tripped by old code |
@@ -126,51 +126,93 @@ signature caught live, with the caller stack one `Log.getStackTraceString` away.
counterpart hooks `Intent.getData` and flags `..`, `file://`, `intent://` in the returned URI —
traversal and scheme-abuse inputs without a full monitor.
## 3. WebView and the JS bridge — audit surface, not attack surface
## 3. WebView and the JS bridge — audit surface and probing channel
For reverse engineering the interesting facts are configuration and bridge inventory, both readable
statically (grep a jadx export) and dynamically (hook the registration):
The bridge is not just a risk surface: it is a **probe the app ships for you**. A bridge method is
a Java entry point callable without writing a hook, and its return values, errors, and logcat
echoes are oracles into the app's core logic. The flow below moves from static width to active
probing; sanctioned scope is probing the app's own bridge from a page the app loads — no renderer
sandbox-escape work.
**3.1 Config width (static).** Grep a jadx export; the defaults column decides what *absence* means.
*(Defaults verified against frameworks/base `core/java/android/webkit/WebSettings.java` and
`WebView.java` javadoc via the cnb.cool AOSP mirror, 2026-10-19.)*
| Setting | Effect if `true` | Default |
|---|---|---|
| `setAllowUniversalAccessFromFileURLs` | a `file://` page can read any origin — the app's private files included | **false since API 16**; deprecated — an explicit `true` in jadx is required for the exfil chain on any modern target |
| `setAllowFileAccessFromFileURLs` | `file://` pages can read other `file://` URLs | same: false since 16, deprecated |
| `setAllowFileAccess` | `file://` loadable at all | **true up to targetSdk 29 (Q), false from 30 (R)** — absence is not "off" on a ≤29 app |
| `setAllowContentAccess` | `content://` loadable | true |
| `setJavaScriptEnabled` | the base widening — required by most bridges, rarely the finding itself | false |
| `setDomStorageEnabled` | web-side state under `/data/data/<pkg>/app_webview/` — where tokens often live | false |
| `setMixedContentMode(MIXED_CONTENT_ALWAYS_ALLOW)` | plain-HTTP subresources readable without any TLS fight (`references/tls-and-cert.md`) | NEVER_ALLOW on ≥21 |
| `setWebContentsDebuggingEnabled` | **a free remote-debug entry for you** — see 3.4 | false; **auto-enabled for `android:debuggable` apps on WebView 113.0.5656.0+** (javadoc, same verification) |
**3.2 Bridge inventory (hook, corrected).** Since API 17 the injected object exposes only public
methods annotated `@JavascriptInterface` — for apps *targeting* API ≤16 **all public methods** are
exposed (javadoc-verified, same source). So tag the inventory:
```javascript
// unverified — absorbed from milksu.org notes, not run by this repo
// unverified — absorbed from milksu.org notes, corrected with the annotation filter
var WV = Java.use('android.webkit.WebView');
var JI = Java.use('android.webkit.JavascriptInterface').class;
WV.addJavascriptInterface.implementation = function(obj, name) {
console.log('[WV] bridge: ' + name + ' -> ' + obj.getClass().getName());
var ms = obj.getClass().getDeclaredMethods();
for (var i = 0; i < ms.length; i++) console.log(' ' + ms[i].getName());
for (var i = 0; i < ms.length; i++)
console.log(' ' + (ms[i].getAnnotation(JI) !== null ? '[exposed] ' : '[internal] ')
+ ms[i].getName());
return this.addJavascriptInterface(obj, name);
};
```
The same one-liner shape on `WebView.loadUrl.overload('java.lang.String')` records every URL the
WebView is asked to open — the dynamic counterpart of the `loadUrl` grep row in
`references/recon.md` §5. When the app leaves debugging disabled, `adb logcat | grep Console`
still carries the web side's `console.log` output (`I/chromium ... [INFO:CONSOLE(n)]`) — the
fallback observation channel.
On a targetSdk<17 app the unfiltered list is the truth. The same one-liner shape on
`WebView.loadUrl.overload('java.lang.String')` records every URL opened — the dynamic counterpart
of the `loadUrl` grep row in `references/recon.md` §5. Note which bridges never transit this hook:
URL-scheme interception (`shouldOverrideUrlLoading` parsing a custom scheme) and the
`WebMessageListener`/`postWebMessage` message family — grep for both in the jadx export.
Settings that widen what web content can reach, in descending order of risk:
**3.3 The probing playbook** — run from the app's own page, pasted from `chrome://inspect` or from
a probe page the app is made to load (deep link / `am start --es url`):
Once the bridge is found, the static pass over a jadx export covers four more surfaces the config
table does not: `shouldOverrideUrlLoading` (does it hand `intent://`/`file://` straight to
`startActivity`), `onPageFinished` (what JS gets injected into every page),
`shouldInterceptRequest` (can a responder swap content), and the cookie/storage pair —
`CookieManager` tokens and `localStorage` contents are the exfiltration targets the config rows
measure.
1. **Global diff** — `Object.getOwnPropertyNames(window)` filtered for
`/bridge|android|native|jsb|hybrid|weixin|alipay/i`. A non-standard global names the SDK and
routes to vendor identification; `typeof window[k] !== 'undefined'` is the cheapest existence
oracle there is.
2. **Dictionary probe** — enumeration of the injected object usually returns `[]` on modern
WebView (non-enumerable methods), so make the *dictionary* primary: method names harvested from
the jadx bridge class (3.2), from the bundled front-end JS (`assets/www/*.js`, every
`window.bridge.x(` call site), and from known SDK globals. `typeof o[m]` per candidate:
`'function'` = exposed, `undefined` = not.
3. **Arity/type oracle** — call with `[]`, with extra args, with non-coercible types; extra args
are ignored, missing args arrive as null/defaults, and the error surfaces in the **console
channel or logcat, not necessarily as a JS exception** — so run the probe with
`adb logcat -s chromium Console` watching alongside. A Java-side exception stack in logcat can
name the handler class **without any hook**. (Console relay requires the app not to have
overridden `WebChromeClient` without relaying — `inferred`; if the channel is dead, that is
itself a finding.)
4. **Return oracle** — `undefined` vs `void` is ambiguous until calibrated: call a bridge method
the jadx export shows returning `void`, record what JS actually receives, and unknown returns
become interpretable. For async message bridges, probe args named `callback`/`cb` and watch for
the response arriving on an injected `window.__*` object (discoverable by step 1).
5. **Origin gate** — a bridge that silently no-ops from your page but works from the app's own
page is an origin check: record it, and probe from a page origin the app trusts instead.
| Setting | Effect if `true` |
|---|---|
| `setAllowUniversalAccessFromFileURLs` | a `file://` page can read any origin — the app's private files included |
| `setAllowFileAccessFromFileURLs` | `file://` pages can read other `file://` URLs |
| `setAllowFileAccess` / `setAllowContentAccess` | `file://` / `content://` loadable at all |
| `setJavaScriptEnabled` | the base widening — required by most bridges, so rarely the finding itself |
| `setDomStorageEnabled` | web-side state under `/data/data/<pkg>/app_webview/` — where tokens often live |
| `setMixedContentMode(MIXED_CONTENT_ALWAYS_ALLOW)` | plain-HTTP subresources readable without any TLS fight (`references/tls-and-cert.md`) |
| `setWebContentsDebuggingEnabled` | **in production: a free remote-debug entry for you** — `adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>` then `chrome://inspect`, no root needed |
Then route to Java: method name → jadx string cross-ref (`recon.md` §6) → the
`@JavascriptInterface` method → owning class → ordinary Java hook (`dynamic-frida.md`). The hook in
3.2 names the class for `addJavascriptInterface` bridges; for the other two bridge shapes the
static grep row is the entry.
A `file://` deep link plus the first two settings is the classic private-data-exfiltration chain:
drop an HTML file, get it opened, read `/data/data/<pkg>/…` from JS. When the app's logic you are
chasing lives behind a JS bridge method (`window.android.foo()`), the hook above names the Java
class; from there it is an ordinary Java-layer hook, not a web problem.
**3.4 DevTools entry.** Two channels, different plumbing: `chrome://inspect` does its own adb
discovery and needs no forward; the manual route is for direct HTTP — find the socket first
(`adb shell cat /proc/net/unix | grep devtools`; the name `webview_devtools_remote_<pid>` carries
the pid), then `adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>` and read
`http://localhost:9222/json`. The switch is **static and process-wide** — every WebView in the
process, not per-instance — and auto-enabled on debuggable apps from WebView 113
(javadoc-verified, 3.1). When it is off and no debuggable flag, `adb logcat | grep Console`
(`I/chromium ... [INFO:CONSOLE(n)]`) is the fallback observation channel — if the app has not
overridden the chrome client without relaying.
## 4. SELinux — when it blocks *your* tools, not the app