diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/AndroidManifest_reversed.xml b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/AndroidManifest_reversed.xml index 6fc94caa16c..1a9f73edd17 100644 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/AndroidManifest_reversed.xml +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/AndroidManifest_reversed.xml @@ -20,7 +20,6 @@ android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:debuggable="true" - android:testOnly="true" android:allowBackup="true" android:supportsRtl="true" android:extractNativeLibs="false" diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/MASTG-DEMO-0128.md b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/MASTG-DEMO-0128.md index 3a01f884dda..0f3bf62bec4 100644 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/MASTG-DEMO-0128.md +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/MASTG-DEMO-0128.md @@ -17,24 +17,41 @@ However, `SecretActivity` is declared as exported in the `AndroidManifest.xml` w ## Steps -1. Use @MASTG-TECH-0117 to obtain the AndroidManifest.xml. -2. Use @MASTG-TECH-0160 to list the exported activities and their associated `android:permission` by running `run.sh`. +1. Use @MASTG-TECH-0013 to reverse engineer the app and @MASTG-TECH-0117 to obtain the `AndroidManifest.xml`. +2. Use @MASTG-TECH-0160 to enumerate the activities, their export state, and any associated `android:permission`. `run.sh` does this by running @MASTG-TOOL-0110 in its "Stage 1" with the following rule, which flags activities that are exported without a permission and lists the permission-protected ones separately: + +{{ ../../../../rules/mastg-android-exported-activity.yml }} + +3. Use @MASTG-TECH-0014 to inspect the decompiled code of each exported activity. `run.sh` runs @MASTG-TOOL-0110 in its "Stage 2" with the following rule to locate the lifecycle entry points reachable when the activity is started (`onCreate`, `onStart`, `onResume`, `onNewIntent`): + +{{ ../../../../rules/mastg-android-activity-entrypoints.yml }} {{ run.sh }} +4. Run `evaluate.sh` to reduce the exported, unprotected activities from "Stage 1" to the ones the app itself declares. Activities in framework or library namespaces (`android.*`, `androidx.*`, `com.google.android.*`) ship with dependencies rather than the app's own code, so they are triaged separately: + +{{ evaluate.sh }} + ## Observation -The output reveals the exported activities and their associated permissions: +"Stage 1" lists the exported activities and their permissions, and "Stage 2" points at the activity code to review: -{{ output.txt }} +{{ manifest_scan.txt # code_scan.txt }} + +`evaluate.sh` narrows the exported, unprotected activities down to the app's own components: + +{{ evaluation.txt }} ## Evaluation The test case fails because `SecretActivity` exposes sensitive functionality and is exported (`android:exported="true"`) without any permission protection, so external callers can start it directly by using an explicit intent. -`PinEntryActivity` does not protect the underlying exported activity; access control must be enforced at the `SecretActivity` boundary. +The "Stage 1" scan reports four exported, unprotected activities, and we discard three of them: -The activity displays account data in `onCreate` without checking whether the user completed the PIN challenge: +- `androidx.compose.ui.tooling.PreviewActivity` is a Compose tooling activity used by Android Studio to run composable previews, and `androidx.activity.ComponentActivity` is a generic host activity added by the Compose libraries. Both live in the `androidx.*` namespace, so `evaluate.sh` drops them as library code; they are not part of the app's authentication flow and should only be reviewed if the tested build is meant for production. +- `MainActivity` is app-owned, so it survives the namespace filter and remains in `evaluation.txt`, but the "Stage 1" scan shows it carries the `LAUNCHER` intent filter. Launcher activities must be exported so Android and the launcher can start the app (see [Android's guidance](https://developer.android.com/about/versions/12/behavior-changes-12#exported)), and its `onCreate` only shows the app's entry screen, so it is discarded after manual review. + +`org.owasp.mastestapp.MastgTest$SecretActivity` is the remaining app-owned activity. Its `onCreate` displays sensitive account data without checking whether the user completed the PIN challenge. ```kotlin class SecretActivity : Activity() { @@ -45,10 +62,79 @@ class SecretActivity : Activity() { } ``` -The output also lists other exported activities. These are triaged but not reported as vulnerable in this test case. +### Confirm the Exposure + +You can use @MASTG-TECH-0160 to start `SecretActivity` directly and confirm that the sensitive screen is reachable without entering the PIN: + +```bash +adb shell am start -n 'org.owasp.mastestapp/org.owasp.mastestapp.MastgTest\$SecretActivity' +``` + +The secret screen appears without any PIN prompt, confirming the authentication bypass. + +An external app can start `SecretActivity` directly, but that does not automatically let the external app read the activity's UI contents or obtain the displayed data programmatically. Android does not normally return another activity's screen text to the caller. + +The security issue is that the protected screen becomes reachable without completing the PIN challenge. This can still expose sensitive data to anyone using the device, to screen capture or accessibility based threats, or to any flow where the attacker can trick the user into opening the activity. If the activity also returns data through results, sends broadcasts, writes files, accepts attacker controlled extras, or performs account actions on launch, the impact could be higher. + +In this sample, the finding is an authentication bypass because `SecretActivity` displays sensitive account data without verifying that the user completed the PIN challenge. The direct launch proves unauthorized access to the protected screen, even though the calling app does not automatically read the displayed data. + +## Fix + +There are two ways to fix this, and the right choice depends on whether `SecretActivity` needs to be reachable by external apps at all. + +**Option 1: Set `android:exported="false"` (recommended for most apps)** + +If `SecretActivity` has no legitimate reason to be started by another app, simply prevent external apps from reaching it: + +```xml + +``` + +Trying to start `SecretActivity` again with `adb` after this change will fail with an error, confirming that the activity is no longer reachable from outside the app: + +```bash +adb shell am start -n 'org.owasp.mastestapp/org.owasp.mastestapp.MastgTest\$SecretActivity' +Starting: Intent { cmp=org.owasp.mastestapp/.MastgTest$SecretActivity } + +Exception occurred while executing 'start': +java.lang.SecurityException: Permission Denial: starting Intent { flg=0x10000000 xflg=0x4 cmp=org.owasp.mastestapp/.MastgTest$SecretActivity } from null (pid=29738, uid=2000) not exported from uid 10225 +``` + +This is the right choice for the vast majority of activities that display sensitive data or are part of an internal authentication flow. Android 12 and later require you to explicitly set `android:exported` on any activity with an ``; setting it to `false` on activities that don't need it is the minimal, correct fix. + +**Option 2: Keep `android:exported="true"` but enforce a `android:permission`** + +If the activity must be reachable by a trusted partner app (for example, a companion widget or a deep-link handler used by a first-party browser), you can keep it exported but gate access with a custom signature-level permission: + +```xml + + + + + +``` + +With `protectionLevel="signature"`, only apps signed with the same certificate are granted the permission automatically. A real-world example is a banking app that exposes a payment-confirmation activity to its own companion wearable app. Both are signed with the bank's certificate, so only the wearable can start the activity, while any third-party app is rejected by the OS before `onCreate` is even called. -`MainActivity` is the launcher activity. Launcher activities normally need to be exported so Android and the launcher can start the app. [Android's guidance](https://developer.android.com/about/versions/12/behavior-changes-12#exported) says activities with the `LAUNCHER` category should use `android:exported="true"`, while most other components should use `false`. +This permission-based fix only resolves the finding if the permission cannot be obtained by untrusted apps. If the activity were protected by a broadly grantable permission, such as a custom permission with `normal` or `dangerous` protection level, the demo would still fail because untrusted apps could still obtain the permission and start the activity. See @MASTG-KNOW-0017 for Android permission protection levels. + +Trying to start `SecretActivity` again with `adb` after this change will fail with a different error, confirming that the activity is still exported but now requires a permission that the calling app does not have: + +```bash +adb shell am start -n 'org.owasp.mastestapp/org.owasp.mastestapp.MastgTest\$SecretActivity' +Starting: Intent { cmp=org.owasp.mastestapp/.MastgTest$SecretActivity } + +Exception occurred while executing 'start': +java.lang.SecurityException: Permission Denial: starting Intent { flg=0x10000000 xflg=0x4 cmp=org.owasp.mastestapp/.MastgTest$SecretActivity } from null (pid=29880, uid=2000) requires org.owasp.mastestapp.ACCESS_SECRET +``` -`androidx.activity.ComponentActivity` is commonly added by the Compose UI test manifest as a generic host activity for Compose tests. This is expected in debug or test builds, but should be reviewed if it appears in a production build. +**Why not rely solely on the PIN check in the calling activity?:** -`androidx.compose.ui.tooling.PreviewActivity` is a Compose tooling activity used by Android Studio to run composable previews. It is not part of the app's authentication flow and should normally be treated as development tooling unless the tested build is intended for production. +Enforcing authentication only in `PinEntryActivity` and trusting that `SecretActivity` is always reached through it is a broken client-side control. Android's activity model makes no such guarantee: any exported activity can be started directly. Authentication state must be checked inside the activity that performs the sensitive operation, or the activity must not be exported. diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/MastgTest_reversed.java b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/MastgTest_reversed.java new file mode 100644 index 00000000000..ae0de0575f4 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/MastgTest_reversed.java @@ -0,0 +1,120 @@ +package org.owasp.mastestapp; + +import android.app.ActionBar; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import androidx.compose.material.MenuKt; +import kotlin.Metadata; +import kotlin.jvm.internal.Intrinsics; +import org.owasp.mastestapp.MastgTest; + +/* JADX INFO: compiled from: MastgTest.kt */ +/* JADX INFO: loaded from: classes3.dex */ +@Metadata(d1 = {"\u0000\u001a\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u000e\n\u0002\b\u0003\b\u0007\u0018\u00002\u00020\u0001:\u0002\b\tB\u000f\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0004\b\u0004\u0010\u0005J\u0006\u0010\u0006\u001a\u00020\u0007R\u000e\u0010\u0002\u001a\u00020\u0003X\u0082\u0004¢\u0006\u0002\n\u0000¨\u0006\n"}, d2 = {"Lorg/owasp/mastestapp/MastgTest;", "", "context", "Landroid/content/Context;", "", "(Landroid/content/Context;)V", "mastgTest", "", "PinEntryActivity", "SecretActivity", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) +public final class MastgTest { + public static final int $stable = 8; + private final Context context; + + public MastgTest(Context context) { + Intrinsics.checkNotNullParameter(context, "context"); + this.context = context; + } + + public final String mastgTest() { + Context context = this.context; + Intent $this$mastgTest_u24lambda_u240 = new Intent(this.context, (Class) PinEntryActivity.class); + $this$mastgTest_u24lambda_u240.addFlags(268435456); + context.startActivity($this$mastgTest_u24lambda_u240); + return "Launching PIN entry screen..."; + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000\u0018\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0012\u0010\u0004\u001a\u00020\u00052\b\u0010\u0006\u001a\u0004\u0018\u00010\u0007H\u0014¨\u0006\b"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$PinEntryActivity;", "Landroid/app/Activity;", "", "()V", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class PinEntryActivity extends Activity { + public static final int $stable = 0; + + @Override // android.app.Activity + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(1); + layout.setPadding(64, MenuKt.InTransitionDuration, 64, 64); + TextView $this$onCreate_u24lambda_u241 = new TextView(this); + $this$onCreate_u24lambda_u241.setText("MASTestApp - Secure Area"); + $this$onCreate_u24lambda_u241.setTextSize(22.0f); + TextView $this$onCreate_u24lambda_u242 = new TextView(this); + $this$onCreate_u24lambda_u242.setText("Enter your PIN to access the secret screen."); + $this$onCreate_u24lambda_u242.setTextSize(16.0f); + $this$onCreate_u24lambda_u242.setPadding(0, 24, 0, 48); + final EditText $this$onCreate_u24lambda_u243 = new EditText(this); + $this$onCreate_u24lambda_u243.setHint("PIN"); + $this$onCreate_u24lambda_u243.setInputType(18); + Button $this$onCreate_u24lambda_u245 = new Button(this); + $this$onCreate_u24lambda_u245.setText("Start"); + $this$onCreate_u24lambda_u245.setOnClickListener(new View.OnClickListener() { // from class: org.owasp.mastestapp.MastgTest$PinEntryActivity$$ExternalSyntheticLambda0 + @Override // android.view.View.OnClickListener + public final void onClick(View view) { + MastgTest.PinEntryActivity.onCreate$lambda$5$lambda$4($this$onCreate_u24lambda_u243, this, view); + } + }); + layout.addView($this$onCreate_u24lambda_u241); + layout.addView($this$onCreate_u24lambda_u242); + layout.addView($this$onCreate_u24lambda_u243); + layout.addView($this$onCreate_u24lambda_u245); + setContentView(layout); + } + + /* JADX INFO: Access modifiers changed from: private */ + public static final void onCreate$lambda$5$lambda$4(EditText pinInput, PinEntryActivity this$0, View it) { + Intrinsics.checkNotNullParameter(pinInput, "$pinInput"); + Intrinsics.checkNotNullParameter(this$0, "this$0"); + if (Intrinsics.areEqual(pinInput.getText().toString(), "4321")) { + this$0.startActivity(new Intent(this$0, (Class) SecretActivity.class)); + } else { + new AlertDialog.Builder(this$0).setTitle("Wrong PIN").setMessage("Incorrect PIN. Try again.").setPositiveButton("OK", (DialogInterface.OnClickListener) null).show(); + } + } + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000\u0018\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0012\u0010\u0004\u001a\u00020\u00052\b\u0010\u0006\u001a\u0004\u0018\u00010\u0007H\u0014¨\u0006\b"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$SecretActivity;", "Landroid/app/Activity;", "", "()V", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class SecretActivity extends Activity { + public static final int $stable = 0; + + @Override // android.app.Activity + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + StringBuilder $this$onCreate_u24lambda_u240 = new StringBuilder(); + $this$onCreate_u24lambda_u240.append("SECRET SCREEN (reached without authentication)\n\n"); + $this$onCreate_u24lambda_u240.append("Account: 1234-5678-9012-3456\n"); + $this$onCreate_u24lambda_u240.append("Balance: 10,000\n"); + $this$onCreate_u24lambda_u240.append("Recovery PIN: 4321"); + String secret = $this$onCreate_u24lambda_u240.toString(); + ScrollView scrollView = new ScrollView(this); + TextView $this$onCreate_u24lambda_u241 = new TextView(this); + $this$onCreate_u24lambda_u241.setText(secret); + $this$onCreate_u24lambda_u241.setTextSize(28.0f); + $this$onCreate_u24lambda_u241.setPadding(48, 48, 48, 48); + scrollView.addView($this$onCreate_u24lambda_u241); + setContentView(scrollView); + } + } +} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/code_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/code_scan.txt new file mode 100644 index 00000000000..b2f3394459c --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/code_scan.txt @@ -0,0 +1,72 @@ + + +┌─────────────────┐ +│ 2 Code Findings │ +└─────────────────┘ + + MastgTest_reversed.java + ❱ rules.mastg-android-activity-entrypoint + [MASVS-PLATFORM] Activity entry point reached when the activity is started. Review it for sensitive + functionality that should not be reachable by an external caller. + + 46┆ @Override // android.app.Activity + 47┆ protected void onCreate(Bundle savedInstanceState) { + 48┆ super.onCreate(savedInstanceState); + 49┆ ActionBar actionBar = getActionBar(); + 50┆ if (actionBar != null) { + 51┆ actionBar.hide(); + 52┆ } + 53┆ LinearLayout layout = new LinearLayout(this); + 54┆ layout.setOrientation(1); + 55┆ layout.setPadding(64, MenuKt.InTransitionDuration, 64, 64); + 56┆ TextView $this$onCreate_u24lambda_u241 = new TextView(this); + 57┆ $this$onCreate_u24lambda_u241.setText("MASTestApp - Secure Area"); + 58┆ $this$onCreate_u24lambda_u241.setTextSize(22.0f); + 59┆ TextView $this$onCreate_u24lambda_u242 = new TextView(this); + 60┆ $this$onCreate_u24lambda_u242.setText("Enter your PIN to access the secret screen."); + 61┆ $this$onCreate_u24lambda_u242.setTextSize(16.0f); + 62┆ $this$onCreate_u24lambda_u242.setPadding(0, 24, 0, 48); + 63┆ final EditText $this$onCreate_u24lambda_u243 = new EditText(this); + 64┆ $this$onCreate_u24lambda_u243.setHint("PIN"); + 65┆ $this$onCreate_u24lambda_u243.setInputType(18); + 66┆ Button $this$onCreate_u24lambda_u245 = new Button(this); + 67┆ $this$onCreate_u24lambda_u245.setText("Start"); + 68┆ $this$onCreate_u24lambda_u245.setOnClickListener(new View.OnClickListener() { // from + class: org.owasp.mastestapp.MastgTest$PinEntryActivity$$ExternalSyntheticLambda0 + 69┆ @Override // android.view.View.OnClickListener + 70┆ public final void onClick(View view) { + 71┆ + MastgTest.PinEntryActivity.onCreate$lambda$5$lambda$4($this$onCreate_u24lambda_u243, this, + view); + 72┆ } + 73┆ }); + 74┆ layout.addView($this$onCreate_u24lambda_u241); + 75┆ layout.addView($this$onCreate_u24lambda_u242); + 76┆ layout.addView($this$onCreate_u24lambda_u243); + 77┆ layout.addView($this$onCreate_u24lambda_u245); + 78┆ setContentView(layout); + 79┆ } + ⋮┆---------------------------------------- + 98┆ @Override // android.app.Activity + 99┆ protected void onCreate(Bundle savedInstanceState) { + 100┆ super.onCreate(savedInstanceState); + 101┆ ActionBar actionBar = getActionBar(); + 102┆ if (actionBar != null) { + 103┆ actionBar.hide(); + 104┆ } + 105┆ StringBuilder $this$onCreate_u24lambda_u240 = new StringBuilder(); + 106┆ $this$onCreate_u24lambda_u240.append("SECRET SCREEN (reached without + authentication)\n\n"); + 107┆ $this$onCreate_u24lambda_u240.append("Account: 1234-5678-9012-3456\n"); + 108┆ $this$onCreate_u24lambda_u240.append("Balance: 10,000\n"); + 109┆ $this$onCreate_u24lambda_u240.append("Recovery PIN: 4321"); + 110┆ String secret = $this$onCreate_u24lambda_u240.toString(); + 111┆ ScrollView scrollView = new ScrollView(this); + 112┆ TextView $this$onCreate_u24lambda_u241 = new TextView(this); + 113┆ $this$onCreate_u24lambda_u241.setText(secret); + 114┆ $this$onCreate_u24lambda_u241.setTextSize(28.0f); + 115┆ $this$onCreate_u24lambda_u241.setPadding(48, 48, 48, 48); + 116┆ scrollView.addView($this$onCreate_u24lambda_u241); + 117┆ setContentView(scrollView); + 118┆ } + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/evaluate.sh b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/evaluate.sh new file mode 100755 index 00000000000..39500891b1a --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/evaluate.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Stage 3 (reduce) — from the exported, unprotected activities found in Stage 1, keep only the +# ones the app itself declares. Activities in framework/library namespaces (android.*, androidx.*, +# com.google.android.*) are shipped by dependencies, not authored by the app, so they are triaged +# separately. Whatever remains is the shortlist to inspect manually for sensitive functionality. +jq -r '.results[] + | select(.check_id | endswith("activity-exported-without-permission")) + | .extra.message + | sub("(?s).*activity="; "") | gsub("\\s+"; "")' manifest_scan.json \ + | grep -vE '^(android|androidx|com\.google\.android|com\.android)\.' \ + > evaluation.txt diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/evaluation.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/evaluation.txt new file mode 100644 index 00000000000..68fb557fd5b --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/evaluation.txt @@ -0,0 +1,2 @@ +org.owasp.mastestapp.MainActivity +org.owasp.mastestapp.MastgTest.SecretActivity diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/manifest_scan.json b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/manifest_scan.json new file mode 100644 index 00000000000..8f57104b81e --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/manifest_scan.json @@ -0,0 +1 @@ +{"version":"1.142.1","results":[{"check_id":"rules.mastg-android-activity-exported-without-permission","path":"AndroidManifest_reversed.xml","start":{"line":30,"col":9,"offset":1304},"end":{"line":39,"col":20,"offset":1739},"extra":{"message":"[MASVS-PLATFORM] Exported activity without android:permission, reachable by any app on the device. Inspect its code for sensitive functionality: activity=org.owasp.mastestapp.MainActivity\n","metadata":{"summary":"Detects an exported activity that does not enforce any caller permission.","masvs":["MASVS-PLATFORM"]},"severity":"WARNING","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}},{"check_id":"rules.mastg-android-activity-exported-without-permission","path":"AndroidManifest_reversed.xml","start":{"line":44,"col":9,"offset":1944},"end":{"line":47,"col":38,"offset":2128},"extra":{"message":"[MASVS-PLATFORM] Exported activity without android:permission, reachable by any app on the device. Inspect its code for sensitive functionality: activity=org.owasp.mastestapp.MastgTest.SecretActivity\n","metadata":{"summary":"Detects an exported activity that does not enforce any caller permission.","masvs":["MASVS-PLATFORM"]},"severity":"WARNING","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}},{"check_id":"rules.mastg-android-activity-exported-without-permission","path":"AndroidManifest_reversed.xml","start":{"line":48,"col":9,"offset":2137},"end":{"line":50,"col":38,"offset":2255},"extra":{"message":"[MASVS-PLATFORM] Exported activity without android:permission, reachable by any app on the device. Inspect its code for sensitive functionality: activity=androidx.compose.ui.tooling.PreviewActivity\n","metadata":{"summary":"Detects an exported activity that does not enforce any caller permission.","masvs":["MASVS-PLATFORM"]},"severity":"WARNING","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}},{"check_id":"rules.mastg-android-activity-exported-without-permission","path":"AndroidManifest_reversed.xml","start":{"line":51,"col":9,"offset":2264},"end":{"line":54,"col":38,"offset":2450},"extra":{"message":"[MASVS-PLATFORM] Exported activity without android:permission, reachable by any app on the device. Inspect its code for sensitive functionality: activity=androidx.activity.ComponentActivity\n","metadata":{"summary":"Detects an exported activity that does not enforce any caller permission.","masvs":["MASVS-PLATFORM"]},"severity":"WARNING","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}}],"errors":[],"paths":{"scanned":["AndroidManifest_reversed.xml"]},"time":{"rules":[],"rules_parse_time":0.0015790462493896484,"profiling_times":{"config_time":0.09379100799560547,"core_time":0.2906486988067627,"ignores_time":2.47955322265625e-05,"total_time":0.38464784622192383},"parsing_time":{"total_time":0.0,"per_file_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"scanning_time":{"total_time":0.005553007125854492,"per_file_time":{"mean":0.005553007125854492,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"matching_time":{"total_time":0.0,"per_file_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_files":[]},"tainting_time":{"total_time":0.0,"per_def_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_defs":[]},"fixpoint_timeouts":[],"prefiltering":{"project_level_time":0.0,"file_level_time":0.0,"rules_with_project_prefilters_ratio":0.0,"rules_with_file_prefilters_ratio":1.0,"rules_selected_ratio":1.0,"rules_matched_ratio":1.0},"targets":[],"total_bytes":0,"max_memory_bytes":77331200},"engine_requested":"OSS","skipped_rules":[]} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/manifest_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/manifest_scan.txt new file mode 100644 index 00000000000..1a9b268b861 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/manifest_scan.txt @@ -0,0 +1,48 @@ + + +┌─────────────────┐ +│ 4 Code Findings │ +└─────────────────┘ + + AndroidManifest_reversed.xml + ❯❱ rules.mastg-android-activity-exported-without-permission + [MASVS-PLATFORM] Exported activity without android:permission, reachable by any app on the device. + Inspect its code for sensitive functionality: activity=org.owasp.mastestapp.MainActivity + + 30┆ + 35┆ + 36┆ + 37┆ + 38┆ + 39┆ + ⋮┆---------------------------------------- + ❯❱ rules.mastg-android-activity-exported-without-permission + [MASVS-PLATFORM] Exported activity without android:permission, reachable by any app on the device. + Inspect its code for sensitive functionality: activity=org.owasp.mastestapp.MastgTest.SecretActivity + + 44┆ + ⋮┆---------------------------------------- + ❯❱ rules.mastg-android-activity-exported-without-permission + [MASVS-PLATFORM] Exported activity without android:permission, reachable by any app on the device. + Inspect its code for sensitive functionality: activity=androidx.compose.ui.tooling.PreviewActivity + + 48┆ + ⋮┆---------------------------------------- + ❯❱ rules.mastg-android-activity-exported-without-permission + [MASVS-PLATFORM] Exported activity without android:permission, reachable by any app on the device. + Inspect its code for sensitive functionality: activity=androidx.activity.ComponentActivity + + 51┆ + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/output.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/output.txt deleted file mode 100644 index 14b8fe7102b..00000000000 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/output.txt +++ /dev/null @@ -1,4 +0,0 @@ -Exported activity: org.owasp.mastestapp.MainActivity | permission: none -Exported activity: org.owasp.mastestapp.MastgTest.SecretActivity | permission: none -Exported activity: androidx.compose.ui.tooling.PreviewActivity | permission: none -Exported activity: androidx.activity.ComponentActivity | permission: none diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/run.sh b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/run.sh index e1815430f58..ce82d4a5c0a 100755 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/run.sh +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0128/run.sh @@ -1,13 +1,11 @@ #!/bin/bash -# List the activities declared as exported in the AndroidManifest. -python3 - <<'PY' > output.txt -import re -xml = open("AndroidManifest_reversed.xml").read() -for m in re.finditer(r"|)", xml, re.S): - block = m.group(0) - name = re.search(r'android:name="([^"]+)"', block) - exported = re.search(r'android:exported="([^"]+)"', block) - permission = re.search(r'android:permission="([^"]+)"', block) - if exported and exported.group(1) == "true": - print("Exported activity:", name.group(1) if name else "?", "| permission:", permission.group(1) if permission else "none") -PY + +# Stage 1 (capture) — enumerate the manifest-declared activities. The rule flags every +# activity that is exported without an android:permission, and lists the permission-protected +# ones separately so they can be triaged. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-exported-activity.yml ./AndroidManifest_reversed.xml --text --max-lines-per-finding 0 > manifest_scan.txt +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-exported-activity.yml ./AndroidManifest_reversed.xml --json > manifest_scan.json 2>/dev/null + +# Stage 2 (inspect code) — locate the lifecycle entry points reachable when an activity is +# started in the decompiled code. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-activity-entrypoints.yml ./MastgTest_reversed.java --text --max-lines-per-finding 0 > code_scan.txt diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/AndroidManifest_reversed.xml b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/AndroidManifest_reversed.xml index 04acf0c6c4d..cc23ba7cddc 100644 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/AndroidManifest_reversed.xml +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/AndroidManifest_reversed.xml @@ -20,7 +20,6 @@ android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:debuggable="true" - android:testOnly="true" android:allowBackup="true" android:supportsRtl="true" android:extractNativeLibs="false" @@ -75,6 +74,15 @@ + + + + + + + + + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/MASTG-DEMO-0129.md b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/MASTG-DEMO-0129.md index 5c0e18a53a0..2f3dd752c85 100644 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/MASTG-DEMO-0129.md +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/MASTG-DEMO-0129.md @@ -15,24 +15,36 @@ The sample implements a small password vault. Tapping **Start** opens `VaultActi ## Steps -1. Use @MASTG-TECH-0117 to obtain the AndroidManifest.xml. -2. Use @MASTG-TECH-0161 to list the exported services and their associated `android:permission` by running `run.sh`. +1. Use @MASTG-TECH-0013 to reverse engineer the app and @MASTG-TECH-0117 to obtain the `AndroidManifest.xml`. +2. Use @MASTG-TECH-0161 to enumerate the services, their export state, and any associated `android:permission`. `run.sh` does this by running @MASTG-TOOL-0110 with the following rule, which flags services that are exported without a permission and lists the permission-protected ones separately: -The `run.sh` script lists the exported services declared in the reverse-engineered manifest. +{{ ../../../../rules/mastg-android-exported-service.yml }} + +3. Use @MASTG-TECH-0014 to inspect the decompiled code of each exported service. `run.sh` runs @MASTG-TOOL-0110 with the following rule to locate the entry points reachable when the service is started or bound (`onStartCommand`, `onBind`, `onRebind`, `onHandleIntent`) and any runtime caller-permission checks: + +{{ ../../../../rules/mastg-android-service-entrypoints.yml }} {{ run.sh }} +4. Run `evaluate.sh` to reduce the exported, unprotected services from "Stage 1" to the ones the app itself declares. Services in framework or library namespaces (`android.*`, `androidx.*`, `com.google.android.*`) ship with dependencies rather than the app's own code, so they are triaged separately: + +{{ evaluate.sh }} + ## Observation -The output reveals the exported services and their associated permissions: +"Stage 1" lists the exported services and their permissions, and "Stage 2" points at the service code to review: + +{{ manifest_scan.txt # code_scan.txt }} + +`evaluate.sh` narrows the exported, unprotected services down to the app's own components: -{{ output.txt }} +{{ evaluation.txt }} ## Evaluation -The test case fails because `AuthService` exposes a security-relevant operation (changing the vault password) and is exported (`android:exported="true"`) without any permission protection. Because `AuthService` is exported and unprotected, external callers that can address the component can start it directly and overwrite the password. +The test case fails because `org.owasp.mastestapp.MastgTest$AuthService` exposes a security-relevant operation (changing the vault password) and is exported (`android:exported="true"`) without any permission protection. Because this service is exported and unprotected, external callers that can address the component can start it directly and overwrite the password. -The service changes the stored password from an intent extra in `onStartCommand` without enforcing any caller permission: +The "Stage 2" code scan flags the `onStartCommand` entry point and finds no runtime caller-permission check (no `checkCallingPermission`/`enforceCallingPermission`), so nothing restricts the caller. The service changes the stored password from an intent extra in `onStartCommand`: ```kotlin override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/MastgTest_reversed.java b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/MastgTest_reversed.java new file mode 100644 index 00000000000..1aad3cb0909 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/MastgTest_reversed.java @@ -0,0 +1,136 @@ +package org.owasp.mastestapp; + +import android.app.ActionBar; +import android.app.Activity; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.IBinder; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.compose.material.MenuKt; +import androidx.core.app.NotificationCompat; +import kotlin.Metadata; +import kotlin.jvm.internal.Intrinsics; +import org.owasp.mastestapp.MastgTest; + +/* JADX INFO: compiled from: MastgTest.kt */ +/* JADX INFO: loaded from: classes3.dex */ +@Metadata(d1 = {"\u0000\u001a\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u000e\n\u0002\b\u0004\b\u0007\u0018\u0000 \b2\u00020\u0001:\u0003\b\t\nB\u000f\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0004\b\u0004\u0010\u0005J\u0006\u0010\u0006\u001a\u00020\u0007R\u000e\u0010\u0002\u001a\u00020\u0003X\u0082\u0004¢\u0006\u0002\n\u0000¨\u0006\u000b"}, d2 = {"Lorg/owasp/mastestapp/MastgTest;", "", "context", "Landroid/content/Context;", "", "(Landroid/content/Context;)V", "mastgTest", "", "Companion", "VaultActivity", "AuthService", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) +public final class MastgTest { + public static final String DEFAULT_PASSWORD = "originalPass123"; + public static final String KEY_PASSWORD_STORE = "vault_password"; + public static final String PREFS = "secure_prefs"; + private final Context context; + public static final int $stable = 8; + + public MastgTest(Context context) { + Intrinsics.checkNotNullParameter(context, "context"); + this.context = context; + } + + public final String mastgTest() { + SharedPreferences prefs = this.context.getSharedPreferences(PREFS, 0); + if (!prefs.contains(KEY_PASSWORD_STORE)) { + prefs.edit().putString(KEY_PASSWORD_STORE, DEFAULT_PASSWORD).apply(); + } + Context context = this.context; + Intent $this$mastgTest_u24lambda_u240 = new Intent(this.context, (Class) VaultActivity.class); + $this$mastgTest_u24lambda_u240.addFlags(268435456); + context.startActivity($this$mastgTest_u24lambda_u240); + return "Opening the password vault…"; + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0003\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0012\u0010\u0006\u001a\u00020\u00072\b\u0010\b\u001a\u0004\u0018\u00010\tH\u0014J\b\u0010\n\u001a\u00020\u0007H\u0014J\b\u0010\u000b\u001a\u00020\u0007H\u0002R\u000e\u0010\u0004\u001a\u00020\u0005X\u0082.¢\u0006\u0002\n\u0000¨\u0006\f"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$VaultActivity;", "Landroid/app/Activity;", "", "()V", NotificationCompat.CATEGORY_STATUS, "Landroid/widget/TextView;", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "onResume", "showPassword", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class VaultActivity extends Activity { + public static final int $stable = 8; + private TextView status; + + @Override // android.app.Activity + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(1); + layout.setPadding(64, MenuKt.InTransitionDuration, 64, 64); + TextView $this$onCreate_u24lambda_u241 = new TextView(this); + $this$onCreate_u24lambda_u241.setText("MASTestApp – Password Vault"); + $this$onCreate_u24lambda_u241.setTextSize(22.0f); + TextView $this$onCreate_u24lambda_u242 = new TextView(this); + $this$onCreate_u24lambda_u242.setTextSize(18.0f); + $this$onCreate_u24lambda_u242.setPadding(0, 48, 0, 48); + this.status = $this$onCreate_u24lambda_u242; + Button $this$onCreate_u24lambda_u244 = new Button(this); + $this$onCreate_u24lambda_u244.setText("Refresh"); + $this$onCreate_u24lambda_u244.setOnClickListener(new View.OnClickListener() { // from class: org.owasp.mastestapp.MastgTest$VaultActivity$$ExternalSyntheticLambda0 + @Override // android.view.View.OnClickListener + public final void onClick(View view) { + MastgTest.VaultActivity.onCreate$lambda$4$lambda$3(this.f$0, view); + } + }); + layout.addView($this$onCreate_u24lambda_u241); + TextView textView = this.status; + if (textView == null) { + Intrinsics.throwUninitializedPropertyAccessException(NotificationCompat.CATEGORY_STATUS); + textView = null; + } + layout.addView(textView); + layout.addView($this$onCreate_u24lambda_u244); + setContentView(layout); + showPassword(); + } + + /* JADX INFO: Access modifiers changed from: private */ + public static final void onCreate$lambda$4$lambda$3(VaultActivity this$0, View it) { + Intrinsics.checkNotNullParameter(this$0, "this$0"); + this$0.showPassword(); + } + + @Override // android.app.Activity + protected void onResume() { + super.onResume(); + showPassword(); + } + + private final void showPassword() { + String pwd = getSharedPreferences(MastgTest.PREFS, 0).getString(MastgTest.KEY_PASSWORD_STORE, ""); + TextView textView = this.status; + if (textView == null) { + Intrinsics.throwUninitializedPropertyAccessException(NotificationCompat.CATEGORY_STATUS); + textView = null; + } + textView.setText("Current vault password:\n\n" + pwd); + } + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\b\n\u0002\b\u0004\b\u0007\u0018\u0000 \f2\u00020\u0001:\u0001\fB\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0012\u0010\u0004\u001a\u0004\u0018\u00010\u00052\u0006\u0010\u0006\u001a\u00020\u0007H\u0016J\"\u0010\b\u001a\u00020\t2\b\u0010\u0006\u001a\u0004\u0018\u00010\u00072\u0006\u0010\n\u001a\u00020\t2\u0006\u0010\u000b\u001a\u00020\tH\u0016¨\u0006\r"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$AuthService;", "Landroid/app/Service;", "", "()V", "onBind", "Landroid/os/IBinder;", "intent", "Landroid/content/Intent;", "onStartCommand", "", "flags", "startId", "Companion", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class AuthService extends Service { + public static final int $stable = 0; + public static final String KEY_PASSWORD = "org.owasp.mastestapp.PASSWORD"; + + @Override // android.app.Service + public IBinder onBind(Intent intent) { + Intrinsics.checkNotNullParameter(intent, "intent"); + return null; + } + + @Override // android.app.Service + public int onStartCommand(Intent intent, int flags, int startId) { + String newPassword = intent != null ? intent.getStringExtra(KEY_PASSWORD) : null; + if (newPassword != null) { + getApplicationContext().getSharedPreferences(MastgTest.PREFS, 0).edit().putString(MastgTest.KEY_PASSWORD_STORE, newPassword).apply(); + return 2; + } + return 2; + } + } +} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/code_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/code_scan.txt new file mode 100644 index 00000000000..3adb3201b8e --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/code_scan.txt @@ -0,0 +1,28 @@ + + +┌─────────────────┐ +│ 2 Code Findings │ +└─────────────────┘ + + MastgTest_reversed.java + ❱ rules.mastg-android-service-entrypoint + [MASVS-PLATFORM] Service entry point reached when the service is started or bound. Review it for + sensitive functionality that should not be reachable by an external caller. + + 120┆ @Override // android.app.Service + 121┆ public IBinder onBind(Intent intent) { + 122┆ Intrinsics.checkNotNullParameter(intent, "intent"); + 123┆ return null; + 124┆ } + ⋮┆---------------------------------------- + 126┆ @Override // android.app.Service + 127┆ public int onStartCommand(Intent intent, int flags, int startId) { + 128┆ String newPassword = intent != null ? intent.getStringExtra(KEY_PASSWORD) : null; + 129┆ if (newPassword != null) { + 130┆ getApplicationContext().getSharedPreferences(MastgTest.PREFS, + 0).edit().putString(MastgTest.KEY_PASSWORD_STORE, newPassword).apply(); + 131┆ return 2; + 132┆ } + 133┆ return 2; + 134┆ } + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/evaluate.sh b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/evaluate.sh new file mode 100755 index 00000000000..d18e2275c9f --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/evaluate.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Stage 3 (reduce) — from the exported, unprotected services found in Stage 1, keep only the +# ones the app itself declares. Services in framework/library namespaces (android.*, androidx.*, +# com.google.android.*) are shipped by dependencies, not authored by the app, so they are triaged +# separately. Whatever remains is the shortlist to inspect manually for sensitive functionality. +jq -r '.results[] + | select(.check_id | endswith("service-exported-without-permission")) + | .extra.message + | sub("(?s).*service="; "") | gsub("\\s+"; "")' manifest_scan.json \ + | grep -vE '^(android|androidx|com\.google\.android|com\.android)\.' \ + > evaluation.txt diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/evaluation.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/evaluation.txt new file mode 100644 index 00000000000..6d4bad02928 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/evaluation.txt @@ -0,0 +1 @@ +org.owasp.mastestapp.MastgTest.AuthService diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/manifest_scan.json b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/manifest_scan.json new file mode 100644 index 00000000000..d25c67485b1 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/manifest_scan.json @@ -0,0 +1 @@ +{"version":"1.142.1","results":[{"check_id":"rules.mastg-android-service-exported-without-permission","path":"AndroidManifest_reversed.xml","start":{"line":44,"col":9,"offset":1941},"end":{"line":46,"col":38,"offset":2057},"extra":{"message":"[MASVS-PLATFORM] Exported service without android:permission, which any app on the device can start or bind to. Inspect its code for sensitive functionality: service=org.owasp.mastestapp.MastgTest.AuthService\n","metadata":{"summary":"Detects an exported service that does not enforce any caller permission.","masvs":["MASVS-PLATFORM"]},"severity":"WARNING","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}}],"errors":[],"paths":{"scanned":["AndroidManifest_reversed.xml"]},"time":{"rules":[],"rules_parse_time":0.001622915267944336,"profiling_times":{"config_time":0.09385013580322266,"core_time":0.2849771976470947,"ignores_time":3.0040740966796875e-05,"total_time":0.3790290355682373},"parsing_time":{"total_time":0.0,"per_file_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"scanning_time":{"total_time":0.005820035934448242,"per_file_time":{"mean":0.005820035934448242,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"matching_time":{"total_time":0.0,"per_file_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_files":[]},"tainting_time":{"total_time":0.0,"per_def_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_defs":[]},"fixpoint_timeouts":[],"prefiltering":{"project_level_time":0.0,"file_level_time":0.0,"rules_with_project_prefilters_ratio":0.0,"rules_with_file_prefilters_ratio":1.0,"rules_selected_ratio":1.0,"rules_matched_ratio":1.0},"targets":[],"total_bytes":0,"max_memory_bytes":77331200},"engine_requested":"OSS","skipped_rules":[]} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/manifest_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/manifest_scan.txt new file mode 100644 index 00000000000..36ebe4b343d --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/manifest_scan.txt @@ -0,0 +1,16 @@ + + +┌────────────────┐ +│ 1 Code Finding │ +└────────────────┘ + + AndroidManifest_reversed.xml + ❯❱ rules.mastg-android-service-exported-without-permission + [MASVS-PLATFORM] Exported service without android:permission, which any app on the device can start + or bind to. Inspect its code for sensitive functionality: + service=org.owasp.mastestapp.MastgTest.AuthService + + 44┆ + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/output.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/output.txt deleted file mode 100644 index ec03a478088..00000000000 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/output.txt +++ /dev/null @@ -1 +0,0 @@ -Exported service: org.owasp.mastestapp.MastgTest.AuthService | permission: none diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/run.sh b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/run.sh index 56f3c25d7e7..57548518fbd 100755 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/run.sh +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0129/run.sh @@ -1,14 +1,12 @@ #!/bin/bash -# List the services declared as exported in the AndroidManifest. -python3 - <<'PY' > output.txt -import re -xml = open("AndroidManifest_reversed.xml").read() -for m in re.finditer(r"|)", xml, re.S): - block = m.group(0) - name = re.search(r'android:name="([^"]+)"', block) - exported = re.search(r'android:exported="([^"]+)"', block) - permission = re.search(r'android:permission="([^"]+)"', block) - if exported and exported.group(1) == "true": - print("Exported service:", name.group(1) if name else "?", - "| permission:", permission.group(1) if permission else "none") -PY + +# Stage 1 (capture) — enumerate the manifest-declared services. The rule flags every service +# that is exported without an android:permission, and lists the permission-protected ones +# separately so they can be triaged. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-exported-service.yml ./AndroidManifest_reversed.xml --text --max-lines-per-finding 0 > manifest_scan.txt +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-exported-service.yml ./AndroidManifest_reversed.xml --json > manifest_scan.json 2>/dev/null + +# Stage 2 (inspect code) — locate the entry points reachable when the service is started or +# bound (onStartCommand, onBind, onRebind, onHandleIntent), plus any runtime caller-permission +# checks, in the decompiled code. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-service-entrypoints.yml ./MastgTest_reversed.java --text --max-lines-per-finding 0 > code_scan.txt diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/AndroidManifest_reversed.xml b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/AndroidManifest_reversed.xml index eb26e45b10b..47986541af4 100644 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/AndroidManifest_reversed.xml +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/AndroidManifest_reversed.xml @@ -20,7 +20,6 @@ android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:debuggable="true" - android:testOnly="true" android:allowBackup="true" android:supportsRtl="true" android:extractNativeLibs="false" @@ -79,6 +78,15 @@ + + + + + + + + + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/MASTG-DEMO-0130.md b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/MASTG-DEMO-0130.md index 443f3f379b8..cd634069d3a 100644 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/MASTG-DEMO-0130.md +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/MASTG-DEMO-0130.md @@ -15,24 +15,42 @@ The sample implements a small password vault. Tapping **Start** opens `VaultActi ## Steps -1. Use @MASTG-TECH-0117 to obtain the AndroidManifest.xml. -2. Use @MASTG-TECH-0162 to list the exported broadcast receivers and their associated `android:permission` by running `run.sh`. +1. Use @MASTG-TECH-0013 to reverse engineer the app and @MASTG-TECH-0117 to obtain the `AndroidManifest.xml`. +2. Use @MASTG-TECH-0162 to enumerate the manifest-declared broadcast receivers, their export state, and any associated `android:permission`. `run.sh` does this by running @MASTG-TOOL-0110 with the following rule, which flags receivers that are exported without a permission and lists the permission-protected ones separately: -The `run.sh` script lists the exported receivers declared in the reverse-engineered manifest. +{{ ../../../../rules/mastg-android-exported-receiver.yml }} + +3. Use @MASTG-TECH-0014 to inspect the decompiled code of each exported receiver. `run.sh` runs @MASTG-TOOL-0110 with the following rule to locate the `onReceive` entry point and the attacker-controllable intent extras it reads: + +{{ ../../../../rules/mastg-android-receiver-entrypoints.yml }} {{ run.sh }} +4. Run `evaluate.sh` to reduce the exported, unprotected receivers from "Stage 1" to the ones the app itself declares. Receivers in framework or library namespaces (`android.*`, `androidx.*`, `com.google.android.*`) ship with dependencies rather than the app's own code, so they are triaged separately: + +{{ evaluate.sh }} + ## Observation -The output reveals the exported broadcast receivers and their associated permissions: +"Stage 1" lists the exported receivers and their permissions, and "Stage 2" points at the receiver code to review: + +{{ manifest_scan.txt # code_scan.txt }} + +`evaluate.sh` narrows the exported, unprotected receivers down to the app's own components: -{{ output.txt }} +{{ evaluation.txt }} ## Evaluation -The test case fails because `PasswordResetReceiver` performs a security-relevant action (storing a password and being able to update it) and is exported (`android:exported="true"`) without any permission protection. Because `PasswordResetReceiver` is exported and unprotected, external callers can send a broadcast to it and overwrite the password. +The test case fails because `org.owasp.mastestapp.MastgTest$PasswordResetReceiver` performs a security-relevant action (storing a password and being able to update it) and is exported (`android:exported="true"`) without any permission protection. Because this receiver is exported and unprotected, external callers can send a broadcast to it and overwrite the password. -The `onReceive` method changes the stored password from an intent extra and discloses the old password to the log: +The manifest scan from "Stage 1" reports two exported receivers, and we discard one of them: + +- `androidx.profileinstaller.ProfileInstallReceiver` is added by the AndroidX Profile Installer library. It does not appear in the app's original manifest; it is merged in from a dependency, which is why it shows up in the reverse-engineered manifest. Although exported, it is protected by `android:permission="android.permission.DUMP"`, a signature/privileged permission that ordinary apps can't hold, so the manifest rule lists it under the permission-protected findings, and `evaluate.sh` also drops it because it lives in the `androidx.*` namespace. It is development tooling and is **not** reported as vulnerable in this test case. + +`org.owasp.mastestapp.MastgTest$PasswordResetReceiver` is the only exported, unprotected receiver the app itself declares, so it is the candidate to inspect. + +The "Stage 2" code scan confirms the sensitivity of the receiver. The `onReceive` method changes the stored password from an unvalidated intent extra and discloses the old password to the log: ```kotlin override fun onReceive(context: Context, intent: Intent) { @@ -46,4 +64,87 @@ override fun onReceive(context: Context, intent: Intent) { `VaultActivity` does not protect the underlying exported broadcast receiver. Access control must be enforced at the `PasswordResetReceiver` boundary. -The output also lists `androidx.profileinstaller.ProfileInstallReceiver`. This receiver is added by the AndroidX Profile Installer library and, although exported, is protected by `android:permission="android.permission.DUMP"`, a signature/privileged permission that ordinary apps can't hold. It is development tooling and is not reported as vulnerable in this test case. +### Confirm the Exposure + +You can use @MASTG-TECH-0162 with @MASTG-TOOL-0004 to deliver the broadcast and trigger the action. + +1. Tap **Start** and note the current password shown in `VaultActivity` (`originalPass123`). +2. Send the broadcast, targeting the receiver explicitly so it's delivered on modern Android: + + ```bash + adb shell am broadcast -a org.owasp.mastestapp.RESET_PASSWORD -n 'org.owasp.mastestapp/org.owasp.mastestapp.MastgTest\$PasswordResetReceiver' --es newpass hacked123 + + Broadcasting: Intent { act=org.owasp.mastestapp.RESET_PASSWORD flg=0x400000 cmp=org.owasp.mastestapp/.MastgTest$PasswordResetReceiver (has extras) } + Broadcast completed: result=0 + ``` + +3. Return to the app and tap **Refresh**. The vault password now shows `hacked123`, confirming that an external caller changed it through the exported receiver. + +The disclosed old password is also visible in the log: + +```bash +adb logcat -s MASTG-DEMO +``` + +Output: + +```bash +06-01 09:26:01.334 30881 30881 D MASTG-DEMO: Password changed from originalPass123 to hacked123 +``` + +## Fix + +There are two ways to fix this, and the right choice depends on whether `PasswordResetReceiver` needs to accept broadcasts from external apps at all. + +**Option 1: Set `android:exported="false"` (recommended for most apps)** + +If `PasswordResetReceiver` has no legitimate reason to receive broadcasts from another app, prevent external apps from reaching it: + +```xml + +``` + +Trying to send the broadcast again with `adb` after this change will not necessarily produce an error, but the password will not change and the log will not show the old password, confirming that the receiver is no longer reachable from outside the app. + +```bash +adb shell am broadcast -a org.owasp.mastestapp.RESET_PASSWORD -n 'org.owasp.mastestapp/org.owasp.mastestapp.MastgTest\$PasswordResetReceiver' --es newpass hacked123 +Broadcasting: Intent { act=org.owasp.mastestapp.RESET_PASSWORD flg=0x400000 cmp=org.owasp.mastestapp/.MastgTest$PasswordResetReceiver (has extras) } +Broadcast completed: result=0 +``` + +This is the right choice for the vast majority of receivers that react to internal app events, such as credential-reset or state-change broadcasts, that no external app should be able to influence. + +**Option 2: Keep `android:exported="true"` but enforce a `android:permission`** + +If the receiver must be reachable by a trusted partner app (for example, a companion lock-screen app from the same developer that can trigger a remote wipe or credential reset), you can keep it exported but gate access with a custom signature-level permission: + +```xml + + + + + +``` + +With `protectionLevel="signature"`, only apps signed with the same certificate are granted the permission automatically. A real-world example is an enterprise remote-wipe receiver that only responds to broadcasts from the company's own device management app. Both are signed with the enterprise certificate, so only the management app can send broadcasts, while any third-party app is rejected by the OS before `onReceive` is called. + +This permission-based fix only resolves the finding if the permission cannot be obtained by untrusted apps. If the receiver were protected by a broadly grantable permission, such as a custom permission with `normal` or `dangerous` protection level, the demo would still fail because untrusted apps could still obtain the permission and send broadcasts to the receiver. See @MASTG-KNOW-0017 for Android permission protection levels. + +Trying to send the broadcast again with `adb` after this change will not necessarily produce an error, but it won't have any effect: + +```bash +adb shell am broadcast -a org.owasp.mastestapp.RESET_PASSWORD -n 'org.owasp.mastestapp/org.owasp.mastestapp.MastgTest\$PasswordResetReceiver' --es newpass hacked123 +Broadcasting: Intent { act=org.owasp.mastestapp.RESET_PASSWORD flg=0x400000 cmp=org.owasp.mastestapp/.MastgTest$PasswordResetReceiver (has extras) } +Broadcast completed: result=0 +``` + +**Additional fix - Remove sensitive data from logs:** + +Regardless of whether the receiver itself is protected, no credentials must be written to the app logs, which are readable by any app that holds `READ_LOGS` (granted to shell and ADB). diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/MastgTest_reversed.java b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/MastgTest_reversed.java new file mode 100644 index 00000000000..44958441591 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/MastgTest_reversed.java @@ -0,0 +1,133 @@ +package org.owasp.mastestapp; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.compose.material.MenuKt; +import androidx.core.app.NotificationCompat; +import kotlin.Metadata; +import kotlin.jvm.internal.Intrinsics; +import org.owasp.mastestapp.MastgTest; + +/* JADX INFO: compiled from: MastgTest.kt */ +/* JADX INFO: loaded from: classes3.dex */ +@Metadata(d1 = {"\u0000\u001a\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u000e\n\u0002\b\u0004\b\u0007\u0018\u0000 \b2\u00020\u0001:\u0003\b\t\nB\u000f\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0004\b\u0004\u0010\u0005J\u0006\u0010\u0006\u001a\u00020\u0007R\u000e\u0010\u0002\u001a\u00020\u0003X\u0082\u0004¢\u0006\u0002\n\u0000¨\u0006\u000b"}, d2 = {"Lorg/owasp/mastestapp/MastgTest;", "", "context", "Landroid/content/Context;", "", "(Landroid/content/Context;)V", "mastgTest", "", "Companion", "VaultActivity", "PasswordResetReceiver", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) +public final class MastgTest { + public static final String DEFAULT_PASSWORD = "originalPass123"; + public static final String KEY_PASSWORD_STORE = "vault_password"; + public static final String PREFS = "secure_prefs"; + private final Context context; + public static final int $stable = 8; + + public MastgTest(Context context) { + Intrinsics.checkNotNullParameter(context, "context"); + this.context = context; + } + + public final String mastgTest() { + SharedPreferences prefs = this.context.getSharedPreferences(PREFS, 0); + if (!prefs.contains(KEY_PASSWORD_STORE)) { + prefs.edit().putString(KEY_PASSWORD_STORE, DEFAULT_PASSWORD).apply(); + } + Context context = this.context; + Intent $this$mastgTest_u24lambda_u240 = new Intent(this.context, (Class) VaultActivity.class); + $this$mastgTest_u24lambda_u240.addFlags(268435456); + context.startActivity($this$mastgTest_u24lambda_u240); + return "Opening the password vault…"; + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0003\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0012\u0010\u0006\u001a\u00020\u00072\b\u0010\b\u001a\u0004\u0018\u00010\tH\u0014J\b\u0010\n\u001a\u00020\u0007H\u0014J\b\u0010\u000b\u001a\u00020\u0007H\u0002R\u000e\u0010\u0004\u001a\u00020\u0005X\u0082.¢\u0006\u0002\n\u0000¨\u0006\f"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$VaultActivity;", "Landroid/app/Activity;", "", "()V", NotificationCompat.CATEGORY_STATUS, "Landroid/widget/TextView;", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "onResume", "showPassword", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class VaultActivity extends Activity { + public static final int $stable = 8; + private TextView status; + + @Override // android.app.Activity + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(1); + layout.setPadding(64, MenuKt.InTransitionDuration, 64, 64); + TextView $this$onCreate_u24lambda_u241 = new TextView(this); + $this$onCreate_u24lambda_u241.setText("MASTestApp – Password Vault"); + $this$onCreate_u24lambda_u241.setTextSize(22.0f); + TextView $this$onCreate_u24lambda_u242 = new TextView(this); + $this$onCreate_u24lambda_u242.setTextSize(18.0f); + $this$onCreate_u24lambda_u242.setPadding(0, 48, 0, 48); + this.status = $this$onCreate_u24lambda_u242; + Button $this$onCreate_u24lambda_u244 = new Button(this); + $this$onCreate_u24lambda_u244.setText("Refresh"); + $this$onCreate_u24lambda_u244.setOnClickListener(new View.OnClickListener() { // from class: org.owasp.mastestapp.MastgTest$VaultActivity$$ExternalSyntheticLambda0 + @Override // android.view.View.OnClickListener + public final void onClick(View view) { + MastgTest.VaultActivity.onCreate$lambda$4$lambda$3(this.f$0, view); + } + }); + layout.addView($this$onCreate_u24lambda_u241); + TextView textView = this.status; + if (textView == null) { + Intrinsics.throwUninitializedPropertyAccessException(NotificationCompat.CATEGORY_STATUS); + textView = null; + } + layout.addView(textView); + layout.addView($this$onCreate_u24lambda_u244); + setContentView(layout); + showPassword(); + } + + /* JADX INFO: Access modifiers changed from: private */ + public static final void onCreate$lambda$4$lambda$3(VaultActivity this$0, View it) { + Intrinsics.checkNotNullParameter(this$0, "this$0"); + this$0.showPassword(); + } + + @Override // android.app.Activity + protected void onResume() { + super.onResume(); + showPassword(); + } + + private final void showPassword() { + String pwd = getSharedPreferences(MastgTest.PREFS, 0).getString(MastgTest.KEY_PASSWORD_STORE, ""); + TextView textView = this.status; + if (textView == null) { + Intrinsics.throwUninitializedPropertyAccessException(NotificationCompat.CATEGORY_STATUS); + textView = null; + } + textView.setText("Current vault password:\n\n" + pwd); + } + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000\u001e\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0018\u0010\u0004\u001a\u00020\u00052\u0006\u0010\u0006\u001a\u00020\u00072\u0006\u0010\b\u001a\u00020\tH\u0016¨\u0006\n"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$PasswordResetReceiver;", "Landroid/content/BroadcastReceiver;", "", "()V", "onReceive", "", "context", "Landroid/content/Context;", "intent", "Landroid/content/Intent;", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class PasswordResetReceiver extends BroadcastReceiver { + public static final int $stable = 0; + + @Override // android.content.BroadcastReceiver + public void onReceive(Context context, Intent intent) { + Intrinsics.checkNotNullParameter(context, "context"); + Intrinsics.checkNotNullParameter(intent, "intent"); + String newPassword = intent.getStringExtra("newpass"); + if (newPassword == null) { + return; + } + SharedPreferences prefs = context.getSharedPreferences(MastgTest.PREFS, 0); + String oldPassword = prefs.getString(MastgTest.KEY_PASSWORD_STORE, ""); + Log.d("MASTG-DEMO", "Password changed from " + oldPassword + " to " + newPassword); + prefs.edit().putString(MastgTest.KEY_PASSWORD_STORE, newPassword).apply(); + } + } +} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/code_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/code_scan.txt new file mode 100644 index 00000000000..0adfed77947 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/code_scan.txt @@ -0,0 +1,31 @@ + + +┌─────────────────┐ +│ 2 Code Findings │ +└─────────────────┘ + + MastgTest_reversed.java + ❱ rules.mastg-android-receiver-entrypoint + [MASVS-PLATFORM] Broadcast receiver onReceive entry point. Review it and the code it reaches for + sensitive functionality that should not be triggerable by an external sender. + + 119┆ @Override // android.content.BroadcastReceiver + 120┆ public void onReceive(Context context, Intent intent) { + 121┆ Intrinsics.checkNotNullParameter(context, "context"); + 122┆ Intrinsics.checkNotNullParameter(intent, "intent"); + 123┆ String newPassword = intent.getStringExtra("newpass"); + 124┆ if (newPassword == null) { + 125┆ return; + 126┆ } + 127┆ SharedPreferences prefs = context.getSharedPreferences(MastgTest.PREFS, 0); + 128┆ String oldPassword = prefs.getString(MastgTest.KEY_PASSWORD_STORE, ""); + 129┆ Log.d("MASTG-DEMO", "Password changed from " + oldPassword + " to " + newPassword); + 130┆ prefs.edit().putString(MastgTest.KEY_PASSWORD_STORE, newPassword).apply(); + 131┆ } + + ❱ rules.mastg-android-receiver-sensitive-extra-source + [MASVS-PLATFORM] Reads an intent extra. When the component is exported and unprotected, this value + is attacker-controllable. Trace where it is used. + + 123┆ String newPassword = intent.getStringExtra("newpass"); + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/evaluate.sh b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/evaluate.sh new file mode 100755 index 00000000000..635b4115774 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/evaluate.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Stage 3 (reduce) — from the exported, unprotected receivers found in Stage 1, keep only the +# ones the app itself declares. Receivers in framework/library namespaces (android.*, androidx.*, +# com.google.android.*) are shipped by dependencies, not authored by the app, so they are triaged +# separately. Whatever remains is the shortlist to inspect manually for sensitive functionality. +jq -r '.results[] + | select(.check_id | endswith("receiver-exported-without-permission")) + | .extra.message + | sub("(?s).*receiver="; "") | gsub("\\s+"; "")' manifest_scan.json \ + | grep -vE '^(android|androidx|com\.google\.android|com\.android)\.' \ + > evaluation.txt diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/evaluation.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/evaluation.txt new file mode 100644 index 00000000000..aeddf472243 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/evaluation.txt @@ -0,0 +1 @@ +org.owasp.mastestapp.MastgTest.PasswordResetReceiver diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/manifest_scan.json b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/manifest_scan.json new file mode 100644 index 00000000000..bb0a1ab5378 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/manifest_scan.json @@ -0,0 +1 @@ +{"version":"1.142.1","results":[{"check_id":"rules.mastg-android-receiver-exported-without-permission","path":"AndroidManifest_reversed.xml","start":{"line":44,"col":9,"offset":1941},"end":{"line":50,"col":20,"offset":2221},"extra":{"message":"[MASVS-PLATFORM] Exported broadcast receiver without android:permission, to which any app on the device can send broadcasts. Inspect its onReceive for sensitive functionality: receiver=org.owasp.mastestapp.MastgTest.PasswordResetReceiver\n","metadata":{"summary":"Detects an exported, manifest-declared broadcast receiver that does not enforce any sender permission.","masvs":["MASVS-PLATFORM"]},"severity":"WARNING","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}},{"check_id":"rules.mastg-android-receiver-exported-with-permission","path":"AndroidManifest_reversed.xml","start":{"line":72,"col":9,"offset":3227},"end":{"line":90,"col":20,"offset":4088},"extra":{"message":"[MASVS-PLATFORM] Exported broadcast receiver protected by a permission. Verify the permission protection level matches the intended trust boundary: receiver=androidx.profileinstaller.ProfileInstallReceiver\n","metadata":{"summary":"Detects an exported, manifest-declared broadcast receiver that enforces a sender permission.","masvs":["MASVS-PLATFORM"]},"severity":"INFO","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}}],"errors":[],"paths":{"scanned":["AndroidManifest_reversed.xml"]},"time":{"rules":[],"rules_parse_time":0.0016651153564453125,"profiling_times":{"config_time":0.09348702430725098,"core_time":0.28658604621887207,"ignores_time":2.7179718017578125e-05,"total_time":0.38026905059814453},"parsing_time":{"total_time":0.0,"per_file_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"scanning_time":{"total_time":0.005635976791381836,"per_file_time":{"mean":0.005635976791381836,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"matching_time":{"total_time":0.0,"per_file_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_files":[]},"tainting_time":{"total_time":0.0,"per_def_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_defs":[]},"fixpoint_timeouts":[],"prefiltering":{"project_level_time":0.0,"file_level_time":0.0,"rules_with_project_prefilters_ratio":0.0,"rules_with_file_prefilters_ratio":1.0,"rules_selected_ratio":1.0,"rules_matched_ratio":1.0},"targets":[],"total_bytes":0,"max_memory_bytes":77593344},"engine_requested":"OSS","skipped_rules":[]} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/manifest_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/manifest_scan.txt new file mode 100644 index 00000000000..4e7b872f3b5 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/manifest_scan.txt @@ -0,0 +1,45 @@ + + +┌─────────────────┐ +│ 2 Code Findings │ +└─────────────────┘ + + AndroidManifest_reversed.xml + ❯❱ rules.mastg-android-receiver-exported-without-permission + [MASVS-PLATFORM] Exported broadcast receiver without android:permission, to which any app on the + device can send broadcasts. Inspect its onReceive for sensitive functionality: + receiver=org.owasp.mastestapp.MastgTest.PasswordResetReceiver + + 44┆ + 47┆ + 48┆ + 49┆ + 50┆ + + ❱ rules.mastg-android-receiver-exported-with-permission + [MASVS-PLATFORM] Exported broadcast receiver protected by a permission. Verify the permission + protection level matches the intended trust boundary: + receiver=androidx.profileinstaller.ProfileInstallReceiver + + 72┆ + 78┆ + 79┆ + 80┆ + 81┆ + 82┆ + 83┆ + 84┆ + 85┆ + 86┆ + 87┆ + 88┆ + 89┆ + 90┆ + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/output.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/output.txt deleted file mode 100644 index b5183b500c1..00000000000 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/output.txt +++ /dev/null @@ -1,2 +0,0 @@ -Exported receiver: org.owasp.mastestapp.MastgTest.PasswordResetReceiver | permission: none -Exported receiver: androidx.profileinstaller.ProfileInstallReceiver | permission: android.permission.DUMP diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/run.sh b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/run.sh index 354550a43cd..77bd64064fc 100755 --- a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/run.sh +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0130/run.sh @@ -1,14 +1,11 @@ #!/bin/bash -# List the broadcast receivers declared as exported in the AndroidManifest. -python3 - <<'PY' > output.txt -import re -xml = open("AndroidManifest_reversed.xml").read() -for m in re.finditer(r"|)", xml, re.S): - block = m.group(0) - name = re.search(r'android:name="([^"]+)"', block) - exported = re.search(r'android:exported="([^"]+)"', block) - permission = re.search(r'android:permission="([^"]+)"', block) - if exported and exported.group(1) == "true": - print("Exported receiver:", name.group(1) if name else "?", - "| permission:", permission.group(1) if permission else "none") -PY + +# Stage 1 (capture) — enumerate the manifest-declared receivers. The rule flags every +# receiver that is exported without an android:permission, and lists the permission-protected +# ones separately so they can be triaged. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-exported-receiver.yml ./AndroidManifest_reversed.xml --text --max-lines-per-finding 0 > manifest_scan.txt +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-exported-receiver.yml ./AndroidManifest_reversed.xml --json > manifest_scan.json 2>/dev/null + +# Stage 2 (inspect code) — locate the onReceive entry point and the attacker-controllable +# intent extras it reads in the decompiled code. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-receiver-entrypoints.yml ./MastgTest_reversed.java --text --max-lines-per-finding 0 > code_scan.txt diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/AndroidManifest.xml b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/AndroidManifest.xml new file mode 100644 index 00000000000..13de283b6a3 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/AndroidManifest_reversed.xml b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/AndroidManifest_reversed.xml new file mode 100644 index 00000000000..eddf828b592 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/AndroidManifest_reversed.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MASTG-DEMO-0x01.md b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MASTG-DEMO-0x01.md new file mode 100644 index 00000000000..ebb5dc2ad59 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MASTG-DEMO-0x01.md @@ -0,0 +1,172 @@ +--- +title: Sensitive Action Exposed Through an Unprotected Context-Registered Broadcast Receiver +platform: android +id: MASTG-DEMO-0x01 +code: [kotlin] +test: MASTG-TEST-0366 +kind: fail +--- + +## Sample + +The sample implements a small password vault. Tapping **Start** opens `VaultActivity`, which displays the password currently stored in the app (`originalPass123` on first run). Instead of declaring its broadcast receivers in the `AndroidManifest.xml`, the app registers them at runtime (context-registered): + +- `VaultActivity` registers `PasswordResetReceiver` with `RECEIVER_EXPORTED` and no `broadcastPermission`, so any app on the device can send the `org.owasp.mastestapp.RESET_PASSWORD` broadcast and reset the password. It also logs the old password. +- `VaultActivity` registers `VaultRefreshReceiver` with `RECEIVER_NOT_EXPORTED`, so only the app itself can trigger it. It only refreshes the UI and exposes no sensitive functionality. +- The in-app-only `AdminActivity` registers `AdminCommandReceiver` with `RECEIVER_EXPORTED` but restricted with a signature-level `broadcastPermission`. It can wipe the vault, a sensitive action, but only apps signed with the same certificate can deliver its broadcast. + +Because they are context-registered, none of these receivers appears in the manifest, so they can only be found by analyzing the code. + +{{ MastgTest.kt # AndroidManifest.xml }} + +## Steps + +1. Use @MASTG-TECH-0013 to reverse engineer the app and @MASTG-TECH-0117 to obtain the `AndroidManifest.xml`. +2. Use @MASTG-TECH-0162 to enumerate the manifest-declared receivers. `run.sh` does this by running @MASTG-TOOL-0110 with the following rule. Because the app's receivers are context-registered, this stage shows only the library `ProfileInstallReceiver`, confirming the app declares none of its own in the manifest: + +{{ ../../../../rules/mastg-android-exported-receiver.yml }} + +3. List the custom permissions the manifest declares and their protection levels, so an exported component's `broadcastPermission` can later be judged as strong or weak. `run.sh` runs @MASTG-TOOL-0110 with the following rule, which records each permission's level and flags weak ones (`normal`/`dangerous`, or no level, which defaults to `normal`): + +{{ ../../../../rules/mastg-android-declared-permission-protection-level.yml }} + +4. Use @MASTG-TECH-0014 to find the context-registered receivers in the decompiled code. `run.sh` runs @MASTG-TOOL-0110 with the following rule, which locates `Context.registerReceiver` / `ContextCompat.registerReceiver` calls and classifies each registration as exported with no permission (`RECEIVER_EXPORTED`), exported but restricted with a `broadcastPermission`, not exported (`RECEIVER_NOT_EXPORTED`), or registered without an explicit flag. Decompilers usually inline these flag constants to their integer values (`RECEIVER_EXPORTED == 2`, `RECEIVER_NOT_EXPORTED == 4`), so the rule matches both forms: + +{{ ../../../../rules/mastg-android-context-registered-receiver.yml }} + +5. Use @MASTG-TECH-0014 to inspect the `onReceive` implementations. `run.sh` runs @MASTG-TOOL-0110 with the following rule to locate the `onReceive` entry points and the attacker-controllable intent extras they read: + +{{ ../../../../rules/mastg-android-receiver-entrypoints.yml }} + +{{ run.sh }} + +6. Run `evaluate.sh` to split the context-registered receivers into the exported, unprotected ones (reported as vulnerable) and the exported but permission-restricted ones. For the latter, `evaluate.sh` resolves the `broadcastPermission` to the protection level declared in the manifest and prints a verdict, so a weak (`normal`/`dangerous`) permission is reported as vulnerable instead of being assumed safe: + +{{ evaluate.sh }} + +## Observation + +The manifest scan finds no app-owned receiver (only the permission-protected library `ProfileInstallReceiver`). The permission scan records the protection level of each declared custom permission. The code scan finds the three context-registered receivers and classifies them, and the `onReceive` scan points at their code: + +{{ manifest_scan.txt # permissions_scan.txt # code_scan.txt # onreceive_scan.txt }} + +`evaluate.sh` splits the context-registered receivers into the exported, unprotected one (reported as vulnerable) and the exported but permission-restricted one. For the protected one it resolves the `broadcastPermission` to its declared protection level and prints a verdict, so the reader does not have to look the level up by hand: + +{{ evaluation.txt }} + +## Evaluation + +The test case fails because `PasswordResetReceiver` performs a security-relevant action (resetting the stored password) and is registered as exported with no `broadcastPermission`. + +Manifest enumeration from "Stage 1" was not enough here: the only receiver in the manifest is `androidx.profileinstaller.ProfileInstallReceiver`, which is added by an AndroidX library and is protected by `android:permission="android.permission.DUMP"`, so it is **not** reported as vulnerable. The app's own receivers are registered at runtime and only appear in the code. + +The "Stage 2" code scan finds three context-registered receivers, and we discard two of them: + +- `vaultRefreshReceiver` is registered with `RECEIVER_NOT_EXPORTED`, so other apps cannot deliver broadcasts to it. It is not part of the external attack surface, so `evaluate.sh` drops it. +- `adminCommandReceiver` is registered with `RECEIVER_EXPORTED` but passes a `broadcastPermission`. `evaluate.sh` resolves that permission (`org.owasp.mastestapp.ADMIN_COMMAND_PERMISSION`) against the permission scan and reports `protectionLevel=signature -> OK`, so only apps signed with the same certificate can deliver its broadcast. It performs a sensitive action (wiping the vault), but the strong sender protection means it is **not** reported as vulnerable. Had the permission resolved to `normal` or `dangerous` (or not been declared at all), the verdict would instead read `WEAK -> treat as vulnerable`, because untrusted apps could obtain it. The `AdminActivity` that registers it is not exported, so it is not part of the attack surface either. + +`passwordResetReceiver` is registered with `RECEIVER_EXPORTED` and no `broadcastPermission`, so any app on the device can deliver its broadcast. It is the candidate to inspect. + +Using the output from "Stage 3" scan we can confirm the sensitivity of `passwordResetReceiver`: its `onReceive` changes the stored password from an unvalidated intent extra and discloses the old password to the log: + +```kotlin +override fun onReceive(context: Context, intent: Intent) { + val newPassword = intent.getStringExtra("newpass") ?: return + val prefs = context.getSharedPreferences(MastgTest.PREFS, Context.MODE_PRIVATE) + val oldPassword = prefs.getString(MastgTest.KEY_PASSWORD_STORE, "") + Log.d("MASTG-DEMO", "Password changed from $oldPassword to $newPassword") + prefs.edit().putString(MastgTest.KEY_PASSWORD_STORE, newPassword).apply() +} +``` + +Unlike a manifest-declared receiver, a context-registered receiver only exists while the component that registered it is alive. `VaultActivity` registers `PasswordResetReceiver` in `onCreate` and unregisters it in `onDestroy`, so the receiver is reachable only while `VaultActivity` is active. The exposure is real but bounded to that window. + +### Confirm the Exposure + +You can use @MASTG-TECH-0162 with @MASTG-TOOL-0004 to deliver broadcasts and observe which receivers act on them. In a separate terminal, watch the app's own log tag so you can see each delivery: + +```bash +adb logcat -s MASTG-DEMO +``` + +**The unprotected receiver accepts the broadcast.** + +1. Tap **Start** to open `VaultActivity` (which registers `PasswordResetReceiver`) and note the current password (`originalPass123`). +2. While `VaultActivity` is in the foreground, send the broadcast, targeting the package explicitly so it is delivered on modern Android: + + ```bash + adb shell am broadcast -a org.owasp.mastestapp.RESET_PASSWORD -p org.owasp.mastestapp --es newpass hacked123 + + Broadcasting: Intent { act=org.owasp.mastestapp.RESET_PASSWORD pkg=org.owasp.mastestapp (has extras) } + Broadcast completed: result=0 + ``` + +3. Tap **Refresh**. The vault password now shows `hacked123`, and the disclosed old password appears in logcat, confirming the external broadcast reached the receiver: + + ```bash + 06-13 11:12:55.445 4942 4942 D MASTG-DEMO: Password changed from originalPass123 to hacked123 + ``` + +**The permission-protected receiver rejects the broadcast.** + +`AdminCommandReceiver` listens for the `org.owasp.mastestapp.ADMIN_COMMAND` action (this is the action, *not* the permission name) and is registered with the signature-level `broadcastPermission` `org.owasp.mastestapp.ADMIN_COMMAND_PERMISSION`. Open `AdminActivity` with the **Admin** button (which registers it), then try the same kind of attack: + +```bash +adb shell am broadcast -a org.owasp.mastestapp.ADMIN_COMMAND -p org.owasp.mastestapp --es command wipe + +Broadcasting: Intent { act=org.owasp.mastestapp.ADMIN_COMMAND pkg=org.owasp.mastestapp (has extras) } +Broadcast completed: result=0 +``` + +`Broadcast completed: result=0` only means the system accepted the broadcast for dispatch; it does **not** mean any receiver was invoked. `AdminCommandReceiver` logs every delivery on entry, so if it had been reached you would see: + +```bash +06-13 11:13:10.000 4942 4942 D MASTG-DEMO: AdminCommandReceiver received broadcast: org.owasp.mastestapp.ADMIN_COMMAND +``` + +Because `adb`/shell is not signed with the app's certificate, it does not hold `ADMIN_COMMAND_PERMISSION`, so the OS drops the broadcast before `onReceive` runs. No `AdminCommandReceiver received broadcast` line appears in logcat, and the vault recovery key shown in `AdminActivity` is unchanged, confirming the receiver is protected. This is the difference that matters: the same `am broadcast` that works against `PasswordResetReceiver` is silently rejected here, with no error returned to the sender. + +> Note: targeting the permission string as the action (`-a org.owasp.mastestapp.ADMIN_COMMAND_PERMISSION`) also produces no effect, but for a different reason: no receiver listens for that action at all. Always send the action the receiver's `IntentFilter` declares. + +## Fix + +There are two ways to fix this, and the right choice depends on whether `PasswordResetReceiver` needs to accept broadcasts from external apps at all. + +**Option 1: Register with `RECEIVER_NOT_EXPORTED` (recommended for most apps)** + +If the receiver only reacts to broadcasts the app sends to itself, register it as not exported, exactly as `VaultRefreshReceiver` already does: + +```kotlin +ContextCompat.registerReceiver( + this, + passwordResetReceiver, + IntentFilter(ACTION_RESET_PASSWORD), + ContextCompat.RECEIVER_NOT_EXPORTED +) +``` + +Other apps can no longer deliver the broadcast, so the password can't be reset from outside the app. For purely in-process events, an even stronger option is to avoid a global broadcast entirely and use a `LocalBroadcastManager`-style in-app event bus or a `ViewModel`/`StateFlow`. + +**Option 2: Keep it exported but require a signature `broadcastPermission`** + +If the receiver must be reachable by a trusted partner app from the same developer, keep it exported but pass a custom signature-level permission as the `broadcastPermission` argument so only apps signed with the same certificate can send the broadcast. This is exactly what `AdminActivity` already does for `AdminCommandReceiver` in this sample: + +```kotlin +// declared in the manifest + +ContextCompat.registerReceiver( + this, + passwordResetReceiver, + IntentFilter(ACTION_RESET_PASSWORD), + "org.owasp.mastestapp.SEND_PASSWORD_RESET", + null, + ContextCompat.RECEIVER_EXPORTED +) +``` + +This only resolves the finding if the permission cannot be obtained by untrusted apps. A custom permission with `normal` or `dangerous` protection level would still let untrusted apps send the broadcast. See @MASTG-KNOW-0017 for Android permission protection levels. + +**Additional fix - Remove sensitive data from logs:** + +Regardless of whether the receiver itself is protected, no credentials must be written to the app logs, which are readable by any app that holds `READ_LOGS` (granted to shell and ADB). diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MastgTest.kt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MastgTest.kt new file mode 100644 index 00000000000..6b9ce9eb74b --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MastgTest.kt @@ -0,0 +1,197 @@ +// SUMMARY: This sample implements a small password vault. Tapping Start opens VaultActivity, +// which shows the password currently stored in the app. Instead of declaring its receivers in +// the manifest, the app registers them at runtime (context-registered). VaultActivity registers +// PasswordResetReceiver with RECEIVER_EXPORTED, so any app can send the broadcast and reset the +// password, and VaultRefreshReceiver with RECEIVER_NOT_EXPORTED, which only the app itself can +// trigger. The in-app-only AdminActivity registers AdminCommandReceiver, which can wipe the +// vault, with RECEIVER_EXPORTED but restricted with a signature-level broadcastPermission, so +// only same-signer apps can reach it. Tapping Refresh in VaultActivity then shows the new value. + +package org.owasp.mastestapp + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat + +class MastgTest(private val context: Context) { + + companion object { + const val PREFS = "secure_prefs" + const val KEY_PASSWORD_STORE = "vault_password" + const val DEFAULT_PASSWORD = "originalPass123" + const val ACTION_RESET_PASSWORD = "org.owasp.mastestapp.RESET_PASSWORD" + const val ACTION_VAULT_UPDATED = "org.owasp.mastestapp.VAULT_UPDATED" + const val ACTION_ADMIN_COMMAND = "org.owasp.mastestapp.ADMIN_COMMAND" + const val PERMISSION_ADMIN_COMMAND = "org.owasp.mastestapp.ADMIN_COMMAND_PERMISSION" + } + + fun mastgTest(): String { + // Seed the stored password the first time the demo runs. + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (!prefs.contains(KEY_PASSWORD_STORE)) { + prefs.edit().putString(KEY_PASSWORD_STORE, DEFAULT_PASSWORD).apply() + } + + // Open the legitimate vault screen, which displays the stored password. + context.startActivity( + Intent(context, VaultActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + return "Opening the password vault…" + } + + // Legitimate UI: shows the password currently stored in the vault. It registers its broadcast + // receivers at runtime instead of declaring them in the manifest, so they don't appear there. + class VaultActivity : Activity() { + + private lateinit var status: TextView + private val passwordResetReceiver = PasswordResetReceiver() + private val vaultRefreshReceiver = VaultRefreshReceiver() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + actionBar?.hide() + + val layout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(64, 120, 64, 64) + } + val title = TextView(this).apply { + text = "MASTestApp – Password Vault" + textSize = 22f + } + status = TextView(this).apply { + textSize = 18f + setPadding(0, 48, 0, 48) + } + val refresh = Button(this).apply { + text = "Refresh" + setOnClickListener { showPassword() } + } + val admin = Button(this).apply { + text = "Admin" + setOnClickListener { + startActivity(Intent(this@VaultActivity, AdminActivity::class.java)) + } + } + layout.addView(title) + layout.addView(status) + layout.addView(refresh) + layout.addView(admin) + setContentView(layout) + + // FAIL: [MASTG-TEST-0366] Registered as exported, so any app on the device can deliver + // the RESET_PASSWORD broadcast and reset the vault password. + ContextCompat.registerReceiver( + this, + passwordResetReceiver, + IntentFilter(ACTION_RESET_PASSWORD), + ContextCompat.RECEIVER_EXPORTED + ) + + // PASS: Registered as not exported, so only broadcasts the app sends to itself can + // reach it. It is not part of the external attack surface. + ContextCompat.registerReceiver( + this, + vaultRefreshReceiver, + IntentFilter(ACTION_VAULT_UPDATED), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + + showPassword() + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(passwordResetReceiver) + unregisterReceiver(vaultRefreshReceiver) + } + + private fun showPassword() { + val pwd = getSharedPreferences(MastgTest.PREFS, Context.MODE_PRIVATE) + .getString(MastgTest.KEY_PASSWORD_STORE, "") + status.text = "Current vault password:\n\n$pwd" + } + } + + // Legitimate in-app admin screen, reachable only from within the app (not exported). It shows + // sensitive data (the vault recovery key) and registers a context-registered receiver that can + // wipe the vault. + class AdminActivity : Activity() { + + private val adminCommandReceiver = AdminCommandReceiver() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + actionBar?.hide() + + val recoveryKey = getSharedPreferences(MastgTest.PREFS, Context.MODE_PRIVATE) + .getString(MastgTest.KEY_PASSWORD_STORE, "") + + val view = TextView(this).apply { + text = "ADMIN CONSOLE\n\nVault recovery key: $recoveryKey" + textSize = 18f + setPadding(64, 120, 64, 64) + } + setContentView(view) + + // PASS: Registered as exported, but restricted with a signature-level broadcastPermission, + // so only apps signed with the same certificate can deliver the ADMIN_COMMAND broadcast. + ContextCompat.registerReceiver( + this, + adminCommandReceiver, + IntentFilter(ACTION_ADMIN_COMMAND), + PERMISSION_ADMIN_COMMAND, + null, + ContextCompat.RECEIVER_EXPORTED + ) + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(adminCommandReceiver) + } + } + + // FAIL: [MASTG-TEST-0366] Exposes a sensitive action (changing the stored password) and is + // registered as exported, so external callers can trigger it and disclose the old password. + class PasswordResetReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val newPassword = intent.getStringExtra("newpass") ?: return + val prefs = context.getSharedPreferences(MastgTest.PREFS, Context.MODE_PRIVATE) + val oldPassword = prefs.getString(MastgTest.KEY_PASSWORD_STORE, "") + Log.d("MASTG-DEMO", "Password changed from $oldPassword to $newPassword") + prefs.edit().putString(MastgTest.KEY_PASSWORD_STORE, newPassword).apply() + } + } + + // PASS: Registered as not exported and only refreshes the UI, exposing no sensitive action. + class VaultRefreshReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d("MASTG-DEMO", "Vault updated event received, refreshing UI") + } + } + + // PASS: Performs a sensitive action (wiping the vault) but is registered with a signature-level + // broadcastPermission, so untrusted apps cannot deliver the broadcast that triggers it. + class AdminCommandReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Logged on every delivery. If this line never appears after sending the broadcast, + // the OS rejected the sender before onReceive ran (it lacked the broadcastPermission). + Log.d("MASTG-DEMO", "AdminCommandReceiver received broadcast: ${intent.action}") + if (intent.getStringExtra("command") != "wipe") return + context.getSharedPreferences(MastgTest.PREFS, Context.MODE_PRIVATE) + .edit().remove(MastgTest.KEY_PASSWORD_STORE).apply() + Log.d("MASTG-DEMO", "Vault wiped by admin command") + } + } +} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MastgTest_reversed.java b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MastgTest_reversed.java new file mode 100644 index 00000000000..1ce85050e07 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/MastgTest_reversed.java @@ -0,0 +1,218 @@ +package org.owasp.mastestapp; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.compose.material.MenuKt; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; +import kotlin.Metadata; +import kotlin.jvm.internal.Intrinsics; +import org.owasp.mastestapp.MastgTest; + +/* JADX INFO: compiled from: MastgTest.kt */ +/* JADX INFO: loaded from: classes3.dex */ +@Metadata(d1 = {"\u0000\u001a\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u000e\n\u0002\b\u0007\b\u0007\u0018\u0000 \b2\u00020\u0001:\u0006\b\t\n\u000b\f\rB\u000f\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0004\b\u0004\u0010\u0005J\u0006\u0010\u0006\u001a\u00020\u0007R\u000e\u0010\u0002\u001a\u00020\u0003X\u0082\u0004¢\u0006\u0002\n\u0000¨\u0006\u000e"}, d2 = {"Lorg/owasp/mastestapp/MastgTest;", "", "context", "Landroid/content/Context;", "", "(Landroid/content/Context;)V", "mastgTest", "", "Companion", "VaultActivity", "AdminActivity", "PasswordResetReceiver", "VaultRefreshReceiver", "AdminCommandReceiver", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) +public final class MastgTest { + public static final String ACTION_ADMIN_COMMAND = "org.owasp.mastestapp.ADMIN_COMMAND"; + public static final String ACTION_RESET_PASSWORD = "org.owasp.mastestapp.RESET_PASSWORD"; + public static final String ACTION_VAULT_UPDATED = "org.owasp.mastestapp.VAULT_UPDATED"; + public static final String DEFAULT_PASSWORD = "originalPass123"; + public static final String KEY_PASSWORD_STORE = "vault_password"; + public static final String PERMISSION_ADMIN_COMMAND = "org.owasp.mastestapp.ADMIN_COMMAND_PERMISSION"; + public static final String PREFS = "secure_prefs"; + private final Context context; + public static final int $stable = 8; + + public MastgTest(Context context) { + Intrinsics.checkNotNullParameter(context, "context"); + this.context = context; + } + + public final String mastgTest() { + SharedPreferences prefs = this.context.getSharedPreferences(PREFS, 0); + if (!prefs.contains(KEY_PASSWORD_STORE)) { + prefs.edit().putString(KEY_PASSWORD_STORE, DEFAULT_PASSWORD).apply(); + } + Context context = this.context; + Intent $this$mastgTest_u24lambda_u240 = new Intent(this.context, (Class) VaultActivity.class); + $this$mastgTest_u24lambda_u240.addFlags(268435456); + context.startActivity($this$mastgTest_u24lambda_u240); + return "Opening the password vault…"; + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000,\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0003\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0012\u0010\n\u001a\u00020\u000b2\b\u0010\f\u001a\u0004\u0018\u00010\rH\u0014J\b\u0010\u000e\u001a\u00020\u000bH\u0014J\b\u0010\u000f\u001a\u00020\u000bH\u0002R\u000e\u0010\u0004\u001a\u00020\u0005X\u0082.¢\u0006\u0002\n\u0000R\u000e\u0010\u0006\u001a\u00020\u0007X\u0082\u0004¢\u0006\u0002\n\u0000R\u000e\u0010\b\u001a\u00020\tX\u0082\u0004¢\u0006\u0002\n\u0000¨\u0006\u0010"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$VaultActivity;", "Landroid/app/Activity;", "", "()V", NotificationCompat.CATEGORY_STATUS, "Landroid/widget/TextView;", "passwordResetReceiver", "Lorg/owasp/mastestapp/MastgTest$PasswordResetReceiver;", "vaultRefreshReceiver", "Lorg/owasp/mastestapp/MastgTest$VaultRefreshReceiver;", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "onDestroy", "showPassword", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class VaultActivity extends Activity { + public static final int $stable = 8; + private TextView status; + private final PasswordResetReceiver passwordResetReceiver = new PasswordResetReceiver(); + private final VaultRefreshReceiver vaultRefreshReceiver = new VaultRefreshReceiver(); + + @Override // android.app.Activity + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(1); + layout.setPadding(64, MenuKt.InTransitionDuration, 64, 64); + TextView $this$onCreate_u24lambda_u241 = new TextView(this); + $this$onCreate_u24lambda_u241.setText("MASTestApp – Password Vault"); + $this$onCreate_u24lambda_u241.setTextSize(22.0f); + TextView $this$onCreate_u24lambda_u242 = new TextView(this); + $this$onCreate_u24lambda_u242.setTextSize(18.0f); + $this$onCreate_u24lambda_u242.setPadding(0, 48, 0, 48); + this.status = $this$onCreate_u24lambda_u242; + Button $this$onCreate_u24lambda_u244 = new Button(this); + $this$onCreate_u24lambda_u244.setText("Refresh"); + $this$onCreate_u24lambda_u244.setOnClickListener(new View.OnClickListener() { // from class: org.owasp.mastestapp.MastgTest$VaultActivity$$ExternalSyntheticLambda0 + @Override // android.view.View.OnClickListener + public final void onClick(View view) { + MastgTest.VaultActivity.onCreate$lambda$4$lambda$3(this.f$0, view); + } + }); + Button $this$onCreate_u24lambda_u246 = new Button(this); + $this$onCreate_u24lambda_u246.setText("Admin"); + $this$onCreate_u24lambda_u246.setOnClickListener(new View.OnClickListener() { // from class: org.owasp.mastestapp.MastgTest$VaultActivity$$ExternalSyntheticLambda1 + @Override // android.view.View.OnClickListener + public final void onClick(View view) { + MastgTest.VaultActivity.onCreate$lambda$6$lambda$5(this.f$0, view); + } + }); + layout.addView($this$onCreate_u24lambda_u241); + TextView textView = this.status; + if (textView == null) { + Intrinsics.throwUninitializedPropertyAccessException(NotificationCompat.CATEGORY_STATUS); + textView = null; + } + layout.addView(textView); + layout.addView($this$onCreate_u24lambda_u244); + layout.addView($this$onCreate_u24lambda_u246); + setContentView(layout); + ContextCompat.registerReceiver(this, this.passwordResetReceiver, new IntentFilter(MastgTest.ACTION_RESET_PASSWORD), 2); + ContextCompat.registerReceiver(this, this.vaultRefreshReceiver, new IntentFilter(MastgTest.ACTION_VAULT_UPDATED), 4); + showPassword(); + } + + /* JADX INFO: Access modifiers changed from: private */ + public static final void onCreate$lambda$4$lambda$3(VaultActivity this$0, View it) { + Intrinsics.checkNotNullParameter(this$0, "this$0"); + this$0.showPassword(); + } + + /* JADX INFO: Access modifiers changed from: private */ + public static final void onCreate$lambda$6$lambda$5(VaultActivity this$0, View it) { + Intrinsics.checkNotNullParameter(this$0, "this$0"); + this$0.startActivity(new Intent(this$0, (Class) AdminActivity.class)); + } + + @Override // android.app.Activity + protected void onDestroy() { + super.onDestroy(); + unregisterReceiver(this.passwordResetReceiver); + unregisterReceiver(this.vaultRefreshReceiver); + } + + private final void showPassword() { + String pwd = getSharedPreferences(MastgTest.PREFS, 0).getString(MastgTest.KEY_PASSWORD_STORE, ""); + TextView textView = this.status; + if (textView == null) { + Intrinsics.throwUninitializedPropertyAccessException(NotificationCompat.CATEGORY_STATUS); + textView = null; + } + textView.setText("Current vault password:\n\n" + pwd); + } + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0002\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0012\u0010\u0006\u001a\u00020\u00072\b\u0010\b\u001a\u0004\u0018\u00010\tH\u0014J\b\u0010\n\u001a\u00020\u0007H\u0014R\u000e\u0010\u0004\u001a\u00020\u0005X\u0082\u0004¢\u0006\u0002\n\u0000¨\u0006\u000b"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$AdminActivity;", "Landroid/app/Activity;", "", "()V", "adminCommandReceiver", "Lorg/owasp/mastestapp/MastgTest$AdminCommandReceiver;", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "onDestroy", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class AdminActivity extends Activity { + public static final int $stable = 0; + private final AdminCommandReceiver adminCommandReceiver = new AdminCommandReceiver(); + + @Override // android.app.Activity + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + String recoveryKey = getSharedPreferences(MastgTest.PREFS, 0).getString(MastgTest.KEY_PASSWORD_STORE, ""); + TextView $this$onCreate_u24lambda_u240 = new TextView(this); + $this$onCreate_u24lambda_u240.setText("ADMIN CONSOLE\n\nVault recovery key: " + recoveryKey); + $this$onCreate_u24lambda_u240.setTextSize(18.0f); + $this$onCreate_u24lambda_u240.setPadding(64, MenuKt.InTransitionDuration, 64, 64); + setContentView($this$onCreate_u24lambda_u240); + ContextCompat.registerReceiver(this, this.adminCommandReceiver, new IntentFilter(MastgTest.ACTION_ADMIN_COMMAND), MastgTest.PERMISSION_ADMIN_COMMAND, null, 2); + } + + @Override // android.app.Activity + protected void onDestroy() { + super.onDestroy(); + unregisterReceiver(this.adminCommandReceiver); + } + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000\u001e\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0018\u0010\u0004\u001a\u00020\u00052\u0006\u0010\u0006\u001a\u00020\u00072\u0006\u0010\b\u001a\u00020\tH\u0016¨\u0006\n"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$PasswordResetReceiver;", "Landroid/content/BroadcastReceiver;", "", "()V", "onReceive", "", "context", "Landroid/content/Context;", "intent", "Landroid/content/Intent;", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class PasswordResetReceiver extends BroadcastReceiver { + public static final int $stable = 0; + + @Override // android.content.BroadcastReceiver + public void onReceive(Context context, Intent intent) { + Intrinsics.checkNotNullParameter(context, "context"); + Intrinsics.checkNotNullParameter(intent, "intent"); + String newPassword = intent.getStringExtra("newpass"); + if (newPassword == null) { + return; + } + SharedPreferences prefs = context.getSharedPreferences(MastgTest.PREFS, 0); + String oldPassword = prefs.getString(MastgTest.KEY_PASSWORD_STORE, ""); + Log.d("MASTG-DEMO", "Password changed from " + oldPassword + " to " + newPassword); + prefs.edit().putString(MastgTest.KEY_PASSWORD_STORE, newPassword).apply(); + } + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000\u001e\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0018\u0010\u0004\u001a\u00020\u00052\u0006\u0010\u0006\u001a\u00020\u00072\u0006\u0010\b\u001a\u00020\tH\u0016¨\u0006\n"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$VaultRefreshReceiver;", "Landroid/content/BroadcastReceiver;", "", "()V", "onReceive", "", "context", "Landroid/content/Context;", "intent", "Landroid/content/Intent;", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class VaultRefreshReceiver extends BroadcastReceiver { + public static final int $stable = 0; + + @Override // android.content.BroadcastReceiver + public void onReceive(Context context, Intent intent) { + Intrinsics.checkNotNullParameter(context, "context"); + Intrinsics.checkNotNullParameter(intent, "intent"); + Log.d("MASTG-DEMO", "Vault updated event received, refreshing UI"); + } + } + + /* JADX INFO: compiled from: MastgTest.kt */ + @Metadata(d1 = {"\u0000\u001e\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\b\u0007\u0018\u00002\u00020\u0001B\t\b\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0018\u0010\u0004\u001a\u00020\u00052\u0006\u0010\u0006\u001a\u00020\u00072\u0006\u0010\b\u001a\u00020\tH\u0016¨\u0006\n"}, d2 = {"Lorg/owasp/mastestapp/MastgTest$AdminCommandReceiver;", "Landroid/content/BroadcastReceiver;", "", "()V", "onReceive", "", "context", "Landroid/content/Context;", "intent", "Landroid/content/Intent;", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48) + public static final class AdminCommandReceiver extends BroadcastReceiver { + public static final int $stable = 0; + + @Override // android.content.BroadcastReceiver + public void onReceive(Context context, Intent intent) { + Intrinsics.checkNotNullParameter(context, "context"); + Intrinsics.checkNotNullParameter(intent, "intent"); + Log.d("MASTG-DEMO", "AdminCommandReceiver received broadcast: " + intent.getAction()); + if (Intrinsics.areEqual(intent.getStringExtra("command"), "wipe")) { + context.getSharedPreferences(MastgTest.PREFS, 0).edit().remove(MastgTest.KEY_PASSWORD_STORE).apply(); + Log.d("MASTG-DEMO", "Vault wiped by admin command"); + } + } + } +} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/code_scan.json b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/code_scan.json new file mode 100644 index 00000000000..be5660a01d0 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/code_scan.json @@ -0,0 +1 @@ +{"version":"1.142.1","results":[{"check_id":"rules.mastg-android-context-registered-receiver-exported","path":"MastgTest_reversed.java","start":{"line":105,"col":13,"offset":6979},"end":{"line":105,"col":131,"offset":7097},"extra":{"message":"[MASVS-PLATFORM] Context-registered receiver registered as exported (RECEIVER_EXPORTED) with no broadcastPermission, so any app on the device can deliver broadcasts to it. Inspect its onReceive for sensitive functionality: receiver=this.passwordResetReceiver\n","metadata":{"summary":"Detects a context-registered broadcast receiver that is exported (RECEIVER_EXPORTED) without a broadcastPermission, reachable by other apps.","masvs":["MASVS-PLATFORM"]},"severity":"WARNING","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}},{"check_id":"rules.mastg-android-context-registered-receiver-not-exported","path":"MastgTest_reversed.java","start":{"line":106,"col":13,"offset":7111},"end":{"line":106,"col":129,"offset":7227},"extra":{"message":"[MASVS-PLATFORM] Context-registered receiver registered as not exported (RECEIVER_NOT_EXPORTED). Other apps cannot deliver broadcasts to it, so it is not part of the external attack surface: receiver=this.vaultRefreshReceiver\n","metadata":{"summary":"Detects a context-registered broadcast receiver that is not exported (RECEIVER_NOT_EXPORTED).","masvs":["MASVS-PLATFORM"]},"severity":"INFO","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}},{"check_id":"rules.mastg-android-context-registered-receiver-exported-with-permission","path":"MastgTest_reversed.java","start":{"line":159,"col":13,"offset":10396},"end":{"line":159,"col":171,"offset":10554},"extra":{"message":"[MASVS-PLATFORM] Context-registered receiver registered as exported (RECEIVER_EXPORTED) but restricted with a broadcastPermission. Verify the permission protection level matches the intended sender set: receiver=this.adminCommandReceiver permission=MastgTest.PERMISSION_ADMIN_COMMAND\n","metadata":{"summary":"Detects a context-registered broadcast receiver that is exported but restricted with a broadcastPermission.","masvs":["MASVS-PLATFORM"]},"severity":"INFO","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}}],"errors":[],"paths":{"scanned":["MastgTest_reversed.java"]},"time":{"rules":[],"rules_parse_time":0.0032091140747070312,"profiling_times":{"config_time":0.09575295448303223,"core_time":0.3055849075317383,"ignores_time":2.6226043701171875e-05,"total_time":0.4015488624572754},"parsing_time":{"total_time":0.0,"per_file_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"scanning_time":{"total_time":0.01799297332763672,"per_file_time":{"mean":0.01799297332763672,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"matching_time":{"total_time":0.0,"per_file_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_files":[]},"tainting_time":{"total_time":0.0,"per_def_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_defs":[]},"fixpoint_timeouts":[],"prefiltering":{"project_level_time":0.0,"file_level_time":0.0,"rules_with_project_prefilters_ratio":0.0,"rules_with_file_prefilters_ratio":1.0,"rules_selected_ratio":1.0,"rules_matched_ratio":1.0},"targets":[],"total_bytes":0,"max_memory_bytes":79690496},"engine_requested":"OSS","skipped_rules":[]} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/code_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/code_scan.txt new file mode 100644 index 00000000000..95a0101daa2 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/code_scan.txt @@ -0,0 +1,31 @@ + + +┌─────────────────┐ +│ 3 Code Findings │ +└─────────────────┘ + + MastgTest_reversed.java + ❯❱ rules.mastg-android-context-registered-receiver-exported + [MASVS-PLATFORM] Context-registered receiver registered as exported (RECEIVER_EXPORTED) with no + broadcastPermission, so any app on the device can deliver broadcasts to it. Inspect its onReceive + for sensitive functionality: receiver=this.passwordResetReceiver + + 105┆ ContextCompat.registerReceiver(this, this.passwordResetReceiver, new + IntentFilter(MastgTest.ACTION_RESET_PASSWORD), 2); + + ❱ rules.mastg-android-context-registered-receiver-not-exported + [MASVS-PLATFORM] Context-registered receiver registered as not exported (RECEIVER_NOT_EXPORTED). + Other apps cannot deliver broadcasts to it, so it is not part of the external attack surface: + receiver=this.vaultRefreshReceiver + + 106┆ ContextCompat.registerReceiver(this, this.vaultRefreshReceiver, new + IntentFilter(MastgTest.ACTION_VAULT_UPDATED), 4); + + ❱ rules.mastg-android-context-registered-receiver-exported-with-permission + [MASVS-PLATFORM] Context-registered receiver registered as exported (RECEIVER_EXPORTED) but + restricted with a broadcastPermission. Verify the permission protection level matches the intended + sender set: receiver=this.adminCommandReceiver permission=MastgTest.PERMISSION_ADMIN_COMMAND + + 159┆ ContextCompat.registerReceiver(this, this.adminCommandReceiver, new + IntentFilter(MastgTest.ACTION_ADMIN_COMMAND), MastgTest.PERMISSION_ADMIN_COMMAND, null, 2); + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/evaluate.sh b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/evaluate.sh new file mode 100755 index 00000000000..7a0481b2d0d --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/evaluate.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Stage 4 (reduce) — classify the context-registered receivers found in Stage 2 into two lists, +# resolving each broadcastPermission to its declared protection level so the reader does not have +# to look it up by hand. + +# "permission name protection level" pairs from the declared-permission scan (Stage 1b). +PERMS=$(jq -r '.results[] + | select(.check_id | test("declared-permission")) + | .extra.message + | gsub("\\s+"; " ") + | capture("permission=(?[^ ]+) level=(?[^ ]+)") + | "\(.n)\t\(.l)"' permissions_scan.json 2>/dev/null) + +# Look up the protection level declared for a permission name (empty if undeclared). +lookup_level() { printf '%s\n' "$PERMS" | awk -F'\t' -v n="$1" '$1==n{print $2; exit}'; } + +# Resolve a broadcastPermission token (a string literal or a constant reference such as +# MastgTest.PERMISSION_ADMIN_COMMAND) to the actual permission name by reading the decompiled +# constant declaration. +resolve_name() { + case "$1" in + \"*\") printf '%s' "${1//\"/}";; + *) local simple="${1##*.}" + grep -oE "String[[:space:]]+${simple}[[:space:]]*=[[:space:]]*\"[^\"]+\"" MastgTest_reversed.java \ + | grep -oE '"[^"]+"' | tr -d '"' | head -1;; + esac +} + +{ + # Reported as vulnerable: exported (RECEIVER_EXPORTED) with no broadcastPermission. Any app on + # the device can reach these, so they are the candidates to inspect for sensitive functionality. + echo "# Exported, no permission (reported as vulnerable):" + jq -r '.results[] + | select(.check_id | endswith("context-registered-receiver-exported")) + | .extra.message + | sub("(?s).*receiver="; "") | gsub("\\s+"; "") | sub("^this\\."; "")' code_scan.json + + echo + # Exported but restricted with a broadcastPermission: only safe if the resolved protection level + # is strong (signature/knownSigner/internal). A normal or dangerous level (or none) is reported + # as vulnerable, because untrusted apps can obtain it and send the broadcast. + echo "# Exported, permission-restricted (protection level resolved from the manifest):" + jq -r '.results[] + | select(.check_id | endswith("context-registered-receiver-exported-with-permission")) + | .extra.message + | gsub("\\s+"; " ") + | capture("receiver=(?[^ ]+) permission=(?

[^ ]+)") + | "\(.r)\t\(.p)"' code_scan.json \ + | while IFS=$'\t' read -r receiver token; do + receiver="${receiver#this.}" + name=$(resolve_name "$token") + [ -z "$name" ] && name="$token" + level=$(lookup_level "$name") + case "$level" in + *signature*|*knownSigner*|*internal*) verdict="OK (strong, untrusted apps cannot hold it)";; + "") verdict="REVIEW (permission not declared in this manifest; resolve it elsewhere)";; + *) verdict="WEAK -> treat as vulnerable (normal/dangerous can be held by untrusted apps)";; + esac + echo "$receiver permission=$name protectionLevel=${level:-unknown} -> $verdict" + done +} > evaluation.txt diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/evaluation.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/evaluation.txt new file mode 100644 index 00000000000..bd95f16212e --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/evaluation.txt @@ -0,0 +1,5 @@ +# Exported, no permission (reported as vulnerable): +passwordResetReceiver + +# Exported, permission-restricted (protection level resolved from the manifest): +adminCommandReceiver permission=org.owasp.mastestapp.ADMIN_COMMAND_PERMISSION protectionLevel=signature -> OK (strong, untrusted apps cannot hold it) diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/manifest_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/manifest_scan.txt new file mode 100644 index 00000000000..bcae10c10dc --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/manifest_scan.txt @@ -0,0 +1,32 @@ + + +┌────────────────┐ +│ 1 Code Finding │ +└────────────────┘ + + AndroidManifest_reversed.xml + ❱ rules.mastg-android-receiver-exported-with-permission + [MASVS-PLATFORM] Exported broadcast receiver protected by a permission. Verify the permission + protection level matches the intended trust boundary: + receiver=androidx.profileinstaller.ProfileInstallReceiver + + 72┆ + 78┆ + 79┆ + 80┆ + 81┆ + 82┆ + 83┆ + 84┆ + 85┆ + 86┆ + 87┆ + 88┆ + 89┆ + 90┆ + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/onreceive_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/onreceive_scan.txt new file mode 100644 index 00000000000..a2f95abdabd --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/onreceive_scan.txt @@ -0,0 +1,60 @@ + + +┌─────────────────┐ +│ 5 Code Findings │ +└─────────────────┘ + + MastgTest_reversed.java + ❱ rules.mastg-android-receiver-entrypoint + [MASVS-PLATFORM] Broadcast receiver onReceive entry point. Review it and the code it reaches for + sensitive functionality that should not be triggerable by an external sender. + + 174┆ @Override // android.content.BroadcastReceiver + 175┆ public void onReceive(Context context, Intent intent) { + 176┆ Intrinsics.checkNotNullParameter(context, "context"); + 177┆ Intrinsics.checkNotNullParameter(intent, "intent"); + 178┆ String newPassword = intent.getStringExtra("newpass"); + 179┆ if (newPassword == null) { + 180┆ return; + 181┆ } + 182┆ SharedPreferences prefs = context.getSharedPreferences(MastgTest.PREFS, 0); + 183┆ String oldPassword = prefs.getString(MastgTest.KEY_PASSWORD_STORE, ""); + 184┆ Log.d("MASTG-DEMO", "Password changed from " + oldPassword + " to " + newPassword); + 185┆ prefs.edit().putString(MastgTest.KEY_PASSWORD_STORE, newPassword).apply(); + 186┆ } + + ❱ rules.mastg-android-receiver-sensitive-extra-source + [MASVS-PLATFORM] Reads an intent extra. When the component is exported and unprotected, this value + is attacker-controllable. Trace where it is used. + + 178┆ String newPassword = intent.getStringExtra("newpass"); + + ❱ rules.mastg-android-receiver-entrypoint + [MASVS-PLATFORM] Broadcast receiver onReceive entry point. Review it and the code it reaches for + sensitive functionality that should not be triggerable by an external sender. + + 194┆ @Override // android.content.BroadcastReceiver + 195┆ public void onReceive(Context context, Intent intent) { + 196┆ Intrinsics.checkNotNullParameter(context, "context"); + 197┆ Intrinsics.checkNotNullParameter(intent, "intent"); + 198┆ Log.d("MASTG-DEMO", "Vault updated event received, refreshing UI"); + 199┆ } + ⋮┆---------------------------------------- + 207┆ @Override // android.content.BroadcastReceiver + 208┆ public void onReceive(Context context, Intent intent) { + 209┆ Intrinsics.checkNotNullParameter(context, "context"); + 210┆ Intrinsics.checkNotNullParameter(intent, "intent"); + 211┆ Log.d("MASTG-DEMO", "AdminCommandReceiver received broadcast: " + intent.getAction()); + 212┆ if (Intrinsics.areEqual(intent.getStringExtra("command"), "wipe")) { + 213┆ context.getSharedPreferences(MastgTest.PREFS, + 0).edit().remove(MastgTest.KEY_PASSWORD_STORE).apply(); + 214┆ Log.d("MASTG-DEMO", "Vault wiped by admin command"); + 215┆ } + 216┆ } + + ❱ rules.mastg-android-receiver-sensitive-extra-source + [MASVS-PLATFORM] Reads an intent extra. When the component is exported and unprotected, this value + is attacker-controllable. Trace where it is used. + + 212┆ if (Intrinsics.areEqual(intent.getStringExtra("command"), "wipe")) { + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/permissions_scan.json b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/permissions_scan.json new file mode 100644 index 00000000000..f7605c495d8 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/permissions_scan.json @@ -0,0 +1 @@ +{"version":"1.142.1","results":[{"check_id":"rules.mastg-android-declared-permission-level","path":"AndroidManifest_reversed.xml","start":{"line":14,"col":5,"offset":507},"end":{"line":16,"col":46,"offset":633},"extra":{"message":"[MASVS-PLATFORM] Declared permission. permission=org.owasp.mastestapp.ADMIN_COMMAND_PERMISSION level=signature\n","metadata":{"summary":"Reports each custom permission declared in the manifest together with its protection level.","masvs":["MASVS-PLATFORM"]},"severity":"INFO","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}},{"check_id":"rules.mastg-android-declared-permission-level","path":"AndroidManifest_reversed.xml","start":{"line":17,"col":5,"offset":638},"end":{"line":19,"col":46,"offset":780},"extra":{"message":"[MASVS-PLATFORM] Declared permission. permission=org.owasp.mastestapp.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION level=signature\n","metadata":{"summary":"Reports each custom permission declared in the manifest together with its protection level.","masvs":["MASVS-PLATFORM"]},"severity":"INFO","fingerprint":"requires login","lines":"requires login","validation_state":"NO_VALIDATOR","engine_kind":"OSS"}}],"errors":[],"paths":{"scanned":["AndroidManifest_reversed.xml"]},"time":{"rules":[],"rules_parse_time":0.0017669200897216797,"profiling_times":{"config_time":0.09489679336547852,"core_time":0.3145630359649658,"ignores_time":2.6226043701171875e-05,"total_time":0.4096560478210449},"parsing_time":{"total_time":0.0,"per_file_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"scanning_time":{"total_time":0.006823062896728516,"per_file_time":{"mean":0.006823062896728516,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"matching_time":{"total_time":0.0,"per_file_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_files":[]},"tainting_time":{"total_time":0.0,"per_def_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_defs":[]},"fixpoint_timeouts":[],"prefiltering":{"project_level_time":0.0,"file_level_time":0.0,"rules_with_project_prefilters_ratio":0.0,"rules_with_file_prefilters_ratio":1.0,"rules_selected_ratio":1.0,"rules_matched_ratio":1.0},"targets":[],"total_bytes":0,"max_memory_bytes":77593344},"engine_requested":"OSS","skipped_rules":[]} diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/permissions_scan.txt b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/permissions_scan.txt new file mode 100644 index 00000000000..2acce153690 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/permissions_scan.txt @@ -0,0 +1,23 @@ + + +┌─────────────────┐ +│ 2 Code Findings │ +└─────────────────┘ + + AndroidManifest_reversed.xml + ❱ rules.mastg-android-declared-permission-level + [MASVS-PLATFORM] Declared permission. permission=org.owasp.mastestapp.ADMIN_COMMAND_PERMISSION + level=signature + + 14┆ + ⋮┆---------------------------------------- + ❱ rules.mastg-android-declared-permission-level + [MASVS-PLATFORM] Declared permission. + permission=org.owasp.mastestapp.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION level=signature + + 17┆ + diff --git a/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/run.sh b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/run.sh new file mode 100755 index 00000000000..9c7b25660a4 --- /dev/null +++ b/demos/android/MASVS-PLATFORM/MASTG-DEMO-0x01/run.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Context-registered receivers are registered at runtime with Context.registerReceiver and do +# NOT appear in the AndroidManifest, so manifest enumeration alone misses them. + +# Stage 1 (capture, manifest) — enumerate the manifest-declared receivers. This confirms the app +# declares no receiver of its own in the manifest (only the library ProfileInstallReceiver shows up). +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-exported-receiver.yml ./AndroidManifest_reversed.xml --text --max-lines-per-finding 0 > manifest_scan.txt + +# Stage 1b (capture, permissions) — list the custom permissions declared in the manifest with +# their protection levels, and flag weak ones (normal/dangerous, or no level) that untrusted apps +# can hold. evaluate.sh uses this to resolve each receiver's broadcastPermission to its level. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-declared-permission-protection-level.yml ./AndroidManifest_reversed.xml --text --max-lines-per-finding 0 > permissions_scan.txt +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-declared-permission-protection-level.yml ./AndroidManifest_reversed.xml --json > permissions_scan.json 2>/dev/null + +# Stage 2 (capture, code) — search the decompiled code for context-registered receivers and +# classify each registration as exported (RECEIVER_EXPORTED, flag 2), not exported +# (RECEIVER_NOT_EXPORTED, flag 4), or registered without an explicit flag. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-context-registered-receiver.yml ./MastgTest_reversed.java --text --max-lines-per-finding 0 > code_scan.txt +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-context-registered-receiver.yml ./MastgTest_reversed.java --json > code_scan.json 2>/dev/null + +# Stage 3 (inspect code) — locate the onReceive entry points and the attacker-controllable intent +# extras they read, to review for sensitive functionality. +NO_COLOR=true semgrep -c ../../../../rules/mastg-android-receiver-entrypoints.yml ./MastgTest_reversed.java --text --max-lines-per-finding 0 > onreceive_scan.txt diff --git a/rules/mastg-android-activity-entrypoints.yml b/rules/mastg-android-activity-entrypoints.yml new file mode 100644 index 00000000000..6d135d2f9e0 --- /dev/null +++ b/rules/mastg-android-activity-entrypoints.yml @@ -0,0 +1,17 @@ +rules: + - id: mastg-android-activity-entrypoint + languages: [java] + severity: INFO + metadata: + summary: Locates the lifecycle entry points reachable when an activity is started, so they can be reviewed for sensitive functionality. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Activity entry point reached when the activity is + started. Review it for sensitive functionality that should not be + reachable by an external caller. + pattern-either: + - pattern: void onCreate(...) { ... } + - pattern: void onStart(...) { ... } + - pattern: void onResume(...) { ... } + - pattern: void onNewIntent(...) { ... } diff --git a/rules/mastg-android-context-registered-receiver.yml b/rules/mastg-android-context-registered-receiver.yml new file mode 100644 index 00000000000..5a4bfd3e3c8 --- /dev/null +++ b/rules/mastg-android-context-registered-receiver.yml @@ -0,0 +1,93 @@ +rules: + - id: mastg-android-context-registered-receiver-exported + languages: [java] + severity: WARNING + metadata: + summary: Detects a context-registered broadcast receiver that is exported (RECEIVER_EXPORTED) without a broadcastPermission, reachable by other apps. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Context-registered receiver registered as exported + (RECEIVER_EXPORTED) with no broadcastPermission, so any app on the device + can deliver broadcasts to it. Inspect its onReceive for sensitive + functionality: receiver=$R + pattern-either: + # Named flag constant, as written in source or preserved by some tools (3/4-arg forms have no permission slot). + - patterns: + - pattern-either: + - pattern: $C.registerReceiver($R, $F, $FLAG) + - pattern: ContextCompat.registerReceiver($CTX, $R, $F, $FLAG) + - pattern: androidx.core.content.ContextCompat.registerReceiver($CTX, $R, $F, $FLAG) + - pattern-regex: \bRECEIVER_EXPORTED\b + # Inlined integer flag value (RECEIVER_EXPORTED == 2), as commonly seen in decompiled code. + - pattern: $C.registerReceiver($R, $F, 2) + - pattern: ContextCompat.registerReceiver($CTX, $R, $F, 2) + - pattern: androidx.core.content.ContextCompat.registerReceiver($CTX, $R, $F, 2) + # Exported with an explicitly null broadcastPermission is still unprotected. + - pattern: $C.registerReceiver($R, $F, null, $S, 2) + - pattern: ContextCompat.registerReceiver($CTX, $R, $F, null, $S, 2) + - pattern: androidx.core.content.ContextCompat.registerReceiver($CTX, $R, $F, null, $S, 2) + + - id: mastg-android-context-registered-receiver-exported-with-permission + languages: [java] + severity: INFO + metadata: + summary: Detects a context-registered broadcast receiver that is exported but restricted with a broadcastPermission. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Context-registered receiver registered as exported + (RECEIVER_EXPORTED) but restricted with a broadcastPermission. Verify the + permission protection level matches the intended sender set: + receiver=$R permission=$PERM + patterns: + - pattern-either: + - pattern: $C.registerReceiver($R, $F, $PERM, $S, 2) + - pattern: ContextCompat.registerReceiver($CTX, $R, $F, $PERM, $S, 2) + - pattern: androidx.core.content.ContextCompat.registerReceiver($CTX, $R, $F, $PERM, $S, 2) + # An explicitly null broadcastPermission is not protection; it is handled by the rule above. + - pattern-not: $C.registerReceiver($R, $F, null, $S, 2) + - pattern-not: ContextCompat.registerReceiver($CTX, $R, $F, null, $S, 2) + - pattern-not: androidx.core.content.ContextCompat.registerReceiver($CTX, $R, $F, null, $S, 2) + + - id: mastg-android-context-registered-receiver-not-exported + languages: [java] + severity: INFO + metadata: + summary: Detects a context-registered broadcast receiver that is not exported (RECEIVER_NOT_EXPORTED). + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Context-registered receiver registered as not exported + (RECEIVER_NOT_EXPORTED). Other apps cannot deliver broadcasts to it, so it + is not part of the external attack surface: receiver=$R + pattern-either: + # Named flag constant, as written in source or preserved by some tools. + - patterns: + - pattern-either: + - pattern: $C.registerReceiver($R, ...) + - pattern: ContextCompat.registerReceiver($CTX, $R, ...) + - pattern: androidx.core.content.ContextCompat.registerReceiver($CTX, $R, ...) + - pattern-regex: \bRECEIVER_NOT_EXPORTED\b + # Inlined integer flag value (RECEIVER_NOT_EXPORTED == 4), as commonly seen in decompiled code. + - pattern: $C.registerReceiver($R, $F, 4) + - pattern: ContextCompat.registerReceiver($CTX, $R, $F, 4) + - pattern: androidx.core.content.ContextCompat.registerReceiver($CTX, $R, $F, 4) + - pattern: $C.registerReceiver($R, $F, $P, $S, 4) + - pattern: ContextCompat.registerReceiver($CTX, $R, $F, $P, $S, 4) + - pattern: androidx.core.content.ContextCompat.registerReceiver($CTX, $R, $F, $P, $S, 4) + + - id: mastg-android-context-registered-receiver-no-flag + languages: [java] + severity: WARNING + metadata: + summary: Detects a two-argument context-registered receiver with no export flag, which is implicitly exported on older target SDKs. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Context-registered receiver registered without an export + flag. On apps targeting below Android 14 (API level 34 makes the flag + mandatory for unprotected receivers) this registration is implicitly + exported. Confirm the target SDK and treat it as exported unless a + broadcastPermission restricts senders: receiver=$R + pattern: $C.registerReceiver($R, $F) diff --git a/rules/mastg-android-declared-permission-protection-level.yml b/rules/mastg-android-declared-permission-protection-level.yml new file mode 100644 index 00000000000..c42c67a7fab --- /dev/null +++ b/rules/mastg-android-declared-permission-protection-level.yml @@ -0,0 +1,42 @@ +rules: + - id: mastg-android-declared-permission-level + languages: [xml] + severity: INFO + metadata: + summary: Reports each custom permission declared in the manifest together with its protection level. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Declared permission. permission=$NAME level=$LEVEL + pattern: + + - id: mastg-android-declared-permission-no-level + languages: [xml] + severity: WARNING + metadata: + summary: Detects a custom permission declared without a protection level, which defaults to normal (any app can hold it). + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Declared permission with no android:protectionLevel; it + defaults to "normal", so any app can be granted it. permission=$NAME level=normal + patterns: + - pattern: + - pattern-not: + + - id: mastg-android-weak-custom-permission + languages: [xml] + severity: WARNING + metadata: + summary: Detects a custom permission whose protection level is normal or dangerous, which untrusted apps can obtain. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Weak custom permission. A "normal" or "dangerous" + protection level can be obtained by untrusted apps, so it does not + restrict access to trusted-app identity. permission=$NAME level=$LEVEL + patterns: + - pattern: + - metavariable-regex: + metavariable: $LEVEL + regex: (^|\|)(normal|dangerous)(\||$) diff --git a/rules/mastg-android-exported-activity.yml b/rules/mastg-android-exported-activity.yml new file mode 100644 index 00000000000..a7c5d46e21b --- /dev/null +++ b/rules/mastg-android-exported-activity.yml @@ -0,0 +1,28 @@ +rules: + - id: mastg-android-activity-exported-without-permission + languages: [xml] + severity: WARNING + metadata: + summary: Detects an exported activity that does not enforce any caller permission. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Exported activity without android:permission, reachable + by any app on the device. Inspect its code for sensitive functionality: + activity=$NAME + patterns: + - pattern: + - pattern-not: + + - id: mastg-android-activity-exported-with-permission + languages: [xml] + severity: INFO + metadata: + summary: Detects an exported activity that enforces a caller permission. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Exported activity protected by a permission. Verify the + permission protection level matches the intended trust boundary: + activity=$NAME + pattern: diff --git a/rules/mastg-android-exported-receiver.yml b/rules/mastg-android-exported-receiver.yml new file mode 100644 index 00000000000..8cddba8852d --- /dev/null +++ b/rules/mastg-android-exported-receiver.yml @@ -0,0 +1,28 @@ +rules: + - id: mastg-android-receiver-exported-without-permission + languages: [xml] + severity: WARNING + metadata: + summary: Detects an exported, manifest-declared broadcast receiver that does not enforce any sender permission. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Exported broadcast receiver without android:permission, + to which any app on the device can send broadcasts. Inspect its onReceive + for sensitive functionality: receiver=$NAME + patterns: + - pattern: + - pattern-not: + + - id: mastg-android-receiver-exported-with-permission + languages: [xml] + severity: INFO + metadata: + summary: Detects an exported, manifest-declared broadcast receiver that enforces a sender permission. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Exported broadcast receiver protected by a permission. + Verify the permission protection level matches the intended trust + boundary: receiver=$NAME + pattern: diff --git a/rules/mastg-android-exported-service.yml b/rules/mastg-android-exported-service.yml new file mode 100644 index 00000000000..dfc21a021dd --- /dev/null +++ b/rules/mastg-android-exported-service.yml @@ -0,0 +1,28 @@ +rules: + - id: mastg-android-service-exported-without-permission + languages: [xml] + severity: WARNING + metadata: + summary: Detects an exported service that does not enforce any caller permission. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Exported service without android:permission, which any + app on the device can start or bind to. Inspect its code for sensitive + functionality: service=$NAME + patterns: + - pattern: + - pattern-not: + + - id: mastg-android-service-exported-with-permission + languages: [xml] + severity: INFO + metadata: + summary: Detects an exported service that enforces a caller permission. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Exported service protected by a permission. Verify the + permission protection level matches the intended trust boundary: + service=$NAME + pattern: diff --git a/rules/mastg-android-receiver-entrypoints.yml b/rules/mastg-android-receiver-entrypoints.yml new file mode 100644 index 00000000000..46e5be34549 --- /dev/null +++ b/rules/mastg-android-receiver-entrypoints.yml @@ -0,0 +1,32 @@ +rules: + - id: mastg-android-receiver-entrypoint + languages: [java] + severity: INFO + metadata: + summary: Locates the onReceive entry point of a broadcast receiver, so it can be reviewed for sensitive functionality. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Broadcast receiver onReceive entry point. Review it and + the code it reaches for sensitive functionality that should not be + triggerable by an external sender. + pattern: void onReceive(...) { ... } + + - id: mastg-android-receiver-sensitive-extra-source + languages: [java] + severity: INFO + metadata: + summary: Detects reading attacker-controllable intent extras inside receiver/component code. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Reads an intent extra. When the component is exported and + unprotected, this value is attacker-controllable. Trace where it is used. + pattern-either: + - pattern: $I.getStringExtra(...) + - pattern: $I.getIntExtra(...) + - pattern: $I.getBooleanExtra(...) + - pattern: $I.getByteArrayExtra(...) + - pattern: $I.getSerializableExtra(...) + - pattern: $I.getParcelableExtra(...) + - pattern: $I.getExtras(...) diff --git a/rules/mastg-android-service-entrypoints.yml b/rules/mastg-android-service-entrypoints.yml new file mode 100644 index 00000000000..07e670f1484 --- /dev/null +++ b/rules/mastg-android-service-entrypoints.yml @@ -0,0 +1,35 @@ +rules: + - id: mastg-android-service-entrypoint + languages: [java] + severity: INFO + metadata: + summary: Locates the entry points reachable when a service is started or bound, so they can be reviewed for sensitive functionality. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Service entry point reached when the service is started + or bound. Review it for sensitive functionality that should not be + reachable by an external caller. + pattern-either: + - pattern: $T onStartCommand(...) { ... } + - pattern: $T onBind(...) { ... } + - pattern: $T onRebind(...) { ... } + - pattern: $T onHandleIntent(...) { ... } + + - id: mastg-android-component-caller-permission-check + languages: [java] + severity: INFO + metadata: + summary: Detects a runtime caller-permission check, which can restrict which apps reach the component. + masvs: + - MASVS-PLATFORM + message: > + [MASVS-PLATFORM] Runtime caller-permission check found. Confirm it gates + the sensitive functionality and that the enforced permission has an + appropriate protection level. Its absence means there is no runtime + restriction on the caller. + pattern-either: + - pattern: $C.checkCallingPermission(...) + - pattern: $C.enforceCallingPermission(...) + - pattern: $C.checkCallingOrSelfPermission(...) + - pattern: $C.enforceCallingOrSelfPermission(...) diff --git a/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0364.md b/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0364.md index a6ed8a6aa46..26b7947ff24 100644 --- a/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0364.md +++ b/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0364.md @@ -3,7 +3,7 @@ platform: android title: Exported And Unprotected Activities That Expose Sensitive Functionality id: MASTG-TEST-0364 type: [static, config, code, manual] -weakness: MASWE-0x01 +weakness: MASWE-0119 best-practices: [MASTG-BEST-0052] profiles: [L1, L2] knowledge: [MASTG-KNOW-0132, MASTG-KNOW-0017, MASTG-KNOW-0020] @@ -24,22 +24,52 @@ This test checks whether the app exposes sensitive functionality through exporte ## Observation -The output should contain a list of exported activities and the relevant parts of their implementation. +The output should contain all activities from the app. For each activity, record: + +1. Activity name or class. +2. Accepted actions, categories, data schemes, hosts, paths, or intent filters. +3. Export state by recording `android:exported`. +4. Required caller permission, if any, by recording `android:permission` and its protection level. +5. Relevant entry points or flows reachable when the activity is started, for example `onCreate`, `onStart`, `onResume`, `onNewIntent`, or any flow reached from those methods. ## Evaluation -The test case fails if any exported activity is not protected by an appropriate `android:permission` that restricts which apps can start it and exposes or performs sensitive functionality, for example by displaying sensitive data, performing a security-relevant action, or allowing a caller to bypass authentication. +The test fails only if all of the following are true: + +1. The activity is exported. For example, an activity with `android:exported="true"`. +2. The activity does not enforce strong caller protection. +3. The activity exposes or performs sensitive functionality. **Further Validation Required:** -Inspect each exported activity using @MASTG-TECH-0023 to determine whether it exposes sensitive functionality: +Use the following decision flow: + +```mermaid +flowchart TD + A[Activity] --> B{Exported} + B -->|No| C[Pass] + B -->|Yes| D{Strong caller protection} + D -->|Yes| C + D -->|No| E{Sensitive functionality} + E -->|No| C + E -->|Yes| F[Fail] +``` + +Strong caller protection means that the activity enforces a permission or equivalent access control appropriate for the intended caller set. A `signature` permission is usually appropriate when only apps signed by the same developer or organization should be able to start the activity. + +The activity does not enforce strong caller protection when any of the following applies: + +1. It has no required caller permission. +2. It uses a `normal` permission. +3. It uses a `dangerous` permission where trusted-app identity is required. +4. It uses a custom permission that is not declared with `` by the target app or another trusted package. +5. It uses a custom permission that is declared incorrectly, for example with the wrong name or the wrong protection level. + +A `dangerous` permission may be acceptable only when the intended trust boundary is user-granted access rather than trusted-app identity. For example, this may be acceptable when the activity is intentionally available to any app that the user allowed to hold a specific runtime permission. It is not appropriate when the activity should only accept requests from a specific trusted app, vendor app, companion app, or same-signer app. -- Determine whether the activity displays or returns sensitive data (for example, account details, messages, or stored secrets). -- Determine whether the activity performs a security-relevant action (for example, changing settings or credentials). -- Determine whether starting the activity directly bypasses an authentication step, such as a login or PIN screen, that the app relies on elsewhere. +A custom permission is only strong protection if it resolves to a trusted `` declaration with an appropriate protection level, usually `signature`. The trusted declaration may be in the target app, a trusted companion app, a trusted SDK package, the platform, or an OEM package. -Then determine whether external access to the activity is appropriately restricted for the functionality it exposes and the app's intended trust boundary: +Inspect each exported activity using @MASTG-TECH-0023 to determine whether `onCreate`, `onStart`, `onResume`, `onNewIntent`, or any flow reached from those methods exposes or performs sensitive functionality. -- Determine whether the activity has a legitimate reason to be started by third-party apps. If it doesn't, it shouldn't be exported. -- If external access is required, determine whether the activity is protected by an appropriate `android:permission` or an equivalent access control. Appropriate means the control matches the sensitivity of the activity and the set of apps that should be allowed to start it. -- Verify that the permission is effective for that trust boundary, for example by using a `signature` protection level or another control that is not broadly grantable to untrusted apps. +!!! info + `MainActivity` is a special case because it declares `MAIN` and `LAUNCHER`. Android expects launcher activities to be exported so the launcher can start the app, so `android:exported="true"` is not itself a vulnerability. However, the activity is still externally reachable and must be reviewed. If it displays sensitive data, performs sensitive actions, or processes attacker controlled intent data without enforcing authentication or authorization inside the activity, it can still be vulnerable. A custom signature permission is generally not appropriate for the real launcher activity because ordinary launcher apps are not signed with the app's certificate. \ No newline at end of file diff --git a/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0365.md b/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0365.md index 5b945c9cbbf..f4cdc25aad0 100644 --- a/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0365.md +++ b/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0365.md @@ -24,22 +24,40 @@ This test checks whether the app exposes sensitive functionality through exporte ## Observation -The output should contain a list of exported services and the relevant parts of their implementation, including the interface they expose and any permission checks they perform. +The output should contain all services from the app. For each service, record: + +1. Service name or class. +2. Whether it is started, bound, or both. +3. Accepted actions or intent filters. +4. Export state by recording `android:exported`. +5. Required caller permission, if any, by recording `android:permission` and its protection level. +6. Exposed bound-service interface, if any, for example Binder, Messenger, AIDL, or another bound-service API. +7. Relevant runtime caller checks, if any, for example `checkCallingPermission`, `enforceCallingPermission`, `checkCallingOrSelfPermission`, or `enforceCallingOrSelfPermission`. +8. Every entry point or flow reachable when the service is started or bound, for example `onStartCommand`, `onBind`, `onRebind`, `onHandleIntent`, or any exposed bound-service interface method. ## Evaluation -The test case fails if any exported service is not protected by an appropriate `android:permission` that restricts which apps can start or bind to it and exposes or performs sensitive functionality, for example by returning sensitive data, performing a security-relevant action, or allowing a caller to invoke a bound-service interface without authorization. +The test fails only if all of the following are true: + +1. The service is exported. For example, a service with `android:exported="true"`. +2. The service does not enforce strong caller protection. +3. The service exposes or performs sensitive functionality. **Further Validation Required:** -Inspect each exported service using @MASTG-TECH-0023 to determine whether it exposes sensitive functionality: +Use the following decision flow: -- Determine whether the service returns sensitive data or performs a security-relevant action (for example, changing a password or PIN) in response to a request. -- Determine whether the service exposes a started-service or bound-service interface that lets callers trigger sensitive operations or access sensitive data. +```mermaid +flowchart TD + A[Service] --> B{Exported} + B -->|No| C[Pass] + B -->|Yes| D{Strong caller protection} + D -->|Yes| C + D -->|No| E{Sensitive functionality} + E -->|No| C + E -->|Yes| F[Fail] +``` -Then determine whether external access to the service is appropriately restricted for the functionality it exposes and the app's intended trust boundary: +Strong caller protection means that the service enforces a permission or equivalent access control appropriate for the intended caller set. Use the same protection-level criteria described in @MASTG-TEST-0364. -- Determine whether the service has a legitimate reason to accept start or bind requests from third-party apps. If it doesn't, it shouldn't be exported. -- If external access is required, determine whether the service is protected by an appropriate `android:permission` or an equivalent access control. Appropriate means the control matches the sensitivity of the service operation and the set of apps that should be allowed to start or bind to it. -- Verify that the permission is effective for that trust boundary, for example by using a `signature` protection level or another control that is not broadly grantable to untrusted apps. -- Determine whether the service verifies the caller's permission at runtime (for example, with `checkCallingPermission` or `enforceCallingPermission`) before processing sensitive requests. +Inspect each exported service using @MASTG-TECH-0023 to determine whether `onStartCommand`, `onBind`, `onRebind`, `onHandleIntent`, or any exposed bound-service interface reaches sensitive functionality. diff --git a/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0366.md b/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0366.md index 3af5177e6f1..2f486c94063 100644 --- a/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0366.md +++ b/tests-beta/android/MASVS-PLATFORM/MASTG-TEST-0366.md @@ -11,7 +11,11 @@ knowledge: [MASTG-KNOW-0134, MASTG-KNOW-0017, MASTG-KNOW-0020] ## Overview -If an exported receiver does not define [`android:permission`](https://developer.android.com/guide/topics/manifest/receiver-element#prmsn) with a proper protection level and performs or grants access to sensitive functionality, another third-party app outside the intended trust boundary can send a broadcast to it and invoke that functionality. See @MASTG-KNOW-0134 for details on broadcast receivers, @MASTG-KNOW-0017 for permissions and protection levels, and @MASTG-KNOW-0020 for the IPC model of Android. +If a broadcast receiver is exported, not protected against untrusted senders, and performs or grants access to sensitive functionality, another third-party app outside the intended trust boundary can send a broadcast to it and invoke that functionality from their `onReceive` method. + +For manifest-declared receivers, relevant manifest attributes include [`android:exported`](https://developer.android.com/guide/topics/manifest/receiver-element#exported) and [`android:permission`](https://developer.android.com/guide/topics/manifest/receiver-element#prmsn). For context-registered receivers, relevant APIs and arguments include the `Context.registerReceiver()` and `ContextCompat.registerReceiver()` overloads that set `broadcastPermission` and `flags` (especially the `RECEIVER_EXPORTED` and `RECEIVER_NOT_EXPORTED` flags). + +See @MASTG-KNOW-0x03 for the full list of relevant overloads and more general details on broadcast receivers. This test checks whether the app exposes sensitive functionality through exported and unprotected broadcast receivers. @@ -19,27 +23,47 @@ This test checks whether the app exposes sensitive functionality through exporte 1. Use @MASTG-TECH-0013 to reverse engineer the app. 2. Use @MASTG-TECH-0117 to obtain the AndroidManifest.xml. -3. Use @MASTG-TECH-0162 to list the exported broadcast receivers and their associated `android:permission`, including context-registered receivers found in the code. -4. Use @MASTG-TECH-0014 to inspect the `onReceive` implementation of each exported receiver. +3. Use @MASTG-TECH-0x03 to list manifest-declared broadcast receivers, their export status, intent filters, and associated `android:permission`. +4. Use @MASTG-TECH-0014 to look for the relevant APIs. ## Observation -The output should contain a list of exported broadcast receivers and the relevant parts of their `onReceive` implementation, including how they use data from the received intent and any permission they require. +The output should contain all broadcast receivers from the app. For each receiver, record: + +1. Receiver name or class. +2. Whether it is manifest-declared or context-registered. +3. Accepted actions or intent filters. +4. Export state: + - For manifest-declared receivers, record `android:exported`. + - For context-registered receivers, record `RECEIVER_EXPORTED`, `RECEIVER_NOT_EXPORTED`, or the absence of an explicit flag. +5. Required sender permission, if any: + - For manifest-declared receivers, record `android:permission` and its protection level. + - For context-registered receivers, record `broadcastPermission` and its protection level. +6. Its `onReceive` implementation. ## Evaluation -The test case fails if any exported broadcast receiver is not protected by an appropriate `android:permission` that restricts which apps can send broadcasts to it and exposes or performs sensitive functionality, for example by performing a security-relevant action or disclosing sensitive data when it receives a broadcast. +The test fails only if all of the following are true: + +1. The receiver is exported. For example, a manifest-declared receiver with `android:exported="true"` or a context-registered receiver with `RECEIVER_EXPORTED`. +2. The receiver does not enforce strong sender protection. +3. The receiver exposes or performs sensitive functionality. **Further Validation Required:** -Inspect each exported broadcast receiver using @MASTG-TECH-0023 to determine whether it exposes sensitive functionality: +Use the following decision flow: -- Determine whether `onReceive` performs a security-relevant action or discloses sensitive data based on the received intent (for example, reading extras and using them to send a message or change state). -- Determine whether the receiver validates the data it reads from the intent before acting on it. +```mermaid +flowchart TD + A[Broadcast receiver] --> B{Exported} + B -->|No| C[Pass] + B -->|Yes| D{Strong sender protection} + D -->|Yes| C + D -->|No| E{Sensitive functionality} + E -->|No| C + E -->|Yes| F[Fail] +``` -Then determine whether external access to the receiver is appropriately restricted for the functionality it exposes and the app's intended trust boundary: +Strong sender protection means that the receiver enforces a permission or equivalent access control appropriate for the intended sender set. Use the same protection-level criteria described in @MASTG-TEST-0364. -- Determine whether the receiver has a legitimate reason to accept broadcasts from third-party apps. If it doesn't, it shouldn't be exported. -- If external access is required, determine whether the receiver is protected by an appropriate `android:permission` or an equivalent access control. Appropriate means the control matches the sensitivity of the receiver action and the set of apps that should be allowed to send broadcasts to it. -- Verify that the permission is effective for that trust boundary, for example by using a `signature` protection level or another control that is not broadly grantable to untrusted apps. -- Determine whether context-registered receivers are registered with `RECEIVER_NOT_EXPORTED` when they don't need to receive broadcasts from other apps. +Inspect each exported broadcast receiver using @MASTG-TECH-0023 to determine whether `onReceive` or any code reached from it exposes or performs sensitive functionality.