2023-04-16 22:59:59 +08:00
..
2023-04-16 22:59:59 +08:00

第十二章 逆向实战

12.1 案例实战

经过对AOSP源码的不断探索,对Android中应用深入了解,在最后一章中,将结合前文中学习的知识,来进行一个案例的实战,在实战的过程中,首先要对需求进行分析,然后将需要实现的目标进行拆分,最后对其逐一实现,最后组合并测试效果。

JniTrace是一个逆向分析中常用的基于frida实现的工具,在native函数调用时,c++代码可以通过JNI来访问Java中的类,类成员以及函数,而JniTrace工具主要负责对所有JNI的函数调用进行监控,输出所有调用的JNI函数,传递的参数,以及其返回值。

但是由于frida过于知名,导致大多数情况下,开发者们都会对其进行检测,所以在使用时常常会面临各种反制手段导致无法进行下一步的分析。为了能够躲避检测并继续使用JniTrace,逆向人员将其迁移到了更隐蔽的类Xposed框架中(例如LSPosed)。

而对比Hook的方案来说,从AOSP中修改,则完全不存在有Hook的痕迹,但是相对而言,开发也更沉重一些,因为需要对系统有一定的理解,并且需要重复的编译系统来进行测试。

在这一章的实战中,将讲解如何从AOSP的角度完成JniTrace这样的功能,并且使用配置进行管理,让其仅对目标进程生效,仅对目标native函数生效。

在前文讲解RegisterNative的输出时,注意到当时的处理将会对所有的进程生效,导致输出过于庞大,在,优化的处理也是从一个配置中,获取到当前进程是否为目标进程,才进行对应的打桩输出。在这个例子中的配置管理同样适用该优化。

12.2 需求

本案例的需求是参考JniTrace,修改AOSP源码实现对JNI函数调用的监控。所以第一步,是了解JniTrace,安装该工具,并开发简单的demo来测试其对JNI函数监控的效果。

12.2.1 功能分析

首先是安装JniTrace,该工具是使用python开发的,该工具是开源的,想要分析其实现的原理也非常方便,地址:https://github.com/chame1eon/jnitrace。安装起来非常方便,使用pip安装即可。

pip install jnitrace

由于该工具是基于frida实现的,需要在手机中运行frida-server,在地址https://github.com/frida/frida/releases中下载frida-server,开发环境是AOSP12的情况直接下载16任意版本即可。然后将其推送到手机的/data/local/tmp目录中,并运行。具体命令如下。

adb push ./frida-server-16.0.11-android-arm64 /data/local/tmp

adb forward tcp:27042 tcp:27042

adb shell 

su

cd /data/local/tmp

chmod +x ./frida-server-16.0.11-android-arm64

// 为防止出现错误先将selinux关闭
setenforce 0

./frida-server-16.0.10-android-arm64

JniTrace的启动环境准备就绪后,接下来准备测试的案例,案例实现如下。

public class MainActivity extends AppCompatActivity {
    static {
        System.loadLibrary("nativedemo");
    }
    private ActivityMainBinding binding;
    Button btn1;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        TextView tv = binding.sampleText;
        btn1=findViewById(R.id.button);
        btn1.setOnClickListener(v->{
            tv.setText(stringFromJNI());
        });
    }
    public String demo(){
        return "hello";
    }
    public native String stringFromJNI();
}

修改stringFromJNI的实现,让其通过JNI调用MainActivity中的demo函数。

extern "C" JNIEXPORT jstring JNICALL
Java_cn_mik_nativedemo_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject obj /* this */) {
    jclass cls= env->FindClass("cn/mik/nativedemo/MainActivity");
    jmethodID mid=env->GetMethodID(cls,"demo","()Ljava/lang/String;");
    jstring data= (jstring)env->CallObjectMethod(obj,mid);
    std::string datatmp= env->GetStringUTFChars(data,nullptr);
    return env->NewStringUTF(datatmp.c_str());
}

案例准备就绪后,接着通过命令,让JniTrace启动应用并监控JNI的调用,操作如下。

jnitrace -l libnativedemo.so cn.mik.nativedemo

