Skip to content

MASTG-DEMO-0152: Custom URL Scheme Handler Without Input Validation with semgrep

Download MASTG-DEMO-0152 APK Open MASTG-DEMO-0152 Folder Build MASTG-DEMO-0152 APK

Sample

The app registers the custom URL scheme mastestapp://transfer on DeepLinkActivity. Any app on the device can open it, for example with mastestapp://transfer?amount=9999999.

The deep link handler entry point is DeepLinkActivity.onCreate, which reads the incoming URI with getIntent().getData() (reverse engineered as DeepLinkActivity_reversed.java). The URI is then processed in MastgTest, which extracts the amount query parameter with getQueryParameter("amount") and passes it directly to processTransfer() as a raw String, without calling toLong() or performing any bounds or range check.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package org.owasp.mastestapp

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle

// SUMMARY: This sample demonstrates a custom URL scheme handler that uses a URL
// query parameter directly without validating it (no numeric type conversion
// and no bounds checking).

/**
 * Receives the custom URL scheme `mastestapp://transfer`. Any app on the device
 * can launch it, for example with:
 *
 *   adb shell am start -a android.intent.action.VIEW -d "mastestapp://transfer?amount=9999999"
 *
 * It remembers the launching URI and routes the user to MainActivity so the
 * deep link can be processed by tapping the Start button.
 */
class DeepLinkActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lastDeepLink = intent?.data
        startActivity(Intent(this, MainActivity::class.java).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
        })
        finish()
    }

    companion object {
        // Holds the URI that launched the app via the custom URL scheme.
        var lastDeepLink: Uri? = null
    }
}

class MastgTest(private val context: Context) {

    fun mastgTest(): String {
        val data: Uri = DeepLinkActivity.lastDeepLink
            ?: return """
                No deep link processed yet.

                Trigger the vulnerable custom URL scheme handler with:
                adb shell am start -a android.intent.action.VIEW -d "mastestapp://transfer?amount=9999999"

                Then press Start again to see the result.
            """.trimIndent()

        val amount = data.getQueryParameter("amount")
            ?: return "Missing 'amount' parameter"

        // FAIL: [MASTG-TEST-0394] The handler uses the "amount" query parameter
        // directly without converting it to a numeric type (e.g. toLong()) or
        // checking it against any bounds. Any app can open
        // mastestapp://transfer?amount=9999999 or amount=-1 to bypass the
        // expected business logic constraints.
        return processTransfer(amount)
    }

