前言
前几篇介绍了Android的静态调试,这篇文章着重讲解一下使用IDA对Dalvik指令,so文件的调试,在Android越来越成熟的阶段,越来越多的app
本系列一共五篇:
《使用IDA对so文件进行动态调试与过反调试(三)》
《hook与注入(四)》
《Android文件保护技术(五)》
调试基础
什么是so文件
Android中的so文件是动态链接库,是二进制文件,即ELF文件。多用于NDK开发中。
readelf –h xxx.so //查看elf的头部信息
so文件的加载
so文件的加载有两种方式:一种是load(),另一种是loadLibrary()
loadLibrary()
这是在 Java 层的调用,直接使用系统提供的接口:
System.loadLibrary(String libName)
System.loadLibrary() 只需要传入so在 Android.mk 中定义的 LOCAL_MODULE 的值即可,系统会调用 System.mapLibraryName 把这个 libName 转化成对应平台的so的全称并尝试去寻找这个so的加载。比如我们的so文件名为 libpass.so ,加载动态库只需要传入 pass 即可:
System.loadLibrary("pass")
加载流程为:
load()
System.load(String fileName)
System.load()为动态加载非apk打包期间内置的so文件提供了可能,也就是说可以使用这个方法来指定我们要加载的so文件的路径来动态的加载so文件,比如在打包期间并不打包so文件,而是在应用运行时将当前设备适用的so文件从服务器上下载下来,放在 /data/data/<package-name>/mydir 下,然后在使用so时调用:
System.load("/data/data/<package-name>/mydir/libmath.so");
即可成功加载这个so,开始调用本地方法了
其实loadLibrary和load最终都会调用 nativeLoad(name, loader, ldLibraryPath) 方法,只是因为loadLibrary的参数传入的仅仅是so的文件名,所以,loadLibrary需要首先找到这个文件的路径,然后加载这个so文件。而load传入的参数是一个文件路径,所以它不需要去寻找这个文件路径,而是直接通过这个路径来加载so文件。
加载机制为:
IDA动态调试so时有哪三个层次?以及如何下断点?
在so加载时候有这个过程:
.init -> .init array -> JNI_Onload -> java_com_xxx
在脱壳的过程中会在一些系统级的.so中下断点比如:fopen,fget,dvmdexfileopen等等
而 .init 以及 .init_array 一般会作为壳的入口地方,那我们索性叫它外壳级的.so文件
这里归纳为三类:
应用级别的:java_com_XXX;
外壳级别的:JNI_Onload,.init,.init_array;
系统级别的:fopen,fget,dvmdexfileopen;
对于在应用级别的和系统级别的就不说了比较简单容易理解,这里也是在实现篇中会重点说的,看到上面的.so的加载执行过程我们知道如果说反调试放在外壳级别的.so文件的话我们就会遇程序在应用级核心函数一下断点就退出的尴尬,事实上多数的反调试会放在这,那么过反调试就必须要在这些地方下断点,下面会重点的说如何在.init_array和JNI_Onload处理下断点。
IDA下断点调试的原理
由于下断点有硬件断点和软件断点,我们在这里只说IDA中的软件断点原理:
X86系列处理器提供了一条专门用来支持调试的指令,即INT 3,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析。
当我们在IDA中对代码的某一行设置断点时,即:F2,调试器会先把这里的本来指令的第一个字节保存起来,然后写入一条INT 3指令,因为INT 3指令的机器码为11001100b(0xCC)当运行到这的时候CPU会捕获一条异常,转去处理异常,CPU会保留上上下文环境,然后中断到调试器,大多数调试器的做法是在被调试程序中断到调试器时,会先将所有断点位置被替换为INT 3的指令恢复成原来的指令,然后再把控制权交给用户。这样我们就可以愉快的开始调试了。
如下图所示也是写调试器的原理图:
反调试与反附加的区别
反调试
就是阻止你进行动态调试所采用的一种手段,在下文中会进行具体的讲解反调试的手段,以及解决反调试的办法。
反附加
在这块重要的是说jdb的反附加,很多情况下jdb会附加不上,就是会出现“无法附加到目标的VM”这样的问题那是因为在每个应用程序下,有这个android:debuggable="true"才能调试。
IDA调试常用的快捷键
快捷键 | 功能 |
F2 | 在所在行下断点 |
F5 | 可以将ARM指令转化为可读的C代码,同时可以使用Y键,对JNIEnv指针做一个类型转换,从而对JNI里经常使用的JNIEnv方法能够识别 |
F7 | 单步进入调试 |
F8 | 按照顺序一行一行,单步调试 |
F9 | 直接跳到下一个断点处 |
Shift + F12 | 快速查看so文件中的字符串信息,分析过程中通过一些关键字符串能够迅速定位到关键函数 |
Ctrl + s | 有两个用途,在IDA View页面中可以查看文件so文件的所有段信息,在调试页面可以查看程序中所有so文件映射到内存的基地址。tips:在进行so调试过程中,很有用的一个小技巧就是IDA双开,一个用于进行静态分析;一个用于动态调试。比如说调试过程中要找到一个函数的加载到内存中的位置, |
Esc | 回退键,能够倒回上一部操作的视图(只有在反汇编窗口才是这个作用,如果是在其他窗口按下esc,会关闭该窗口) |
g | 直接跳到某个地址 |
y | 更改变量的类型 |
x | 对着某个函数、变量按该快捷键,可以查看它的交叉引用 |
n | 更改变量的名称 |
p | 创建函数 |
常见命名意义
名字 | 含义 |
sub | 指令和字函数的起点 |
loc | 指令 |
locret | 返回指令 |
off | 包含偏移量 |
seg | 包含段地址值 |
asc | ASCII字符 |
byte | 字节(或字节数组) |
word | 16位数据(或字数组) |
dword | 32位数据(或双字数组) |
qword | 64位数据(或4字数组) |
flt | 32位浮点数(或浮点数组) |
dbl | 64位浮点数(或双精度数组) |
tbyte | 80位浮点数(或扩展精度浮点数) |
stru | 结构体或结构体数组 |
algn | 对齐指示 |
unk | 未处理字节 |
Jni-字符串与数组
1.新建Java字符串
在JNI中,如果需要使用一个Java字符串,可以采用如下方式新建String对象。
jstring NewString(JNIEnv *env, const jchar *unicodeChars,jsize len);
- unicodeChars:一个指向Unicode编码的字符数组的指针。
- len:unicodeChars的长度
- return:Java字符串对象
2.获取Java字符串长度
通过以下方法我们能够获取到Java字符串的长度
jsize GetStringLength(JNIEnv *env, jstring string);
- string:Java字符串对象
- return:字符串长度
3.从Java字符串获取字符数组
我们可以通过以下方法从Java字符串获取字符数组,当使用完毕后,我们需要调用ReleaseStringChars进行释放。
const jchar * GetStringChars(JNIEnv *env, jstring string,jboolean *isCopy);
- isCopy:注意,这个参数很重要,这是一个指向Java布尔类型的指针。函数返回之后应当检查这个参数的值,如果值为JNI_TRUE表示返回的字符是Java字符串的拷贝,我们可以对其中的值进行任意修改。如果返回值为JNI_FALSE,表示这个字符指针指向原始Java字符串的内存,这时候对字符数组的任何修改都将会原始字符串的内容。如果你不关系字符数组的来源,或者说你的操作不会对字符数组进行任何修改,可以传入NULL。
- return:指向字节数组的指针
4.释放从Java字符串中获取的字符数组
void ReleaseStringChars(JNIEnv *env, jstring string,const jchar *chars);
- string:Java字符串对象。
- chars:字符数组。
5.新建UTF-8编码字符串
jstring NewStringUTF(JNIEnv *env, const char *bytes);
- bytes:UTF-8编码的字节数组。
- return:UTF-8编码的Java字符串对象
6.获取UTF-8编码的Java字符串的
const char * GetStringUTFChars(JNIEnv *env, jstring string,jboolean *isCopy);
7.释放从UTF-8字符串中获取的字符数组
void ReleaseStringUTFChars(JNIEnv *env, jstring string,const char *utf);
8.从Java字符串中截取一段字符
如果我们想要从字符串中获取其中的一段内容,可以采用如下方式:
void GetStringRegion(JNIEnv *env, jstring str, jsize start, jsize len, jchar *buf);
- str:Java字符串对象。
- start:起始位置。
- len:截取长度。
- buf:保存截取结果的缓冲区。
9.获取数组长度
jsize GetArrayLength(JNIEnv *env, jarray array);
10.新建对象数组
使用如下方法可以创建一个对象数组。
jobjectArray NewObjectArray(JNIEnv *env, jsize length,jclass elementClass, jobject initialElement);
- length:数组的长度。
- elementClass:数组中的对象类型。
- initialElement:数组中的每个元素都会使用这个值进行初始化,可以为NULL。
- return:对象数组,创建失败返回NULL
IDA对so文件调试
使用的是吾爱破解上apk的例子,吾爱破解动态调试参考文章:《教我兄弟学Android逆向09 IDA动态破解登陆验证》
1. 使用Jadx反编译apk,打开AndroidManifest.xml查看程序入口 这里 android:debuggable="true"表示此apk可以动态调试,如果是false动态调试的时候需要改成true,否则不可被动态调试。
2. 找到MainActivity入口类,通过静态分析java代码可知,用户在输入用户名和密码后程序会调用Native方法check来校验用户名和密码是否正确。
可以看到加载了libJniTest.so这个文件里面的check方法来校验用户输入
重新打包安装再试一下,点击登录后直接闪退了,说明在so层做了某种验证。
3. 解压apk,找到 lib\armeabi\libJniTest.so,并用IDA打开 找到check函数并分析此函数
可以发现代码可读性较差,这里对F5生成的伪c代码进行优化。
(1)还原JNI函数方法名
一般JNI函数方法名首先是一个指针加上一个数字,然后将这个地址作为一个方法指针进行方法调用,并且第一个参数就是指针自己,比如上图的v5+676(v5…)。这实际上就是我们在JNI里经常用到的JNIEnv方法。因为Ida并不会自动的对这些方法进行识别,所以当我们对so文件进行调试的时候经常会见到却搞不清楚这个函数究竟在干什么,因为这个函数实在是太抽象了。解决方法非常简单,只需要对JNIEnv指针做一个类型转换即可,比如说上面提到 v5 指针。可以先选中 v5 变量,然后按 Y 键:
然后将类型声明为:JNIEnv*
点击OK之后再来查看代码
发现已经修改成为JNIEnv方法对应的数字,地址以及方法声明
(2)可以看到上图有 &unk_223c 这些地址符,这是ida无法解析字符串,双击进去
点击看到地址对应的字符串,返回伪c代码窗口,按F5刷新
可以看到字符串已经被正常加载
(3)导入jni.h分析jni库函数。
导入jni.h文件
代码优化完毕,开始来分析代码
到这里就解释了为什么重新打包安装,点击登录后会直接闪退
经过以上分析 在输入用户名和密码后 程序会调用 libJniTest.so 中的check方法校验用户名和密码是否正确 如果正确check方法返回字符串登陆成功,否则返回字符串登录失败。
这里有两种思路:
(1)修改so的16进制,改程序的跳转逻辑,实现破解
(2)动态调试so,在程序运行的时候改变程序的跳转逻辑
4. 将IDA的 \dbgsrv 目录下的 android_server push 到手机 /data/local/tmp/ 目录下,并赋予777权限
运行android_server(一定要使用管理员权限运行),监听 23946 端口,与IDA通信:
5. 设置本地端口转发,并启动app
6.启动IDA pro,点击 Debugger->attach->Remote ARMLinux/Android debugger,输入127.0.0.1/localhost,选择要调试的进程即可
如果出错的话,尝试其他的调试选项,比如 "Remote Linux debugger"...
点击左上角的 "Debug options"
选择要附加的进程
点击OK
7. 在modules窗口中 Ctrl+F 搜索找到 libJniTest.so,点进去会有so中的函数方法的列表,找到check方法并点击,查看Debug窗口的汇编代码
8. 按F5将 ARM 汇编转换成伪C语言,可导入jni.h文件并优化代码的可读性。
再按一次F5键刷新一下代码,可以看到程序已经识别出来了strcmp函数。
9. 按Esc键返回到汇编视图,分析check函数汇编,可以看到三处strcmp分别是校验签名,用户名,和密码是否正确,由于我用的是原包测试所以这里签名是正确的,这里在校验用户名和密码处的strcmp分别下一个断点
10. 按F9运行程序,然后在手机正在运行的apk程序随便输入一个用户名和密码,这里尝试输入用户名:koudai,密码:joker123,点击登录,可以看到程序断在了第一个strcmp处,此时右边寄存器窗口中 R0 和 R1 寄存器分别是strcmp函数的两个参数,鼠标先点击 Hex View-1 窗口,然后再点击 R0 寄存器后面的跳转地址,即可在Hex窗口中看到我刚刚输入的用户名 "koudai" 和寄存器 R1 储存正确的用户名 "koudai"
可以看到这里调试出了当前程序的签名
继续运行,就能看到 "登陆失败"的提示
下面介绍一下如何破解
(1) 置标志位破解
先来分析一下汇编代码的逻辑
这样的话,如果在寄存器窗口中把R0寄存器的值改为 00000000
这样就可以成功绕过判断,登陆成功
在R0寄存器上点右键,选择 "Zero value",把寄存器的值重置为0
F8程序单步运行或F9直接运行程序,可以看到屏幕上已经显示 "登陆成功"
可以使用这种方法绕过so层对签名的校验!!!
(2) 修改内存16进制破解
由于正确密码 "black" 对应的hex编码为 62 6C 61 63 6B
而我们输入的密码 "joker123" 对应的hex编码为 6A 6F 6B 65 72 31 32 33
在要修改的Hex上右键,点击 Edit 或者 F2
将 "joker123" 对应的hex十六进制编码 修改成 "black" 对应的hex十六进制编码
修改完成后,右键,点击 Apply changes 或者 F2
F8程序单步运行或F9直接运行程序,可以看到屏幕上已经显示 "登陆成功"
IDA过反调试检测
反调试内容包括apk参考与《教我兄弟学Android逆向10 静态分析反调试apk》
反调试技术是为了保护自己程序的代码,增加逆向分析的难度,防止程序被破解,针对动态分析。
所以在使用ida进行动态调试,ida会莫名卡死或者程序闪退。这里学习一下如何过反调试检测
这里把apk先拖入Jadx进行分析
将apk解压,把 /lib/armeabi/libsix.so 文件拖入IDA中,来静态分析
IDA调试端口检测
在调试器作远程调试时,会占用一些固定的端口号
通过读取 /proc/net/tcp,查找IDA远程调试所用的23946端口(也可以在运行netstat -apn的结果中搜索23946端口),若发现说明进程正在被IDA调试。
将so文件拖入IDA中静态分析,发现在函数导出表中没有找到 checkport() 这个函数,但是发现了 JNI_OnLoad() 这个函数,说明函数是动态注册的。
使用Ctrl + s打开 segment 表,找到 .data.rel.ro.local,这个段存放的是动态注册的函数,点进去就可以找到 checkport() 这个函数,函数的定义在 dword_1168 中
点进这个 dword_1168,发现这部分代码IDA没有充分解析,这里需要手动编译一下,点住 dword_1168 ,右键 Data 转化成数据,然后鼠标放在 __unwind 上按住P键就可以转换成函数,这个函数就是 checkport() 对应的函数
可以看到 checkport() 函数基本结构已经打印出来,这里可以使用 F5 将代码转换成伪C的代码,导入Jni.h文件,还原JNI函数方法,加载IDA无法解析的字符串,再次使用 F5 刷新
绕过方法:
由于只对23946端口进行判断,可以通过 ./android_server -p 23947 ,将IDA调试端口改为23947或者其他端口,注意端口转发和IDA调试的端口号都要改成23947
系统源码修改检测
Android native最流行的反调试方案是读取进程的status或者stat来检查 tracepid ,调试状态下的进程 Tracepid 不为0
虽然已经绕过端口号的检测,但是测试仍然无法调试,经过分析发现由于这个函数的反调试并没有放到线程里面重复去判断,说明判断执行一次就结束,说明在 checkport() 也就是JNI_OnLoad() 在动态加载函数之前,就已经有反调试的检测。由于.init_array 是so最先加载的一个段信息,时机最早,而JNI_OnLoad()是so被System.loadLibrary调用的时候执行,他的时机要早于那些native方法执行,但是没有.init_array时机早,把反调试和so的解密放到 .init_array 中是比较好的选择。
JNI_Onload()函数因为有符号表所以非常容易找到,但是.init_array里的函数需要自己去找一下。首先打开Segments表(ctrl + s)。然后点击 .init.array,就可以看到.init_array中的函数了。
使用F5转换成伪c代码,分析函数
双击进入thread_function()函数
在命令行中查看调试状态
通过上面分析可知 thread_create() 函数是创建线程循环读取当前程序的 Tracepid 的值,如果值大于0说明程序当前正在被动态调试并退出程序,那么现在就知道为什么在程序弹出恭喜你,挑战成功框后我们进行动态调试,程序还是会退出了,因为这里开启了一个线程进行循环反调试。
绕过方法:
对于这种调试的检测手段,最彻底是修改系统源码后重新编译,让 Tracepid 永远为0,也可以创建一个子进程,让子进程主动ptrace自身设为调试状态,此时正常情况下,子进程的 Tracepid 应该不为0。此时我们检测子进程的 Tracepid 是否为0,如果为0说明源码被修改
参考文章《逆向修改内核,绕过TracerPID反调试 》
调试器进程名检测
那么到这里程序的反调试是不是就找完了呢?刚刚我们也说了除了 .init_array 还有一个地方 JNI_OnLoad 函数也会在so刚加载的时候运行,来看一下JNI_OnLoad函数
可以看到有一个可疑的函数 SearchObjProcess()
进入这个函数
经过上面分析知道 JNI_OnLoad() 函数中调用 SearchObjProcess() 函数进行反调试,这个函数通过ps列出当前手机的所有进程,然后如果进程名中包含android_server,gdbserver,gdb等名称,则认为程序当前被动态调试,退出程序
解决方法:
可以将android_server改成其他名字然后运行
同时也可以通过exit(0)函数定位到反调试位置并patch掉当前函数,这里以 thread_create() 函数为例子 ,因为此函数在 .init_array 段里面,所以是没有直接调用的地方的,这里把第二条指令改成pop直接出栈
对于第二处检测 SearchObjProcess() 函数,直接找到调用此函数的位置,然后nop掉,或者进函数里面把exit给nop掉都行,最后一处反调试这里也不演示了,方法相同
以前我们都是用IDA插件modifyfile.plw来patch 其实还有一种patch的方法 直接用IDA Patch Program插件来Patch也是可以的 点菜单Edit->Patch Program
最后将patch后的so替换原包的so,重打包签名,运行,即可过反调试。
.....
反调试检测还有很多种,这里直介绍这几种,这里推荐《ANDROID调试检测技术汇编》
下载链接:https://pan.baidu.com/s/1sY4ENtS5XGs35NNbwZVJ5A
提取码:gxhb
-------------------------------------------
于2019.12.21补充
尽管在ida里面可以使用F5把汇编代码转换成伪c代码,但如果不优化还是很难看懂
以下是转载《在ida中看伪C代码更直观的方法》方法来优化
1.需要一个导入一个头文件,jni.h
2.还原jni函数名
点“OK”之后,当前页面好像更容易理解些,就是调用了一些jni方法
3.但是这样还不够直观,还有最后一步,如下图:
4.最后变成这样,是不是更直观呢
参考连接: