Android 程序的动态调试

  • 编译生成原生程序
  • 使用ndk-build编译原生程序
  • 使用Eclipse自动编译原生程序
  • 使用IDA Pro调试Android原生程序
  • 远程运行调试Android原生可执行程序
  • 远程附加调试Android原生动态链接库
  • 总结IDA调试的流程
  • 一些问题及解决方法


编译生成原生程序

  • NDK简介:
    NDK(英语:Native Development Kit,简称NDK)是一种基于原生程序接口的软件开发工具。通过此工具开发的程序直接以本地语言运行,而非虚拟机。因此只有Java等基于虚拟机运行的语言的程序才会有原生开发工具包。
  • NDK是一系列工具的集合:
    NDK提供了一系列的工具,帮助开发者快速开发C(或C++)的动态库,并能自动将so和java应用一起打包成apk。这些工具对开发者的帮助是巨大的;
    NDK集成了交叉编译器,并提供了相应的.mk文件以隔离CPU、平台、ABI等差异,开发人员只需要简单修改.mk文件,指出“哪些文件需要编译”、“编译特性要求”等,就可以创建出.so文件;
    NDK可以自动地将.so文件和Java应用一起打包,极大地减轻了开发人员的打包工作。
  • 我们为什么要使用NDK?
    a. 代码的保护。由于apk的java层代码很容易被反编译,而C/C++库的反汇编难度较大;
    b. 可以方便地使用现存的开源库。大部分现存的开源库都是用C/C++代码编写的;
    c. 提高程序的执行效率。将要求高性能的应用逻辑使用C开发,从而提高应用程序的执行效率;
    d. 便于移植。用C/C++写出的库可以方便地在其他嵌入式平台上再次使用。

使用ndk-build编译原生程序

总体思路:

在Eclipse中建立一个Android工程,在工程的根目录下新建一个名称为jni的文件夹,将编写好的 test.c文件复制到此文件夹中,或者直接在jni文件夹下新建一个文件,命名为test.c,接着编写ndk-build所需的脚本文件。

Android.mk文件是工程的编译脚本,告知编译系统关于源文件的一些信息,描述了编译原生程序所需的编译选项、头文件、源文件等等。

一个Android.mk文件由若干条定义语句组成,此文件中常用到的几个语句包括:

  • LOCAL_PATH := $(call my-dir)
    Android.mk文件开头必须先定义好LOCAL_PATH变量。LOCAL_PATH定义了本地源码的路径,它用于在开发树中查找源文件。call my-dir指定了调用my-dir宏,这个宏函数是编译系统提供的,用于返回Android.mk文件本身所在的路径。
  • include $(CLEAR_VARS)
    CLEAR_VARS由编译系统提供,指定让编译系统清除掉一些已经定义过的宏(如LOCAL_MODULE,LOCAL_SRC_FILES,LOCAL_STATIC_LIBRARIES等等),这些宏的定义都是全局的。当一个GNU MAKE在编译多个模块时,必须清除并重新设置它们。
  • LOCAL_ARM_MODE := arm
    LOCAL_ARM_MODE指定生成的原生程序所使用的ARM指令模式,取值为arm或者thumb。arm表示使用32位的arm指令系统,thumb表示使用16位的arm指令系统。
  • LOCAL_MODULE :=test
    LOCAL_MODULE指定模块的名称,即原生程序生成后的文件名。注意,如果生成共享库模块,编译系统会自动产生合适的前缀和后缀,对于本例中的源文件最终将生成名为libtest.so的共享库。
  • LOCAL_SRC_FILES := test.c
    LOCAL_SRC_FILES指定将要编译打包进模块中的C\C++源代码文件列表,此处只有一个test.c文件。
  • include $(BUILD_SHARED_LIBRARY)
    此语句用来指定生成的文件类型。BUILD_EXECUTABLE表示生成可执行程序,BUILD_SHARED_LIBRARY表示生成动态库BUILD_STATIC_LIBRARY表示生成静态库

实验步骤:

①安装Android NDK:

在电脑上安装NDK,我使用的版本为android-ndk-r8-windows-x86_64。安装完成后,新建一个系统变量,命名为NDK_ROOT,值为安装路径,最后将%NDK_ROOT%添加到环境变量Path中后,Android NDK安装完成。

