Android 反逆向工程防御¶
概述¶
通用免责声明¶
**缺少任何这些措施并不会导致漏洞** - 相反,它们旨在提高应用程序抵抗逆向工程和特定客户端攻击的弹性。
这些措施都不能保证 100% 的有效性,因为逆向工程师将始终可以完全访问设备,因此始终会获胜(给予足够的时间和资源)!
例如,防止调试几乎是不可能的。如果应用程序是公开可用的,则可以在完全受攻击者控制的不可信设备上运行它。一个非常坚定的攻击者最终将设法通过修补应用程序二进制文件或通过使用 Frida 等工具在运行时动态修改应用程序的行为来绕过应用程序的所有反调试控件。
您可以在这些 OWASP 文档中了解有关逆向工程和代码修改的原则和技术风险的更多信息
Root 检测和常见的 Root 检测方法¶
在反逆向工程的上下文中,Root 检测的目标是使在已 Root 的设备上运行应用程序更加困难,这反过来又会阻止逆向工程师喜欢使用的一些工具和技术。像大多数其他防御措施一样,Root 检测本身并不是很有效,但是实施分散在整个应用程序中的多个 Root 检查可以提高整体防篡改方案的有效性。
对于 Android,我们更广泛地定义了“Root 检测”,包括自定义 ROM 检测,即确定设备是库存 Android 构建版本还是自定义构建版本。
Root 检测也可以通过 RootBeer 等库来实现。
Google Play Integrity¶
Google 推出了 Google Play Integrity API,以提高 Android 上应用程序和游戏的安全性及完整性,从 Android 4.4(级别 19)开始。之前的官方 API SafetyNet 并未涵盖 Google 为平台所需的所有安全需求,因此开发了 Play Integrity,其中包含先前 API 的基本功能并集成了其他功能。此更改旨在保护用户免受危险和欺诈性交互。
Google Play Integrity 提供以下保护
- 验证真实的 Android 设备:它验证应用程序是否在合法的 Android 设备上运行。
- 用户许可验证:它指示应用程序或游戏是通过 Google Play 商店安装或购买的。
- 未修改的二进制文件验证:它确定应用程序是否与 Google Play 识别的原始二进制文件交互。
API 提供了四个宏观类别的信息,以帮助安全团队做出决策。这些类别包括
-
请求详细信息:在本节中,获取有关请求完整性检查的应用包的详细信息,包括其格式(例如,com.example.myapp)、开发人员提供的 Base64 编码 ID 以将请求与完整性证书链接,以及请求的执行时间(以毫秒为单位)。
-
应用完整性:本节提供有关应用完整性的信息,包括验证结果(denominated verdict),该结果指示应用的安装来源是否受信任(通过 Play 商店)或未知/可疑。如果安装来源被认为是安全的,还将显示应用版本。
-
帐户详细信息:此类别提供有关应用许可状态的信息。结果可以是
LICENSED
,表示用户在 Google Play 商店购买或安装了该应用;UNLICENSED
,表示用户不拥有该应用或未通过 Google Play 商店获取该应用;或者UNEVALUATED
,表示由于缺少必要的要求而无法评估许可详细信息,也就是说,设备可能不够可信,或者 Google Play 商店无法识别已安装的应用版本。 -
设备完整性:本节提供验证应用运行所在的 Android 环境的真实性的信息。
-
MEETS_DEVICE_INTEGRITY
:应用位于具有 Google Play 服务的 Android 设备上,通过了系统完整性检查和兼容性要求。 MEETS_BASIC_INTEGRITY
:应用位于可能未获准运行 Google Play 服务的设备上,但通过了基本完整性检查,这可能是由于无法识别的 Android 版本、解锁的引导加载程序或缺少制造商认证。MEETS_STRONG_INTEGRITY
:应用位于具有 Google Play 服务的设备上,确保强大的系统完整性,具有硬件保护的启动等功能。MEETS_VIRTUAL_INTEGRITY
:应用在具有 Google Play 服务的模拟器中运行,通过了系统完整性检查并满足 Android 兼容性要求。
API 错误
API 可以返回本地错误,例如 APP_NOT_INSTALLED
和 APP_UID_MISMATCH
,这可能表示欺诈企图或攻击。此外,过时的 Google Play 服务或 Play 商店也可能导致错误,因此检查这些情况以确保正确的完整性验证功能,并确保环境不是有意设置为攻击环境非常重要。您可以在 官方页面 上找到更多详细信息。
最佳实践
- 将 Play Integrity 用作更广泛的安全策略的一部分。用其他安全措施(例如输入数据验证、用户身份验证和反欺诈保护)来补充它。
-
尽量减少对 Play Protect API 的查询,以减少设备资源的影响。例如,仅将 API 用于必要的设备完整性验证。
-
在完整性验证请求中包含一个
NONCE
。此随机值由应用程序或服务器生成,有助于验证服务器确认响应与原始请求匹配,而没有第三方篡改。
限制:Google Play Services Integrity Verification API 请求的默认每日限制为每天 10,000 个请求。需要更多请求的应用程序必须联系 Google 以请求增加限制。
示例请求
{
"requestDetails": {
"requestPackageName": "com.example.your.package",
"timestampMillis": "1666025823025",
"nonce": "kx7QEkGebwQfBalJ4...Xwjhak7o3uHDDQTTqI"
},
"appIntegrity": {
"appRecognitionVerdict": "UNRECOGNIZED_VERSION",
"packageName": "com.example.your.package",
"certificateSha256Digest": [
"vNsB0...ww1U"
],
"versionCode": "1"
},
"deviceIntegrity": {
"deviceRecognitionVerdict": [
"MEETS_DEVICE_INTEGRITY"
]
},
"accountDetails": {
"appLicensingVerdict": "UNEVALUATED"
}
}
程序化检测¶
文件存在性检查¶
也许最广泛使用的程序化检测方法是检查通常在已 Root 设备上找到的文件,例如常见 Root 应用程序的软件包文件及其关联的文件和目录,包括以下内容
/system/app/Superuser.apk
/system/etc/init.d/99SuperSUDaemon
/dev/com.koushikdutta.superuser.daemon/
/system/xbin/daemonsu
检测代码通常还会查找在设备 Root 后通常安装的二进制文件。这些搜索包括检查 busybox 并尝试在不同位置打开 *su* 二进制文件
/sbin/su
/system/bin/su
/system/bin/failsafe/su
/system/xbin/su
/system/xbin/busybox
/system/sd/xbin/su
/data/local/su
/data/local/xbin/su
/data/local/bin/su
检查 su
是否在 PATH 上也有效
public static boolean checkRoot(){
for(String pathDir : System.getenv("PATH").split(":")){
if(new File(pathDir, "su").exists()) {
return true;
}
}
return false;
}
文件检查可以在 Java 和本机代码中轻松实现。以下 JNI 示例(改编自 rootinspector)使用 stat
系统调用检索有关文件的信息,如果该文件存在,则返回“1”。
jboolean Java_com_example_statfile(JNIEnv * env, jobject this, jstring filepath) {
jboolean fileExists = 0;
jboolean isCopy;
const char * path = (*env)->GetStringUTFChars(env, filepath, &isCopy);
struct stat fileattrib;
if (stat(path, &fileattrib) < 0) {
__android_log_print(ANDROID_LOG_DEBUG, DEBUG_TAG, "NATIVE: stat error: [%s]", strerror(errno));
} else
{
__android_log_print(ANDROID_LOG_DEBUG, DEBUG_TAG, "NATIVE: stat success, access perms: [%d]", fileattrib.st_mode);
return 1;
}
return 0;
}
执行 su
和其他命令¶
确定 su
是否存在的另一种方法是尝试通过 Runtime.getRuntime.exec
方法执行它。如果 su
不在 PATH 上,则会抛出 IOException。相同的方法可用于检查通常在已 Root 设备上找到的其他程序,例如 busybox 和通常指向它的符号链接。
检查运行中的进程¶
Supersu(迄今为止最流行的 Root 工具)运行一个名为 daemonsu
的身份验证守护程序,因此此进程的存在是设备已 Root 的另一个标志。可以使用 ActivityManager.getRunningAppProcesses
和 manager.getRunningServices
API、ps
命令以及浏览 /proc
目录来枚举正在运行的进程。以下是在 rootinspector 中实现的一个示例
public boolean checkRunningProcesses() {
boolean returnValue = false;
// Get currently running application processes
List<RunningServiceInfo> list = manager.getRunningServices(300);
if(list != null){
String tempName;
for(int i=0;i<list.size();++i){
tempName = list.get(i).process;
if(tempName.contains("supersu") || tempName.contains("superuser")){
returnValue = true;
}
}
}
return returnValue;
}
检查已安装的应用包¶
您可以使用 Android 包管理器获取已安装包的列表。以下包名称属于流行的 Root 工具
eu.chainfire.supersu
com.noshufou.android.su
com.koushikdutta.superuser
com.zachspong.temprootremovejb
com.ramdroid.appquarantine
com.topjohnwu.magisk
检查可写分区和系统目录¶
系统目录上的异常权限可能表示自定义或已 Root 的设备。尽管系统和数据目录通常以只读方式挂载,但您有时会发现当设备已 Root 时它们以读写方式挂载。查找使用“rw”标志挂载的这些文件系统,或尝试在数据目录中创建一个文件。
检查自定义 Android 构建版本¶
检查测试构建版本和自定义 ROM 的迹象也很有帮助。一种方法是检查 BUILD 标记的 test-keys,这通常 表示自定义 Android 映像。检查 BUILD 标记如下
private boolean isTestKeyBuild()
{
String str = Build.TAGS;
if ((str != null) && (str.contains("test-keys")));
for (int i = 1; ; i = 0)
return i;
}
缺少 Google Over-The-Air (OTA) 证书是自定义 ROM 的另一个标志:在库存 Android 构建版本上,OTA 更新 Google 的公共证书。
反调试¶
调试是分析运行时应用程序行为的一种非常有效的方法。它允许逆向工程师逐步执行代码、在任意点停止应用程序执行、检查变量的状态、读取和修改内存等等。
反调试功能可以是预防性的或反应性的。顾名思义,预防性反调试首先会阻止调试器附加;反应性反调试涉及检测调试器并以某种方式对其做出反应(例如,终止应用程序或触发隐藏行为)。“越多越好”规则适用:为了最大限度地提高效率,防御者组合了在不同 API 层上运行并在整个应用程序中分布良好的多种预防和检测方法。
正如“逆向工程和篡改”一章中提到的,我们必须处理 Android 上的两个调试协议:我们可以在 Java 级别使用 JDWP 进行调试,也可以在本机层使用基于 ptrace 的调试器进行调试。一个好的反调试方案应该防御这两种类型的调试。
JDWP 反调试¶
在“逆向工程和篡改”一章中,我们讨论了 JDWP,这是调试器和 Java 虚拟机之间用于通信的协议。我们展示了通过修补其清单文件并更改 ro.debuggable
系统属性来为任何应用程序启用调试很容易,该系统属性为所有应用程序启用调试。让我们看看开发人员为了检测和禁用 JDWP 调试器所做的一些事情。
检查 ApplicationInfo 中的 Debuggable 标志¶
我们已经遇到了 android:debuggable
属性。Android 清单中的此标志确定是否为应用程序启动 JDWP 线程。可以通过应用程序的 ApplicationInfo
对象以编程方式确定其值。如果设置了该标志,则表明清单已被篡改,并允许调试。
public static boolean isDebuggable(Context context){
return ((context.getApplicationContext().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0);
}
isDebuggerConnected¶
虽然这对于逆向工程师来说可能很容易规避,但您可以使用 android.os.Debug
类中的 isDebuggerConnected
来确定是否连接了调试器。
public static boolean detectDebugger() {
return Debug.isDebuggerConnected();
}
可以通过访问 DvmGlobals 全局结构,通过本机代码调用相同的 API。
JNIEXPORT jboolean JNICALL Java_com_test_debugging_DebuggerConnectedJNI(JNIenv * env, jobject obj) {
if (gDvm.debuggerConnected || gDvm.debuggerActive)
return JNI_TRUE;
return JNI_FALSE;
}
计时器检查¶
Debug.threadCpuTimeNanos
指示当前线程执行代码的时间量。由于调试会减慢进程执行速度,您可以使用执行时间的差异来猜测是否附加了调试器。
static boolean detect_threadCpuTimeNanos(){
long start = Debug.threadCpuTimeNanos();
for(int i=0; i<1000000; ++i)
continue;
long stop = Debug.threadCpuTimeNanos();
if(stop - start < 10000000) {
return false;
}
else {
return true;
}
}
干扰与 JDWP 相关的数据结构¶
在 Dalvik 中,可以通过 DvmGlobals
结构访问全局虚拟机状态。全局变量 gDvm 保存指向此结构的指针。DvmGlobals
包含各种变量和指针,这些变量和指针对于 JDWP 调试很重要,并且可以被篡改。
struct DvmGlobals {
/*
* Some options that could be worth tampering with :)
*/
bool jdwpAllowed; // debugging allowed for this process?
bool jdwpConfigured; // has debugging info been provided?
JdwpTransportType jdwpTransport;
bool jdwpServer;
char* jdwpHost;
int jdwpPort;
bool jdwpSuspend;
Thread* threadList;
bool nativeDebuggerActive;
bool debuggerConnected; /* debugger or DDMS is connected */
bool debuggerActive; /* debugger is making requests */
JdwpState* jdwpState;
};
例如,将 gDvm.methDalvikDdmcServer_dispatch 函数指针设置为 NULL 会导致 JDWP 线程崩溃
JNIEXPORT jboolean JNICALL Java_poc_c_crashOnInit ( JNIEnv* env , jobject ) {
gDvm.methDalvikDdmcServer_dispatch = NULL;
}
即使 gDvm 变量不可用,您也可以通过在 ART 中使用类似的技术来禁用调试。ART 运行时将 JDWP 相关类的某些 vtable 作为全局符号导出(在 C++ 中,vtable 是保存指向类方法的指针的表)。这包括 JdwpSocketState
和 JdwpAdbState
类的 vtable,它们分别通过网络套接字和 ADB 处理 JDWP 连接。您可以通过覆盖关联 vtable 中的方法指针(已存档)来操纵调试运行时的行为。
覆盖方法指针的一种方法是用 JdwpAdbState::Shutdown
的地址覆盖函数 jdwpAdbState::ProcessIncoming
的地址。这将导致调试器立即断开连接。
#include <jni.h>
#include <string>
#include <android/log.h>
#include <dlfcn.h>
#include <sys/mman.h>
#include <jdwp/jdwp.h>
#define log(FMT, ...) __android_log_print(ANDROID_LOG_VERBOSE, "JDWPFun", FMT, ##__VA_ARGS__)
// Vtable structure. Just to make messing around with it more intuitive
struct VT_JdwpAdbState {
unsigned long x;
unsigned long y;
void * JdwpSocketState_destructor;
void * _JdwpSocketState_destructor;
void * Accept;
void * showmanyc;
void * ShutDown;
void * ProcessIncoming;
};
extern "C"
JNIEXPORT void JNICALL Java_sg_vantagepoint_jdwptest_MainActivity_JDWPfun(
JNIEnv *env,
jobject /* this */) {
void* lib = dlopen("libart.so", RTLD_NOW);
if (lib == NULL) {
log("Error loading libart.so");
dlerror();
}else{
struct VT_JdwpAdbState *vtable = ( struct VT_JdwpAdbState *)dlsym(lib, "_ZTVN3art4JDWP12JdwpAdbStateE");
if (vtable == 0) {
log("Couldn't resolve symbol '_ZTVN3art4JDWP12JdwpAdbStateE'.\n");
}else {
log("Vtable for JdwpAdbState at: %08x\n", vtable);
// Let the fun begin!
unsigned long pagesize = sysconf(_SC_PAGE_SIZE);
unsigned long page = (unsigned long)vtable & ~(pagesize-1);
mprotect((void *)page, pagesize, PROT_READ | PROT_WRITE);
vtable->ProcessIncoming = vtable->ShutDown;
// Reset permissions & flush cache
mprotect((void *)page, pagesize, PROT_READ);
}
}
}
传统反调试¶
在 Linux 上,ptrace
系统调用用于观察和控制进程(*tracee*)的执行,以及检查和更改该进程的内存和寄存器。ptrace
是在本机代码中实现系统调用跟踪和断点调试的主要方法。大多数 JDWP 反调试技巧(对于基于计时器的检查来说可能是安全的)不会捕获基于 ptrace
的经典调试器,因此,许多 Android 反调试技巧都包括 ptrace
,通常利用一次只能将一个调试器附加到进程这一事实。
检查 TracerPid¶
当您调试应用程序并在本机代码中设置断点时,Android Studio 会将所需文件复制到目标设备并启动 lldb-server,该服务器将使用 ptrace
附加到该进程。从此刻起,如果您检查调试进程的 状态文件(/proc/<pid>/status
或 /proc/self/status
),您将看到“TracerPid”字段的值与 0 不同,这是调试的标志。
请记住,**这仅适用于本机代码**。如果您只调试 Java/Kotlin 应用程序,“TracerPid”字段的值应为 0。
此技术通常在 C 的 JNI 本机库中应用,如 Google 的 gperftools(Google 性能工具)Heap Checker IsDebuggerAttached
方法的实现所示。但是,如果您希望将此检查作为 Java/Kotlin 代码的一部分包含在内,则可以参考 Tim Strazzere 的 Anti-Emulator 项目中 hasTracerPid
方法的 Java 实现。
当尝试自己实现此类方法时,您可以使用 ADB 手动检查 TracerPid 的值。以下列表使用 Google 的 NDK 示例应用程序 hello-jni (com.example.hellojni) 在附加 Android Studio 的调试器后执行检查
$ adb shell ps -A | grep com.example.hellojni
u0_a271 11657 573 4302108 50600 ptrace_stop 0 t com.example.hellojni
$ adb shell cat /proc/11657/status | grep -e "^TracerPid:" | sed "s/^TracerPid:\t//"
TracerPid: 11839
$ adb shell ps -A | grep 11839
u0_a271 11839 11837 14024 4548 poll_schedule_timeout 0 S lldb-server
您可以看到 com.example.hellojni(PID=11657)的状态文件包含一个 TracerPID 为 11839,我们可以将其识别为 lldb-server 进程。
使用 Fork 和 ptrace¶
您可以通过 Fork 一个子进程并通过类似于以下简单示例代码的代码将其作为调试器附加到父进程来防止进程调试
void fork_and_attach()
{
int pid = fork();
if (pid == 0)
{
int ppid = getppid();
if (ptrace(PTRACE_ATTACH, ppid, NULL, NULL) == 0)
{
waitpid(ppid, NULL, 0);
/* Continue the parent process */
ptrace(PTRACE_CONT, NULL, NULL);
}
}
}
通过附加的子进程,进一步尝试附加到父进程将会失败。我们可以通过将代码编译为 JNI 函数并将其打包到我们在设备上运行的应用程序中来验证这一点。
root@android:/ # ps | grep -i anti
u0_a151 18190 201 1535844 54908 ffffffff b6e0f124 S sg.vantagepoint.antidebug
u0_a151 18224 18190 1495180 35824 c019a3ac b6e0ee5c S sg.vantagepoint.antidebug
尝试使用 gdbserver 附加到父进程会失败并显示错误
root@android:/ # ./gdbserver --attach localhost:12345 18190
warning: process 18190 is already traced by process 18224
Cannot attach to lwp 18190: Operation not permitted (1)
Exiting
但是,您可以通过终止子进程并“释放”要跟踪的父进程来轻松绕过此故障。因此,您通常会发现更精细的方案,涉及多个进程和线程以及某种形式的监视以阻止篡改。常见方法包括
- Fork 多个进程,这些进程相互跟踪,
- 跟踪正在运行的进程以确保子进程保持活动状态,
- 监视
/proc
文件系统中的值,例如/proc/pid/status
中的 TracerPID。
让我们看一下上述方法的一个简单改进。在初始 fork
之后,我们在父进程中启动一个额外的线程,该线程持续监视子进程的状态。根据应用程序是在调试模式还是发布模式下构建的(这由清单中的 android:debuggable
标志指示),子进程应执行以下操作之一
- 在发布模式下:对 ptrace 的调用失败,子进程立即崩溃并出现分段错误(退出代码 11)。
- 在调试模式下:对 ptrace 的调用有效,子进程应无限期地运行。因此,对
waitpid(child_pid)
的调用永远不会返回。如果返回了,则说明有些可疑,我们会终止整个进程组。
以下是使用 JNI 函数实现此改进的完整代码
#include <jni.h>
#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <pthread.h>
static int child_pid;
void *monitor_pid() {
int status;
waitpid(child_pid, &status, 0);
/* Child status should never change. */
_exit(0); // Commit seppuku
}
void anti_debug() {
child_pid = fork();
if (child_pid == 0)
{
int ppid = getppid();
int status;
if (ptrace(PTRACE_ATTACH, ppid, NULL, NULL) == 0)
{
waitpid(ppid, &status, 0);
ptrace(PTRACE_CONT, ppid, NULL, NULL);
while (waitpid(ppid, &status, 0)) {
if (WIFSTOPPED(status)) {
ptrace(PTRACE_CONT, ppid, NULL, NULL);
} else {
// Process has exited
_exit(0);
}
}
}
} else {
pthread_t t;
/* Start the monitoring thread */
pthread_create(&t, NULL, monitor_pid, (void *)NULL);
}
}
JNIEXPORT void JNICALL
Java_sg_vantagepoint_antidebug_MainActivity_antidebug(JNIEnv *env, jobject instance) {
anti_debug();
}
同样,我们将其打包到 Android 应用程序中,看看它是否有效。就像以前一样,当我们运行应用程序的调试版本时,会出现两个进程。
root@android:/ # ps | grep -I anti-debug
u0_a152 20267 201 1552508 56796 ffffffff b6e0f124 S sg.vantagepoint.anti-debug
u0_a152 20301 20267 1495192 33980 c019a3ac b6e0ee5c S sg.vantagepoint.anti-debug
但是,如果我们在此时终止子进程,父进程也会退出
root@android:/ # kill -9 20301
130|root@hammerhead:/ # cd /data/local/tmp
root@android:/ # ./gdbserver --attach localhost:12345 20267
gdbserver: unable to open /proc file '/proc/20267/status'
Cannot attach to lwp 20267: No such file or directory (2)
Exiting
为了绕过这一点,我们必须稍微修改应用程序的行为(最简单的方法是用 NOP 修补对 _exit
的调用,并挂钩 libc.so
中的函数 _exit
)。此时,我们已经进入了众所周知的“军备竞赛”:实现更复杂的此防御形式以及绕过它始终是可能的。
文件完整性检查¶
有两个与文件完整性相关的主题
- 代码完整性检查:您可以使用 CRC 检查作为应用程序字节码、本机库和重要数据文件的额外保护层。这样,即使代码签名有效,应用程序也只能在其未修改的状态下正确运行。
- 文件存储完整性检查:应保护应用程序存储在 SD 卡或公共存储上的文件的完整性以及存储在
SharedPreferences
中的键值对的完整性。
示例实现 - 应用程序源代码¶
完整性检查通常计算所选文件的校验和或哈希。常见的受保护文件包括
- AndroidManifest.xml,
- 类文件 *.dex,
- 本机库 (*.so)。
以下来自 Android Cracking 博客的示例实现计算 classes.dex
的 CRC 并将其与预期值进行比较。
private void crcTest() throws IOException {
boolean modified = false;
// required dex crc value stored as a text string.
// it could be any invisible layout element
long dexCrc = Long.parseLong(Main.MyContext.getString(R.string.dex_crc));
ZipFile zf = new ZipFile(Main.MyContext.getPackageCodePath());
ZipEntry ze = zf.getEntry("classes.dex");
if ( ze.getCrc() != dexCrc ) {
// dex has been modified
modified = true;
}
else {
// dex not tampered with
modified = false;
}
}
示例实现 - 存储¶
在存储本身上提供完整性时,您可以创建一个给定键值对的 HMAC(对于 Android SharedPreferences
)或创建一个文件系统提供的完整文件的 HMAC。
使用 HMAC 时,您可以使用 Bouncy Castle 实现或 AndroidKeyStore 来 HMAC 给定内容。
使用 BouncyCastle 生成 HMAC 时,请完成以下过程
- 确保 BouncyCastle 或 SpongyCastle 已注册为安全提供程序。
- 使用密钥(可以存储在密钥库中)初始化 HMAC。
- 获取需要 HMAC 的内容的字节数组。
- 使用字节码在 HMAC 上调用
doFinal
。 - 将 HMAC 附加到步骤 3 中获取的字节数组。
- 存储步骤 5 的结果。
使用 BouncyCastle 验证 HMAC 时,请完成以下过程
- 确保 BouncyCastle 或 SpongyCastle 已注册为安全提供程序。
- 将消息和 HMAC 字节提取为单独的数组。
- 重复生成 HMAC 的过程的步骤 1-4。
- 将提取的 HMAC 字节与步骤 3 的结果进行比较。
当基于 Android Keystore 生成 HMAC 时,最好仅对 Android 6.0(API 级别 23)及更高版本执行此操作。
以下是一个方便的 HMAC 实现,无需 AndroidKeyStore
public enum HMACWrapper {
HMAC_512("HMac-SHA512"), //please note that this is the spec for the BC provider
HMAC_256("HMac-SHA256");
private final String algorithm;
private HMACWrapper(final String algorithm) {
this.algorithm = algorithm;
}
public Mac createHMAC(final SecretKey key) {
try {
Mac e = Mac.getInstance(this.algorithm, "BC");
SecretKeySpec secret = new SecretKeySpec(key.getKey().getEncoded(), this.algorithm);
e.init(secret);
return e;
} catch (NoSuchProviderException | InvalidKeyException | NoSuchAlgorithmException e) {
//handle them
}
}
public byte[] hmac(byte[] message, SecretKey key) {
Mac mac = this.createHMAC(key);
return mac.doFinal(message);
}
public boolean verify(byte[] messageWithHMAC, SecretKey key) {
Mac mac = this.createHMAC(key);
byte[] checksum = extractChecksum(messageWithHMAC, mac.getMacLength());
byte[] message = extractMessage(messageWithHMAC, mac.getMacLength());
byte[] calculatedChecksum = this.hmac(message, key);
int diff = checksum.length ^ calculatedChecksum.length;
for (int i = 0; i < checksum.length && i < calculatedChecksum.length; ++i) {
diff |= checksum[i] ^ calculatedChecksum[i];
}
return diff == 0;
}
public byte[] extractMessage(byte[] messageWithHMAC) {
Mac hmac = this.createHMAC(SecretKey.newKey());
return extractMessage(messageWithHMAC, hmac.getMacLength());
}
private static byte[] extractMessage(byte[] body, int checksumLength) {
if (body.length >= checksumLength) {
byte[] message = new byte[body.length - checksumLength];
System.arraycopy(body, 0, message, 0, message.length);
return message;
} else {
return new byte[0];
}
}
private static byte[] extractChecksum(byte[] body, int checksumLength) {
if (body.length >= checksumLength) {
byte[] checksum = new byte[checksumLength];
System.arraycopy(body, body.length - checksumLength, checksum, 0, checksumLength);
return checksum;
} else {
return new byte[0];
}
}
static {
Security.addProvider(new BouncyCastleProvider());
}
}
提供完整性的另一种方法是签署您获取的字节数组并将签名添加到原始字节数组。
检测逆向工程工具¶
逆向工程师常用的工具、框架和应用程序的存在可能表明有人试图对应用程序进行逆向工程。其中一些工具只能在已 Root 的设备上运行,而另一些工具则强制应用程序进入调试模式或依赖于在手机上启动后台服务。因此,应用程序可以通过不同的方式来实现检测逆向工程攻击并对其做出反应,例如,通过终止自身。
您可以通过查找关联的应用程序包、文件、进程或其他工具特定的修改和工件来检测以未修改形式安装的流行逆向工程工具。在以下示例中,我们将讨论检测 Frida Instrumentation 框架的不同方法,该框架在本指南中得到了广泛使用。其他工具(如 ElleKit 和 Xposed)也可以类似地检测到。请注意,DBI/injection/hooking 工具通常可以通过运行时完整性检查来隐式检测到,这将在下面讨论。
例如,在其在已 Root 设备上的默认配置中,Frida 以 frida-server 的形式在设备上运行。当您显式附加到目标应用程序时(例如,通过 frida-trace 或 Frida REPL),Frida 会将 frida-agent 注入到应用程序的内存中。因此,您可能会期望在附加到应用程序后在那里找到它(而不是之前)。如果您检查 /proc/<pid>/maps
,您将找到 frida-agent 作为 frida-agent-64.so
bullhead:/ # cat /proc/18370/maps | grep -i frida
71b6bd6000-71b7d62000 r-xp /data/local/tmp/re.frida.server/frida-agent-64.so
71b7d7f000-71b7e06000 r--p /data/local/tmp/re.frida.server/frida-agent-64.so
71b7e06000-71b7e28000 rw-p /data/local/tmp/re.frida.server/frida-agent-64.so
另一种方法(也适用于非 Root 设备)包括将 frida-gadget 嵌入到 APK 中,并*强制*应用程序将其作为其本机库之一加载。如果您在启动应用程序后检查应用程序内存映射(无需显式附加到它),您将找到嵌入式 frida-gadget 作为 libfrida-gadget.so。
bullhead:/ # cat /proc/18370/maps | grep -i frida
71b865a000-71b97f1000 r-xp /data/app/sg.vp.owasp_mobile.omtg_android-.../lib/arm64/libfrida-gadget.so
71b9802000-71b988a000 r--p /data/app/sg.vp.owasp_mobile.omtg_android-.../lib/arm64/libfrida-gadget.so
71b988a000-71b98ac000 rw-p /data/app/sg.vp.owasp_mobile.omtg_android-.../lib/arm64/libfrida-gadget.so
查看 Frida *留下的*这两个*痕迹*,您可能已经想象到检测到这些将是一项微不足道的任务。实际上,绕过该检测也将非常微不足道。但是事情可能会变得更加复杂。下表简要介绍了一组典型的 Frida 检测方法以及有关其有效性的简短讨论。
以下一些检测方法在 Berdhard Mueller 的“检测 Frida 的柔术”(已存档)一文中介绍。请参考它以获取更多详细信息和示例代码片段。
方法 | 描述 | 讨论 |
---|---|---|
检查应用程序签名 | 为了将 frida-gadget 嵌入到 APK 中,需要重新打包并重新签名。您可以在应用程序启动时检查 APK 的签名(例如,自 API 级别 28 起的 GET_SIGNING_CERTIFICATES),并将其与您在 APK 中固定的签名进行比较。 | 不幸的是,这太微不足道了,无法绕过,例如,通过修补 APK 或执行系统调用挂钩。 |
检查环境以获取相关工件 | 工件可以是包文件、二进制文件、库、进程和临时文件。对于 Frida,这可以是运行在目标(已 Root)系统中的 frida-server(负责通过 TCP 公开 Frida 的守护程序)。检查正在运行的服务 (getRunningServices ) 和进程 (ps ) 以搜索名称为“frida-server”的进程。您还可以遍历已加载库的列表并检查可疑库(例如,名称中包含“frida”的库)。 |
由于 Android 7.0(API 级别 24)起,检查正在运行的服务/进程不会显示像 frida-server 这样的守护程序,因为它不是由应用程序本身启动的。即使可以,绕过它也就像重命名相应的 Frida 工件(frida-server/frida-gadget/frida-agent)一样容易。 |
检查打开的 TCP 端口 | frida-server 进程默认绑定到 TCP 端口 27042。检查此端口是否打开是检测守护程序的另一种方法。 | 此方法以其默认模式检测 frida-server,但是侦听端口可以通过命令行参数进行更改,因此绕过此方法有点太微不足道了。 |
检查响应 D-Bus Auth 的端口 | frida-server 使用 D-Bus 协议进行通信,因此您可以期望它响应 D-Bus AUTH。将 D-Bus AUTH 消息发送到每个打开的端口并检查响应,希望 frida-server 会暴露自身。 |
这是一种检测 frida-server 的相当可靠的方法,但是 Frida 提供了不需要 frida-server 的替代操作模式。 |
扫描进程内存以获取已知工件 | 扫描内存以获取 Frida 库中找到的工件,例如 frida-gadget 和 frida-agent 的所有版本中都存在的字符串“LIBFRIDA”。例如,使用 Runtime.getRuntime().exec 并遍历 /proc/self/maps 或 /proc/<pid>/maps (取决于 Android 版本)中列出的内存映射,搜索该字符串。 |
此方法更有效一些,并且仅使用 Frida 很难绕过它,特别是如果添加了一些混淆并且正在扫描多个工件。但是,所选工件可能会在 Frida 二进制文件中修补。在 Berdhard Mueller 的 GitHub 上找到源代码。 |
请记住,此表远非详尽无遗。我们可以开始讨论检测命名管道(frida-server 用于外部通信)和跳板(插入到函数序言中的间接跳转向量),这将有助于检测 ElleKit 或 Frida 的 Interceptor。还存在许多其他技术,每种技术都取决于你使用的是否是已 root 的设备、root 方法的特定版本和/或工具本身的版本。此外,应用程序可以尝试通过使用各种混淆技术来使其更难以检测已实施的保护机制。最后,这是保护在不受信任的环境(在用户设备中运行的应用程序)中处理的数据的猫捉老鼠游戏的一部分。
重要的是要注意,这些控制措施只会增加逆向工程过程的复杂性。如果使用,最好的方法是巧妙地组合这些控制措施,而不是单独使用它们。但是,它们都不能保证 100% 的有效性,因为逆向工程师将始终拥有对设备的完全访问权限,因此将始终获胜!你还必须考虑到将某些控制措施集成到你的应用程序中可能会增加你应用程序的复杂性,甚至对其性能产生影响。
模拟器检测¶
在反逆向工程的上下文中,模拟器检测的目标是增加在模拟设备上运行应用程序的难度,这阻碍了一些工具和技术,这些是逆向工程师喜欢使用的。这种增加的难度迫使逆向工程师击败模拟器检查或使用物理设备,从而阻止了大规模设备分析所需的访问权限。
有几个指标表明相关设备正在被模拟。尽管所有这些 API 调用都可以被 hook,但这些指标提供了一个适度的第一道防线。
第一组指标位于文件 build.prop
中。
API Method Value Meaning
Build.ABI armeabi possibly emulator
BUILD.ABI2 unknown possibly emulator
Build.BOARD unknown emulator
Build.Brand generic emulator
Build.DEVICE generic emulator
Build.FINGERPRINT generic emulator
Build.Hardware goldfish emulator
Build.Host android-test possibly emulator
Build.ID FRF91 emulator
Build.MANUFACTURER unknown emulator
Build.MODEL sdk emulator
Build.PRODUCT sdk emulator
Build.RADIO unknown possibly emulator
Build.SERIAL null emulator
Build.USER android-build emulator
你可以在已 root 的 Android 设备上编辑文件 build.prop
,或者在从源代码编译 AOSP 时修改它。这两种技术都允许你绕过上面的静态字符串检查。
下一组静态指标利用 Telephony 管理器。所有 Android 模拟器都具有此 API 可以查询的固定值。
API Value Meaning
TelephonyManager.getDeviceId() 0's emulator
TelephonyManager.getLine1 Number() 155552155 emulator
TelephonyManager.getNetworkCountryIso() us possibly emulator
TelephonyManager.getNetworkType() 3 possibly emulator
TelephonyManager.getNetworkOperator().substring(0,3) 310 possibly emulator
TelephonyManager.getNetworkOperator().substring(3) 260 possibly emulator
TelephonyManager.getPhoneType() 1 possibly emulator
TelephonyManager.getSimCountryIso() us possibly emulator
TelephonyManager.getSimSerial Number() 89014103211118510720 emulator
TelephonyManager.getSubscriberId() 310260000000000 emulator
TelephonyManager.getVoiceMailNumber() 15552175049 emulator
请记住,像 Xposed 或 Frida 这样的 hooking 框架可以 hook 此 API 以提供虚假数据。
运行时完整性验证¶
此类别中的控制措施验证应用程序内存空间的完整性,以防御在运行时应用的内存补丁。此类补丁包括对二进制代码、字节码、函数指针表和重要数据结构的不需要的更改,以及加载到进程内存中的恶意代码。可以通过以下方式验证完整性
- 将内存内容或内容校验和与良好值进行比较,
- 在内存中搜索不需要的修改的签名。
与“检测逆向工程工具和框架”类别存在一些重叠,事实上,当我们在本章中展示如何搜索进程内存中的 Frida 相关字符串时,我们已经演示了基于签名的方法。以下是各种完整性监控的更多示例。
检测 Java 运行时的篡改¶
诸如 Xposed 这样的 Hooking 框架会将自己注入到 Android 运行时中,并在这样做时留下不同的痕迹。可以检测到这些痕迹,如来自 XPosedDetector 项目的此代码片段所示。
static jclass findXposedBridge(C_JNIEnv *env, jobject classLoader) {
return findLoadedClass(env, classLoader, "de/robv/android/xposed/XposedBridge"_iobfs.c_str());
}
void doAntiXposed(C_JNIEnv *env, jobject object, intptr_t hash) {
if (!add(hash)) {
debug(env, "checked classLoader %s", object);
return;
}
#ifdef DEBUG
LOGI("doAntiXposed, classLoader: %p, hash: %zx", object, hash);
#endif
jclass classXposedBridge = findXposedBridge(env, object);
if (classXposedBridge == nullptr) {
return;
}
if (xposed_status == NO_XPOSED) {
xposed_status = FOUND_XPOSED;
}
disableXposedBridge(env, classXposedBridge);
if (clearHooks(env, object)) {
#ifdef DEBUG
LOGI("hooks cleared");
#endif
if (xposed_status < ANTIED_XPOSED) {
xposed_status = ANTIED_XPOSED;
}
}
}
检测 Native Hook¶
通过使用 ELF 二进制文件,可以通过覆盖内存中的函数指针(例如,全局偏移表或 PLT hooking)或修补函数代码本身的部分(内联 hooking)来安装原生函数 hook。检查各个内存区域的完整性是检测此类 hook 的一种方法。
全局偏移表 (GOT) 用于解析库函数。在运行时,动态链接器使用全局符号的绝对地址修补此表。GOT hook 覆盖存储的函数地址并将合法的函数调用重定向到对手控制的代码。可以通过枚举进程内存映射并验证每个 GOT 条目是否指向合法加载的库来检测此类型的 hook。
与 GNU ld
相比,它仅在首次需要符号地址时才解析符号地址(延迟绑定),而 Android 链接器解析所有外部函数并在加载库后立即写入各个 GOT 条目(立即绑定)。因此,你可以期望所有 GOT 条目在运行时指向其各自库的代码段中的有效内存位置。GOT hook 检测方法通常遍历 GOT 并验证这一点。
内联 hook 通过覆盖函数代码开头或结尾的几个指令来工作。在运行时,这个所谓的 trampoline 将执行重定向到注入的代码。你可以通过检查库函数的序言和结语中是否存在可疑指令(例如,到库外部位置的远跳转)来检测内联 hook。
代码混淆¶
章节 “移动应用程序篡改和逆向工程” 介绍了几种通用的在移动应用程序中使用的著名的混淆技术。
Android 应用程序可以使用不同的工具实现其中的一些混淆技术。例如, Proguard 提供了一种简单的方法来缩小和混淆代码,并从 Android Java 应用程序的字节码中删除不需要的调试信息。它将类名、方法名和变量名等标识符替换为无意义的字符串。这是一种布局混淆,它不会影响程序的性能。
反编译 Java 类是微不足道的,因此建议始终对生产字节码应用一些基本的混淆。
了解更多关于 Android 混淆技术的知识
- Gautam Arvind 的《Android Native Code 的安全加固》
- Eduardo Novella 的《APKiD:快速识别 AppShielding 产品》 ( APKiD)
- Pierre Graux 的《原生 Android 应用程序的挑战:混淆和漏洞》
使用 ProGuard¶
开发人员使用 build.gradle 文件启用混淆。在下面的示例中,你可以看到设置了 minifyEnabled
和 proguardFiles
。创建例外以保护某些类免受混淆(使用 -keepclassmembers
和 -keep class
)是很常见的。因此,审核 ProGuard 配置文件以查看哪些类被豁免很重要。getDefaultProguardFile('proguard-android.txt')
方法从 <Android SDK>/tools/proguard/
文件夹获取默认的 ProGuard 设置。
有关如何缩小、混淆和优化你的应用程序的更多信息,请参见Android 开发人员文档。
当你使用 Android Studio 3.4 或 Android Gradle 插件 3.4.0 或更高版本构建项目时,该插件不再使用 ProGuard 执行编译时代码优化。相反,该插件使用 R8 编译器。R8 适用于所有现有的 ProGuard 规则文件,因此更新 Android Gradle 插件以使用 R8 不应要求你更改现有的规则。
R8 是来自 Google 的新代码缩小器,它是在 Android Studio 3.3 beta 中引入的。默认情况下,R8 会删除对调试有用的属性,包括行号、源文件名和变量名。R8 是一款免费的 Java 类文件缩小器、优化器、混淆器和预验证器,并且比 ProGuard 更快,另请参阅 Android 开发人员博客文章了解更多详情。它随 Android 的 SDK 工具一起提供。要为发布版本激活缩小,请将以下内容添加到 build.gradle
android {
buildTypes {
release {
// Enables code shrinking, obfuscation, and optimization for only
// your project's release build type.
minifyEnabled true
// Includes the default ProGuard rules files that are packaged with
// the Android Gradle plugin. To learn more, go to the section about
// R8 configuration files.
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
...
}
文件 proguard-rules.pro
是你定义自定义 ProGuard 规则的地方。使用标志 -keep
,你可以保留某些未被 R8 删除的代码,否则可能会产生错误。例如,要保留常见的 Android 类,就像在我们的示例配置 proguard-rules.pro
文件中一样
...
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
...
你可以使用 以下语法 在项目中的特定类或库上更精细地定义它
-keep public class MyClass
混淆通常会带来运行时性能方面的成本,因此通常仅应用于代码的某些非常特定的部分,通常是那些处理安全性和运行时保护的部分。
设备绑定¶
设备绑定的目标是阻止攻击者尝试将应用程序及其状态从设备 A 复制到设备 B,并在设备 B 上继续执行该应用程序。在确定设备 A 是可信的之后,它可能比设备 B 具有更多的权限。当应用程序从设备 A 复制到设备 B 时,这些差异权限不应更改。
在我们描述可用的标识符之前,让我们快速讨论如何将它们用于绑定。有三种方法允许设备绑定
-
使用设备标识符来扩充用于身份验证的凭据。如果应用程序需要频繁地重新验证自身和/或用户,这将是有意义的。
-
使用与设备强绑定的密钥材料加密存储在设备中的数据可以加强设备绑定。Android Keystore 提供了不可导出的私钥,我们可以将其用于此目的。当恶意行为者从设备中提取此类数据时,将无法解密数据,因为密钥不可访问。实施此操作需要以下步骤
- 使用
KeyGenParameterSpec
API 在 Android Keystore 中生成密钥对。
//Source: <https://developer.android.com.cn/reference/android/security/keystore/KeyGenParameterSpec.html> KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore"); keyPairGenerator.initialize( new KeyGenParameterSpec.Builder( "key1", KeyProperties.PURPOSE_DECRYPT) .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) .build()); KeyPair keyPair = keyPairGenerator.generateKeyPair(); Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate()); ... // The key pair can also be obtained from the Android Keystore any time as follows: KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); keyStore.load(null); PrivateKey privateKey = (PrivateKey) keyStore.getKey("key1", null); PublicKey publicKey = keyStore.getCertificate("key1").getPublicKey();
- 为 AES-GCM 生成密钥
//Source: <https://developer.android.com.cn/reference/android/security/keystore/KeyGenParameterSpec.html> KeyGenerator keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); keyGenerator.init( new KeyGenParameterSpec.Builder("key2", KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .build()); SecretKey key = keyGenerator.generateKey(); // The key can also be obtained from the Android Keystore any time as follows: KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); keyStore.load(null); key = (SecretKey) keyStore.getKey("key2", null);
- 使用 AES-GCM 密码通过密钥加密身份验证数据和应用程序存储的其他敏感数据,并使用设备特定参数(例如实例 ID 等)作为关联数据
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); final byte[] nonce = new byte[GCM_NONCE_LENGTH]; random.nextBytes(nonce); GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, nonce); cipher.init(Cipher.ENCRYPT_MODE, key, spec); byte[] aad = "<deviceidentifierhere>".getBytes();; cipher.updateAAD(aad); cipher.init(Cipher.ENCRYPT_MODE, key); //use the cipher to encrypt the authentication data see 0x50e for more details.
- 使用存储在 Android Keystore 中的公钥加密密钥,并将加密的密钥存储在应用程序的私有存储中。
- 每当需要访问令牌或其他敏感数据等身份验证数据时,使用存储在 Android Keystore 中的私钥解密密钥,然后使用解密的密钥解密密文。
- 使用
-
使用基于令牌的设备身份验证(实例 ID)以确保使用应用程序的同一实例。