作者:FloatingGuy 转载请注明出处:https://floatingguy.github.io/


Title: Calling JNI Functions with Java Object Arguments from the Command Line

Core Skill: 在 native 层创建 Android 虚拟机,并调用 jni 方法。

Caleb Fenton’s Blog中包含很多关于 android 开发的技术,特别是虚拟机相关的技术。介绍的非常详细

这里介绍 Caleb Fenton’s Blog中关于 Android 虚拟机的系列文章,对文章中的技术做汇总和实验。

目前作者给出了 如下2篇文章:

重点介绍第二篇 文章, 因为第一篇只是一个技术基础。第二篇包含了一些案例 更有价值一些。

从 navice 层创建 Android VM

在介绍第二篇之前先将第一篇公布的完整代码公布出来。

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
#include <dlfcn.h>
#include <jni.h>
typedef int (*JNI_CreateJavaVM_t)(void *, void *, void *);
typedef jint (*registerNatives_t)(JNIEnv* env, jclass clazz);
static int init_jvm(JavaVM **p_vm, JNIEnv **p_env) {
// https://android.googlesource.com/platform/frameworks/native/+/ce3a0a5/services/surfaceflinger/DdmConnection.cpp
JavaVMOption opt[4];
opt[0].optionString = "-Djava.class.path=/data/local/tmp/shim_app.apk";
opt[1].optionString = "-agentlib:jdwp=transport=dt_android_adb,suspend=n,server=y";
opt[2].optionString = "-Djava.library.path=/data/local/tmp";
opt[3].optionString = "-verbose:jni"; // may want to remove this, it's noisy
JavaVMInitArgs args;
args.version = JNI_VERSION_1_6;
args.options = opt;
args.nOptions = 4;
args.ignoreUnrecognized = JNI_FALSE;
void *libdvm_dso = dlopen("libdvm.so", RTLD_NOW);
void *libandroid_runtime_dso = dlopen("libandroid_runtime.so", RTLD_NOW);
if (!libdvm_dso || !libandroid_runtime_dso) {
return -1;
}
JNI_CreateJavaVM_t JNI_CreateJavaVM;
JNI_CreateJavaVM = (JNI_CreateJavaVM_t) dlsym(libdvm_dso, "JNI_CreateJavaVM");
if (!JNI_CreateJavaVM) {
return -2;
}
registerNatives_t registerNatives;
registerNatives = (registerNatives_t) dlsym(libandroid_runtime_dso, "Java_com_android_internal_util_WithFramework_registerNatives");
if (!registerNatives) {
return -3;
}
if (JNI_CreateJavaVM(&(*p_vm), &(*p_env), &args)) {
return -4;
}
if (registerNatives(*p_env, 0)) {
return -5;
}
return 0;
}

在命令行调用 JNI 函数(参数可以包括 Java 对象)

当破解或者分析恶意代码时,可能关键的值(字符串)是在 Native层计算出来的,这时候我们有几种办法获取native 函数的返回值。

  1. hook native函数 [简单、不稳定、不方便]
  2. 静态分析 native 函数算法,重写方法 [复杂]
  3. 动态调试 [更不方便]
  4. 插桩 [不方便、校验完整性的防护]
  5. 创建一个可执行文件,加载目标 so 调用目标函数。通过命令行传递参数给目标函数。 [简单、有局限(无法创建 JNIEnv参数)]
  6. ….

下面我们会介绍 本文的技术来解决这个调用 jni 函数的问题。

首先下载我们的实验app, 使用下面的方法编译 apk.

1
2
3
4
5
git clone https://github.com/CalebFenton/native-harness-target.git
cd native-harness-target
echo 'ndk.dir=$ANDROID_NDK' > local.properties
echo 'sdk.dir=$ANDROID_SDK' >> local.properties
./gradlew build

APKs 输出目录:app/build/outputs/apk/

实验的设备:Nexus5
系统版本:android 6.0.1

Harness 服务端工具

Harness的灵感来自 shim项目,shim 的功能是加载 library 并调用其 JNI_OnLoad 函数。这样就简化了调试工作,现在只需要让调试器去启动 shim 并通过参数传递要目标 library,然后使用调试器下断点并 绕过 JNI_OnLoad。

首先我们需要使用上一节介绍的技术,在 native 层创建 java vm,并将 JavaVM 实例传递给 JNI_OnLoad 函数。

