NDK与JNI基础

Android 的 SDK 主要是基于 Java 的,同时,开发者可以通过 JNI 调用自己的 C/C++ 动态库,于是 NDK 应运而生。本文主要介绍 NDK 和 JNI 的一些概念以及 JNI 接口的使用和 hook 方法。

NDK

什么是 NDK

Google 对 NDK 的介绍如下

Android NDK 是一个工具集,可让您使用 C 和 C++ 等语言以原生代码实现应用的各个部分。对于特定类型的应用,这可以帮助您重复使用以这些语言编写的代码库。

原生开发套件 (NDK) 是一套工具,使您能够在 Android 应用中使用 C 和 C++ 代码,并提供众多平台库,您可使用这些平台库管理原生 Activity 和访问实体设备组件,例如传感器和触摸输入。NDK 可能不适合大多数 Android 编程初学者,这些初学者只需使用 Java 代码和框架 API 开发应用。然而,如果您需要实现以下一个或多个目标,那么 NDK 就能派上用场

为什么使用 NDK

  • 进一步提升设备性能,以降低延迟或运行游戏或物理模拟等计算密集型应用。
  • 重复使用自己或其他开发者的 C 或 C++ 库。
  • 对代码的保护。APK 的 Java 层代码很容易被反编译,而 C/C++ 库反编译难度大。

ABI

ABI 与指令集

不同的 Android 设备使用不同的 CPU,而不同的 CPU 支持不同的指令集。CPU 与指令集的每种组合都有专属的应用二进制接口 (ABI)。NDK 为我们提供了各种 ABI,确保针对正确的架构和 CPU 进行编译。

ABI 支持的指令集 备注
armeabi-v7a armeabi
Thumb-2
VFPv3-D16
与 ARMv5/v6 设备不兼容。
arm64-v8a AArch64
x86 x86 (IA-32)
MMX
SSE/2/3
SSSE3
不支持 MOVBE 或 SSE4。
x86_64 x86-64
MMX
SSE/2/3
SSSE3
SSE4.1、4.2
POPCNT

注意:NDK 以前支持 ARMv5 (armeabi) 以及 32 位和 64 位 MIPS,但 NDK r17 已不再支持。

查看支持的 ABI

我们可以新建一个 Android 项目,使用以下代码即可打印出当前设备支持的 ABI 信息。

1
2
3
4
5
TextView tv = binding.sampleText;
tv.setText("Supported ABI:\n");
for (int i = 0; i < Build.SUPPORTED_ABIS.length; i++) {
    tv.append(Build.SUPPORTED_ABIS[i] + "\n");
}

image-20220101225405936

为特定 ABI 生成代码

默认情况下,Gradle(无论是通过 Android Studio 使用,还是从命令行使用)会针对所有非弃用 ABI 进行构建。要限制应用支持的 ABI 集,请使用 abiFilters。例如,要仅针对 64 位 ABI 进行构建,请在 build.gradle 中设置以下配置:

1
2
3
4
5
6
7
android {
    defaultConfig {
        ndk {
            abiFilters 'arm64-v8a', 'x86_64'
        }
    }
}

JNI

什么是 JNI

JNI 是 Java 调用 C++ 的规范,并非 Android 自创。一般的 Java 程序使用的 JNI 标准与 Android 存在差异,Android 的 JNI 更简单。

JNI,全称为 Java Native Interface,JNI 是 Java 调用 Native 语言的一种特性。通过 JNI 可以使得 Java 与 C/C++ 进行交互,即可以在 Java 代码中调用 C/C++ 等语言的代码,反之亦可。由于 JNI 是 JVM 规范的一部分,因此我们写的 JNI 程序可以在任何实现了 JNI 规范的 Java 虚拟机中运行。同时,这个特性使我们可以复用以前用 C/C++ 写的大量代码。

JNI 的三个角色

img

JNI 的命名规范

