Android无源码调试Native代码(使用GDB)

时间:2024-03-19 15:13:21

在前面的Android无源码调试APK一篇中,介绍了一种可以在无源码的情况下调试APKDalvik代码的方法。但是,现在越来越多的程序出于安全、性能或代码复用的考虑,使用JNI调用Native代码来实现某些功能。

其实,在Android平台上,想要对Native程序进行调试,过程非常简单,主要是用到了GDB。大家知道,Android底层其实就是Linux,所谓的Dalvik虚拟机什么的都是在Linux系统上构建的,而JNI调用的Native程序只不过是一个加载进来的.so动态加载库。所以,在Linux上功能非常强大的GDB调试工具,也可以在Android平台上发挥作用。

但是,与普通的GDB调试有点不同的是,在Android平台上,是在PC上调试Android设备上的程序,所以需要采用GDB的远程调试模式。因此,在正式调试之前,需要在Android设备上有GDB服务器端(gdbserver)程序,并且在PC上有GDB客户端(gdb)程序。

如果打算在Android模拟器上调试的话,因为其自带gdbserver程序,所以不需要做什么。但是,真机上是不自带gdbserver程序的,因此如果想在真机上调试的话,必须拷贝一个可用的gdbserver程序到设备上。幸运的是在NDKprebuilt\android-arm\gdbserver目录下可以找到一个Google预先编译好的gdbserver程序,省去了自己编译的麻烦。直接将gdbserver通过adb push命令推送到Android手机上,将其属性改成可执行。

至于GDB客户端,NDK自带了一个可在Windows上运行的版本,可以在NDK

toolchains\arm-linux-androideabi-4.8\prebuilt\windows-x86_64\bin目录下找到,其文件名是arm-linux-androideabi-gdb.exe,能直接在PC上的命令行模式下运行起来。

最后一个条件是,请确保已经获得了被调试设备的root权限。

好了,现在万事具备,已经可以使用GDB命令对设备上的程序进行调试了。具体来说,有以下几个步骤:

1) 启动要调试的程序

直接在调试设备上点击你要启动程序的图标,就可以让程序跑起来了。

光跑起来还不行,还需要知道这个程序在系统中当前的进程号是多少。可以在调试设备的adb shell上,通过下面的命令查到:

        ps | grep <PackageName>

其中,PackageName就是你要调试程序对应的包名,例如:

Android无源码调试Native代码(使用GDB)

可以看到,对于本例来说,其进程号是8143。

2) 启动GDB服务器端

在调试Android设备上启动gdbserver,并让其attach到前一步运行的那个要调试的进程上去。命令如下:

        gdbserver :<PORT>--attach:<PID>

这个命令必须要用root用户来执行。命令中的PID参数就是前一步查看到的被调试进程号。由于要采用GDB远程调试模式,所以要让gdbserver打开一个端口,这里的PORT参数就是指定要打开哪个端口进行监听。

还是接着上面的例子,假设打开端口号是1234,并且从上一步查看到的进程号是8143,则结果是:

Android无源码调试Native代码(使用GDB)

对了,还有一点需要注意的是,Android从4.4开始,强制打开了SELinux,其规则是不允许一个进程attach到一个非自己的子进程或兄弟进程上进行调试的,哪怕这个进程是以root用户启动的也不行。想要知道当前SELinux的工作模式,可以在adb shell下键入getenforce命令,例如:

Android无源码调试Native代码(使用GDB)

这是在我运行Android 5.0系统的Google Nexus 5上运行的结果,可以看出,其已经默认打开了强制(Enforcing)模式。所以,要想调试成功,必须要关闭SELinux的强制模式,可以通过下面的命令来关闭:

        echo 0> /sys/fs/selinux/enforce

注意,这条命令必须用root用户来运行。下面看看运行后的结果:

Android无源码调试Native代码(使用GDB)

可以看出,SELinux的模式已经从强制变成了允许(Permissive)。

3)建立PCAndroid设备间的端口转发

前面的命令已经让gdbserverAndroid设备上打开了一个端口,监听远端GDB的调试命令。不过,这个监听端口只在Android设备上有效,在PC端根本访问不到。这时候,需要用adbAndroid设备上的端口转发到PC机上。请在PC上再打开一个控制台,并键入如下命令:

        adb forward tcp:<PC_PORT>tcp:<DEVICE_PORT>

这条命令的作用就是将发往PC机上端口为PC_PORTTCP报文,发送到Android设备上DEVICE_PORT端口上。

假设还是想在PC上打开1234端口,映射到上一步在设备上打开的1234端口:

Android无源码调试Native代码(使用GDB)

4)启动GDB客户端

GDB的客户端启动非常简单,只要在命令行下或者Cygwin下执行NDK中自带的gdb程序就可以了:

Android无源码调试Native代码(使用GDB)

接下来,就要让这个在PC上的GDB客户端,连接上在Android设备上的GDB服务器了,命令如下:

        (gdb) target remote localhost:<PORT>

其中,参数PORT就是在第三步中,在PC上建立的转发端口。对于前面的例子,就是:

Android无源码调试Native代码(使用GDB)

这时,在gdbserver端就会显示:

Android无源码调试Native代码(使用GDB)

这就表示GDB客户端与服务端已经连接成功了。

经过以上四个步骤之后,就可以在PC端使用常用的GDB命令,对Android设备上指定的程序进行调试了。

