在本文,动态分析指的是通过附加调试或者注入进程来进行的分析。
不包含透明型沙箱和仿真器的相关内容。
利用成熟的调试器, 比如 IDA
, LLDB
, GDB
, JDB
等来进行打断点、单步调试等。
Native
调试器需要一个 server
端通过特定协议来实现 Android
终端上与调试器通信、执行命令,通过 ptrace
函数,来附加并控制进程。
例如 IDA
需要 android_server
, GDB
需要 gdbserver
, LLDB
需要 debugserver
而 Java 调试器 JDB
, 是通过 JDWP
协议来进行通信,但不需要自己起另外的 server
,因为 Android
本身就带有这个调试的功能,所以也自带这个协议。
使用 adb forward
命令,可以将手机上的 JDWP
协议转换为本机的 TCP
端口, 随后使用 JDB
连接端口进行调试。
通过附加到进程之后,可以漫游目标进程的内存空间。
进程的内存空间被分为一段一段(Segment
),在有权限的情况下(本进程或者 root 权限的进程),通过读取 /proc/pid/mmap
文件,可以看到当前进程的内存情况。
在 so 被加载的时候,加载器就会解析 SO 文件里的 Segment
,按照文件格式里设定的属性将其加载到内存里。还有 malloc
的时候,也会创建专属于 malloc
的段。每个段都会有权限,分别是: R(read) W(write) X(execute)
。
搜索或者读取内存的时候,需要按每个段的起始位置以及段大小以及段权限确定自己搜索或者读取的内存范围,否则会因为权限问题而导致崩溃。或者可以通过调用 mprotect
函数来设置 Segment
的权限
附加到进程之后,想要对 Java 虚拟机的内存进行漫游,还需要获取到 JavaVM
或者 JNIEnv
对象,通过他们,可以实现操纵 Java 对象,调用 Java 方法等功能。
通过向目标进程注入代码,修改内存,达到监控函数、查看参数、修改参数等目的。代表性工具为:Xposed
, Frida
, Cydia Substrate
等。
Hook 也分为几种方式,不过实现的目的大同小异,一般是通过各种方式,令函数的入口修改为自己的跳板、蹦床(trampoline)函数,跳板令程序跳转到 Hook
后的函数入口,以实现一个函数替换的目的。当然,其中各种实现方式都各有差异,各有千秋。
Android 上对 Java 代码的 Hook,其实也分为两种模式,对应着 dalvik
以及 art
两款虚拟机。
在 dvm 模式下,只需要在内存中修改将Method
中accessFlags
修改为native
,代表这个是一个 Native
函数, 再将 nativeFunc
中的地址改成跳板函数的地址即可。
ART 模式也类似,也是修改accessFlags
,再修改入口地址,只不过修改的位置不一样了。
当然,这只是大体上的思路,实现上也有很多细节这里不再展开。
Inline Hook
是针对汇编的 Hook
, 通常做法是通过修改函数头部的几条汇编指令,修改为跳转到跳板函数的指令。原理听起来简单,但想实现一个稳定且兼容性高的 Inline Hook
其实还是比较有难度的。
PLT/GOT Hook
是针对导出函数的 Hook
。PLT/GOT
分别是 ELF 文件里的两个节(Section)
,在被加载的时候,有一个动态链接的过程,会有一个填充 PLT 的过程,PLT 将引导程序跳入函数入口,只要在填充之后将表内的偏移修改,即可成功跳至目标函数。
通过调试断点或者 Hook 的方式,实现函数调用、指令执行、IO 操作、网络操作等行为的跟踪。
通过调试器实现,类似于自动化单步调试。。
一般是指令 Trace 过滤 BL
, BLX
等跳转指令,或者通过 Hook
指定函数实现。
通过对 fopen
, fclose
, fflush
等文件操作系列函数进行断点或者 Hook 实现。
通过对 send
, recv
, bind
等网络操作系列函数进行断点或者 Hook 实现。
沙箱的实现方式有很多,常见的有 通过Hook的方式来监控敏感操作
或者 使用修改源码的硬核方式来监控敏感操作
,或者使用 kernel 级别的 hook 或者修改源码来达到监控 IO操作
等目的,此处不展开。
一般来说,所有的动态分析都绕不过两点: ptrace
和 注入(inject)
。部分沙箱以及内核级的跟踪除外。
ptrace
其实是 linux
里的一个函数,作用类似于用来附加一个进程。所有的调试器,在注入进程之前都需要使用这个函数来附加到目标进程上面。附加进程之后,可以对目标进程的寄存器进行控制,对内存进行读写,相当于就是上帝视角。大多调试器的功能也是基于 ptrace 来实现的。
检测
当一个进程被附加之后,在
/proc/pid/
目录下的部分文件会发生变化,比如status
文件里的TracePid
或者wchan
文件。通过判断此类文件然后自杀的方法,防止被调试。
因为调试器需要
server
端,其需要网络协议来通信,大多调试器都会有默认端口,通过判断本机端口的方式也可以检测到调试器。若使用非默认的端口,则可以伪造调试器协议向本地端口发起测试,但正经产品不会这么做,因为容易引发系统或者应用崩溃。
还可以尝试通过扫描当前运行的进程名来判断。
通过触发异常来检测,原理是主动触发一异常并尝试"try"住,如果"try"不住说明被调试器接管了。
通过指令执行时间差检测,若一段代码正在被调试,被手动单步跳过或者打断点,那么这段代码执行的耗时将会变长。
断点指令检测,调试器下断点的时候需要将目标地址修改为
breakpoint
指令,所以可以扫描重要函数是否包含此指令或者对函数做 crc 校验
对于
JDB
调试,则需要通过Java API: isDebuggerConnected
来判断,不过一般来说,Java 调试用处比较少,因为静态分析可以满足大部分需求,并且可以很轻松就被绕过。
占坑
只要我调试自己的速度足够快,调试器就附加不上我。因为一个进程同时只能被一个进程附加,所以只要自己
fork
一个进程ptrace
自己先占到坑,调试器就无法附加调试。但光靠这个通常是没有用的,因为攻击者作为上帝模式,可以在代码执行之前就附加进程。
双进程守护,在子进程或者另一线程中对主进程的
TracePid
进行轮询监控,若被附加调试,且附加者不是自己的进程,则自杀重启。
三进程守护, 再起一个子子进程,作用与子进程类似,不过更恶心。
将主线程放到子进程中运行,让母进程沦为守护进程,这样即使你抢到
ptrace
的先机也不管用。
注入是指令代码或者数据注入在目标进程中加载,因为一个进程只能操作本身进程空间里的内存,无法操作不属于自己的进程空间。所以一般是通过 ptrace
或者重打包的方式,将自己的代码注入到目标进程中,以达到内存漫游的目的。
检测
如果将一个 SO 注入到了进程空间里,那么 /proc/self/mmap
文件里就会出现以这个 SO 名称命名的 Segment
。这个时候我们可以通过扫描 Segment
过滤关键字的方式来检测到内存注入, 比如匹配 Xposed
, Frida
等关键字。
通过扫描风险 SO 的特征码匹配检测,但速度比较慢,并且需要提取各种风险 SO 的特征。
如果不是常见SO
, 肯定无法扫描到。不过,很多情况下,注入都是为了修改代码,所以需要将原本权限是 rx
或者 x
的代码段添加 w
权限。所以扫描内存时可以关注自身 so 的代码段是否拥有 write
权限来检测自身是否被修改。
因为 Hook 的形式多种多样,所以防护的办法细分起来也非常多。通用的检测方法之一,就是上面注入的检测方法,但也有可能会误杀,最准确的方法就是根据各个框架的实现方法来一一针对。
针对 Java anti-hook
常用办法是检测调用栈,通过匹配调用栈里的风险方法来检测。但本身获取到的调用栈本身就有可能不可信。
针对 Xposed
, 除了调用栈,还有一些文件特征可以帮助识别。
另外还有可以通过反射 Xposed
特有的类来判断当前进程是否受 Xposed
影响。
针对 Frida
, 基本上套路跟反调试器类似,可以检测端口、进程名。
扫描重要函数的头部指令,匹配 Hook 指令的特征或者进行 crc 校验。
扫描两个表,判断函数偏移是否在自身 SO 内存之中。
暂时先想到这些,关于 Anti-Anti
的内容,可能会在其他文中体现,这里不再一一举例。