1
2
3
4
5
6
7
extern "C" JNIEXPORT jstring JNICALL
Java_dev_svip_easymd5_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}
  • jstring 是返回值类型
  • Java_dev_svip_easymd5 是包名
  • MainActivity 是类名
  • stringFromJNI 是方法名

类型对照

上述代码在 Java 中的声明如下

1
public native String stringFromJNI();

我们可以看到,在 JNI 方法中返回值是 jstring,而在 Java 代码中返回值为 String,二者之间具有对应关系,其他类型的对应关系如下

img◎ JNI 与 Java 类型对照

JNI 接口使用和追踪

此处我们使用的项目为 Android Studio 自动生成,新建 Native C++ 项目即可。

使用 objection 查看模块名

1
2
objection -g dev.svip.easymd5 explore
memory list modules

image-20220102170356852

搜索包名可以得到我们需要的模块名

使用 jnitrace 进行追踪,代码中方法定义如下,可以看出,成功追踪到方法调用。

1
2
3
4
5
6
7
extern "C" JNIEXPORT jstring JNICALL
Java_dev_svip_easymd5_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

image-20220102170741749◎ jnitrace结果

创建 md5 项目

代码主要使用 https://github.com/itechbear/AndroidNativeMD5/blob/master/jni/md5.c

将 md5.c 的内容移植到本地项目后,编译运行即可,本文所用项目请点击此处

注意前面有提到,Gradle 会针对所有非弃用 ABI 进行构建,但在 Android Studio 中直接点击运行时,只会构建当前设备需要的 ABI,我的手机默认构建的 ABI 为 arm64-v8a,而此处我们所用代码对架构有要求,导致 md5 计算错误,此处我们只需指定要构建的 ABI 即可使结果正确。

1
2
3
4
5
6
7
android {
    defaultConfig {
        ndk {
            abiFilters 'armeabi-v7a'
        }
    }
}

另一种修复方式:

修改源码中定义的 UINT4 类型即可(第 46 行)

1
2
3
/* typedef a 32 bit type */
//typedef unsigned long int UINT4;
typedef uint32_t UINT4;

C 与 C++ 版本对照

本项目中的 md5 实现有 C/C++ 两套版本(仅有轻微改动,分别对应 native-lib.c 和 native-lib.cpp),在 CMakeLists.txt 中修改引入的文件名即可指定所用版本。使用 objection 查看导出函数,可发现以下区别。如图所示,后者我们可以通过 c++filt 命令查看函数的具体声明。

image-20220102224628861◎ C版本导出函数

image-20220102225626897◎ C++版本导出函数

Frida hook JNI 函数

使用刚才创建的 easymd5 项目。Frida 脚本使用 TypeScript,与 JavaScript 语法仅有很少的差异,这也是 Frida 官方推荐的方式。

 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
