作者:周知日@长亭科技

WebView 可在应用中嵌入一个内置的 Web 浏览器,是 Android 应用开发常用的组件之一。通过 WebView 对 Android 应用的攻击案例屡见不鲜,比如几年前就被玩坏的 addJavascriptInterface 远程代码执行。但修复了 addJavascriptInterface 并不表示就能高枕无忧。应用在 WebView 上为 Javascript 提供的扩展接口,可能因为接口本身的问题而变成安全漏洞。

除此之外,在没有启用进程隔离的 WebView 与 App 具有相同权限,获得任意代码执行后可以访问应用私有数据或其他系统接口,可以将浏览器漏洞移植到手机平台上对应用进行针对性攻击。部分厂商使用自行基于开源浏览器引擎 fork 而来的内核,也可能因为同步上游补丁代码不及时而出现可利用的漏洞。

在 Android N 中增加了一个开发者选项,就是在所有的应用中将 WebView 的渲染进程运行在独立的沙箱中。即使恶意网页通过漏洞在渲染进程中执行了代码,还需要更多的漏洞绕过沙箱的限制。这一特性将在 Android O 中默认启用。但在这一缓解措施正式部署到大部分设备之前,通过攻击 WebView 获得远程代码执行进而直接攻击应用仍然是可行的。

Beyond addJavascriptInterface

本文并不打算炒 addJavascriptInterface 的冷饭,而是关注在接口本身的实现上。

即使是使用了相对安全的通信手段(如 shouldOverrideUrlLoading 或 onJsAlert 之类回调的方案,或是其他基于类似方案的开源通信库),如果应用接口设计不当,仍然存在被恶意页面通过 js 执行任意代码的可能。

利用可写入的可执行文件

这一种攻击方式需要结合两种类型的漏洞,一是能在本地写入路径和内容可控的文件,二是应用中存在动态加载不可信代码的逻辑。逻辑漏洞不涉及内存破坏,利用起来非常稳定。另外此类漏洞调用逻辑相对复杂,可能较难通过完全自动化的方式扫描识别。

在 Android 中因为开发者不严谨造成任意文件写入的漏洞较为常见。首先是写文件的接口可能本身设计上就允许传入任意路径的参数,另一种情况就是直接拼接路径导致可以 “…/” 进行目录穿越。

常见的场景有:

最后一个点比较少人注意到。Content Disposition 是常见的 HTTP 协议 header,在文件下载时可以告诉客户端浏览器下载的文件名。例如服务器返回 Content-Disposition: attachment; filename="cool.html" ,浏览器将弹出另存为对话框(或直接保存),默认的文件名就是 cool.html

但这个 filename 参数显然是不可信任的。例如恶意网站返回的文件名包含 ../,当 Android 应用尝试将这个文件保存到 /sdcard/Downloads 时,攻击者就有机会把文件写入到 /data/ 目录中了:

如果用户不小心点击确认下载,文件将会被写入到指定的位置。这种攻击甚至完全不需要 WebView 允许执行 Javascript(setJavaScriptEnabled(true)),只要简单在 HTTP 服务器中添加一个恶意 header 即可实现。

在写入文件后便是代码的加载。几种常见的 Android 下动态加载可执行代码的方式:

如果应用动态加载代码之前未做签名校验,利用存在任意文件写入问题的 WebView 扩展接口进行覆盖,可实现稳定的任意代码执行。此外由于在文件系统中写入了可执行文件,还可以实现持久化攻击的效果。

SQLite 接口

部分应用为 WebView 提供了可执行任意 SQL 语句的扩展接口,允许打开和查询文件名可控的数据库;除此之外,在 WebKit 中有一个比较少用的 WebDatabase 功能,已被 W3C 标准废弃,但 WebKit 和 Chromium 仍然保留了实现。SQLite3 中存在一些已知的攻击面(如 load_extensionfts3_tokenizer 等),因此浏览器的 WebSQL 对 SQL 中可查询的函数做了白名单限制。

但长亭安全实验室发现,即使是浏览器白名单中的 SQLite3 函数依然存在可利用的安全性问题,最终可实现一套利用在 Chrome 和 Safari 两大浏览器上通用的代码执行。此漏洞被用于 2017 年 Pwn2Own 黑客大赛上攻击 Safari 浏览器。此漏洞影响所有支持 WebDatabase 的浏览器(Windows、Linux、macOS、iOS、Android 上的 Chrome、Safari),包括多个 App 厂商基于 blink 或 WebKit 分支开发的浏览器引擎,影响数量非常可观。漏洞目前已被 SQLite 和相关浏览器引擎修复。关于漏洞利用细节,长亭安全实验室将在 BlackHat 大会上进行详细讲解:

https://www.blackhat.com/us-17/briefings/schedule/index.html#many-birds-one-stone-exploiting-a-single-sqlite-vulnerability-across-multiple-software-7024

即使是做了权限限制的 WebDatabase 依然会出现问题,而我们不时可以看到一些应用直接将 SQLite 查询接口不做任何限制就暴露给了 WebView。这意味着使用之前已知的攻击方式(fts3_tokenizer、load_extension、attach 外部数据库等)将可以结合脚本的能力得到充分利用。

一些应用允许通过参数打开指定文件名,实现上存在任意路径拼接的漏洞。恶意页面可以打开任意 App 沙盒目录下任意数据库进行查询,将私有数据完全暴露给攻击者。

为了安全以及实际开发工程量考虑,我们建议在开发混合应用时,如需为 HTML5 应用提供离线存储能力,可直接使用 localStorage、IndexedDB 等 API。

其他可通过扩展接口触发的问题

扩展接口在增强了 Web 内容的表现力的同时,也为应用增大了攻击面。一些需要本地才能触发的问题,如 Intent、ContentProvider 等,可以通过扩展接口提供的便利得以远程利用。

例如,使用 js 唤起 Activity 是很常见的功能;开启 setAllowContentAccess 后 WebView 可以通过 content:// 访问 ContentProvider,甚至扩展接口本身提供了这样的能力……这些原本需要本地安装恶意应用,需要导出 Activity、ContentProvider 才能触发的问题,可以被远程调用了。

应用本身的实现也有可能存在命令注入、允许 js 访问反射等安全问题。比如这篇文章介绍了某 Android 上的浏览器 App,存在任意文件写入、SQL 注入、XSS 等问题,最终可以跨域获取用户信息、远程执行代码:http://d3adend.org/blog/?p=851

应用开发者在做接口的时候,不仅需要小心避免代码本身的安全漏洞,在 js 调用者的域上做好限制。

从 shellcode 到攻击载荷

由于目前(< Android O)默认没有启用隔离进程的 WebView,将浏览器引擎的漏洞移植到 Android 平台来攻击带 WebView 的应用。多数浏览器引擎漏洞利用会最终执行一段 shellcode。不过仅仅反弹一个 shell 显然不足以实现攻击 App,还要有针对性地调用一些 Android 虚拟机运行时的特性。

例如通过 App 权限读取短信、联系人,或者需要解密应用自身使用的某个 SQLite 数据库的内容,就需要使用 JNI 实现相应的逻辑。

载荷的载入

就攻击特定应用的场景而言,将载荷完全使用 shellcode 甚至 ROP 并非不可能,但或多或少增加工作量。有一个 shell 之后可以做什么?很容易想到下载一个可执行文件然后加载。Android 没有自带 wget 或 curl,除非用户自行 root 并安装 busybox。不过有 xxd 命令可以使用,使用 echo 和管道重定向的方式还是可以实现下载可执行文件的。

如果不想在文件系统留下痕迹,手工模拟动态链接、重定位 ELF,可在内存中直接加载可执行文件。BadKernel 是一个利用了 V8 上游已经修补,但未及时同步到第三方 fork 中的漏洞,攻击某知名即时聊天应用的案例。在 BadKernel 的利用代码 中,调用 JNI 查询 ContentProvider 获取短信的逻辑是单独编译到一个 so 中的。

在作者公开的利用代码中,首先通过 javascript 任意地址读写,搜索一行调用 dlsym 的机器码,从中解析出 dlopen@plt 的地址,再加上三条指令的长度获得 dlsym@plt 的地址。触发任意代码执行时将这两个函数指针传入 shellcode,以进一步解析所需的各种符号。最后进入 shellcode 中实现的简化版 linker,直接将 ELF 文件内容放在 RWX 内存中重定位处理后,执行其 so_main 导出函数。

JNI 基础

Android 中 JVM 和 C/C++ 开发的本地代码互相调用,可以使用 JNI(Java Native Interface)。在 System.loadLibrary 载入一个动态链接库之后,JVM 会调用 ELF 中导出的 JNI_OnLoad(JavaVM *jvm, void *reserved) 函数,在这里可以做一些初始化的工作,以及使用 JNIEnv 的 RegisterNatives 方法动态将 Java 方法与本地代码绑定。

本地代码为 JNI 提供的方法的第一个参数是 JNIEnv 的指针,通过这个上下文可以访问 JVM 当前加载的类,通过反射机制调用 Java 层的功能。例如如下 Java 代码:

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("MicroMsg/CompatibleInfo.cfg"));
HashMap<Integer, String> hashMap = (HashMap<Integer, String>)ois.readObject();
String deviceId = hashMap.get(Integer.valueOf(258));

