Android native 逆向

前置知识

NDK

NDK 即 Native Development Kit,是 Android 中的一个开发工具包,使您能够在 Android 应用中使用 C 和 C++ 代码。

使用NDK可以快速开发 C、 C++ 的动态库,并自动将 so 和应用一起打包成 APK。即可通过 NDK使 Java 与 Native 代码(如 C、C++)交互。

在这里插入图片描述

JNI

JNI 即 Java Native Interface,是一种编程框架,使得 Java 虚拟机中的 Java 程序可以调用本地应用或库,也可以被其他程序调用。 本地程序一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序

使用JNI 首先要声明native方法,之后实现native方法,并生成so文件,最终加载so文件,调用native方法。

JNI 是一个编程框架,是一个抽象的东西,NDK 是一个工具包

ABI

ABI 即 Application Binary Interface。我们上面说了,每个CPU系统只能使用相对应的二进制文件,不同的 Android 设备使用不同的 CPU,而不同的 CPU 支持不同的指令集。

image-20220331174010239

image-20220403183650728

在 Android 手机上安装一个应用时,只有手机CPU架构支持的ABI架构对应的.so文件会被安装。如果支持多个ABI架构,会按照优先级进行安装。x86架构的模拟器也能运行arm的程序是因为其中间对so文件进行了转换,所以在调试或者是用frida hook 时会因此出现不匹配或找不到的问题。

JNI方法的使用

静态注册

native 层的方法名为:Java_<包名>*<类名>*<方法名>(__<参数>)

linux: mkdir -p jni/com/example/task

注册步骤如下

1
2
3
4
5
6
7
8
9
10
11
package com.example.task; //包名
public class test {
public native void init();

public native void func(int a);

public native boolean func(String a);
//只有当 native 方法出现需要重载的时候,native 层的方法名后才需要跟上参数

}
//java层 native 方法声明 通常在一个类中

xxxx/jni/com/example/task目录下编译java文件

javac 编译test.java文件生成test.class

进入到 xxx/jni 目录下使用命令: javah com.example.task.test 会生成 com_example_task_test.h 文件,内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_example_task_test */

#ifndef _Included_com_example_task_test
#define _Included_com_example_task_test
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: com_example_task_test
* Method: init
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_com_example_task_test_init
(JNIEnv *, jobject);

/*
* Class: com_example_task_test
* Method: func
* Signature: (I)V
*/
JNIEXPORT void JNICALL Java_com_example_task_test_func__I
(JNIEnv *, jobject, jint);

/*
* Class: com_example_task_test
* Method: func
* Signature: (Ljava/lang/String;)Z
*/
JNIEXPORT jboolean JNICALL Java_com_example_task_test_func__Ljava_lang_String_2
(JNIEnv *, jobject, jstring);

#ifdef __cplusplus
}
#endif
#endif

可以观察 函数是 java_包名_类名_函数名 参数是 JNIEnv* 和 jobject + 额外参数

com_example_task_test.h 修改为Myjni.h 或其他 按照自己喜好👨‍💻。

此时我们只拿到了在java中声明的native函数,还没有给出函数体,故下一步是通过c/c++完善函数并编译出so文件。

1
2
3
4
5
6
7
8
9
#include "MyJni.h" //导入修改后的头文件 编写函数实体
#include <stdio.h>

JNIEXPORT jboolean JNICALL Java_com_example_task_test_func__Ljava_lang_String_2 //头文件中声明的native函数
(JNIEnv *, jobject, jstring){
char *cstr=(char *)(*env)->GetStringUTFChars(env,jstr,NULL); //获取传入的字符
printf("%s\n",cstr);
}
....

之后通过gcc 编译MyJni.c生成so文件

1
gcc -fPIC -shared -o libMyJni.so MyJni.c -I xxxx/include

-I xxxx/include 指定头文件搜索目录,xxx为jdk的安装路径,要用到Jni.h文件 , 并且输出so文件必须以lib开头

如果要想实例运行,test.java中需要调用so文件,如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.task; //包名
public class test {

public native boolean func(String a);
//只有当 native 方法出现需要重载的时候,native 层的方法名后才需要跟上参数
static{
System.loadLibrary("MyJni"); //加载so文件
}
public static void main(String[] argv){ //主函数
System.out.println("hello");
new test().func("jni callback!");
}
}