在DOS中输入ndk-build进行测试,输出如下信息,说明Android NDK已经正常安装了。

ndk r18 对应的安卓版本 android 安卓ndk是什么_android


ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_02


ndk r18 对应的安卓版本 android 安卓ndk是什么_IDA Pro_03


②新建一个Android工程,命名为JNI,右键工程JNI,新建一个文件夹,将其命名为jni。

新建Android工程JNI,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_android_04

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_05


在工程中新建一个文件夹,命名为jni,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_06


添加文件夹jni后的工程结构如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_android_07


③在JNI工程的jni文件夹下新建两个文件,按下图所示命名:

ndk r18 对应的安卓版本 android 安卓ndk是什么_android_08


其中Android.mk的内容如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_09


test.c的内容如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_动态调试_10


④Android.mk与test.c两个文件编写完后,在DOS窗口下进入工程下的jni目录。输入ndk-build命令,在工程根目录下生成了一个libs文件夹,其中的armeabi文件夹中就存放着生成的可执行文件或者共享库文件(.so文件)。

  • Android.mk中最后一行为BUILD_EXECUTABLE时,可以生成名为test的可执行程序;
  • 而Android.mk中最后一行为BUILD_SHARED_LIBRARY时,可以生成名为libtest.so的共享库文件。

在DOS中,进入工程文件夹下包含Android.mk与test.c两个文件的jni目录,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_11


当Android.mk中最后一行为上面的 BUILD_EXECUTABLE 时,输入 ndk-build 命令:

ndk r18 对应的安卓版本 android 安卓ndk是什么_动态调试_12


执行命令后,在工程根目录下生成了一个libs文件夹,其中的armeabi文件夹中生成了名为test的可执行程序,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_动态调试_13


将Android.mk中最后一行修改为 BUILD_SHARED_LIBRARY,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_14


再次执行ndk-build命令,生成共享库文件libtest.so如图:

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_15


在工程根目录下的libs\armeabi文件夹中生成了名为libtest.so的共享库文件,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_16


将生成的可执行程序test复制到模拟器中,在命令行窗口下输入命令让其运行,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_android_17


可见成功输出了字符串“JNI Test!”。

使用Eclipse自动编译原生程序

由于C/C++代码需要用ndk-build来进行编译,而java代码需要用Android sdk编译,所以为了开发快捷,需要在每次更改完C语言代码后可以自动编译C语言为.so库。使用Eclipse自动编译原生程序的原理依旧是使用ndk-build工具,但是自动化的操作会使原生程序的开发更加高效。

①在Eclipse中配置NDK:

要想使用Eclipse自动编译原生程序,首先需要在Eclipse中对NDK进行配置。打开Eclipse,进入Eclipse窗口中,在 Window—>Prefernces—>Android—>NDK 选择NDK Location的存放路径后,点击Apply and Close,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_动态调试_18

②Eclipse自动编译原生程序:

首先,新建一个Build,此后编写代码时,保存修改后,Eclipse会自动编译生成原生程序。

第一步,在JNI工程上右键选择Properties,点击Builders选项,再点击Builders选项页右侧的New按钮,然后双击Program项打开Edit Configuration对话框,在对话框的Name栏输入Builder的名称,这里输入“JNI_Builder”,在 Location 栏输入“F:\android-ndk-r8\ndk-build.cmd” 设置要执行的命令,点击Working Directory右侧的Browse Workspace按钮选择JNI工程,最后点击Apply按钮应用更改,操作完成后,效果如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_19


然后,单击Refresh标签,勾选“Refresh resources upon completion”复选框,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_20


下一步,单击Build Options标签,勾选“During auto builds”与“Specify working set of relevant resources”复选框,点击“Specify Resources”按钮,勾选JNI工程的jni目录,点击finish按钮,点击OK按钮关闭“Edit Configuration”对话框,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_android_21


ndk r18 对应的安卓版本 android 安卓ndk是什么_IDA Pro_22

最后,点击OK按钮关闭Properties对话框,这时JNI工程就会自动编译,可以在libs\armeabi目录下生成test.c文件的可执行文件test,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_android_23


而若将Android.mk中最后一行修改为BUILD_SHARED_LIBRARY,则Eclipse则会自动将其编译为libtest.so文件,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_android_24


以后若每次在Eclipse中对jni目录下的任何文件进行修改或保存,都会触发JNI_Builder重新编译原来的工程,直接简便。

