From ce227a13c525ec15b2bb3736048a1bf3d196ed7a Mon Sep 17 00:00:00 2001 From: dabao1955 Date: Mon, 19 Oct 2026 21:52:42 +0800 Subject: [PATCH] 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. --- .../references/advanced-unpacking.md | 10 ++ .../apk-reverse/references/dynamic-frida.md | 13 ++- skills/apk-reverse/references/environment.md | 21 +++- skills/apk-reverse/references/recon.md | 3 +- .../references/system-ipc-audit.md | 102 ++++++++++++------ 5 files changed, 114 insertions(+), 35 deletions(-) diff --git a/skills/apk-reverse/references/advanced-unpacking.md b/skills/apk-reverse/references/advanced-unpacking.md index 683ea3f..59d471a 100644 --- a/skills/apk-reverse/references/advanced-unpacking.md +++ b/skills/apk-reverse/references/advanced-unpacking.md @@ -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 diff --git a/skills/apk-reverse/references/dynamic-frida.md b/skills/apk-reverse/references/dynamic-frida.md index 459f28e..f4206d4 100644 --- a/skills/apk-reverse/references/dynamic-frida.md +++ b/skills/apk-reverse/references/dynamic-frida.md @@ -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 diff --git a/skills/apk-reverse/references/environment.md b/skills/apk-reverse/references/environment.md index 05b28b5..1e90921 100644 --- a/skills/apk-reverse/references/environment.md +++ b/skills/apk-reverse/references/environment.md @@ -29,6 +29,18 @@ Root: adb -s 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 : `, then `adb connect :`. +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 ` when more than one device is attached** — otherwise adb errors out or picks the wrong one. @@ -177,9 +189,16 @@ adb -s shell "su -c 'pm uninstall '" adb push out.apk /data/local/tmp/x.apk adb -s 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//` (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 `** works against any debuggable build without root — `run-as ls files/`, + `run-as 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 diff --git a/skills/apk-reverse/references/recon.md b/skills/apk-reverse/references/recon.md index cb67a06..adbe4f1 100644 --- a/skills/apk-reverse/references/recon.md +++ b/skills/apk-reverse/references/recon.md @@ -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 | diff --git a/skills/apk-reverse/references/system-ipc-audit.md b/skills/apk-reverse/references/system-ipc-audit.md index c8592b0..6650114 100644 --- a/skills/apk-reverse/references/system-ipc-audit.md +++ b/skills/apk-reverse/references/system-ipc-audit.md @@ -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//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//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_` 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//…` 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_` carries +the pid), then `adb forward tcp:9222 localabstract:webview_devtools_remote_` 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