完成上述步骤后可通过 java -Djava.library.path=xxx/jni/com/example/task/ com.example.task.test进行运行测试。

上述是静态注册的方法,静态注册的函数名都按照java_包名_类名_方法名的格式来命名,长度比较长,并且类名或包名修改后还要重复上述步骤生成新的so文件。

动态注册

动态注册需要将native方法构造成JNINativeMethod数组,JNINativeMethod结构体定义如下。

1
2
3
4
5
6
7
8
typedef struct {

const char* name; // Java层native方法名称

const char* signature; // 方法签名

void* fnPtr; // native层方法指针
} JNINativeMethod;

之后需要重写JNI_OnLoad函数,该函数会在 System.loadLibrary加载完so文件后被调用,在其中完成动态注册。

一个MyJni.c的结构如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include<jni.h>
#include<stdio.h>
static JNINativeMethod methods[] = { //JNINativeMethod数组 每一项的值 包括 name sign 和 函数指针
{"init", "()V", (void *)c_init1},
{"init", "(I)V", (void *)c_init2},
{"init", "(Ljava/lang/String;)Z", (void *)c_init3},
{"update", "()V", (void *)c_update},
.....
};

JNIEXPORT jint JNICALL //JNIEXPORT
JNI_OnLoad(JavaVM* vm, void* reserved) { //注意参数 JavaVM* vm 和 void* reserved
JNIEnv *env = NULL;
jint result = -1;

// 获取JNI env变量
if (vm->GetEnv((void**) &env, JNI_VERSION_1_6) != JNI_OK) {
// 失败返回-1
return result;
}

// 获取native方法所在类
const char* className = "com/example/ndk/NativeTest";
jclass clazz = env->FindClass(className);
if (clazz == NULL) {
return result;
}

// 动态注册native方法
if (env->RegisterNatives(clazz, methods, sizeof(methods) / sizeof(methods[0])) < 0) {
return result;
}

// 返回成功
result = JNI_VERSION_1_6;
return result;
}

extern "C" JNIEXPORT void JNICALL
c_init1(JNIEnv *env, jobject thiz) {
// 函数实体
}
...........

因为JNI_OnLoad是导出函数,所以IDA在exports中可以找到。

其中,主要用RegisterNatives解析

1
2
jint RegisterNatives(jclass clazz, const JNINativeMethod* methods, jint nMethods)//参数依次是 native所在类 函数数组 和 函数个数
//注册成功则返回 0,失败则返回一个负值

上述只是对JNI编程结构有一定的了解,如果要深入的话还要系统学习一下Android开发和java等😔。

libxxx.so文件函数修复

👀易忽略点:so文件是一个elf格式的文件,在so被加载之前,会执行init段的代码,在结束的时候,会执行fini段的代码。所以在init_array中可能会有smc数据解密的代码,往往存在着数据解密。

静态注册的native函数因为命名规范很容易识别,所以难点在动态注册的函数。

jadx中查看native声明,并且之后会调用check函数。

image-20220401003254289

打开lib.so文件,其中native的check函数通过动态注册,因为导出表中无xxxx_check函数。可见JNI_OnLoad函数本身的参数是正确的,但是其中的局部变量类型却不正确😭。

image-20220331230040806

根据上文所述动态注册的步骤(获取JNI的env变量,获取包含native的类,通过RegisterNatives和method数组来动态注册),调整变量修复函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
jint v3; // w19
JNIEnv *v5; // x20
jclass v6; // x0
JNIEnv *v7[2]; // [xsp+0h] [xbp-30h] BYREF

v3 = 65542;
v7[1] = *(JNIEnv **)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40);
v7[0] = 0LL;
sub_1724(); // 反调试
if ( (*vm)->GetEnv(vm, (void **)v7, 65542LL) )// 通过vm获取JNI env变量存到v7
return -1;
v5 = v7[0];
v6 = (*v7[0])->FindClass(v7[0], class_name); // com/test/hufu22/TestActivity
if ( !v6
|| (((__int64 (__fastcall *)(JNIEnv *, jclass, char **, __int64))(*v5)->RegisterNatives)(v5, v6, &method, 1LL) & 0x80000000) != 0 )
{
return -1; // 动态注册了一个函数 并且在TestActivity类中的调用
}
return v3;
}