但如果是使用ndk-build手动编译工程,那么每次修改完jni目录下的文件后都要在命令行窗口下对工程进行重新手动编译,相对来说就比较麻烦。

使用IDA Pro调试Android原生程序

现在的许多App为了安全或者效率的问题,会把一些重要的功能放到native层。一般在Android中,native层使用的是so库文件。

使用NDK开发能够编译C/C++程序,最终生成so文件。而.so文件是一个二进制文件,我们是无法直接分析.so文件的,所以这里需要用到IDA Pro。IDA Pro能够对so文件进行反汇编,从而将二进制代码转化为汇编语言,利用IDA Pro的F5功能还能将汇编语言反编译成C/C++程序。

使用IDA Pro调试Android原生程序一般有远程运行远程附加两种方式,远程运行调试用来调试原生可执行程序,远程附加调试用来调试Android原生动态链接库

远程运行调试Android原生可执行程序

首先,需要用到前面实验编写的原生可执行程序实例test,以及IDA Pro软件目录下的android_server文件,找到后我把它们放到了一个新创建的名为tmp的文件夹中。

android_server所在路径为:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_25


然后,进入tmp文件夹所在路径,将test与android_server两个文件复制到模拟器的data/local/tmp目录中,在DOS中执行以下命令可以对文件进行上传:

adb push test /data/local/tmp
adb push android_server /data/local/tmp

执行结果如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_26


之后进入终端查看上传是否成功:

ndk r18 对应的安卓版本 android 安卓ndk是什么_IDA Pro_27


如上图所示,可以看到两个文件已经上传完成。

然后,执行以下两行命令给两个文件加上可执行权限:

chmod 755 /data/local/tmp/test
chmod 755 /data/local/tmp/android_server

如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_28


接着执行“./android_server”,以启动android_server,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_IDA Pro_29


打开另一个命令窗口,然后再看,这里监听了设备的23946端口,那么如果要想让IDA Pro和这个android_server进行通信,必须让PC端的IDA也连上这个端口,这时候就需要借助于adb命令:

adb forward tcp:远端设备端口号(进行调试程序端) tcp:本地设备端口(被调试程序端)

这里端口号均为23946,然后就可以把android_server端口转发出去了:

ndk r18 对应的安卓版本 android 安卓ndk是什么_动态调试_30


这时,我们只要在PC端使用IDA Pro连接23946这个端口就可以了,因为后面在使用IDA Pro进行连接的时候,IDA Pro把这个端口设置死了,就是23946,所以我们没办法自定义这个端口。

执行后,启动IDA Pro,这里要使用IDA Android 32-bit,所以在打开IDA的时候一定要是32位的IDA,不能是64位的,否则保存后就会有两个可执行程序,一个是32位,一个是64位,如果打开不正确则会报错。

点击菜单栏“Debugger->Run->Remote AmLinux/Android debugger”,打开调试程序对话框。这里看到,端口是给定的:23946,不能进行修改,所以上面的adb forward进行端口转发的时候必须是23946。这里PC本地机就是调试端,所以host就是本机的ip地址:127.0.0.1

在Application栏输入“/data/local/tmp/test”,在Directory栏输入“/data/local/tmp”,在Hostname栏输入“localhost”或者“127.0.0.1”,如下图所示 :

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_31


设置完成后点击OK按钮,IDA Pro就会远程执行test,并自动切换到调试界面,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_IDA Pro_32

远程附加调试Android原生动态链接库

调试Android原生动态链接库需要先安装并运行包含该动态链接库的程序,然后使用IDA Pro远程附加程序进程的方式来进行调试。

编写实例程序JavaCTest,导出为APK文件,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_33


将其导入到Android模拟器中,界面如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_34


点击“Change Text”按钮后,程序会调用动态链接库libjavac.so库中的stringFromJNI()方法返回一个字符串“Java C Test!”,并且更改字符串“Hello Xidain!”为此字符串。

接下来,对libjavac.so中stringFromJNI()方法的执行过程进行动态调试。执行下面的命令启动IDA Pro的Android调试服务器:

adb shell 
./android_server

命令执行成功后,Android调试服务器会监听23946端口,结果如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_35


打开另一个命令行窗口,输入以下命令进行端口转发。

adb forward tcp:23946 tcp:23946