默认会以spawn的方式进行附加,所以应用会自动拉起,点击按钮触发JNI调用,JniTrace则会输出日志如下。

        /* TID 6996 */
        
    309 ms [+] JNIEnv->FindClass							// 调用的JNI函数
    309 ms |- JNIEnv*          : 0x7d3892f610				// 参数1的类型和值
    309 ms |- char*            : 0x7c011aaf00				// 参数2的类型和值
    309 ms |:     cn/mik/nativedemo/MainActivity
    309 ms |= jclass           : 0x71    { cn/mik/nativedemo/MainActivity }// 参数3的类型和值
	// 下面是调用的堆栈
    309 ms ---------------------------------------Backtrace---------------------------------------
    309 ms |->       0x7c011919c4: _ZN7_JNIEnv9FindClassEPKc+0x2c (libnativedemo.so:0x7c01183000)
    309 ms |->       0x7c011919c4: _ZN7_JNIEnv9FindClassEPKc+0x2c (libnativedemo.so:0x7c01183000)

           /* TID 6996 */
    310 ms [+] JNIEnv->GetMethodID
    310 ms |- JNIEnv*          : 0x7d3892f610
    310 ms |- jclass           : 0x71    { cn/mik/nativedemo/MainActivity }
    310 ms |- char*            : 0x7c011aaf1f
    310 ms |:     demo
    310 ms |- char*            : 0x7c011aaf24
    310 ms |:     ()Ljava/lang/String;
    310 ms |= jmethodID        : 0x39    { demo()Ljava/lang/String; }

    310 ms ----------------------------------------------Backtrace----------------------------------------------
    310 ms |->       0x7c01191a0c: _ZN7_JNIEnv11GetMethodIDEP7_jclassPKcS3_+0x3c (libnativedemo.so:0x7c01183000)
    310 ms |->       0x7c01191a0c: _ZN7_JNIEnv11GetMethodIDEP7_jclassPKcS3_+0x3c (libnativedemo.so:0x7c01183000)

           /* TID 6996 */
    311 ms [+] JNIEnv->CallObjectMethodV
    311 ms |- JNIEnv*          : 0x7d3892f610
    311 ms |- jobject          : 0x7ff8e863e8
    311 ms |- jmethodID        : 0x39    { demo()Ljava/lang/String; }
    311 ms |- va_list          : 0x7ff8e861f0
    311 ms |= jobject          : 0x85

    311 ms ------------------------------------------------------Backtrace------------------------------------------------------
    311 ms |->       0x7c01191adc: _ZN7_JNIEnv16CallObjectMethodEP8_jobjectP10_jmethodIDz+0xc4 (libnativedemo.so:0x7c01183000)
    311 ms |->       0x7c01191adc: _ZN7_JNIEnv16CallObjectMethodEP8_jobjectP10_jmethodIDz+0xc4 (libnativedemo.so:0x7c01183000)

           /* TID 6996 */
    313 ms [+] JNIEnv->GetStringUTFChars
    313 ms |- JNIEnv*          : 0x7d3892f610
    313 ms |- jstring          : 0x85
    313 ms |- jboolean*        : 0x0
    313 ms |= char*            : 0x7c8893f330

    313 ms ------------------------------------------------Backtrace------------------------------------------------
    313 ms |->       0x7c01191b4c: _ZN7_JNIEnv17GetStringUTFCharsEP8_jstringPh+0x34 (libnativedemo.so:0x7c01183000)
    313 ms |->       0x7c01191b4c: _ZN7_JNIEnv17GetStringUTFCharsEP8_jstringPh+0x34 (libnativedemo.so:0x7c01183000)

           /* TID 6996 */
    314 ms [+] JNIEnv->NewStringUTF
    314 ms |- JNIEnv*          : 0x7d3892f610
    314 ms |- char*            : 0x7ff8e862c1
    314 ms |:     hello
    314 ms |= jstring          : 0x99    { hello }

    314 ms -----------------------------------------Backtrace-----------------------------------------
    314 ms |->       0x7c01191bdc: _ZN7_JNIEnv12NewStringUTFEPKc+0x2c (libnativedemo.so:0x7c01183000)
    314 ms |->       0x7c01191bdc: _ZN7_JNIEnv12NewStringUTFEPKc+0x2c (libnativedemo.so:0x7c01183000)

从日志中能非常清晰的看到JNI调用函数的具体参数和参数类型,被调用的Java函数,以及调用的堆栈等信息。在该工具分析时,能帮助逆向分析人员快速定位到JNI函数的调用位置。

12.2.2 模块划分