修改之后,即可直观看出是对一个函数进行了动态注册,跟进method变量,观察其3个值,是经过smc修改的,手动修一下,如下。

image-20220331231932296

动态注册的check函数已经找到,接下来就要逆向分析函数逻辑,不过在此之前,.init_array的内容需要解决,因为本题中发现其中存在解密代码,对一些静态数据进行了修改。

image-20220331234953540

根据jadx中check函数的参数和JNI函数的结构修复check函数。

image-20220403203347019

对于Init段存在字符解密的so文件,可以用frida hook dump出so,这样拿到就是字符串解密后的so文件,可惜👦手上没有root机,暂且硬怼。

image-20220403210636506

首先对传入字符进行base64解密,解密函数也是通过索引表映射来实现的,打印出索引表,b64表被修改,这和RE2 shellcode中所用函数一致。

1
2
3
4
a=[...] #删除末尾重复0
for i in range(64):
print(chr(a.index(i)),end='')
#zTsI01htNUF/VoE4uQnpyA9bCqD53fgiBd6GXcKHLjJv7Pm2rSkMlxwW8OeRZa+Y

之后进入第一个处理函数,pre_getstr通过交叉引用知是java层调用prenative函数传入的字符串,分析知该函数为AES128的秘钥扩展。

image-20220403210433884

不过这里的sbox经过换表了,接下来是sub_2DE8函数,是AES的加密主体。由控制流程图知,他在进行加密的时候没有用循环,代码复用导致函数比较大,比较难读。

image-20220403224816348

类似这种块主要进行轮秘钥加和行位移,之后sub_2668执行列混淆,用到了一个数组

image-20220404000101335

通过IDAPYTHON dump出数组,根据数组去github上搜一下,找到了类似符号的源码aes_c.c - github,这段逻辑也主要用于列混淆中。