想进一步了解GDB命令的使用方法,可以参考《GDB常用命令》


但Android和普通的Linux平台还是有点区别的,而且又无源代码,所以调试的时候需要使用一些技巧,下面稍微介绍几个常用的:

1) 如何获得.so文件的加载地址

AndroidNative代码都是编译成动态共享库形式的.so文件,其在加载的时候位置是不固定的。可以在设备上的adb shell上,用下面的命令查看到其具体的加载位置:

cat /proc/<PID>/maps | grep <NativeFileName>

例如,想查看进程号为8143内,某个.so文件的加载位置,结果如下:

Android无源码调试Native代码(使用GDB)

可以看到,一共有三项,而且加载位置都不一样。但是代码都是可执行的,而只有第一项有执行属性(r-xp),所以这个.so的加载起始地址就是0xb3f93000

注意,每次程序被重新执行时,加载位置都有可能会改变,所以每次都要查看一下。

2) 如何在指定代码的位置设置断点

由于要调试的Native程序无源码,而且基本上也不会是用debug模式编译的,所以要想在指定的位置设置断点,基本只能靠算。要想算出来,需要知道两部分信息:一是想要调试的.so文件,当前被加载到了什么位置。关于这个问题,前面已经介绍过了。二是要知道想要设置断点的代码距离.so头部的偏移。最后只要将这两个值相加就是代码在当前进程中的真实地址了。

关于第二个问题,可以通过IDA来辅助解决(笔者使用的版本是IDA 6.4)。

下面还是通过接着上面的例子来解释,假设我想分析前面的.so文件中的一个函数的具体逻辑。先用IDA将这个.so文件打开,在左边的“Functionswindows”中:

Android无源码调试Native代码(使用GDB)

找到那个感兴趣的函数,双击它,右边的代码显示框会跳转到你选择的那个函数处:

Android无源码调试Native代码(使用GDB)

在代码行的左边,就是这个函数在.so文件中的偏移,本例就是0x000235EC。而这个.so文件在进程中的加载地址,前面我们也看到过了,是0xb3F93000。所以这个函数的加载地址是0xB3F93000+0x000235EC=0xB3FB65EC。下面,用GDB在这个位置设置断点,继续执行程序:

Android无源码调试Native代码(使用GDB)

可以看到,代码真的断在了这个位置。接下来就可以用ni来单步执行,具体分析了。

3) 如何定位对导入函数的调用

正常情况下,Native程序都会调用别的动态链接库中的功能,Android中也自带了很多包含各种功能的.so动态链接库文件(例如最基本的libc.so,处理加密的libssl.so等)。我们经常想查看在调用这些公共动态链接库中的某个函数时,传入的参数是什么,这就需要先定位,到底在什么地方调用了这些函数。

这里先补充一点知识,对于这种要调用别的模块导出函数的情况,在Linux上都要经过PLTProcedure Link Table,过程链接表)表(其实还有GOT表)来重定位。对应每一个要调用的外部函数,都有且只有一个PLT表项。而要调试的.so模块中无论要调用多少次,都必须跳转到这个PLT表项上。所以,如果想知道模块中到底在哪里调用了这个外部函数,只要找到这个对应的PLT表项,再查看到底哪些位置用跳转指令跳转到这个表项上就行了。

接着上面的例子,还是要用到IDA。如果我想知道哪些地方调用到了libssl.so模块提供的SSL_write函数,用IDA将这个.so文件打开,在左边的“Functions windows”中找到SSL_write,双击它,右边的代码显示框会自动跳转到对应SSL_write导入函数的PLT表项上。右键点击SSL_write,选择“List cross references to…”:

Android无源码调试Native代码(使用GDB)

弹出的对话框中会列出所有跳转到这个位置的代码位置:

Android无源码调试Native代码(使用GDB)

双击每一项,都将自动跳转到调用的代码位置,可以用上面的方法在其上设置断点。

4) 如何查看函数的入口参数

根据APCSARM ProcedureCall Standard)的规定,函数调用的前4个非浮点参数是通过ARMR0~R3寄存器来传递的(第一个参数对应R0,第二个参数对应R1,以此类推)。

所以,你在一个函数中,想查看调用时的前4个非浮点参数的到底是什么内容话,可以通过查看ARM的寄存器来得到。

还是上面的例子,假设现在已经断在了调用SSL_Write函数之前的位置,并且想查看传入的第二个参数是什么(第二个参数就是指向要加密传输的原始字符串的指针),则可以这样:

Android无源码调试Native代码(使用GDB)

先通过info register(i r)命令查看存放在R1寄存器中指针执行的地址,然后再通过x命令查看那个地址存放的字符串到底是什么。

5) 如何知道要调试的代码是Thumb指令集的还是ARM指令集的

这个问题的答案其实很简单,只要看一下指令代码的长度就可以了,Thumb指令长度是2个字节,而ARM指令长度是4个字节。


最后总结一下这种调试方法的特点,大致有如下几个:

1) 如果在真机上调试的话,必须要求获得手机的root权限;

2) 不需要打开被调试程序的debug选项(AndroidManifest.xml中显式申明android:debuggable=”true”);

3) 由于原理不同,可以结合Android无源码调试APK的方法一起使用,达到联合调试Dalvik代码和Native代码的目的。请不要自找麻烦,用GDB来调试Dalvik的代码。

2