有了一个输出的样例作为参考后,就可以开始对该功能进行模块划分了,将一个完整的需求拆分为若干个小块,再针对每个小块逐步实现,下面是对功能进行细化的分割。

  • 配置管理,在进程启动后,在Java层中,读取配置文件,该配置信息中存储着需要被监控JNI调用的进程名称,需要被监控的动态库名称,以及需要监控的native函数(监控该函数调用中触发的所有JNI),将这些信息传递到AOSPnative中,并存储在一个全局都能很方便访问到的位置。

  • JNI调用分析,并进行打桩,从存储在某个全局的配置来判断当前调用是否应该输出,符合条件则打桩输出基本信息。

  • 打桩函数分类,由于JNI调用的各类函数需要输出的信息不一致,但大致的输出格式一致,所以要准备几种函数来分别处理。

  • 调用堆栈信息展示,为了便于追踪调用位置,所以需要输出其调用栈信息。

  • 解析参数的类型和值,进行细化输出信息,参考JniTrace的输出进行优化展示。

12.3 配置管理

12.3.1 配置文件的访问权限

既然是配置管理,那么肯定是从一个文件中读取数据,而该配置文件必须符合条件是所有APP应用都有权限读取,而在Android中,每个应用都有各自的用户身份,而不同用户之间的访问权限是受限的。在Android8以前,sdcard中还没有用户访问具体目录时,只要打开sdcard权限,即可访问同一个文件。但是在当前编译的AOSP12中已经无法访问sdcard下的任意文件了。

要解决这种各类应用访问同一个配置文件,有多种解决方式。例如通过自定义系统服务来访问具体文件,这样所有进程只要调用系统服务获取配置数据即可。例如通过共享内存,也可以达到相同的效果。

在这个案例中,将采用另一种简单的方式来解决该问题。在Android中有一个特殊的目录是/data/local/tmp,下面开始简单测试,在该目录创建一个文件。

echo "test" > /data/local/tmp/config.json

接着写一个简单的案例,来尝试在该目录读取测试文件。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    binding = ActivityMainBinding.inflate(getLayoutInflater());
    setContentView(binding.getRoot());

    String res= FileHelper.readTextFile("/data/local/tmp/mydemo");
    Log.i("MainActivity",res);
}

测试发现,能成功的读取到文件,这里的重点在于,该文件是shell身份创建的,不带有身份标识,所以只要有访问权限就能正常读取,selinux不会拦截该操作。而App应用创建的文件则无法进行读取。下面看看shell创建的文件和应用创建的文件之间的区别。

-rw-r--r-- 1 root    root    u:object_r:shell_data_file:s0                             5 2023-04-13 23:19 config.json
-rw-rw-rw- 1 u0_a240 u0_a240 u:object_r:shell_data_file:s0:c240,c256,c512,c768         4 2023-04-13 23:16 mydemo

可以看到mydemosetlinux安全策略限制了哪些用户才能访问该文件。因此对于配置文件的处理,只需要用shell创建即可满足条件。

12.3.2 配置文件的结构

为了访问方便,配置文件以json的格式进行存储,在执行进入应用主进程后,则读取该配置文件,然后再根据配置的值进行相应的处理。下面是该配置文件的内容。

[{"packageName":"cn.mik.nativedemo","isJNIMethodPrint":true,"isRegisterNativePrint":true,"jniModuleName":"libnativedemo.so","jniFuncName":"stringFromJNI"}]

为了便于访问,使用一个对应的类对象来解析该配置文件,类结构定义如下。

public class PackageItem {
    //应用包名
    public String packageName;
    //是否打印native函数注册
    public boolean isRegisterNativePrint;
    //是否打印JNI的函数调用
    public boolean isJNIMethodPrint;
    //监控触发JNI调用的模块名
    public String jniModuleName;
    //监控触发JNI调用的函数名
    public String jniFuncName;

    public PackageItem(){
        packageName="";
        jniModuleName="";
        jniFuncName="";
    }
}

12.3.3 解析配置文件

当任意应用程序启动到ActivityThread中的主进程入口时,就可以执行解析配置文件逻辑,然后进行相应的处理了,而在ActivityThreadApplication创建后调用的时机,和应用中的onCreate调用时机其实相差不大的,但是在测试的时候,在ActivityThread中写代码会导致每次修改后,要等待重新编译和刷机,所以完全可以选择先在正常的应用onCreate中写入要解析的代码,在最后流程完全跑通后,再将测试无误的代码放入ActivityThread中。

这里使用fastjson将配置文件内容解析成类对象,下面是解析的代码。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    binding = ActivityMainBinding.inflate(getLayoutInflater());
    setContentView(binding.getRoot());

    String packageName= this.getPackageName();
    String configJson= FileHelper.readTextFile("/data/local/tmp/config.json");
    if(configJson.isEmpty()){
        Log.i(TAG,"not found config json "+packageName);
        return;
    }
    if(!configJson.contains("{")){
        Log.i(TAG,"config data is error "+packageName);
        return;
    }

    List<PackageItem> packageItems= JSON.parseObject(configJson,new TypeReference<List<PackageItem>>(){});
    if(packageItems.size()<=0){
        Log.i(TAG,"not found config json parse "+packageName);
        return;
    }
}

