近年来,移动设备市场呈指数级增长,彻底改变了人们与技术互动的方式。目前,全球有超过 65 亿台移动设备在使用,预计到 2027 年这一数字将达到 77 亿台。下图说明了移动操作系统的全球市场份额。根据数据,2009 年至 2024 年间,Android 设备将占 71.65%,是 iOS 设备的两倍多,后者占据 27.62% 的市场份额。随着移动操作系统的增长,移动应用市场也大幅扩张。在分发 Android 应用的 Google Play Store 中,估计目前有超过 370 万个可用选项,由超过 115 万名开发人员创建。
市场上有大量适用于 Android 的移动应用程序,因此需要不断关注其安全性,因为应用程序的激增会增加用户遭受网络威胁的可能性。许多应用程序可能存在漏洞,这些漏洞会危及个人数据(例如密码和财务信息),还会使设备面临安全风险。攻击者可以利用这些漏洞,因此开发人员在创建和更新应用程序时必须采取严格的安全措施。
Android 应用程序通常使用 Java 和 Kotlin 开发。Java 语言以其安全性和可移植性而闻名,其设计采用了谨慎的安全编码方法。然而,Android 应用程序还可能包含使用 C、C++ 和 Assembly 等原生语言开发的组件,这些组件虽然性能更好,但安全风险也更高。尽管有大量研究专注于 Android 应用程序的 Java 部分,无论是通过静态分析还是动态分析,但在考虑原生开发的组件时仍存在明显差距。这些组件受到的监控较少,通常安全性较低,可能存在可被利用的严重漏洞。
在此背景下,模糊测试成为一种强大的软件漏洞测试和识别技术。通过对原生 Android 组件应用模糊测试(如本文所述),专业人员可以防止可能危及全球数百万台设备的严重故障。AFL++ 和 QEMU 等工具的组合允许对 Android 应用程序中的漏洞场景进行受控且有效的测试,从而提高安全性,更好地应对现代网络威胁。
本文是有关在 Android 应用程序中对本机代码进行模糊测试的系列文章的开篇。在第一篇文章中,我们将介绍模糊测试的基本概念、本机组件在 Android 应用程序中的作用、AFL++ 模糊测试器的使用,最后,我们将创建一个工具来对示例库执行模糊测试。
模糊测试入门
模糊测试是一种软件测试技术,它涉及注入格式错误或不可预测的输入,以检测漏洞和安全问题。这些输入通过称为模糊测试器的工具生成并发送到正在测试的软件。不同的输入生成策略可以将模糊测试器分为:
-
黑盒模糊测试器:这些模糊测试器不需要访问应用程序的源代码,只关注其输入,通过生成随机输入及其输出,无需了解程序的内部细节即可运行。
-
灰盒模糊器:这些模糊器可以通过部分访问源代码或有限的程序知识进行操作,集成了黑盒和白盒模糊器的特点。
-
白盒模糊测试器:这些依赖于对程序的源代码或内部信息的访问,允许生成以有针对性和结构化的方式探索代码的输入。
此外,这些输入可以通过两种主要方式生成:通过基于变异的模糊测试和基于生成的模糊测试:
基于变异的模糊测试:通过修改现有的有效输入来生成输入,探索小而渐进的变化来测试程序的不同执行路径。
基于生成的模糊测试:输入完全基于输入格式的模型或规范而生成。为此,需要了解被测试应用程序使用的文件格式或协议。
如前所述,模糊测试器是一种重要的工具,它通过使用各种各样且经常出乎意料的输入来识别应用程序中的漏洞和错误。在其主要特性和功能中,以下几点尤为突出:
-
输入生成:模糊测试器使用语料库作为基础,为被测软件创建或操纵数据输入。语料库是一组测试输入,可以包含有效和无效示例,并作为生成新输入的起点。输入可以随机创建,基于特定模型创建,也可以通过修改现有模型来创建,具体取决于模糊测试器采用的策略。
-
软件执行:模糊测试器将输入馈送到软件中,以观察它如何响应不同类型的数据。这可以通过两种方式完成:直接执行,通过执行特定函数或通过网络协议发送数据,或间接使用线束。线束负责为目标函数的执行创建有效的环境,读取模糊测试机制提供的输入,并将执行定向到目标函数,并以这些输入作为参数。我们稍后会看到,由于 Android 应用程序中的本机组件是通过动态库生成的,因此线束的作用是加载这些库并将模糊测试器的输入定向到正在测试的函数。
-
代码覆盖率:模糊测试器测量代码覆盖率,以评估程序的哪些部分在测试期间被执行。位图通常用于表示此覆盖率,以清晰地可视化已分析的区域。目标是最大限度地提高覆盖率,以探索尽可能多的软件路径和条件,帮助发现可能无法使用更常见输入进行测试的区域中的故障。
-
故障检测:通过仪器或事件监视技术,模糊器观察软件的行为以检测故障,例如崩溃、未处理的异常或意外行为。
-
结果报告:当发现故障时,模糊测试器会生成报告,其中包含导致问题的输入信息以及检测到的故障类型。这有助于开发人员识别和修复漏洞或错误。
这些组件之间的相互作用如下图所示。
在我们的测试中,我们将使用 AFL++ 模糊测试器和 QEMU。正如我们将看到的,AFL++ 是一个模糊测试工具,它可以自动生成输入以测试程序的安全性,识别缺陷和漏洞。QEMU 是一个硬件模拟器和虚拟机,允许您在虚拟化环境中运行操作系统和应用程序,模拟不同的硬件架构和配置。将 QEMU 与 AFL++ 结合使用可以在虚拟化环境中进行模糊测试,提供灵活性来模拟各种配置和架构,而无需物理硬件。这对于测试移动设备应用程序尤其重要,因为移动设备应用程序使用的处理器架构和要求与 PC 中常见的处理器架构和要求不同,因此可以在准确复制目标设备真实条件的平台上进行模糊测试。
使用 QEMU 进行模糊测试而非使用真实 Android 设备的优势在于它提供的模拟和控制功能。QEMU 允许创建一个虚拟化环境,我们可以在其中测试模拟系统上的应用程序,并且可以灵活地修改系统的配置和状态,而无需物理硬件。这使得在不同配置和场景中运行测试变得更加容易,而不会有损坏真实设备的风险,并且可以进行详细的检测和实时分析。此外,使用 QEMU 可以轻松扩展测试,在不同的虚拟实例中同时运行多个测试,从而提高安全分析的效率和广度。
Android 应用原生组件
用 Java 或 Kotlin 编写的 Android 应用程序由 Android Runtime (ART) 执行。Android Runtime 是一个执行环境,它使用即时 (JIT) 编译在运行时将 Android 应用程序字节码转换为本机代码,通过预编译常用方法来优化性能。尽管 Android Runtime 通过即时编译为 Android 应用程序字节码提供了显著的优化,但在某些情况下需要计算密集型操作,例如信号处理、加密操作、网络操作和 Unity 和 Unreal Engine 等游戏引擎中的物理模拟。在这些情况下,Android Runtime 可能无法提供必要的性能。幸运的是,可以将执行本机代码的组件直接集成到应用程序中,从而大幅提高性能。此功能由 Android NDK(本机开发工具包)实现。
Android NDK 是一套工具,允许开发人员使用 C 或 C++ 等语言以本机代码实现应用程序代码的部分内容。这些部分可以通过使用 JNI(Java 本机接口)与用 Java 或 Kotlin 编写的代码连接。本机代码被编译成动态库 (*.so) 并存储在库Android应用程序包内的目录中,可以在执行过程中根据需要动态加载。
如前所述,JNI 允许集成 Java 应用程序组件以与用 C 或 C++ 编写的组件连接。在 Java 端,将使用 JNI 接口的类通过系统.加载库()方法在其静态类初始化程序中传递库的名称作为参数,不带“lib”前缀和文件扩展名。对类中本机实现的方法的引用在声明前有 native 关键字。
package com.conviso.example.jni;
public class HelloJni {
static {
System.loadLibrary(“hello-jni”);
}
public native String stringFromJNI();
…
}
在本机组件方面,与 Java 组件的交互通过 JNI 函数调用进行。通常,本机方法具有以下签名格式,所有这些字段都由下划线 (_) 符号分隔:
-
Java 前缀;
-
包名称;
-
班级名称;
-
方法名称,如 Java 类中定义。
值得注意的是,所有本机方法始终将 JNI 环境指针和该方法附加到的 Java 对象分别作为前两个参数,即 JNIEnv * 和 object。
JNIEXPORT jstring JNICALL
Java_com_conviso_example_jni_HelloJni_stringFromJNI(
JNIEnv* env,
jobject thiz
)
{
return (*env)->NewStringUTF(env, "Hello from JNI!");
}
JNI 的核心包含在库原生帮助程序库。为了让本机代码访问 JNI 的函数和定义,必须包含 jni.h 标头。下图显示了 Java 和 C/C++ 组件通过 JNI 进行交互,在libnativehelper.so。
为了将模糊测试过程应用于上述动态库,我们将使用 Android NDK 和 AFL++ 开发线束,下一节将详细介绍。以下是安装 Android NDK 的步骤:
1、下载最新版本的Android NDK:
-
访问 Android NDK下载页面并下载最新版本的 Android NDK。
-
将 android-ndk-<version>-linux.zip 文件解压到适当的目录。
2、配置PATH环境变量:
-
为了使 Android NDK 可以从系统的任何位置访问,请将路径添加到目录<Android NDK 路径>/android-ndk-r27/toolchains/llvm/prebuilt/linux-x86_64/bin添加到 PATH 环境变量中。这可以通过编辑配置文件(例如 .bashrc 或 .profile)来完成。
-
务必更换<Android NDK 路径>与 Android NDK 提取的实际路径。
您可以通过运行 Android NDK 的 CLANG 来验证所有步骤是否成功完成。
使用以下方式编译源代码文件时aarch64-linux-android35-clang编译器会生成针对ARM64架构的可执行文件,编译命令如下:
$ aarch64-linux-android35-clang helloworld.c -o helloworld
我们可以使用 readelf 命令确认生成的二进制文件是适用于 ARM64 架构的 64 位 ELF 文件。执行后,我们发现二进制文件依赖于两个动态库:库文件和库文件,以及动态链接器linker64。这些依赖项对于编译文件的执行至关重要,可以通过Qiling框架获取。
Qiling 框架是一个开源二进制仿真和检测框架,是在 Unicorn 的基础上开发的,Unicorn 是一个 CPU 仿真器,仅限于在没有操作系统上下文的情况下模拟原始指令。虽然 Unicorn 处理低级仿真,但 Qiling 框架负责高级任务,包括支持不同的可执行文件格式、动态链接器以及系统调用和输入/输出处理程序。这使 Qiling 能够执行通常需要本机操作系统的二进制文件。由于 Qiling 框架支持 ARM64 Android 二进制文件,因此我们可以在项目结构中找到这些动态库。下载项目的步骤如下所述。
$ git clone https://github.com/qilingframework/qiling
$ cd qiling
$ git submodule update --init --recursive
下载Qiling框架后,我们可以验证目录中是否存在运行应用程序所需的文件示例/rootfs/arm64_android/系统。
AFL++ ( Fuzzy LOP PLUS PLUS)
AFL++ 是一款高级模糊测试工具,源自 AFL,最初由 Michael Zalewski 在 Google 工作期间创建,用于代码覆盖率分析和漏洞研究。作为 AFL 的增强版,AFL++ 提供了更高的速度、更广泛的配置选项、更有效的突变和改进的代码检测。此外,它还支持自定义模块和其他高级功能。另一个优点是它能够高效管理崩溃转储,从而更容易分析和分类模糊测试期间检测到的故障。
有关 AFL++ 的更多详细信息,请参阅官方项目文档。在可用的各种代码检测选项中,AFL++ 包括对 QEMU、Unicorn 和 Frida 等模块的支持,这些模块对于模糊测试原生 Android 组件非常有用。以下是编译 AFL++ 的步骤:
$ git clone https://github.com/AFLplusplus/AFLplusplus
$ cd AFLplusplus
$ make distrib
编译 AFL++ 并启用 QEMU 对 ARM64 架构的支持后,您就可以测试 AFL++ 了。为此,我们需要对 QEMU 源代码中的两个文件进行一些小改动:
1、AFLplusplus/qemu_mode/qemuafl/linux-user/main.c:注释掉对信号初始化()函数。信号初始化()QEMU 中的函数为用户应用程序设置信号处理,包括致命信号,并为它们定义基本处理程序。信号模拟对于确保 QEMU 模拟并正确响应事件和中断非常重要,就像应用程序在模拟架构的操作系统上运行一样。QEMU 进行的信号模拟和管理可防止 AFL++ 从线束接收致命信号,从而使识别某些输入的崩溃变得更加困难。
2、AFLplusplus/qemu_mode/build_qemu_support.sh:注释掉 QEMU 构建脚本中的所有 git 命令。脚本运行的命令可能会覆盖本地修改,因此,为了确保您在 main.c 中的更改不会丢失,在运行脚本之前必须注释掉这些命令。
对这些文件进行更改后,我们可以使用以下命令继续编译 QEMU 支持:
$ sudo apt install ninja-build
$ cd qemu_mode
$ CPU_TARGET=aarch64 ./build_qemu_support.sh
我们可以通过运行命令来测试 AFL++ 安装afl-fuzz该命令的输出如下所述。
创建线束并使用 AFL++ 执行模糊测试
在正确编译和运行 AFL++ 后,我们可以继续创建线束并启动模糊测试过程。对于我们的测试,我们假设我们可以访问 Android 应用程序的动态库,C 代码如下所示。我们选择这种方法来简化流程并避免对 ARM64 代码进行逆向工程的复杂性,这可以在以后的文章中解决。在实际情况下,有必要在动态库中识别适当的目标函数并使用 IDA Pro、Ghidra、radare2 或 Hopper 等反汇编工具执行代码分析。字符串复制函数将源字符串复制到目标,但如果目标没有足够的空间来存储源字符串,则可能导致内存损坏,从而导致内存重叠和潜在的安全漏洞。
#include <stdio.h>
#include <string.h>
int checkBuffer(const char *data)
{
char localBuffer[256];
if (data[0] == 'c') {
if (data[1] == 'o') {
if (data[2] == 'n') {
if (data[3] == 'v') {
strcpy(localBuffer, data);
return 0;
}
}
}
}
return 1;
}
代表检查缓冲区库中包含的 C 语言代码libfuzzconviso.so。
如果读者希望复制本文中的实验,并且没有目标库,可以生成动态库库文件,我们将在本文中使用它作为演示,使用下面的命令。
$ aarch64-linux-android35-clang libfuzzconviso.c -o libfuzzconviso.so -shared -fPIC
获取并分析动态库后,我们将创建一个线束,它将加载动态库并将 AFL++ 生成的变异输入作为参数传递给目标函数。在我们的实验中,检查缓冲区函数。AFL++ 将监视线束的行为以识别应用程序中的潜在故障或崩溃。以下是开发的线束的代码。线束将获取 AFL++ 生成的文件路径,将其内容读入缓冲区,并将其传递给目标函数。
#include <stdio.h>
extern int checkBuffer(char *);
int main(int argc, char *argv[])
{
char buffer[4096];
FILE *fp = fopen(argv[1], "r");
fread(buffer, 4096, 1, fp);
checkBuffer(buffer);
fclose(fp);
return 0;
}
harness.c
我们可以使用下面的命令编译线束。这也会将线束与动态库链接起来库文件,它应该位于当前目录中。
$ aarch64-linux-android35-clang harness.c -o harness -lfuzzconviso -L .
有了应用程序的动态库和线束,我们需要配置两个环境变量:
-
QEMU_LD_PREFIX:配置 QEMU 查找模拟架构的共享库的位置。此变量必须设置为 Android 的 /system 目录的路径,包含在 Qiling Framework 中,允许在 QEMU 中执行为 Android 编译的二进制文件。/系统Android 中的目录包含必要的操作系统文件,包括二进制文件、库和系统应用程序。
-
QEMU_SET_ENV:配置 QEMU 要模拟的进程的环境变量。在使用 AFL++ 和 QEMU 进行模糊测试时,我们将指定LD_LIBRARY_PATH环境变量,它定义线束使用的其他动态库的路径,例如包含libfuzzconviso.so。
$ export QEMU_LD_PREFIX="/home/thiago/Conviso/qiling/examples/rootfs/arm64_android/"
$ export QEMU_SET_ENV=LD_LIBRARY_PATH="/home/thiago/Conviso/workspace/"
编译完工具并配置好使用 AFL++ 运行 QEMU 所需的环境变量后,我们将创建一个目录来存储模糊测试中使用的语料库。在实际场景中,通常根据要模糊测试的目标类型来选择语料库,以最大限度地提高覆盖率和模糊测试效率。然而,对于检查缓冲区例如,一个简单的文本文件就足够了。最初,目录将只包含一个文本文件,表示要传递给检查缓冲区函数。该文件将被 AFL++ 修改,并通过线束用作 Android 应用程序动态库中目标函数的输入。
$ mkdir afl_in
$ echo "AAAA" > afl_in/input.txt
在正确准备好语料库后,我们可以使用以下命令使用 afl-fuzz 开始模糊测试过程。
$ AFL_INST_LIBS=1 afl-fuzz -Q -i afl_in/ -o afl_out/ -- ./harness @@
在运行 afl-fuzz 之前,让我们分解传递给 afl-fuzz 的选项以了解每个部分的作用:
-
AFL_INST_LIBS=1:此环境变量配置 AFL++ 以检测动态库中包含的代码。由于我们的目标是检测库文件,我们需要设置这个环境变量。
-
-Q:此选项告诉 AFL++ 使用 QEMU 的检测模式来监视和分析目标应用程序在执行期间的行为。
-
-i afl_in/:指定 AFL++ 用于模糊测试过程的语料库文件所在的输入目录。
-
-o afl_out/:定义 AFL++ 存储模糊测试结果的目录,例如导致崩溃的测试用例和执行日志。
-
—:— 是一个分隔符,标记 AFL-fuzz 选项的结束和要执行的目标应用程序命令行的开始,在本例中为线束。
-
./harness:指定在模糊测试过程中要执行的应用程序的路径。
-
@@:这是 AFL++ 使用的占位符,它将被替换为模糊器在执行期间生成的输入文件的路径。
首次运行 AFL++ 时,您可能会遇到以下错误。该消息表明,由于系统配置将核心转储通知发送到外部服务,AFL++ 可能会错误地将崩溃解释为超时,这是因为等待进程函数运行。要修复此问题,请运行建议的命令:回显核心 > /proc/sys/kernel/core_pattern。
经过一段时间的 AFL++ 执行后,您将开始观察到 AFL++ 接口的“总崩溃次数”字段中记录了崩溃。这些崩溃的出现表明 AFL++ 在测试的应用程序中发现了故障情况,可以对其进行分析以识别代码中的漏洞或不良行为。
导致应用程序崩溃的输入存储在目录中./afl_out/default/崩溃。此目录包含导致模糊测试过程中失败的输入数据示例。在下图中,我们看到了其中一个输入的示例。值得注意的是,有问题的输入以字符串“conv”开头。这表明模糊测试器能够满足所有必要的检查和条件,以到达我们库中可能不安全的部分——在本例中,字符串复制功能。模糊器探索不同执行路径的能力是通过代码覆盖率实现的,代码覆盖率允许模糊器测试程序的各个部分。模糊器可以测试的执行路径越多,发现漏洞和隐藏问题的可能性就越大。
为了测试 AFL++ 生成的输入并检查应用程序的响应,我们使用qemu 跟踪通过特定输入来执行它。qemu 跟踪检测应用程序的执行情况,使我们能够监视输入是否导致任何错误或失败。在下面的例子中,我们可以确认输入是否导致应用程序中出现分段错误。
结论
Android 移动应用程序的多样性日益增加,这凸显了在越来越容易受到网络威胁的环境中优先考虑安全性的迫切需要。尽管 Java 和 Kotlin 等语言在安全性方面提供了坚实的基础,但 C、C++ 和 Assembly 中包含的本机组件带来了不容忽视的额外风险。在这种情况下,使用 AFL++ 和 QEMU 等工具进行模糊测试是一种有效的方法,可以识别和缓解低级代码中的常见漏洞,而这些漏洞通常很难检测到。
在下一篇文章中,我们将探索一个真实场景,我们将识别应用程序的本机组件,创建和讨论开发工具的策略,并分析漏洞在 Android 应用程序中的表现方式。
参考
https://www.iprog.it/blog/sicurezza-informatica/mobile-security-harnessing-afl-for-fuzz-testing/
https://asrp.darkwolf.io/ASRP-Plays/fuzz
https://aflplus .plus/building/
https://www.statista.com/statistics/272698/global-market-share-held-by-mobile-operating-systems-since-2009/
https://www.mobiloud.com/blog/mobile-app-statistics
https://www.sidechannel.blog/en/afl-and-an-introduction-to-feedback-based-fuzzing/
https://developer .android.com/ndk/samples
https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/jniTOC.html
原文始发于微信公众号(Ots安全):Android 原生组件模糊测试简介
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论