エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

世に蔓延るAndroidのWebViewでintentスキームを扱う実装の脆弱性

エムスリーエンジニアリンググループ、マルチデバイスチームAndroidエンジニアの星川 (@oboenikui) です。セキュリティチームも兼任しています。

AndroidアプリでWebViewを扱うときに、http、https以外の様々なスキームのURIに対応する場合があります。中でもintentスキームのURIはWebサイトにとっては便利なURIであるため、実装を求められるケースも多いことと思います。しかし、このintentスキームのURIを扱う場合には注意が必要であるにも関わらず、世の中のサンプルコードではそのことに触れられていないケースが散見されます。

本記事ではサンプルコードを交えて、どのような危険性があり、どう実装すべきかを解説していきます。

f:id:oboenikui:20200721225125j:plain
intentスキームのイメージ

intentスキームURIとは

intent: で始まるURIです。WebページでこのURIを利用すると、Chromeから特定のアプリを起動できます。またアプリが未インストールの場合は、ChromeからPlayストアのアプリページにリダイレクトします。

例えばWebページに

<a href="intent://launch/#Intent;scheme=example;package=com.example.sample;end;">
  Launch Sample App
</a>

というタグを埋め込んでおくと、アプリのインストール状態によって以下のように処理が行われます。

  • com.example.sample というIDのアプリがインストールされている場合は example://launch/ というDeep Linkでアプリを起動します
  • 未インストールの場合は com.example.sample に対応するアプリのPlayストアページを開きます

この仕様はChromeの実装によるもので、Androidシステムでサポートしているわけではありません。しかしWebページ側はChromeと同じ挙動を想定していることが多く、WebViewを扱うアプリでは同様の実装が求められることがあります。

developer.chrome.com

よくある実装

以下のような実装がよく解説のコードとして掲載されています。

webView.webViewClient = object: WebViewClient() {
    override fun shouldOverrideUrlLoading(
        view: WebView,
        request: WebResourceRequest
    ): Boolean {
        if (request.url.scheme == "intent") {
            val intent = Intent.parseUri(request.url.toString(), Intent.URI_INTENT_SCHEME)
            if (intent.resolveActivity(packageManager) != null) {
                startActivity(intent)
                return true
            }
            // Playストアを呼び出す実装
            return launchPlayStore(request.url)
        }
        return super.shouldOverrideUrlLoading(view, request)
    }
}

本記事ではPlayストア遷移の実装に関しては言及しませんが、こちらもただPlayストアに遷移させるというシンプルな仕様ではないので注意が必要です。詳しい仕様は上記のChromeの解説ページをご覧ください。

問題となるケース

例えば com.example.sample というアプリにて、 com.example.sample.WebActivity com.example.sample.SecretActivity という2つのActivityがある状況を考えます。

以下のようなAndroidManifest.xmlを想定しています。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.sample">
    <!-- 省略 -->
    <application ...>

        <activity android:name=".WebActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity android:name=".SecretActivity" />

    </application>
</manifest>

WebActivity では「よくある実装」にて述べた設定がなされています。

SecretActivity はユーザーが起動することを想定していない画面で、intent filterを設定していないため、外部のアプリから起動することはできません。

このとき、 intent:#Intent;component=com.example.sample/.SecretActivity;end; というURIを設定したリンクを WebActivity 上で踏むと、 SecretActivity が起動してしまいます。

例として「ユーザーが起動することを想定していない画面」と書きましたが、例えば認証画面を経由しないと起動してはいけない画面や、Intentのextraに自由に値を渡されるべきではない画面がある場合も同様に問題となります。Intentフィルターを設定していないActivityを予期しない形で起動できることが問題であるとご理解ください。

問題となりうる状況としては、以下が考えられます。

  1. ユーザー自身が攻撃者で、アプリ制作者が被害者のケース。特定の起動が想定されていないActivityが起動されることで、通常の遷移ではユーザーにはできないはずの操作が行われる。(チートのような使われ方)
  2. 第三者が攻撃者で、ユーザーが被害者のケース。何らかの理由で被害者の端末ロックが解除された状態で攻撃者が操作できる状態で、アプリ側で設定されたパスコードなどを迂回して特定の画面にアクセスする。

f:id:oboenikui:20200722101200p:plain
攻撃の概要

intentスキームURIの罠

そもそもintentスキームのURIはPlayストアにリダイレクトするために存在する機能ではなく、様々なIntentを文字列で表現するための機能です。そのため、 Intent.parseUri はブラウザのようなアプリでセキュアに動くように遷移先を制限するようには作られていません。

加えて、intentスキームのURIの仕様はChromeのページで解説されている以上の機能を有します。幸いパーサのコードは比較的シンプルですので、実際にコードを読むのが早いと思います。

android.googlesource.com

回避方法

1. Chrome Custom Tabsを使用する

ブラウザアプリでなく、機能的にもChromeと同等で良いのであれば、Chrome Custom Tabsを使用することをお勧めします。

developer.chrome.com

推奨する理由は以下のとおりです。

  • 正しく実装した場合WebViewより高速になるケースがある
  • セキュリティ的に独自WebViewより信頼できる
  • WebView固有の問題に悩まされる必要がない

ただし、JavascriptBridgeを利用するなど独自のカスタマイズが必要な場合には利用できません。

2. 生成されるIntentから情報を削る

Chromiumの実装を参考に、Intentから情報を削ることで回避できます。

    /**
     * Sanitize intent to be passed to {@link queryIntentActivities()}
     * ensuring that web pages cannot bypass browser security.
     */
    private void sanitizeQueryIntentActivitiesIntent(Intent intent) {
        intent.setFlags(intent.getFlags() & ALLOWED_INTENT_FLAGS);
        intent.addCategory(Intent.CATEGORY_BROWSABLE);
        intent.setComponent(null);
        Intent selector = intent.getSelector();
        if (selector != null) {
            selector.addCategory(Intent.CATEGORY_BROWSABLE);
            selector.setComponent(null);
        }
    }

github.com

この中で最も重要なのは intent.setComponent(null); および selector.setComponent(null); です。これらの処理により、特定のActivityが起動されるのを防いでいます。また、categoryは Intent.CATEGORY_BROWSABLE に制限されています。こちらも予期せぬActivityが起動することを防いでいます。

intent.setFlags(intent.getFlags() & ALLOWED_INTENT_FLAGS); では、利用できるIntentのフラグを制限しています。ここは最近追加された行で、セキュリティ的に非常にまずい問題は見つかっていないようですが、別のバグと併用して脆弱性となることを防ぐために追加されたのではないかと考えられます。組み合わせることで危険になりかねないフラグには Intent.FLAG_GRANT_WRITE_URI_PERMISSION などが考えられます。

まとめ

Intent.parseUri を用いる際には細心の注意を払う必要があります。もし自分のWebViewを使った実装で同じようなことをしている方は一度確認してみてください。

We are hiring!!

エムスリーでは、セキュリティエンジニアもAndroidエンジニアも募集しています!

興味のある方は、ぜひ以下よりカジュアル面談をお申し込みください!

jobs.m3.com

参考文献

Takeshi Terada / Mitsui Bussan Secure Directions, Inc., Whitepaper – Attacking Android browsers via intent scheme URLs, 2014 https://www.mbsd.jp/Whitepaper/IntentScheme.pdf