前言
近两年端侧发现的漏洞很大一部分都出在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跳转顺利绕过了域名白名单校验。
解决方案
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:
首先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如下:
问题完美解决。
总结
前面跟了小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://android.googlesource.com/platform/frameworks/base/+/4afa0352d6c1046f9e9b67fbf0011bcd751fcbb5
https://android.googlesource.com/platform/frameworks/base/+/0b57631939f5824afef06517df723d2e766e0159
*本文作者:rebeyond,本文属FreeBuf原创奖励计划,未经许可禁止转载。