12.3.4 配置参数的传递

由于JNI的调用部分是在native中进行,所以获取到的配置内容,需要将其传递到native层,并将其保存在一个可以全局访问的位置。便于后续打桩时获取配置的参数。

传递数据到native层,必然是需要新定义一个native函数,在这个案例实现中,在文件libcore/dalvik/src/main/java/dalvik/system/DexFile.java中添加了native函数实现配置数据的传递。修改如下。

public final class DexFile {
    ...
    @UnsupportedAppUsage
    private static native void initConfig(Object item);
}

接着找到其对应的实现文件art/runtime/native/dalvik_system_DexFile.cc,添加对应的实现。

static void
DexFile_initConfig(JNIEnv* env, jobject ,jobject item) {
    ...
}

static JNINativeMethod gMethods[] = {
  ...
  NATIVE_METHOD(DexFile, getDexFileOptimizationStatus,
                "(Ljava/lang/String;Ljava/lang/String;)[Ljava/lang/String;"),
  NATIVE_METHOD(DexFile, setTrusted, "(Ljava/lang/Object;)V"),
  NATIVE_METHOD(DexFile, initConfig,"(Ljava/lang/Object;)V"),
};

参数传递到native层后,需要将其保存到一个全局能够访问的位置,便于后续JNI触发时进行判断。在案例中,我选择将其存放在Runtime中。修改art/runtime/runtime.h文件如下。

typedef struct{
    char packageName[128];
    char jniModuleName[128];
    char jniFuncName[128];
    bool isRegisterNativePrint;
    bool isJNIMethodPrint;
    bool jniEnable;
}PackageItem;

class Runtime {
    ...
    public
    ...
        void SetConfigItem(PackageItem item){
            configItem=item;
        }

        PackageItem GetConfigItem(){
            return configItem;
        }
    ...
    private:
    	...
    	PackageItem configItem;
    	...
}

这样在能访问到Runtime的任意地方都能获取到该配置了。现在就可以实现前面的initConfig函数了,将java传递过来的对象,转换为c++对象存储到Runtime中。具体实现如下。


static void
DexFile_initConfig(JNIEnv* env, jobject ,jobject item) {

    Runtime* runtime=Runtime::Current();
    // 将各字段取出
    jclass jcInfo = env->FindClass("cn/krom/PackageItem");
    jfieldID jPackageName = env->GetFieldID(jcInfo, "packageName", "Ljava/lang/String;");
    jfieldID jJniModuleName = env->GetFieldID(jcInfo, "jniModuleName", "Ljava/lang/String;");
    jfieldID jJniFuncName = env->GetFieldID(jcInfo, "jniFuncName", "Ljava/lang/String;");
    jfieldID jIsRegisterNativePrint = env->GetFieldID(jcInfo, "isRegisterNativePrint", "Z");
    jfieldID jIsJNIMethodPrint = env->GetFieldID(jcInfo, "isJNIMethodPrint", "Z");

    PackageItem citem;
	// 将java的值转换为c++的值
    jstring jstrPackageName = (jstring)env->GetObjectField(item, jPackageName);
    const char* pPackageName = (char*)env->GetStringUTFChars(jstrPackageName, 0);
    strcpy(citem.packageName, pPackageName);

    jstring jstrJniModuleName = (jstring)env->GetObjectField(item, jJniModuleName);
    const char* pJniModuleName = (char*)env->GetStringUTFChars(jstrJniModuleName, 0);
    strcpy(citem.jniModuleName, pJniModuleName);

    jstring jstrJniFuncName = (jstring)env->GetObjectField(item, jJniFuncName);
    const char* pJniFuncName = (char*)env->GetStringUTFChars(jstrJniFuncName, 0);
    strcpy(citem.jniFuncName, pJniFuncName);

    citem.isRegisterNativePrint = env->GetBooleanField(item, jIsRegisterNativePrint);
    citem.isJNIMethodPrint = env->GetBooleanField(item, jIsJNIMethodPrint);
	
    // 配置存储到全局
    runtime->SetConfigItem(citem);
}

到这里就成功从配置文件中读取数据,并解析后通过native函数将其存储到全局能访问的位置了。

12.4 JNI调用分析

12.5 打桩函数分类

12.6 调用栈展示

12.7 解析参数和返回值