function hook_dlopen() {
    //Android低版本使用dlopen
    //Android高版本使用android_dlopen_ext
    //我使用的是 Android 11,所以代码只写了android_dlopen_ext部分,如果使用较低版本,可将代码复制到dlopen方法中
    var dlopen = Module.findExportByName(null, "dlopen");
    if (dlopen) {
        Interceptor.attach(dlopen, {
            onEnter: function (args) {
                var pathptr = args[0];
                if (pathptr !== undefined && pathptr != null) {
                    var path = pathptr.readCString();
                    console.log("dlopen =>", path);
                }
            }, onLeave: function (_retval) {

            }
        });
    }

    var android_dlopen_ext = Module.findExportByName(null, "android_dlopen_ext");
    if (android_dlopen_ext) {
        Interceptor.attach(android_dlopen_ext, {
            onEnter: function (args) {
                var pathptr = args[0];
                this.target = false;
                if (pathptr !== undefined && pathptr != null) {
                    var path = pathptr.readCString();
                    // console.log("android_dlopen_ext =>", path);
                    if (path) {
                        if (path.indexOf("libeasymd5.so") >= 0) {
                            this.target = true;
                            console.log("find android_dlopen_ext =>", path);
                        }  
                    }
                }
            }, onLeave: function (_retval) {
                if (this.target) {
                    var mdString_addr = Module.findExportByName("libeasymd5.so", "Java_dev_svip_easymd5_MainActivity_mdString");
                    // console.log("mdString address =>", mdString_addr);
                    // if (mdString_addr) {
                    //     Interceptor.attach(mdString_addr, {
                    //         onEnter: function (args) {
                    //             console.log("jstring =>", Java.vm.tryGetEnv().getStringUtfChars(args[2]).readCString());
                    //         }, onLeave: function (retval) {
                    //             console.log("retval is =>", Java.vm.tryGetEnv().getStringUtfChars(retval).readCString());
                    //         }
                    //     });
                    // }
                    if (mdString_addr) {
                        var mdString = new NativeFunction(mdString_addr, "pointer", ["pointer", "pointer", "pointer"]);
                        var mymdString = new NativeCallback(function (env, jobject, jstring) {
                            //打印原始参数
                            console.log("jstring =>", Java.vm.tryGetEnv().getStringUtfChars(jstring).readCString());
                            var newJstring = Java.vm.tryGetEnv().newStringUtf("111");
                            //修改参数
                            var retval = mdString(env, jobject, newJstring);
                            //打印原来的返回值
                            console.log("retval =>", Java.vm.tryGetEnv().getStringUtfChars(retval).readCString());
                            //直接返回自定义的值
                            return newJstring;
                        }, "pointer", ["pointer", "pointer", "pointer"]);

                        Interceptor.replace(mdString, mymdString);
                    }
                }
            }
        });
    }
}

动态注册

将 C/C++ 代码中的函数 Java_dev_svip_easymd5_MainActivity_mdString 注释掉,然后添加以下代码

 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
45
46
47
48
49
50
#define NELEM(x) ((int) (sizeof(x) / sizeof((x)[0])))

//原Java_dev_svip_easymd5_MainActivity_mdString的实现
jstring ffff(JNIEnv *env, jobject obj, jstring str) {
    MD5_CTX mdContext;
    char *inString = const_cast<char *>(env -> GetStringUTFChars(str, NULL));

    unsigned int len = strlen(inString);

    MD5Init(&mdContext);
    MD5Update(&mdContext, reinterpret_cast<unsigned char *>(inString), len);
    MD5Final(&mdContext);

    int i;
    char dest[32] = {0};
    for (i = 0; i < 16; i++) {
        sprintf(dest + i * 2, "%02x", mdContext . digest[i]);
    }

    return env -> NewStringUTF(dest);
}

static JNINativeMethod method_table[] = {
        //这里的第二个值可以通过jadx查看原app获得,详情见下面的说明
        {"mdString", "(Ljava/lang/String;)Ljava/lang/String;", (void *) ffff},
};

static int
registerMethods(JNIEnv *env, const char *className, JNINativeMethod *gMethods, int numMethods) {
    jclass clazz = env -> FindClass(className);
    if (clazz == nullptr) {
        return JNI_FALSE;
    }
    if (env -> RegisterNatives(clazz, gMethods, numMethods) < 0) {
        return JNI_FALSE;
    }
    return JNI_TRUE;
}

JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env = nullptr;
    if (vm -> GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }
    //注册native方法
    if (!registerMethods(env, "dev/svip/easymd5/MainActivity", method_table, NELEM(method_table))) {
        return JNI_ERR;
    }
    return JNI_VERSION_1_6;
}

变量 method_table[] 中的第二个值可以使用 jadx 查看之前静态注册生成的 apk,在 Smali 中,搜索方法名,后面的内容即为需要的内容。

image-20220105142103990

查看导出函数,可以发现已无法找到函数 mdString

image-20220105143559274◎ 静态注册后的导出函数

参考资料

1、https://developer.android.com/ndk/guides

2、https://www.jianshu.com/p/87ce6f565d37

3、https://github.com/itechbear/AndroidNativeMD5

4、https://github.com/svipblog/easymd5

updatedupdated2022-01-172022-01-17