前言
JNI(Java Native Interface)意为Java本地接口,我们可以通过JNI来实现Java与C/C++之间互调。
前面我们介绍了重构方法的方案,不过重构后的方法仍然是Java方法,因此分析起来相对也比较容易。
这一章我将介绍的是重构到本地方法中,即需要把字节码转换为本地代码。
方案选择
实现方案 |
优势 |
劣势 |
方案一 |
把字节码直接转换为相应的汇编代码 |
字节码是基于栈的,而汇编中直接支持栈的操作,因此实现比较容易且运行效率非常高。 汇编代码不跨平台,因而需要针对每个平台来编写对应的汇编代码。 |
方案二 |
把字节码转换为相应的C/C++代码 跨平台,实现起来比较简单。 |
运行效率相对较低。 |
如果是为了做优化,建议选择方案一,但如果只是为了混淆,那么方案二是个好选择,毕竟编程起来更容易,也更好维护。这里选择的是方案二。
另外,这里选择使用C,因为C编译出来的体积更少。
初步设计
bytecode是基于栈的,如果我们要模拟bytecode的功能,先模拟栈的功能会事半功倍。汇编中自带了栈操作的指令,因此使用汇编实现的话会比较简单。C++的STL库中也有实现好的栈,但不好的地方就是使用C++编译后的文件会比较大。而由于C中没有栈的实现,需要自己实现,相对来说比较麻烦。不过幸运的是,bytecode中栈和本地变量的大小都是固定的,也就是说我们完全可以使用一个固定的数组来实现。
我们知道Java中有8种基本数据类型和引用类型,分别对应于JNI中的jboolean、jbyte、jchar、jshort、jint、jfloat、jlong、jdouble、jobject。除此之外,JNI中还是jarray、jclass等类型,不过这些类型实际上还是jobject类型。因此,为了保证栈可以保存所有类型,我们可以定义一个union类型,里面包括了所有数据类型:
1 2 3 4 5 6 7 8 9 10 11
| typedef union { jboolean Z; jbyte B; jchar C; jshort S; jint I; jfloat F; jlong J; jdouble D; jobject O; }place;
|
接下来,我们需要根据每个方法中的字节码,生成对应的C函数。由于我们需要自动化生成对应的C代码,也就是需要字符串拼接,而有些地方的设计是通用的,因此我们可以先设计一个模板,然后根据实际情况进行替换。
1 2 3 4 5 6 7 8 9 10 11
| @_retType_@ @_functionName_@(JNIEnv* env, jobject obj@_argType_@) { int stackTop = 0; place locals[@_maxLocals_@]; place stack[@_maxStack_@]; memset(locals, 0, sizeof(locals)); memset(stack, 0, sizeof(stack)); @_argInit_@ @_code_@ }
|
其中locals对应于bytecode的本地变量,stack对应于bytecode的栈。
这里我用@xxx@来代表要替换的内容,含义如下:
retType:返回值类型。
functionName:函数名,需要保证不重复。
argType:包含的参数。
maxLocals:本地变量的大小,可通过MethodNode.maxLocals来获取。
maxStack:栈的大小,可通过MethodNode.maxStack来获取。
argInit:如果有参数的话,则初始化参数到相应的locals中。
code:根据bytecode转换出来的C代码。
这样我们只需要把相应的内容替换进模板中,就可以得到一个完整的JNI函数了。接着我们只要把此函数与对应的Java的native方法关联起来就可以了。
代码生成
假设有如下代码:
1 2 3 4 5 6 7
| package com.tencent.asm; public class Record { int count; public void clear() { count = 0; } }
|
对应的字节码为:
1 2 3 4 5 6
| ALOAD 0 ICONST_0 PUTFIELD com/tencent/asm/Record.count : I RETURN MAXSTACK = 2 MAXLOCALS = 1
|
现在我们需要把它转换为C语言的实现,最简单的方式当然是每个指令作相应的转换。我们先按照模板填一下除了code以外对应的信息:
1 2 3 4 5 6 7 8 9 10 11
| void com_tencent_asm_record_clear_v(JNIEnv* env, jobject obj) { int stackTop = 0; place locals[1]; place stack[2]; memset(locals, 0, sizeof(locals)); memset(stack, 0, sizeof(stack)); // 注意下,此时obj为this指针,在bytecode中为第0个本地变量 locals[0] = obj; @_code_@ }
|
为了更好的调试,建议名字取得与原方法的名字类似,另外,生成对应的代码也做好注释(后面生成代码时后看到)。
模板生成后了,就差具体代码的实现了。不知大家有没注意到,模板中有个stackTop的变量,这个变量用来代表当前栈顶的位置,这样在生成代码时,我们不需要关心当前使用的是栈的哪个位置,只需对应地更改下stackTop就行。
第一条指令很简单,就是把第0个本地变量的值拷贝到栈中,因此我们可以生成对应的C代码:
1
| stack[stackTop++].O = locals[0];
|
很简单的吧,需要注意的是ALOAD指令加载的是引用,所以用的是place.O(上面定义place时,O为jobject类型,后面我就不再说明)。
第二条指令也很简单,就是把整数0压到栈中,对应的C代码如下:
1
| stack[stackTop++].I = 0;
|
第三条指令就相对复杂一些,我们看下文档,putfield指令要求栈中有两个值,分别是需要设置的对象的引用、需要设置的值。这条指令是用来设置某个类的实例的属性的值,这里我们就需要用到相应的JNI函数了。由于属性的类型为int,因而我们使用的是SetIntField方法,其声明如下:
1
| void (*SetIntField)(JNIEnv*, jobject, jfieldID, jint);
|
其中jfieldID为属性的标识,我们需要先通过FindClass方法获得属性所在类的标识,其声明如下:
1
| jclass (*FindClass)(JNIEnv*, const char*);
|
其中第二个参数为类的全局限定名。接着再通过GetFieldID来获得属性的标识,其声明如下:
1
| jfieldID (*GetFieldID)(JNIEnv*, jclass, const char*, const char*);
|
其中第2、3、4个参数依次为类标识、属性名、属性描述。
我们来看下对应的C代码是怎样的:
1 2 3 4
| jclass record_class = (*env)->FindClass(env, "com/tencent/asm/Record"); jfieldID count_field = (*env)->GetFieldID(env, record_class, "count", "I"); (*env)->SetIntField(env, count_field, stack[stackTop-2].O, stack[stackTop-1].I); stackTop -= 2;
|
至于return指令就不用解释了吧。汇总一下,就得到最终的转换结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| void com_tencent_asm_record_clear_v(JNIEnv* env, jobject obj) { int stackTop = 0; place locals[1]; place stack[3]; memset(locals, 0, sizeof(locals)); memset(stack, 0, sizeof(stack)); locals[0].O = obj; // ALOAD 0 stack[stackTop++].O = locals[0].O; // ICONST_0 stack[stackTop++].I = 0; // PUTFIELD com/tencent/asm/Record.count : I jclass record_class = (*env)->FindClass(env, "com/tencent/asm/Record"); jfieldID count_field = (*env)->GetFieldID(env, record_class, "count", "I"); (*env)->SetIntField(env, count_field, stack[stackTop-2].O, stack[stackTop-1].I); stackTop -= 2; // RETURN return; }
|
动态注册
关于如何注册JNI函数,这里使用静态注册与动态注册均可,这里我说一下如何进行动态注册。
由于我们自动化生成代码时,会处理多个类,而注册JNI函数的方法一次只支持一个类,因此我们可以设计一个结构体,来存放每个类的信息,这样只需做个循环就可以全部注册了。设计如下:
1 2 3 4 5
| typedef struct { const char* className; JNINativeMethod* methods; int size; }reg_info;
|
接着我们加入类似如下的代码:
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
| // 获取数组中元素个数 #define GET_SIZE(x) (sizeof(x) / sizeof(x[0])) // 每个类生成一个对应的数组,来保存相应的映射关系 static JNINativeMethod gMethods_Record[] = { { "clear", "()V", (void*) com_tencent_asm_record_clear_v }, }; // 保存所有类需要注册的信息 static reg_info gInfos[] = { {"com/tencent/asm/Record", gMethods_Record, GET_SIZE(gMethods_Record)}, }; static int registerNativeMethods(JNIEnv* env, const char* className, JNINativeMethod* gMethods, int numMethods) { jclass clazz = (*env)->FindClass(env, className); void* retValue = (*env)->ExceptionOccurred(env); // 类没找到,有些应用神经病地加上了一些android中没有的类来坑人。直接跳过当成已处理就行 if(retValue) { (*env)->ExceptionClear(env); return JNI_TRUE; } if (clazz == NULL) { return JNI_FALSE; } if ((*env)->RegisterNatives(env, clazz, gMethods, numMethods) < 0) { (*env)->DeleteLocalRef(env, clazz); return JNI_FALSE; } (*env)->DeleteLocalRef(env, clazz); return JNI_TRUE; } static int registerNatives(JNIEnv* env) { int i=0; int size = GET_SIZE(gInfos); // 批量注册 for(;i<size;++i) { if(!registerNativeMethods(env, gInfos[i].className, gInfos[i].methods, gInfos[i].size)) { return JNI_FALSE; } } return JNI_TRUE; } // 当Java中调用System.loadLibrary()方法来加载相应的so库时,系统会回调此方法 JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env = NULL; jint result = -1;
if ((*vm)->GetEnv(vm, (void**) &env, JNI_VERSION_1_4) != JNI_OK) { return -1; } assert(env != NULL);
if (!registerNatives(env)) { return -1; } result = JNI_VERSION_1_4; return result; }
|
至于如何进行编译,不懂的请到网上搜下,这里就不细说了。
整型问题
在bytecode的操作本地变量与栈的指令中,boolean、byte、char、short、int这些类型,都会当然整型来处理,而像数组操作等指令则可以操作这些类型,这样一不小心,就可能会出现类型转换的问题了。我们来看个例子,假设有如下代码:
1 2 3 4 5 6
| package com.tencent.asm; public class EncryptUtil { public static int count(byte[] src) { return src[0] + src[1]; } }
|
对应的字节码为:
1 2 3 4 5 6 7 8 9 10
| ALOAD 0 ICONST_0 BALOAD ALOAD 0 ICONST_1 BALOAD IADD IRETURN MAXSTACK = 3 MAXLOCALS = 1
|
接着我把它转换为对应的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
| void com_tencent_asm_encryptutil_count_bi(JNIEnv* env, jclass obj, jarray src) { int stackTop = 0; place locals[1]; place stack[3]; // 增加一个临时变量用来存放返回值 place retValue; memset(locals, 0, sizeof(locals)); memset(stack, 0, sizeof(stack)); // 由于是静态,所以obj为对应的类标识,第1个本地变量为src locals[0].O = src;
// ALOAD 0 stack[stackTop++].O = locals[0].O; // ICONST_0 stack[stackTop++].I = 0; // BALOAD (*env)->GetByteArrayRegion(env, stack[stackTop-2].O, stack[stackTop-1].I, 1, &retValue.B); stack[--stackTop-1].B = retValue.B; // ALOAD 0 stack[stackTop++].O = locals[0]; // ICONST_1 stack[stackTop++].I = 1; // BALOAD (*env)->GetByteArrayRegion(env, stack[stackTop-2].O, stack[stackTop-1].I, 1, &retValue.B); stack[--stackTop-1].B = retValue.B; // IADD stack[stackTop-2].I = stack[stackTop-2].I + stack[stackTop-1].I; --stackTop; // IRETURN return stack[--stackTop].I; }
|
不知道大家有没发现上面的写法存在的问题。jbyte对应于Java中的byte,其在JNI中的定义实际是个signed char,占1个字节,我们针对BALOAD的处理时,把栈中的元素当成是jbyte来处理,而栈的元素实际占了8个字节,也就说高7字节并没被更改,而在后面IADD时,我们无法知道其本身是个byte,所以直接当成int来处理。这样问题就来了,此时int的高3个字节是什么内容呢?为了防止这种情况发生,我们可以在使用栈前清空栈中的内容,这样就不用担心旧数据的影响了。
另外还有个问题,假如byte中的值是-1呢?按照上面的写法,int的高3个字节均为0,所以它的值就变为255了,这显然不符合我们需要吧。这问题其实就是自动类型转换的问题,改起来就很简单,我们只需要把下面代码:
1
| stack[--stackTop-1].B = retValue.B;
|
改成:
1
| stack[--stackTop-1].I = retValue.B;
|
如果你把它改成下面的代码,那么就连之前说的清空栈操作也可以省了。
1
| stack[--stackTop-1].J = retValue.B;
|
Android的Bug
俗话说得好,眼见为实,还是惯例,先给段Java代码:
1 2 3 4 5 6 7 8 9
| package com.tencent.asm; public class AndroidBug { public int debug(Object value) { if(value instanceof String) { return 1; } return 0; } }
|
对应的字节码如下:
1 2 3 4 5 6 7 8 9 10
| ALOAD 1 INSTANCEOF java/lang/String IFEQ L1 ICONST_1 IRETURN L1 ICONST_0 IRETURN MAXSTACK = 1 MAXLOCALS = 2
|
对应的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
| jint com_tencent_asm_androidbug_debug_ov(JNIEnv* env, jobject obj, jobject value) { int stackTop = 0; place locals[2]; place stack[1]; memset(locals, 0, sizeof(locals)); memset(stack, 0, sizeof(stack)); locals[0].O = obj; locals[1].O = value;
// ALOAD 1 stack[stackTop++].O = locals[1].O; // INSTANCEOF java/lang/String jclass string_class = (*env)->FindClass(env, "java/lang/String"); stack[stackTop-1].J = (*env)->IsInstanceOf(env, stack[stackTop-1].O, string_class); // IFEQ L1 if(stack[stackTop-1].I == 0) { goto L1; } // ICONST_1 stack[stackTop++].I = 1; // IRETURN return stack[stackTop-1].I; // L1 // 加入stackTop = stackTop;这行无意义的代码是因为Label后不能跟声明 L1: stackTop = stackTop; // ICONST_0 stack[stackTop++].I = 0; // IRETURN return stack[stackTop-1].I; }
|
大家知道问题出现在哪里吗?不知道没关系,我们可以猜一下。大家看我给的代码非常短,比较关键的代码就在于instanceof了,也就是说应该是instanceof出的问题。那么instanceof会出什么问题呢?我们先看下JNI的实现,下面是Jni.cpp中IsInstanceOf的源码:
1 2 3 4 5 6 7 8 9 10 11 12
| static jboolean IsInstanceOf(JNIEnv* env, jobject jobj, jclass jclazz) { ScopedJniThreadState ts(env);
assert(jclazz != NULL); if (jobj == NULL) { return true; }
Object* obj = dvmDecodeIndirectRef(ts.self(), jobj); ClassObject* clazz = (ClassObject*) dvmDecodeIndirectRef(ts.self(), jclazz); return dvmInstanceof(obj->clazz, clazz); }
|
接下来我们需要看下Dalvik中又是怎么实现的。
Dalvik虚拟机的执行引擎有两个,分别是Fast解释器(用汇编实现)以及Portable解释器(用C语言实现)。为了方便阅读,我们选择Portable解释器来分析。Portable解释器的源码位于dalvik\vm\mterp\out\InterpC-portable.cpp。
我们找到dalvik bytecode中的instance-of指令对应的实现代码:
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
| HANDLE_OPCODE(OP_INSTANCE_OF /*vA, vB, class@CCCC*/) { ClassObject* clazz; Object* obj;
vdst = INST_A(inst); vsrc1 = INST_B(inst); /* object to check */ ref = FETCH(1); /* class to check against */ ILOGV("|instance-of v%d,v%d,class@0x%04x", vdst, vsrc1, ref);
obj = (Object*)GET_REGISTER(vsrc1); if (obj == NULL) { SET_REGISTER(vdst, 0); } else { #if defined(WITH_EXTRA_OBJECT_VALIDATION) if (!checkForNullExportPC(obj, fp, pc)) GOTO_exceptionThrown(); #endif clazz = dvmDexGetResolvedClass(methodClassDex, ref); if (clazz == NULL) { EXPORT_PC(); clazz = dvmResolveClass(curMethod->clazz, ref, true); if (clazz == NULL) GOTO_exceptionThrown(); } SET_REGISTER(vdst, dvmInstanceof(obj->clazz, clazz)); } } FINISH(2); OP_END
|
我们可以看到一大堆宏指令,想深入研究的请自行分析,这里我说明一下关键点。
在Jni.cpp中IsInstanceOf函数里,我们可以看到如下代码:
1 2 3
| if (jobj == NULL) { return true; }
|
这表示了如果对象为空,则返回为真,即代表空对象是所有类的实例。
而在InterpC-portable.cpp中,相应的代码为:
1 2 3
| if (obj == NULL) { SET_REGISTER(vdst, 0); } else {
|
我们知道,在C++中,true即为1,false即为0,也就是说这行代码代表着所有空对象不是任何类的实例。
显然这两行代码是相矛盾的,而我们的字节码是由dalvik虚拟机执行的,因此需要以dalvik虚拟机为准,这样大家应该知道怎么处理了吧?