1
2
s1=[0x00,0x02,0x04,0x06,0x08,0x0a,0x0c,0x0e,0x10,0x12,0x14,0x16,0x18,0x1a,0x1c,0x1e,0x20,0x22,0x24,0x26,0x28,0x2a,0x2c,0x2e,0x30,0x32,0x34,0x36,0x38,0x3a,0x3c,0x3e,0x40,0x42,0x44,0x46,0x48,0x4a,0x4c,0x4e,0x50,0x52,0x54,0x56,0x58,0x5a,0x5c,0x5e,0x60,0x62,0x64,0x66,0x68,0x6a,0x6c,0x6e,0x70,0x72,0x74,0x76,0x78,0x7a,0x7c,0x7e,0x80,0x82,0x84,0x86,0x88,0x8a,0x8c,0x8e,0x90,0x92,0x94,0x96,0x98,0x9a,0x9c,0x9e,0xa0,0xa2,0xa4,0xa6,0xa8,0xaa,0xac,0xae,0xb0,0xb2,0xb4,0xb6,0xb8,0xba,0xbc,0xbe,0xc0,0xc2,0xc4,0xc6,0xc8,0xca,0xcc,0xce,0xd0,0xd2,0xd4,0xd6,0xd8,0xda,0xdc,0xde,0xe0,0xe2,0xe4,0xe6,0xe8,0xea,0xec,0xee,0xf0,0xf2,0xf4,0xf6,0xf8,0xfa,0xfc,0xfe,0x1b,0x19,0x1f,0x1d,0x13,0x11,0x17,0x15,0x0b,0x09,0x0f,0x0d,0x03,0x01,0x07,0x05,0x3b,0x39,0x3f,0x3d,0x33,0x31,0x37,0x35,0x2b,0x29,0x2f,0x2d,0x23,0x21,0x27,0x25,0x5b,0x59,0x5f,0x5d,0x53,0x51,0x57,0x55,0x4b,0x49,0x4f,0x4d,0x43,0x41,0x47,0x45,0x7b,0x79,0x7f,0x7d,0x73,0x71,0x77,0x75,0x6b,0x69,0x6f,0x6d,0x63,0x61,0x67,0x65,0x9b,0x99,0x9f,0x9d,0x93,0x91,0x97,0x95,0x8b,0x89,0x8f,0x8d,0x83,0x81,0x87,0x85,0xbb,0xb9,0xbf,0xbd,0xb3,0xb1,0xb7,0xb5,0xab,0xa9,0xaf,0xad,0xa3,0xa1,0xa7,0xa5,0xdb,0xd9,0xdf,0xdd,0xd3,0xd1,0xd7,0xd5,0xcb,0xc9,0xcf,0xcd,0xc3,0xc1,0xc7,0xc5,0xfb,0xf9,0xff,0xfd,0xf3,0xf1,0xf7,0xf5,0xeb,0xe9,0xef,0xed,0xe3,0xe1,0xe7,0xe5]
s2=[0x00,0x03,0x06,0x05,0x0c,0x0f,0x0a,0x09,0x18,0x1b,0x1e,0x1d,0x14,0x17,0x12,0x11,0x30,0x33,0x36,0x35,0x3c,0x3f,0x3a,0x39,0x28,0x2b,0x2e,0x2d,0x24,0x27,0x22,0x21,0x60,0x63,0x66,0x65,0x6c,0x6f,0x6a,0x69,0x78,0x7b,0x7e,0x7d,0x74,0x77,0x72,0x71,0x50,0x53,0x56,0x55,0x5c,0x5f,0x5a,0x59,0x48,0x4b,0x4e,0x4d,0x44,0x47,0x42,0x41,0xc0,0xc3,0xc6,0xc5,0xcc,0xcf,0xca,0xc9,0xd8,0xdb,0xde,0xdd,0xd4,0xd7,0xd2,0xd1,0xf0,0xf3,0xf6,0xf5,0xfc,0xff,0xfa,0xf9,0xe8,0xeb,0xee,0xed,0xe4,0xe7,0xe2,0xe1,0xa0,0xa3,0xa6,0xa5,0xac,0xaf,0xaa,0xa9,0xb8,0xbb,0xbe,0xbd,0xb4,0xb7,0xb2,0xb1,0x90,0x93,0x96,0x95,0x9c,0x9f,0x9a,0x99,0x88,0x8b,0x8e,0x8d,0x84,0x87,0x82,0x81,0x9b,0x98,0x9d,0x9e,0x97,0x94,0x91,0x92,0x83,0x80,0x85,0x86,0x8f,0x8c,0x89,0x8a,0xab,0xa8,0xad,0xae,0xa7,0xa4,0xa1,0xa2,0xb3,0xb0,0xb5,0xb6,0xbf,0xbc,0xb9,0xba,0xfb,0xf8,0xfd,0xfe,0xf7,0xf4,0xf1,0xf2,0xe3,0xe0,0xe5,0xe6,0xef,0xec,0xe9,0xea,0xcb,0xc8,0xcd,0xce,0xc7,0xc4,0xc1,0xc2,0xd3,0xd0,0xd5,0xd6,0xdf,0xdc,0xd9,0xda,0x5b,0x58,0x5d,0x5e,0x57,0x54,0x51,0x52,0x43,0x40,0x45,0x46,0x4f,0x4c,0x49,0x4a,0x6b,0x68,0x6d,0x6e,0x67,0x64,0x61,0x62,0x73,0x70,0x75,0x76,0x7f,0x7c,0x79,0x7a,0x3b,0x38,0x3d,0x3e,0x37,0x34,0x31,0x32,0x23,0x20,0x25,0x26,0x2f,0x2c,0x29,0x2a,0x0b,0x08,0x0d,0x0e,0x07,0x04,0x01,0x02,0x13,0x10,0x15,0x16,0x1f,0x1c,0x19,0x1a]

如下图,用到了两个表来列混淆,特征比较明显,一般这种使用数组来实现的,可以在github上找到到蛛丝马迹。

image-20220404005222022

通过交叉引用和第三个参数128可知调用了9次,对应AES中间的9轮,所以上述AES单纯魔改了sbox,其余算法不变。

到此,一个动态注册的函数分析完毕,从修复JNI和注册信息,再到定位函数逆向逻辑,最后逆向还原即可💪。

😴至于后续如何,未完待续。。。。

参考:

NDK 入门指南

NDK 开发之 JNI 方法静态注册与动态注册

java jni编程详细步骤及注意细节

Frida环境搭建-基于python3.7