android so分析入门

文章最后更新时间为:2023年08月13日 00:45:22

1. 认识NDK开发

NDK(Native Development Kit)是一种开发工具,允许开发人员使用C和C++等本地编程语言来编写Android应用程序的部分或全部代码。

Android应用通常使用Java或Kotlin编写,但有时候需要使用C或C++编写的代码以实现更高效、更复杂的计算或访问底层硬件。NDK提供了这些功能,允许开发人员使用C或C++编写代码,然后将其编译成本地库,以便在Android应用中使用。

在Android平台上,NDK和JNI通常一起使用,以允许Java应用程序调用本地代码或与本地代码进行交互。开发人员可以使用JNI来编写Java代码和本地代码之间的接口,并使用NDK编写本地代码,然后将其编译为本地库。最后,Java应用程序可以使用System.loadLibrary()方法来加载这些本地库,并使用JNI接口与本地代码交互。

下面通过android studio新建一个ndk项目:

2023-07-17T09:14:19.png

然后在MainActivity中加载so文件,并且调用so中定义的stringFromJNI函数:

2023-07-17T09:16:51.png

在so中的函数需要通过jni接口注册到java代码中,有两种注册方式:静态注册和动态注册。

  • 静态注册:静态注册比较简单,在so中的函数满足"Java_类名_方法名"的命名要求时,便相当于直接注册为jni函数,但是名字太长
    且每个函数都需要符合这种命名规则,不适用于大型项目,所以一般的app都是采用动态注册的方式
  • 动态注册:动态注册是使用了RegisterNatives函数,将so中定义的函数和java中的函数一一对应,动态注册的优点是可以在运行时动态加载本地库并注册本地方法,因此具有更好的灵活性。

下面是动静态注册的案例:

# native-lib.cpp

#include <jni.h>
#include <string>
#include <android/log.h>


// 定义一个名为"Java_类名_方法名"的函数,通过静态注册,在程序启动时自动注册Native方法。
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_ndkdemo_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}


jint func1(JNIEnv *env, jclass clazz, jint a, jint b) {
    return a + b;
}
jint func2(JNIEnv *env, jclass clazz, jint a, jint b) {
    return a - b;
}

/**
 * JNINativeMethod结构体
 * 包含:
 * 1. name: 变量name是Java中函数的名字;
 * 2. signature: 方法签名,用字符串是描述了Java中函数的参数和返回值,比如下面的(II)I对应的就是java中的 int (int a,int b)
 * 3. fnPtr-c: 函数指针,指向native函数。前面都要接 (void *)
 *
 */
static const JNINativeMethod mMethod[]={
        {"add","(II)I", (void *)func1},
        {"del","(II)I", (void *)func2},
};


jint RegisterNatives(JNIEnv *env) {
    jclass clazz = env->FindClass("com/example/ndkdemo/MainActivity");
    if (clazz == NULL) {
        __android_log_print(ANDROID_LOG_INFO, "mmm", "con't find class: com/example/ndkdemo/MainActivity");
        return JNI_ERR;
    }
    int len = sizeof(mMethod) / sizeof(mMethod[0]);

//    RegisterNatives的三个参数:
//    clazz:指定的类,即 native 方法所属的类
//    methods:方法数组,这里需要了解一下 JNINativeMethod 结构体
//    nMethods:方法数组的长度
//    成功则返回 JNI_OK (0),失败则返回一个负值。
    return env->RegisterNatives(clazz, mMethod,
                                len);
}

// JNI_OnLoad动态注册:动态注册通过RegisterNatives方法把C/C++中的方法映射到Java中的native方法
// 该函数的返回值是一个整数,它表示了JNI版本号,这个版本号是由JNI规范定义的,用于验证JNI库是否与当前VM匹配。
jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env = NULL;

    if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }
    jint result = RegisterNatives(env);
    __android_log_print(ANDROID_LOG_INFO, "mmm", "RegisterNatives result: %d", result);
    if (result < 0) {
        return JNI_ERR;
    }
    return JNI_VERSION_1_6;
}

2023-07-17T09:27:17.png

