前言

近两年公司端侧发现的漏洞很大一部分都出在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运行效果:

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块钱:)

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并不是完全可信,比如下图:

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

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

只要在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服务器,访问日志如下图:

该问题在最新的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进行的测试结果:



可以看到畸形的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);
}
}

执行结果如下图:

可以看到,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,如下图:

但是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接口,执行效果如下:

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响应:

然后浏览器侧会再次请求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 运行日志如下:

可以看到我们通过一个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:

可以看到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如下:

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如下:

可以看到我们用存在于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如下:

问题完美解决。

总结

前面跟了小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

【原创】一文彻底搞懂安卓WebView白名单校验的更多相关文章

  1. 一文彻底搞懂Java中的环境变量

    一文搞懂Java环境变量 记得刚接触Java,第一件事就是配环境变量,作为一个初学者,只知道环境变量怎样配,在加上各种IDE使我们能方便的开发,而忽略了其本质的东西,只知其然不知其所以然,随着不断的深 ...

  2. 一文彻底搞懂MySQL分区

    一个执着于技术的公众号 一.InnoDB逻辑存储结构 首先要先介绍一下InnoDB逻辑存储结构和区的概念,它的所有数据都被逻辑地存放在表空间,表空间又由段,区,页组成. 段 段就是上图的segment ...

  3. 一文轻松搞懂redis集群原理及搭建与使用

    今天早上由于zookeeper和redis集群不在同一虚拟机导致出了点很小错误(人为),所以这里总结一下redis集群的搭建以便日后所需同时也希望能对你有所帮助. 笔主这里使用的是Centos7.如果 ...

  4. 一文彻底搞懂 TCP三次握手、四次挥手过程及原理

    原创文章出自公众号:「码农富哥」,欢迎收藏和关注,如转载请注明出处! TCP 协议简述 TCP 提供面向有连接的通信传输,面向有连接是指在传送数据之前必须先建立连接,数据传送完成后要释放连接. 无论哪 ...

  5. 一文彻底搞懂Cookie、Session、Token到底是什么

    > 笔者文笔功力尚浅,如有不妥,请慷慨指出,必定感激不尽 Cookie 洛:大爷,楼上322住的是马冬梅家吧? 大爷:马都什么? 夏洛:马冬梅. 大爷:什么都没啊? 夏洛:马冬梅啊. 大爷:马什 ...

  6. 一文彻底搞懂BP算法:原理推导+数据演示+项目实战(上篇)

    欢迎大家关注我们的网站和系列教程:http://www.tensorflownews.com/,学习更多的机器学习.深度学习的知识! 反向传播算法(Backpropagation Algorithm, ...

  7. 一文快速搞懂MySQL InnoDB事务ACID实现原理(转)

    这一篇主要讲一下 InnoDB 中的事务到底是如何实现 ACID 的: 原子性(atomicity) 一致性(consistency) 隔离性(isolation) 持久性(durability) 隔 ...

  8. 一文轻松搞懂Vuex

    概念: Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式(官网地址:https://vuex.vuejs.org/zh/).它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状 ...

  9. 一文彻底搞懂CAS实现原理 & 深入到CPU指令

    本文导读: 前言 如何保障线程安全 CAS原理剖析 CPU如何保证原子操作 解密CAS底层指令 小结 朋友,文章优先发布公众号,如果你愿意,可否扫文末二维码关注下? 前言 日常编码过程中,基本不会直接 ...

随机推荐

  1. C++智能指针之shared_ptr与右值引用(详细)

    1. 介绍 在 C++ 中没有垃圾回收机制,必须自己释放分配的内存,否则就会造成内存泄露.解决这个问题最有效的方法是使用智能指针(smart pointer).智能指针是存储指向动态分配(堆)对象指针 ...

  2. 免费版:Xshell和Xftp下载路径

    家庭版Xshell和Xftp下载地址: 下载地址:https://www.netsarang.com/zh/free-for-home-school/

  3. maven与eclipse的集成

    由于篇幅问题,本文将不介绍maven的安装和配置. 一.maven的概念 Maven(翻译为"专家","内行")是跨平台的项目管理工具.主要服务于基于Java平 ...

  4. php 经典的算法题-偷苹果

    有5个人偷了一堆苹果,准备在第二天分赃.晚上,有一人遛出来,把所有菜果分成5份,但是多了一个,顺手把这个扔给树上的猴了,自己先拿1/5藏了.没想到其他四人也都是这么想的,都如第一个人一样分成5份把多的 ...

  5. WPF DataGrid RowDetailsTemplate 鼠标滚动通知到 DataGrid 滚动

    前言:上次做了数据驱动UI虽然已经实现,但是在明细中鼠标滚动并不能带动外部 DataGrid 滚动条滚动,上文地址  https://www.cnblogs.com/luguangguang/p/14 ...

  6. oracle 大表在线删除列操作(alter table table_name set unused )

    在某些情况下业务建的表某些列没有用到,需要进行删除,但是如果是数据量很大的大表,直接 alter table table_name drop column column_name;这种方法删除,那么将 ...

  7. MYSQL 连接举例

    内连接:连接的多个数据必须存在才能连接select * from sjh14482条记录 create table sjha as ( select * from sjh1 limit 20 )sel ...

  8. Java基础00-内部类23

    1. 内部类 内部类 1.1 内部类概述 代码示例: 1.2 成员内部类 代码示例: 创建一个成员内部类:定义时没有小括号是因为类是没有形参的.在类的成员位置,就是成员内部类了 创建测试类:这里发现不 ...

  9. File类与常用IO流第三章IO流概述

    一:以内存为基准,按照数据的流动方向,流向内存为输入(读取数据),流出内存为输出.IO流有四大顶级父类: IO流四大顶级父类   输入流 输出流 字节流 字节输入流 InputStream 字节输出流 ...

  10. Java电话薄项目(Java基础入门)

    面向对象程序设计(Java基础) 1.项目介绍: 该项目能够实现对电话薄的添加,查找,修改,删除,排序等基本操作. 用户进入系统中首先进入主菜单中,在主菜单中可以选择相应的操作,用户可以选择每项操作前 ...