    private fun processTransfer(amount: String): String {
        return "Transferring $amount units"
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package org.owasp.mastestapp;

import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import kotlin.Metadata;
import kotlin.jvm.internal.DefaultConstructorMarker;

/* JADX INFO: compiled from: MastgTest.kt */
/* JADX INFO: loaded from: classes3.dex */
@Metadata(d1 = {"\u0000\u001a\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0002\b\u0007\u0018\u0000 \b2\u00020\u0001:\u0001\bB\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\t"}, d2 = {"Lorg/owasp/mastestapp/DeepLinkActivity;", "Landroid/app/Activity;", "<init>", "()V", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "Companion", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48)
public final class DeepLinkActivity extends Activity {
    public static final int $stable = 0;

    /* JADX INFO: renamed from: Companion, reason: from kotlin metadata */
    public static final Companion INSTANCE = new Companion(null);
    private static Uri lastDeepLink;

    @Override // android.app.Activity
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Intent intent = getIntent();
        lastDeepLink = intent != null ? intent.getData() : null;
        Intent $this$onCreate_u24lambda_u240 = new Intent(this, (Class<?>) MainActivity.class);
        $this$onCreate_u24lambda_u240.addFlags(335544320);
        startActivity($this$onCreate_u24lambda_u240);
        finish();
    }

    /* JADX INFO: compiled from: MastgTest.kt */
    @Metadata(d1 = {"\u0000\u0014\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0002\b\u0005\b\u0086\u0003\u0018\u00002\u00020\u0001B\t\b\u0002¢\u0006\u0004\b\u0002\u0010\u0003R\u001c\u0010\u0004\u001a\u0004\u0018\u00010\u0005X\u0086\u000e¢\u0006\u000e\n\u0000\u001a\u0004\b\u0006\u0010\u0007\"\u0004\b\b\u0010\t¨\u0006\n"}, d2 = {"Lorg/owasp/mastestapp/DeepLinkActivity$Companion;", "", "<init>", "()V", "lastDeepLink", "Landroid/net/Uri;", "getLastDeepLink", "()Landroid/net/Uri;", "setLastDeepLink", "(Landroid/net/Uri;)V", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = 48)
    public static final class Companion {
        public /* synthetic */ Companion(DefaultConstructorMarker defaultConstructorMarker) {
            this();
        }

        private Companion() {
        }

        public final Uri getLastDeepLink() {
            return DeepLinkActivity.lastDeepLink;
        }

        public final void setLastDeepLink(Uri uri) {
            DeepLinkActivity.lastDeepLink = uri;
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package org.owasp.mastestapp;

import android.content.Context;
import android.net.Uri;
import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;

/* 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\u0001B\u000f\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0004\b\u0004\u0010\u0005J\u0006\u0010\u0006\u001a\u00020\u0007J\u0010\u0010\b\u001a\u00020\u00072\u0006\u0010\t\u001a\u00020\u0007H\u0002R\u000e\u0010\u0002\u001a\u00020\u0003X\u0082\u0004¢\u0006\u0002\n\u0000¨\u0006\n"}, d2 = {"Lorg/owasp/mastestapp/MastgTest;", "", "context", "Landroid/content/Context;", "<init>", "(Landroid/content/Context;)V", "mastgTest", "", "processTransfer", "amount", "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() {
        Uri data = DeepLinkActivity.INSTANCE.getLastDeepLink();
        if (data == null) {
            return "No deep link processed yet.\n\nTrigger the vulnerable custom URL scheme handler with:\nadb shell am start -a android.intent.action.VIEW -d \"mastestapp://transfer?amount=9999999\"\n\nThen press Start again to see the result.";
        }
        String amount = data.getQueryParameter("amount");
        if (amount == null) {
            return "Missing 'amount' parameter";
        }
        return processTransfer(amount);
    }

    private final String processTransfer(String amount) {
        return "Transferring " + amount + " units";
    }
}

The custom URL scheme is declared in the manifest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    android:versionCode="1"
    android:versionName="1.0"
    android:compileSdkVersion="35"
    android:compileSdkVersionCodename="15"
    package="org.owasp.mastestapp"
    platformBuildVersionCode="35"
    platformBuildVersionName="15">
    <uses-sdk
        android:minSdkVersion="29"
        android:targetSdkVersion="35"/>
    <uses-permission android:name="android.permission.INTERNET"/>
    <permission
        android:name="org.owasp.mastestapp.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
        android:protectionLevel="signature"/>
    <uses-permission android:name="org.owasp.mastestapp.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"/>
    <application
        android:theme="@style/Theme.MASTestApp"
        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"
        android:fullBackupContent="@xml/backup_rules"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:appComponentFactory="androidx.core.app.CoreComponentFactory"
        android:dataExtractionRules="@xml/data_extraction_rules">
        <activity
            android:theme="@style/Theme.MASTestApp"
            android:name="org.owasp.mastestapp.DeepLinkActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.VIEW"/>
                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="android.intent.category.BROWSABLE"/>
                <data
                    android:scheme="mastestapp"
                    android:host="transfer"/>
            </intent-filter>
        </activity>
        <activity
            android:theme="@style/Theme.MASTestApp"
            android:name="org.owasp.mastestapp.MainActivity"
            android:exported="true"
            android:windowSoftInputMode="adjustResize">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <activity
            android:name="androidx.compose.ui.tooling.PreviewActivity"
            android:exported="true"/>
        <activity
            android:theme="@android:style/Theme.Material.Light.NoActionBar"
            android:name="androidx.activity.ComponentActivity"
            android:exported="true"/>
        <provider
            android:name="androidx.startup.InitializationProvider"
            android:exported="false"
            android:authorities="org.owasp.mastestapp.androidx-startup">
            <meta-data
                android:name="androidx.emoji2.text.EmojiCompatInitializer"
                android:value="androidx.startup"/>
            <meta-data
                android:name="androidx.lifecycle.ProcessLifecycleInitializer"
                android:value="androidx.startup"/>
            <meta-data
                android:name="androidx.profileinstaller.ProfileInstallerInitializer"
                android:value="androidx.startup"/>
        </provider>
        <receiver
            android:name="androidx.profileinstaller.ProfileInstallReceiver"
            android:permission="android.permission.DUMP"
            android:enabled="true"
            android:exported="true"
            android:directBootAware="false">
            <intent-filter>
                <action android:name="androidx.profileinstaller.action.INSTALL_PROFILE"/>
            </intent-filter>
            <intent-filter>
                <action android:name="androidx.profileinstaller.action.SKIP_FILE"/>
            </intent-filter>
            <intent-filter>
                <action android:name="androidx.profileinstaller.action.SAVE_PROFILE"/>
            </intent-filter>
            <intent-filter>
                <action android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION"/>
            </intent-filter>
        </receiver>
    </application>
</manifest>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MASTestApp"
        tools:targetApi="31">
        <activity
            android:name=".DeepLinkActivity"
            android:exported="true"
            android:theme="@style/Theme.MASTestApp">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="mastestapp" android:host="transfer" />
            </intent-filter>
        </activity>
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:windowSoftInputMode="adjustResize"
            android:theme="@style/Theme.MASTestApp">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Steps

Run semgrep with two rules. The first rule scans the reversed manifest to locate the custom URL scheme declarations and the activity that handles them. The second rule scans the reverse-engineered handler code to locate the deep link handler entry point (getIntent().getData()) and where the URI parameters are read (getQueryParameter), so they can be reviewed for validation (see Reviewing Decompiled Java Code).

../../../../rules/mastg-android-custom-deeplink-scheme.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
rules:
- id: mastg-android-custom-deeplink-scheme
  severity: INFO
  languages:
    - xml
  metadata:
    summary: This rule looks for custom (non http/https) URL scheme deep links declared in the Android Manifest.
  message: '[MASVS-PLATFORM] Custom deeplink scheme detected in an intent-filter (potentially exposed deeplink). Any app on the device can invoke this handler.'
  patterns:
    - pattern: <data ... android:scheme="$SCHEME" ... />
    - metavariable-regex:
        metavariable: $SCHEME
        regex: ^(?!https?$).+$
    - pattern-inside: |
        <activity ...>
          ...
        </activity>
    - pattern-inside: |
        <intent-filter ...>
          ...
          <action android:name="android.intent.action.VIEW" />
          ...
        </intent-filter>
    - pattern-inside: |
        <intent-filter ...>
          ...
          <category android:name="android.intent.category.BROWSABLE" />
          ...
        </intent-filter>
../../../../rules/mastg-android-deeplink-unvalidated-parameter.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
rules:
- id: mastg-android-deeplink-unvalidated-parameter
  severity: WARNING
  languages:
    - java
  metadata:
    summary: This rule locates the deep link handler entry point and the URI parameters it reads.
  message: '[MASVS-PLATFORM] A deep link handler reads the incoming URI (getIntent/getData) and/or extracts a parameter (getQueryParameter). Verify each value is validated (type conversion, bounds, or sanitization) before it is used.'
  pattern-either:
    - pattern: $INTENT.getData()
    - pattern: $URI.getQueryParameter(...)
run.sh
1
NO_COLOR=true semgrep -c ../../../../rules/mastg-android-custom-deeplink-scheme.yml -c ../../../../rules/mastg-android-deeplink-unvalidated-parameter.yml ./AndroidManifest_reversed.xml ./DeepLinkActivity_reversed.java ./MastgTest_reversed.java --text > output.txt

Observation

The first rule identified the mastestapp://transfer custom URL scheme declared on DeepLinkActivity, confirming that any app on the device can reach this handler. The second rule pointed to the handler entry point in DeepLinkActivity_reversed.java (the reverse engineered DeepLinkActivity), where the incoming URI is read with getIntent().getData() (line 24), and to MastgTest_reversed.java, where the amount query parameter is extracted with getQueryParameter("amount") (line 25).

output.txt
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
┌─────────────────┐
 3 Code Findings 
└─────────────────┘

    AndroidManifest_reversed.xml
      rules.mastg-android-custom-deeplink-scheme
          [MASVS-PLATFORM] Custom deeplink scheme detected in an intent-filter (potentially exposed deeplink).
          Any app on the device can invoke this handler.                                                      

           39 <data
           40     android:scheme="mastestapp"
           41     android:host="transfer"/>

    DeepLinkActivity_reversed.java
    ❯❱ rules.mastg-android-deeplink-unvalidated-parameter
          [MASVS-PLATFORM] A deep link handler reads the incoming URI (getIntent/getData) and/or extracts a
          parameter (getQueryParameter). Verify each value is validated (type conversion, bounds, or       
          sanitization) before it is used.                                                                 

           24 lastDeepLink = intent != null ? intent.getData() : null;

    MastgTest_reversed.java
    ❯❱ rules.mastg-android-deeplink-unvalidated-parameter
          [MASVS-PLATFORM] A deep link handler reads the incoming URI (getIntent/getData) and/or extracts a
          parameter (getQueryParameter). Verify each value is validated (type conversion, bounds, or       
          sanitization) before it is used.                                                                 

           25 String amount = data.getQueryParameter("amount");

Following the URI from the handler entry point, DeepLinkActivity (DeepLinkActivity_reversed.java) reads it with getIntent().getData() and the amount parameter is later extracted with getQueryParameter("amount") in MastgTest_reversed.java, then passed straight to processTransfer() without validation.

Evaluation

The test case fails because the handler uses the amount query parameter directly, without validating it. Analyzing the handler method confirms this:

String amount = data.getQueryParameter("amount");   // raw String from the URI
...
return processTransfer(amount);                      // used as-is
...
private final String processTransfer(String amount) {
    return "Transferring " + amount + " units";      // no toLong(), no bounds check
}

The value is never:

  • Converted to a numeric type (e.g., toLong()).
  • Checked against an expected range.

Any app can therefore trigger mastestapp://transfer?amount=9999999 or supply a non-numeric value to bypass the expected business logic constraints. Use adb to confirm:

adb shell am start -a android.intent.action.VIEW -d "mastestapp://transfer?amount=9999999"
adb shell am start -a android.intent.action.VIEW -d "mastestapp://transfer?amount=-1"
adb shell am start -a android.intent.action.VIEW -d "mastestapp://transfer?amount=not-a-number"

After triggering the deep link, tap Start in the app to process it. The result (for example, Transferring 9999999 units) confirms that the handler accepts the attacker-controlled value and uses it without numeric conversion or bounds checking.