2023-07-17T09:27:52.png

在动态注册时,java函数和ndk函数的对应关系在JNINativeMethod结构体中定义,比如上面代码中的:

/**
 * JNINativeMethod结构体
 * 包含:
 * 1. name: 变量name是Java中函数的名字;
 * 2. signature: 方法签名,用字符串是描述了Java中函数的参数和返回值,比如下面的(II)I对应的就是java中的 int (int a,int b)
 * 3. fnPtr-c: 函数指针,指向native函数。前面都要接 (void *)
 *
 */
static const JNINativeMethod mMethod[]={
        {"add","(II)I", (void *)func1},
        {"del","(II)I", (void *)func2},
};

add是java函数的名字,(void *)func1是native函数的指针,中间的(II)I是类型,I代表int,所以(II)I表示int (int a, int b)

在JNI接口中,Java数据类型和本地代码(C/C++)数据类型是需要进行对应的。下面是一些常见的Java数据类型和本地代码数据类型的对应关系:

signaturec/c++类型Java类型
Vvoidvoid
Zjbooleanboolean
Ijintint
Jjlonglong
Djdoubledouble
Fjfloatfloat
Bjbytebyte
Cjcharchar
Sjshortshort
[IjintArrayint[]
[FjfloatArrayfloat[]
[BjbyteArraybyte[]
[CjcharArraychar[]
[SjshortArrayshort[]
[DjdoubleArraydouble[]
[JjlongArraylong[]
[ZjbooleanArrayboolean[]

上面的都是基本类型。如果Java类型是对象,而其对应的C函数名的类型如下,其中signature类型是以"L"开头,以";"结尾,中间是用"/" 隔开的包及类名,如果JAVA对象位于一个嵌入类,则用$作为类名间的分隔符。

signaturec/c++类型Java类型
Ljava/lang/String;jstringjava.lang.String
同上规则jclassjava.lang.Class
同上规则jobjectjava.lang.Object

2. 认识so文件格式

Android系统使用的.so文件格式是基于标准的可执行和共享库格式ELF(Executable and Linkable Format)。

so文件大体上可分为四部分,一般来说从上往下是ELF Header->Program Header Table->Section Header Table->Section,ELF文件的结构非常灵活,不同的段和节可以根据需要添加或删除,结构图(虫神)如下

2023-07-18T15:17:10.png

主要部分含义为:

  • ELF Header:文件头,位于文件起始位置,包含了ELF文件的基本信息,如文件类型、入口点地址、段表和节表在文件中的偏移量等。
  • Program Header Table:程序头表是由多个程序头(Program Header)组成的表格,每个程序头都描述了一个段(Segment),包括段的类型、大小、文件偏移量、内存偏移量、内存对齐等信息
  • Section Header Table:节区头表,描述了ELF文件中所有节的信息,如节的名称、类型、大小、偏移量等。
  • Section: 一般指ELF文件中的数据段或代码段,包含了程序执行所需的数据和代码信息。android的.so文件中常见的节区包括:

    • .text:包含可执行代码,即程序的代码段。
    • .rodata:包含只读数据,如字符串常量等。
    • .data:包含程序的全局和静态变量,即程序的数据段。
    • .bss:包含未初始化的全局和静态变量,即程序的未初始化数据段。
    • .strtab:字符串表,存放字符串数据。
    • .dynstr:包含动态链接时使用的字符串表,类似于.strtab,但只包含需要导出的字符串。
    • .symtab:包含静态链接时使用的符号表。
    • .dynsym:包含动态链接时使用的符号表,类似于.symtab,但只包含需要导出的符号。
    • .dynamic:包含了一些动态链接器需要的信息,例如导出符号表、库依赖关系、重定位表、初始化和终止函数等信息。

查看so信息可以使用readelf工具,常见用法如下:

 -a 显示so文件所有信息
 -h 查看ELF file header
 -l 查看program headers
 -S 查看section-headers
 -e 查看所有头信息,等于-h -l -S
 -s 显示符号表
 -d 显示动态节 
readelf -h libndkdemo.so
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           AArch64
  Version:                           0x1
  Entry point address:               0x1db4c
  Start of program headers:          64 (bytes into file)
  Start of section headers:          293192 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         25
  Section header string table index: 24

每个字段的含义可以参考android frame中的elf.h,也可以参考https://bbs.kanxue.com/thread-272077.htm

3. arm汇编常见指令

arm指令现在可以用chatgpt进行翻译,但是熟能生巧,先记录一下常见的arm汇编常见指令,下列内容摘抄自互联网:

3.1 寄存器

在ARM64架构下,CPU提供了33个寄存器, 其中前31个(0~30)是通用寄存器 (general-purpose integer registers),最后2个(31,32)是专用寄存器(sp 寄存器和 pc 寄存器)。

前面0~30个通用寄存器的访问方式有2种:

  • 当将其作为 32bit 寄存器的时候,使用 W0 ~ W30 来引用它们。(数据保存在寄存器的低32位)
  • 当将其作为 64bit 寄存器的时候,使用 X0 ~ X30 来引用它们。

第31个专用寄存器的访问方式有4种:

  • 当将其作为 32bit 栈帧指针寄存器(stack pointer) 的时候,使用 WSP 来引用它。
  • 当将其作为 62bit 栈帧指针寄存器(stack pointer) 的时候,使用 SP 来引用它。
  • 当将其作为 32bit 零寄存器( zero register )的时候,使用 WZR 来引用它。
  • 当将其作为 62bit 零寄存器( zero register )的时候,使用 ZR 来引用它。
寄存器说明
X0 寄存器用来保存返回值(或传参)
X1 ~ X7 寄存器用来保存函数的传参
X8寄存器也可以用来保存返回值
X9 ~ X28寄存器一般寄存器,无特殊用途
x29(FP)寄存器用来保存栈底地址
X30 (LR)寄存器用来保存返回地址
X31(SP) 寄存器用来保存栈顶地址
X31(ZR)寄存器零寄存器,恒为0
X32(PC)寄存器用来保存当前执行的指令的地址

3.2 寻址方式

寻址方式描述
立即数寻址直接使用立即数值作为操作数,例如:MOV R0, #5
寄存器直接寻址使用寄存器中的值作为操作数,例如:MOV R0, R1
寄存器间接寻址使用寄存器中的值作为内存地址,访问该地址中的数据,例如:LDR R0, [R1]
寄存器相对寻址使用寄存器中的值加上一个立即偏移量作为内存地址,例如:LDR R0, [R1, #4]
寄存器变址寻址使用两个寄存器中的值相加作为内存地址,例如:LDR R0, [R1, R2]
带有变址寄存器的寄存器相对寻址使用寄存器中的值加上另一个寄存器的值乘以一个比例因子作为内存地址,例如:LDR R0, [R1, R2, LSL #2]
堆栈寻址使用堆栈指针寄存器(如SP)进行操作,例如:PUSH {R0, R1} 或 POP {R0, R1}

3.3 压栈和出栈指令

指令类型指令示例描述
压栈PUSH {R0, R1}将寄存器R0和R1的内容压入堆栈中
压栈PUSH {R0-R5}将寄存器R0到R5的内容压入堆栈中
压栈STMDB SP!, {R0-R5}将寄存器R0到R5的内容压入堆栈中(与PUSH等效)
出栈POP {R0, R1}从堆栈中弹出数据,恢复到寄存器R0和R1中
出栈POP {R0-R5}从堆栈中弹出数据,恢复到寄存器R0到R5中

3.4 跳转指令

指令类型指令示例描述
无条件跳转B label无条件跳转到标签label指向的位置
子程序调用BL label调用子程序,将当前指令的下一条指令地址存入链接寄存器(LR),然后跳转到标签label指向的位置
子程序返回BX LR返回子程序调用前的位置,跳转到链接寄存器(LR)中存储的地址
寄存器跳转BX Rn跳转到寄存器Rn中存储的地址

3.5 算术运算指令

加减乘除相关指令:

指令计算公式备注
ADD Rd, Rn, RmRd = Rn + Rm加法运算,指令为 ADD
ADD Rd, Rn, #immedRd = Rn + #immed加法运算,指令为 ADD
ADC Rd, Rn, RmRd = Rn + Rm + 进位带进位的加法运算,指令为 ADC
ADC Rd, Rn, #immedRd = Rn + #immed + 进位带进位的加法运算,指令为 ADC
SUB Rd, Rn, RmRd = Rn - Rm减法
SUB Rd, #immedRd = Rd - #immed减法
SUB Rd, Rn, #immedRd = Rn - #immed减法
SBC Rd, Rn, #immedRd = Rn - #immed - 借位带借位的减法
SBC Rd, Rn ,RmRd = Rn - Rm - 借位带借位的减法
MUL Rd, Rn, RmRd = Rn * Rm乘法 (32 位)
UDIV Rd, Rn, RmRd = Rn / Rm无符号除法
SDIV Rd, Rn, RmRd = Rn / Rm有符号除法

3.6 逻辑运算

与或非相关指令:

指令C语言说明
AND Rd,RnRd = Rd & Rn按位与
AND Rd,Rn,#immedRd = Rn & #immed按位与
AND Rd,Rn,RmRd = Rn & Rm按位与
ORR Rd,RnRd = Rd | Rn按位或
ORR Rd,Rn,#immedRd = Rn | #immed按位或
ORR Rd,Rn,RmRd = Rn | RmRn,Rm按位或
BIC Rd,RnRd = Rd & ~RnRd清除Rn对应位
BIC Rd,Rn,#immedRd = Rn & ~#immedRd清除Rn对应immed位
BIC Rd,Rn,RmRd = Rn & ~RmRd清除Rn对应Rm位
ORN Rd,Rn,#immedRd = ~(Rn | #immed)Rn或#immed的反码
ORN Rd,Rn,RmRd = ~(Rn | Rm)Rn或Rm的反码
EOR Rd,RnRd = Rd ^ RnRd异或Rn
EOR Rd,Rn,#immedRd = Rn ^ #immedRn异或#immed
EOR Rd,Rn,RmRd = Rn ^ RmRn异或Rm

4. IDA分析so

解压apk文件,so文件位于lib目录下,arm64-v8a代表64位,armeabi-v7a代表32位,在这两个文件夹下的so的功能是一致的,只不过是为了适配不同的arm运行环境。

以上面写的ndkdemo为例,使用ida反编译libndkdemo.so文件,一般来说,第一步就是直接在导出函数中搜索java关键词或者JNI_OnLoad关键词,然后F5大法直接查看伪代码:

2023-07-19T15:47:40.png

2023-07-19T15:49:00.png

由于没有做混淆和加固,这里的伪C代码还是比较清晰的。这里我的ida版本是7.7,可以直接识别JNIEnv类型,如果ida版本较老,未识别出来,则需要手动导入jni.h,然后点击入参,按y修改参数类型,具体过程可以百度或者参考https://blog.csdn.net/LoopherBear/article/details/88689354

4.1 WPeChatGPT插件的使用

chatgpt出来后,我们可以很方便的使用chatgpt帮我们分析汇编语言或者伪代码的含义。在ida中可以使用https://github.com/WPeace-HcH/WPeChatGPT这个插件。安装步骤如下:

运行如下命令安装所需包。
pip install -r ./requirements.txt

  1. 下载github项目,修改脚本WPeChatGPT.py,添加API key到变量 openai.api_key,修改proxy代理地址。
  2. 复制脚本文件WPeChatGPT.py及文件夹Auto-WPeGPT_WPeace到IDA的plugins文件夹
  3. 安装python依赖,注意这里需要将依赖安装到ida使用的python环境中,我这里的安装命令为D:\bin\IDA_Pro_7.7\python38\python.exe -m pip install anytree openai urllib3==1.25.11
  4. 最后重启 IDA 后即可使用。

使用效果如下, 还是很nice的

2023-07-19T16:10:03.png
2023-07-19T16:13:49.png

如果没有key,可以在网页使用,下面是一些prompt

  • 对下面的C语言伪代码函数进行分析,分别推测该函数的使用环境、预期目的、详细的函数功能,最后为这个函数取一个新的名字。(用简体中文回答我,并且回答开始前加上'---GPT_START---'字符串结束后加上'---GPT_END---'字符串:
  • 分析下面的C语言伪代码并用python3代码进行还原:
  • 查找下面这个C语言伪代码函数的漏洞并提出可能的利用方法:

4.2 动态调试

尝试分析下下面的样本:

https://wwtn.lanzout.com/iO4PY1553ibe
密码:hv87

抓取登录包,发现参数如下:

POST /api/v1/auth/login/sms HTTP/1.1
Content-Type: application/json
Connection: close
Charset: UTF-8
User-Agent: Dalvik/2.1.0 (Linux; U; Android 12; Pixel 6 Build/SQ3A.220705.001.B2)
Host: api.xxx.com
Accept-Encoding: gzip
Content-Length: 635

{"app_ver":"100","sign":"a523aed8f3a1ec8f929c4d909664dfd01738e6da","nonce":"914wz91691753188068","tzrd":"P1RlbHWY2DLNB3bdN9HSjrvBDNKFkeKtJT45hctOqsHY1OF1\/ZzMWHNB9St0iAb\/ggfdspsexUSVHyAgWJ\/THVS6HqLhQgBmYJsOR+P0EkZdSpHDfPd1TclNX75mahTuqUya6C3Uf9DwO9\/q5eNmZzEftI4ONEbZMB9tEjTxzkUjJlPw22bY11Zg69q2o5JGdbZDMrUNBqPzHK4usNnOwHul0w2urdt5kV33jlQ40eORK9OkPx1lqzX3iI\/G1SGG3Q6sta0iHQMZIBPptf1vY+RKmQ94rlM93rO4g7UJfPxJOq4GIs4rssn9ouISAnEMCAcUkF7dst4dilql6Cp2KiX7Ig6p3vIbiboRSP3CizLtW6TaBheG2ajUtrb+GQKCMMS5TgNeO\/pnF7VoPj2x8Fqb+7hWR5Yj8faT4FP7C1+U\/Mou09TvRSvXZLYRQrZBAiV+bVVpap\/\/12d0TiE6jykNqhsdswTW\/oF9JmId\/p0=","timestamp":"1691753188"}

尝试分析sign参数和tzrd参数,通过frida hook java算法,得到tzrd参数是在java层AES加密得到,而sign参数是通过native方法生成:
2023-08-12T16:00:01.png

2023-08-12T16:00:22.png

于是通过ida打开libtre,从导出函数找到sign函数:

2023-08-12T16:02:08.png

2023-08-12T16:02:34.png

未正确识别变量,于是按y修改变量名,得到下面这样的结果:

jstring __fastcall Java_com_maihan_tredian_util_TreUtil_sign(JNIEnv *a1, jclass a2, jstring a3)
{
  const char *v5; // r0
  int v6; // r10
  const char *v7; // r4
  size_t v8; // r5
  char *v9; // r5
  JNIEnv v10; // r0
  size_t v11; // r0
  const char *v12; // r4
  size_t v13; // r0
  int v14; // r0
  int v15; // r0
  int i; // r4
  int v17; // r2
  int v19[2]; // [sp+0h] [bp-170h] BYREF
  JNIEnv *v20; // [sp+8h] [bp-168h]
  void *v21; // [sp+Ch] [bp-164h]
  char v22; // [sp+17h] [bp-159h] BYREF
  _DWORD v23[2]; // [sp+18h] [bp-158h] BYREF
  __int16 v24; // [sp+20h] [bp-150h]
  char v25[100]; // [sp+28h] [bp-148h] BYREF
  char v26[20]; // [sp+8Ch] [bp-E4h] BYREF
  int v27[7]; // [sp+A0h] [bp-D0h] BYREF
  __int16 v28; // [sp+BCh] [bp-B4h]
  int v29; // [sp+100h] [bp-70h]
  int v30; // [sp+104h] [bp-6Ch]
  char v31[76]; // [sp+108h] [bp-68h] BYREF

  strcpy(v31, "b2qKgtaW4,9z9D`Fmst?K5JZbLYOY]NP6ssGf2U~;zk9oCNgoytV!}wW7ia+`w9g");
  v5 = (*a1)->GetStringUTFChars(a1, a3, &v22);
  v6 = 0;
  if ( v5 )
  {
    v21 = &_stack_chk_guard;
    v7 = v5;
    v19[0] = (int)a3;
    v8 = strlen(v31);
    v19[1] = (int)v19;
    v9 = (char *)v19 - ((strlen(v7) + v8 + 8) & 0xFFFFFFF8);
    strcpy(v9, v7);
    strcat(v9, v31);
    v10 = *a1;
    v20 = a1;
    v10->ReleaseStringUTFChars(a1, (jstring)v19[0], v7);
    v11 = strlen(v9);
    v12 = (const char *)&v19[-2 * v11];
    j_base64_encode_new(v9, v12, v11);
    v28 = 0;
    v27[0] = 1732584193;
    v27[1] = -271733879;
    v27[2] = -1732584194;
    v27[3] = 271733878;
    v27[4] = -1009589776;
    v27[5] = 0;
    v27[6] = 0;
    v29 = 0;
    v30 = 0;
    memset(v25, 0, sizeof(v25));
    v13 = strlen(v12);
    v14 = j_SHA1Input(v27, v12, v13);
    if ( v14 )
      fprintf((FILE *)((char *)&_sF + 168), "SHA1Input Error %d.\n", v14);
    v15 = j_SHA1Result(v27, v26);
    if ( v15 )
    {
      fprintf((FILE *)((char *)&_sF + 168), "SHA1Result Error %d, could not compute message digest.\n", v15);
    }
    else
    {
      for ( i = 0; i != 20; ++i )
      {
        v17 = (unsigned __int8)v26[i];
        v23[0] = 0;
        v23[1] = 0;
        v24 = 0;
        sprintf((char *)v23, "%02x", v17);
        strncat(v25, (const char *)v23, 5u);
      }
    }
    return (*v20)->NewStringUTF(v20, v25);
  }
  return (jstring)v6;
}

先用chatgpt帮我们分析一下:

2023-08-12T16:09:25.png

看起来就是sha1进行了散列,但是具体输入值是什么,这里尝试通过动态调试so来获取。

1.首先启动android_server

在IDA目录下的dbgsrv,选择跟手机架构一致的server
adb push android_server /data/local/tmp/
adb shell
su
cd /data/local/tmp/
chmod 777 android_server
./android_server

2.转发本地端口

adb forward tcp:23946 tcp:23946

3.以debug模式启动app

首先重打包app,设置debug属性为true
adb shell am start -D -n com.xxx/.activity.WelcomeActivity

4.ida在sign函数开头按f2打上断点
2023-08-12T16:16:17.png

5.按照下列的步骤 attach process

Debugger-->attach debuger选择remote arm android debugger

2023-08-12T16:16:58.png

然后Debugger-->process option写入hostname为127.0.0.1

然后Debugger->attach to process,搜索、选中app进程

  1. 点击运行,在手机上点击登录,便会停在下面的断点处:

2023-08-12T16:21:08.png

尝试F8单步调试即可。

首先可以看到函数的输入就是字符串拼接的其他参数:

2023-08-12T16:27:16.png

然后将输入拼接一段字符串常量,再进行base64加密。

2023-08-12T16:28:56.png

然后对base64密文进行标准的sha1加密

4.3 常用快捷键

  • n:更改变量的名称
  • y: 修改变量的类型
  • Tab/F5: 反编译
  • shift+f12:打开string窗口
  • x:对着某个函数、变量按该快捷键,可以查看它的交叉引用
  • f2: 下断点
  • F7: 单步进入调试
  • F8: 按照顺序一行一行,单步调试
  • F9: 直接跳到下一个断点处
  • g: 直接跳到某个地址

5. 参考

1 + 5 =
快来做第一个评论的人吧~