启动IDA Pro,点击菜单项“Debugger→Attach→Remote ARMLinux/Android debugger”,打开调试程序的设置对话框。在Hostname栏中输入127.0.0.1,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_动态调试_36


然而,此时代码并没有运行在动态链接库部分,要想调试动态链接库,就得为动态链接库中的函数设置断点。将JavaCTest.apk解压,找到libjavac.so文件,开启另一个IDA实例并载入它,找到stringFromJNI() 方法的代码,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_37


从上图反汇编的代码中可以看出,stringFromJNI()方法的代码起始处位于 0x00002050 处。回到IDA Pro的调试窗口,按下“Ctrl+S”快捷键打开段选择对话框,查找libjavac.so动态链接库的基地址,此处的基地址为 0x00001F3C,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_IDA Pro_38


这里的跳转地址是可以计算的,如果想要跳转到某函数,然后下断点,那么可以使用 CTRL+S 查找到.so文件的内存开始的基地址,然后再用IDA View中查看该函数对应的相对地址,相加就是绝对地址,然后跳转即可。

对于一般的基地址,只要程序没有退出,在运行中它的值就不会改变,因为在程序的数据已经加载到内存中后,基地址就不会改变,除非程序退出后又重新运行,把数据又重新加载到内存中。同时相对地址也是不会改变的,只有在修改.so文件的情况下,如果文件大小改变了,相对地址可能会改变,其他情况下不会改变,相对地址就是数据在整个.so文件中的位置。

内存地址等于基地址加上偏移地址,由此可以计算出stringFromJNI()方法的内存地址为 0x00003F8C。关闭段选择对话框,按下快捷键G,打开地址跳转对话框,在“Jump to address”栏中输入 0x00003F8C,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_IDA Pro_39


点击OK按钮,程序会跳转到 stringFromJNI() 方法所在的代码行,并自动分析方法的代码。在 0x00003F8C 行按下快捷键F2设置一个断点,被设置断点的代码行会被红色标记,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_动态调试_40


设置好断点后,回到程序中点击“Change Text”按钮,程序就会自动中断在 0x00003F8C 行上,接下来就可以调试动态链接库中的原生程序了。

ndk r18 对应的安卓版本 android 安卓ndk是什么_android_41

总结IDA调试的流程

  1. 解压APK文件,得到对应的so文件,然后使用IDA工具打开so,找到指定的native层函数;
  2. 通过IDA中的一些快捷键:F5, Ctrl+S, Y 等,静态分析函数的ARM指令,大致了解函数的执行流程;
  3. 再次打开一个IDA来进行调试so;
    ①将IDA目录中的android_server拷贝到设备的指定目录下,修改android_server的运行权限,以Root身份运行android_server;
    ②使用adb forward进行端口转发,让远程调试端的IDA可以连接到被调试端;
    ③使用IDA连接转发的端口,查看设备的所有进程,找到需要调试的进程;
    ④通过打开so文件,找到需要调试的函数的相对地址,然后在调试页面使用 Ctrl+S 找到so文件的基地址,相加之后得到绝对地址,使用 G 键,跳转到函数的地址处,下好断点,点击运行;
    ⑤触发native层的函数,并使用 F8F7 进行单步调试,查看关键寄存器的值,例如函数的参数或者函数的返回值等信息。

一些问题及解决方法

1.问题:

一开始,我在运行IDA Pro的Android服务器时,DOS命令下总是提示:not executable:magic 7F45,如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_动态调试_42


最初我以为是执行权限的问题。然而在修改权限后,依旧不能运行。把文件清空后,重新安装,还是不能奏效。

在查找了相关资料、反复调试后,我终于找到了出错原因:
因为我在Android.mk中使用的是arm的ABI,而我的Android模拟器却使用的是x86的内核,当然不能正常运行。

所以需要安装对应ABI为arm的虚拟机才可以。

2.解决方法:

在AVDM中,安装一个ABI为 arm 的Android模拟器,如下图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_43


安装好的新设备如图所示:

ndk r18 对应的安卓版本 android 安卓ndk是什么_so文件_44


Android模拟器安装好后,进行验证如下:

ndk r18 对应的安卓版本 android 安卓ndk是什么_Android_45


到此,可以看到Android服务器已经打开了 23946 端口,并进行监听,说明安装成功。