1
2
3
4
5
6
7
8
9
10
11
12
printf(" [+] Initializing JavaVM Instance\n");
JavaVM *vm = NULL;
JNIEnv *env = NULL;
int status = init_jvm(&vm, &env);
if (status == 0) {
printf(" [+] Initialization success (vm=%p, env=%p)\n", vm, env);
} else {
printf(" [!] Initialization failure (%i)\n", status);
return -1;
}
printf(" [+] Calling JNI_OnLoad\n");
onLoadFunc(vm, NULL);

最终在代码中开启一个 socket, 通过这个 socket 读取参数作为目标函数的参数。使用 python 脚本可以很容易与它进行交互。

python 脚本就是 在PC 上执行的 客户端了。

逆向 Dex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.local v1, "encryptedStringBytes":[B
invoke-static {}, Lorg/cf/nativeharness/Cryptor;->getInstance()Lorg/cf/nativeharness/Cryptor;
move-result-object v0
.line 21
.local v0, "c":Lorg/cf/nativeharness/Cryptor;
# v3 contains a String made from encrypted bytes
new-instance v3, Ljava/lang/String;
invoke-direct {v3, v1}, Ljava/lang/String;-><init>([B)V
# Call the decryption method, move result back to v3
invoke-virtual {v0, v3}, Lorg/cf/nativeharness/Cryptor;->decryptString(Ljava/lang/String;)Ljava/lang/String;
move-result-object v3

注意:这里调用 native 函数的指令是invoke-virtual, 这个指令一般用来调用实例方法,虚方法等。指令的第一个参数就是 class 实例。

注意:native 方法 和 invoke-virtual 没有直接联系。存在 static native 的方法,这时候使用invoke-static,如下:

1
2
3
4
5
6
7
8
9
private static native void post_arm64Load0(Library this, long arg1) {
}
.method private static native post_arm64Load0(J)V
.end method
调用代码
00000060 iget-wide v2, v0, Library->a:J
00000064 invoke-static Library->post_arm64Load0(J)V, v2, v3


下一步 我们要查看目标 jni 函数的签名,两种方法:

  1. 通过反编译 so
  2. 但是方法1有可能 so 做了加固,查看不到函数签名;所以要使用 javah 生成头文件
    1
    2
    3
    $ d2j-dex2jar.sh app-universal-debug.apk
    dex2jar app-universal-debug.apk -> ./app-universal-debug-dex2jar.jar
    $ javah -cp app-universal-debug-dex2jar.jar:$ANDROID_SDK/platforms/android-19/android.jar org.cf.nativeharness.Cryptor

jni 函数前2个参数是固定的,从第3个参数开始是应用的参数。
这个 jobject 参数应该是 org.cf.nativeharness.Cryptor实例,实际的函数签名:
JNIEXPORT jstring JNICALL Java_org_cf_nativeharness_Cryptor_decryptString (JNIEnv *, jobject, jstring);

定义目标函数类型:
typedef jstring(*decryptString_t)(JNIEnv *, jobject, jstring);

定义一个 server socket

1
2

准备:

1. 将目标 apk(或者 Dex、Jar)放在`/data/local/tmp/target-app.apk`,创建 VM 时的参数(`-Djava.class.path=/data/local/tmp/target-app.apk`)
2. 将需要的 native 库放在 `/data/local/tmp` (`-Djava.library.path=/data/local/tmp`)
3. 使用客户端  python 脚本时,需要 adb forward tcp:5001 tcp:5001

harness.c 文件主要负责:

  1. 加载目标native 动态库
  2. 调用 native 中的 JNI_OnLoad 函数
  3. 创建 VM 虚拟机,加载目标 Dex 和 libs
  4. [可选] 如果不是 static native 方法,需要创建实例对象 jobject, 如果是 static 方法可以给 jobject 传递 NULL.
  5. 开启 TCP socket server (5001端口)

server.c 文件功能:

  1. 绑定 TCP 服务到5001 端口, 负责从客户端接受 参数传递个解密函数。

使用:

harness 开启服务
decrypt_string.py

介绍完整个使用流程,第一感觉就是复杂,每次添加目标函数都要重新编译 harness,还不如 直接 frida-hook。确实,这种方法的好处是 适合解密量较大的情况。

如果要是换个 apk 破解,需要修改流程:

  1. 找到要调用的 jni 函数,参看签名信息(是否需要创建 jobject)
  2. [可选]在 harness.c 中 创建 class 实例
  3. server.c 中修改 调用目标函数的代码。
    注释不要删。。

测试阶段:

  1. 编译 apk 。。。

ChangeLog

Time Change
2017-4-19 创建
测试 art 模式