Skip to content

MASTG-DEMO-0130: Sensitive Action Exposed Through an Unprotected Manifest-Declared Broadcast Receiver

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

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). The app also declares PasswordResetReceiver, a broadcast receiver that changes the stored password from a newpass intent extra and logs the old password. PasswordResetReceiver is declared as exported in the AndroidManifest.xml with no android:permission, so external callers can send the broadcast and reset the password. Tapping Refresh in VaultActivity then shows the new value.

  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
 96
 97
 98
 99
100
101
102
103
104
105
// SUMMARY: This sample implements a small password vault. Tapping Start opens VaultActivity,
// which shows the password currently stored in the app. The app also declares
// PasswordResetReceiver, an exported broadcast receiver that changes the stored password from
// an unvalidated intent extra and logs the old password. Any app can send the broadcast to
// reset the password; tapping Refresh in VaultActivity then shows the new value.
// Inspired by the receiver in the "Android Insecure Bank" app.

package org.owasp.mastestapp

import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView

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"
    }

    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.
    // Not exported. Tap Refresh after running the attack to see the value change.
    class VaultActivity : Activity() {

        private lateinit var status: TextView

        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() }
            }

            layout.addView(title)
            layout.addView(status)
            layout.addView(refresh)
            setContentView(layout)

            showPassword()
        }

        override fun onResume() {
            super.onResume()
            showPassword()
        }

        private fun showPassword() {
            val pwd = getSharedPreferences(MastgTest.PREFS, Context.MODE_PRIVATE)
                .getString(MastgTest.KEY_PASSWORD_STORE, "")
            status.text = "Current vault password:\n\n$pwd"
        }
    }

    // FAIL: [MASTG-TEST-0366] PasswordResetReceiver is exported and does not need any special permissions.
    // External callers, like other apps including adb, can send the broadcast and reset the 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()
        }
    }
}
 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=".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>
        <activity
            android:name="org.owasp.mastestapp.MastgTest$VaultActivity"
            android:exported="false"
            android:theme="@android:style/Theme.Material.Light" />
        <receiver
            android:name="org.owasp.mastestapp.MastgTest$PasswordResetReceiver"
            android:exported="true">
            <intent-filter>
                <action android:name="org.owasp.mastestapp.RESET_PASSWORD" />
            </intent-filter>
        </receiver>
    </application>

</manifest>

Steps

  1. Use Obtaining Information from the AndroidManifest to obtain the AndroidManifest.xml.
  2. Use Enumerating Broadcast Receivers to list the exported broadcast receivers and their associated android:permission by running run.sh.

The run.sh script lists the exported receivers declared in the reverse-engineered manifest.

run.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/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"<receiver\b.*?(?:/>|</receiver>)", 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

Observation

The output reveals the exported broadcast receivers and their associated permissions:

output.txt
1
2
Exported receiver: org.owasp.mastestapp.MastgTest.PasswordResetReceiver | permission: none
Exported receiver: androidx.profileinstaller.ProfileInstallReceiver | permission: android.permission.DUMP

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 onReceive method changes the stored password from an intent extra and discloses the old password to the log:

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()
}

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.