使用 JNI 实现如下:

char *id = malloc(64);
jstring filename = (*env)->NewStringUTF(env, "MicroMsg/CompatibleInfo.cfg");
jclass clsFileInputStream = (*env)->FindClass(env, "java/io/FileInputStream");
jclass clsObjectInputStream = (*env)->FindClass(env, "java/io/ObjectInputStream");
jclass clsHashMap = (*env)->FindClass(env, "java/util/HashMap");

jmethodID constructor = (*env)->GetMethodID(env, clsFileInputStream, "<init>", "(Ljava/lang/String;)V");
jobject fileInputStream = (*env)->NewObject(env, clsFileInputStream, constructor, filename);

constructor = (*env)->GetMethodID(env, clsObjectInputStream, "<init>", "(Ljava/io/InputStream;)V");
jobject objInputStream = (*env)->NewObject(env, clsObjectInputStream, constructor, fileInputStream);
jmethodID readObject = (*env)->GetMethodID(env, clsObjectInputStream, "readObject", "()Ljava/lang/Object;");
jobject hashmap = (*env)->CallObjectMethod(env, objInputStream, readObject);

// cast to hash map
jmethodID get = (*env)->GetMethodID(env, clsHashMap, "get", "(Ljava/lang/Object;)Ljava/lang/Object;");
jmethodID toString = (*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Object"), "toString", "()Ljava/lang/String;");

jclass clsInteger = (*env)->FindClass(env, "java/lang/Integer");
jmethodID valueOf = (*env)->GetStaticMethodID(env, clsInteger, "valueOf", "(I)Ljava/lang/Integer;");
jobject key = (*env)->CallStaticObjectMethod(env, clsInteger, valueOf, 258);
jstring val = (*env)->CallObjectMethod(env, hashmap, get, key);

strncpy(id, (*env)->GetStringUTFChars(env, val, 0), len);

正常情况下,JNIEnv 是系统初始化并传给 native 方法的。但在开发利用载荷的时候不是使用标准的方式加载链接库,因此需要使用一些私有 API。如果代码直接运行在 App 进程中,可通过 android::AndroidRuntime::getJNIEnv 直接获取,或者 JNI_GetCreatedJavaVMs 获得当前进程的唯一 JVM 实例后调用其 GetEnv 方法。如果使用独立的可执行文件,可通过 JNI_CreateJavaVM 创建一个新的 JVM。

Android 调用 JVM 的一些问题

Android N 对 NDK 链接的行为做了变更,禁止链接到私有 API,包括上文提到的 JVM 相关函数。一个非常简单的绕过方式是向 dlopen 传入空指针作为的文件名,dlsym 将会在所有已加载的共享对象中查找符号。

typedef jint (JNICALL *GetCreatedJavaVMs)(JavaVM **, jsize, jsize *);

void *handle = dlopen(NULL, RTLD_NOW);
GetCreatedJavaVMs JNI_GetCreatedJavaVMs =
    (GetCreatedJavaVMs) dlsym(handle, "JNI_GetCreatedJavaVMs");

另外一个坑是,在 ART 下,一个可执行文件如果要调用 JNI_CreateJavaVM 创建 JVM,那么它必须导出 InitializeSignalChainClaimSignalChainUnclaimSignalChainInvokeUserSignalHandlerEnsureFrontOfChain 这几个回调函数,否则会在 logcat 里看到大量类似 "InitializeSignalChain is not exported by the main executable." 的提示,然后 SIGABRT。

AOSP 对应的代码如下,可以看到在输出这行日志之后就会调用 abort(): https://android.googlesource.com/platform/art/+/master/sigchainlib/sigchain_dummy.cc

解决方案非常简单,只要在源文件里创建这几个对应的函数,代码留空,然后加上 JNIEXPORT 宏设置为导出符号即可:

JNIEXPORT void InitializeSignalChain() { }
JNIEXPORT void ClaimSignalChain() { }
JNIEXPORT void UnclaimSignalChain() { }
JNIEXPORT void InvokeUserSignalHandler() { }
JNIEXPORT void EnsureFrontOfChain() { }

小结

WebView 在 Android 应用开发中应用广泛,功能复杂,是颇为理想的攻击面。点开一个链接或者扫描一个二维码就会执行恶意代码并不仅仅是都市传说。开发者在使用 WebView 的时候不仅要注意老生常谈的各种 getSettings()、javascriptInterface 点,还要注意防范通过扩展接口暴露的攻击面和安全问题。

参考资料


源链接

Hacking more

...