Android JNI接口混淆

1,085 阅读8分钟

JNI混淆的问题

首先演示混淆存在的问题, 使用Android Studio新建一个模板为Native C++ 的app项目, 名字为JNIDemo

修改app/build.gradle, 使用官方默认的minifyEnabled

buildTypes {
  release {
    minifyEnabled true
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
    proguardFiles 'proguard-rules.pro'
  }
}

然后运行, 使用jadx-gui看看反编译的结果, native方法并未被混淆

jadx-gui ./app/build/outputs/apk/release/app-release-unsigned.apk

image-20231222103806522.png 再来看看动态库的情况, 方法名也清晰可见

# 使用find . -name "*.so"搜索动态库的输出目录
# nm -D 是输出动态库的动态符号, 也可使用readel或objdump
nm -D ./app/build/xxxx/libjnidemo.so
0000000000015200 T Java_com_test_jnidemo_MainActivity_stringFromJNI
000000000002ca10 T _ZNKSt10bad_typeid4whatEv
000000000002c8e8 T _ZNKSt13bad_exception4whatEv
000000000002c948 T _ZNKSt20bad_array_new_length4whatEv
000000000002c9b4 T _ZNKSt8bad_cast4whatEv
000000000002c918 T _ZNKSt9bad_alloc4whatEv

是不是一目了然, MainActivity.java和动态库的反编译接口都在裸奔

接下来, 把app/build.gradle稍微修改一下, 注释系统规则

buildTypes {
  release {
    minifyEnabled true
    // proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
    proguardFiles 'proguard-rules.pro'
  }
}

反编译看一下, java层的native方法被混淆

image-20231222105309292.png

但是app这个时候跑不起来了, 有两个原因

  • 系统自带的库被混淆, 导致运行出错, 解决方式是在Android SDK路径下找到proguard-android-optimize.txt, 然后将内容拷贝到proguard-rules.pro, 并且注释掉native的规则. 但是这种方法不推荐, 注释掉native的规则也会使其它native依赖库出现问题, 所以最好是将native库从app分离出来, 单独创建个native library, 并且使用独立的混淆规则
# proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
find ~/Library/Android/sdk -name "proguard-android-optimize.txt"
​
# ~/Library/Android/sdk/tools/proguard/proguard-android-optimize.txt
-keepclasseswithmembernames class * {
    native <methods>;
}
  • 查看动态库的符号, 符号并没有改变, 还是stringFromJNI, 所以导致虚拟机找不到对应的Java_com_test_jnidemo_MainActivity_o的实现
nm -D ./app/build/xxxx/libjnidemo.so
0000000000015200 T Java_com_test_jnidemo_MainActivity_stringFromJNI
000000000002ca10 T _ZNKSt10bad_typeid4whatEv
000000000002c8e8 T _ZNKSt13bad_exception4whatEv
000000000002c948 T _ZNKSt20bad_array_new_length4whatEv
000000000002c9b4 T _ZNKSt8bad_cast4whatEv

所以使用系统自带的混淆行不通, 第一个问题可以创建SDK解决, 但第二不行, 反编译动态库, 接口还是暴露的, 接下来我们自己实现插件来混淆

实现一个简单的插件

首先使用kotlin创建一个简单的插件框架, groovy的提示确实不太友好

使用shell手动创建需要的工程文件

# 进入Project目录
mkdir -p plugins/messplugin/src/main/kotlin/com/test/plugin
touch plugins/settings.gradle.kts
touch plugins/messplugin/build.gradle.kts
# 实现混淆的主体
touch plugins/messplugin/src/main/kotlin/com/test/plugin/MessPlugin.kt
# 用户的配置
touch plugins/messplugin/src/main/kotlin/com/test/plugin/MessExtension.kt

plugins是插件的工程目录, messplugin就是需要实现的混淆插件

在JNIDemo工程的settings.gradle中添加includeBuild("./plugins") , 后sync一下

// ...
rootProject.name = "JNIDemo"
include ':app'
includeBuild("./plugins")

这里也可以使用buildSrc的方式, 或是include(":plugins:messplugin")导入, 但是这两种方法会导致, 每次只要修改插件的代码, 都会使得所有代码完全编译一遍, 时间很慢, 小项目还好, 大项目是很蛋疼的. 所以这里采用了gradle的复合编译, 当然速度快只是其中一个优点, 官方说明文档 Demo

plugins/settings.gradle.kts

rootProject.name = "plugins"
include(":messplugin")

plugins/messplugin/build.gradle.kts

buildscript {
    repositories {
        google()
        mavenCentral()
    }
    dependencies {
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20")
        classpath("com.android.tools.build:gradle:8.2.0")
    }
}
​
plugins {
    `kotlin-dsl`
    `java-gradle-plugin`
}
​
repositories {
    google()
    mavenCentral()
}
​
dependencies {
    implementation("com.android.tools.build:gradle:8.2.0")
    // 解析mapping.txt需要的库
    implementation("com.guardsquare:proguard-gradle:7.4.1")
}
​
sourceSets {
    main {
        kotlin {
            srcDirs("src/main/kotlin")  //插件的源码目录
        }
    }
}
​
// 注册插件, 使得其它工程可以导入
gradlePlugin {
    plugins {
        create("messplugin") {
            id = "messplugin"
            implementationClass = "com.dc.plugin.MessPlugin"
        }
    }
}

plugins/messplugin/src/main/kotlin/com/test/plugin/MessExtension.kt

package com.test.pluginclass MessExtension {
    var classAndNative: Map<String, String> ? = null
}

plugins/messplugin/src/main/kotlin/com/test/plugin/MessPlugin.kt

package com.test.plugin
​
import org.gradle.api.Plugin
import org.gradle.api.Project
​
class MessPlugin: Plugin<Project> {
    override fun apply(project: Project) {
        println("enter mess plugin")
​
        // 创建用户配置
        val messExtension = project.extensions
            .create("messConfig", MessExtension::class.java)
    }
}

好了, 插件的结构完成, 接下来看看如何使用

app/build.gradle

plugins {
    id 'com.android.application'
    id "messplugin" //添加这一行
}
messConfig {
    // 配置native注册类和实现的c文件
    classAndNative = ["com.test.jnidemo.MainActivity": "src/main/cpp/native-lib.cpp"]
}
...

使用Android Studio sync一下, 在Build窗口能看到下面的输出

> Task :plugins:messplugin:pluginDescriptors UP-TO-DATE
> Task :plugins:messplugin:processResources UP-TO-DATE
> Task :plugins:messplugin:compileKotlin
> Task :plugins:messplugin:compileJava NO-SOURCE
> Task :plugins:messplugin:classes UP-TO-DATE
> Task :plugins:messplugin:jar
> Task :plugins:messplugin:inspectClassesForKotlinIC
​
> Configure project :app
enter mess plugin

简单的插件就完成了~~~

实现插件混淆

plugins/messplugin/src/main/kotlin/com/test/plugin/MessPlugin.kt

代码只说明流程, 有些异常和判断需要另外处理

package com.test.plugin
​
import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.configurationcache.extensions.capitalized
import proguard.obfuscate.MappingProcessor
import proguard.obfuscate.MappingReader
import java.io.File
​
class MessPlugin : Plugin<Project> {
​
    class Config(
        val className: String, // 被混淆的类
        val nativePath: String // 对应的c文件路径
    ) 
    {
        // className被混淆的新类
        var newClassName: String? = null
​
        // 存储了原方法与混淆方法的对应关系
        var methods: MutableMap<String, String> = mutableMapOf()
​
        // 源码的备份路径, 比如 native-lib.cpp~
        var backupPath: String? = null
        override fun toString() = "$className, $nativePath, $newClassName, $methods"
    }
​
    // 存储了所有混淆类的配置
    // [com.test.jnidemo.MainActivity, /xxx/src/main/cpp/native-lib.cpp,
    // com.test.jnidemo.MainActivity, {stringFromJNI=o}]
    private val configs = mutableListOf<Config>()
​
    override fun apply(project: Project) {
        println("enter mess plugin")
​
        // 创建用户配置
        val messExtension = project.extensions
            .create("messConfig", MessExtension::class.java)
​
        project.afterEvaluate {
            // 将app/build.gradle messConfig配置存储起来
            messExtension.classAndNative!!
                .forEach { (className, nativePath) ->
                    configs.add(
                        Config(className, "${projectDir}/${nativePath}")
                    )
                }
​
            // 获取当前的构建信息
            val releaseVariant = extensions
                .getByType(AppExtension::class.java)
                .applicationVariants.firstOrNull {
                    it.buildType.name.capitalized() == "Release"
                }!!
            // 开启了minifyEnabled后, 会生成mapping.txt
            val mappingFile = releaseVariant.mappingFile
            // 这是编译c代码的task, 不同的gradle版本, 可能不一样, debug模式也不一样
            val nativeBuildTask = tasks
                .findByName("buildCMakeRelWithDebInfo[arm64-v8a]")!!
            // 这是系统混淆的task
            val proguardTask = tasks
                .findByName("minifyReleaseWithR8")!!
​
            // 使native编译在java类混淆之后运行, 应该需要解析mapping后替换
            nativeBuildTask.dependsOn(proguardTask)
​
            nativeBuildTask.doFirst {
                // 编译前解析mapping文件, 和替换c源码
                parseMapping(mappingFile)
                replaceNativeSource()
            }
            nativeBuildTask.doLast {
                // 编译完c文件后, 恢复替换的代码
                restoreNativeSource()
            }
        }
    }
​
    // 解析mapping文件
    private fun parseMapping(mappingFile: File) {
        MappingReader(mappingFile).pump(
            object : MappingProcessor {
                override fun processClassMapping(
                    className: String,
                    newClassName: String
                ): Boolean {
                    // 如果发现配置的类, 则返回true
                    // 如果返回false, processMethodMapping就不会运行
                    return configs.firstOrNull {
                        it.className == className
                    }?.let {
                        it.newClassName = newClassName
                    } != null
                }
​
                override fun processFieldMapping(
                    className: String,
                    fieldType: String,
                    fieldName: String,
                    newClassName: String,
                    newFieldName: String
                ) {
                }
​
                override fun processMethodMapping(
                    className: String,
                    firstLineNumber: Int,
                    lastLineNumber: Int,
                    methodReturnType: String,
                    methodName: String,
                    methodArguments: String,
                    newClassName: String,
                    newFirstLineNumber: Int,
                    newLastLineNumber: Int,
                    newMethodName: String
                ) {
                    // 如果混淆前和混淆后一样, 跳过, 比如构造方法
                    if (methodName == newMethodName) return
                    // 记录类的混淆方法对应关系
                    configs.firstOrNull {
                        it.className == className
                    }?.apply {
                        methods[methodName] = newMethodName
                    }
                }
            })
        println("configs: $configs")
    }
​
    // 在编译c文件前备份和替换
    private fun replaceNativeSource() {
        configs.forEach {
            val nativeFile = File(it.nativePath).apply {
                // 备份文件添加~
                // native-lib.cpp -> native-lib.cpp~
                it.backupPath = "${absolutePath}~"
                copyTo(File(it.backupPath!!), true)
            }
​
            var source = nativeFile.readText()
            if (it.newClassName != null) {
                // 动态注册的类是"com/test/tokenlib/NativeLib"
                // 这里是放类换成混淆后的字符串
                val realClassName = it.className
                    .replace(".", "/")
                val realNewClassName = it.newClassName!!
                    .replace(".", "/")
                source = source.replace(
                    ""$realClassName"",
                    ""$realNewClassName""
                )
            }
​
            it.methods.forEach { (oldMethod, newMethod) ->
                // 这个是替换混淆方法
                source = source.replace(
                    ""$oldMethod"",
                    ""$newMethod""
                )
            }
            nativeFile.writeText(source)
        }
    }
​
    // 编译完成后恢复原来的c文件
    private fun restoreNativeSource() {
        configs.filter {
            it.backupPath != null
        }.forEach {
            File(it.backupPath!!).apply {
                // 恢复并删除备份文件
                copyTo(File(it.nativePath), true)
                delete()
            }
        }
    }
}

说明下流程

  • 首先获取用户的配置messConfig, 并存储到configs
  • 获取native编译和混淆的task, 并且使native编译在混淆之后运行
  • 在native编译之前, 通过mapping.txt解析混淆的类和方法, 并替换native代码
  • native编译之后还原代码

src/main/cpp/native-lib.cpp

#include <jni.h>
#include <string>extern "C" 
// JNIEXPORT
jstring
// JNICALL
stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}
​
const JNINativeMethod gMethods[] = {
        {"stringFromJNI", "()Ljava/lang/String;", (void *) stringFromJNI}
};
​
const char *gClassName = "com/test/jnidemo/MainActivity";
​
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env = NULL;
    if ((vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK)) return -1;
    jclass nativeClass = env->FindClass(gClassName);
    if (nativeClass == NULL) return -1;
    jint count = sizeof(gMethods)/ sizeof(gMethods[0]);
    if ((env->RegisterNatives(nativeClass, gMethods, count) < 0)) return -1;
    return JNI_VERSION_1_6;
}

这里使用了动态注册, 在插件进行了动态替换后, "stringFromJNI"变成了"o"

const JNINativeMethod gMethods[] = {
        {"o", "()Ljava/lang/String;", (void *) stringFromJNI}
};
const char *gClassName = "com/test/jnidemo/MainActivity";
// const char *gClassName = "a/c";

com/test/jnidemo/MainActivity也会被换成类似a/c, 但MainActivity被其它资源文件引用, 所以就没有混淆, 如果把MainActivity类换成NativeLib就会被混淆

stringFromJNI方法注释了两个修饰符

// 让符号保留, 这个肯定要去掉
#define JNIEXPORT  __attribute__ ((visibility ("default")))
// 没有定义内容
#define JNICALL

关于这个

{"stringFromJNI", "()Ljava/lang/String;", (void *) stringFromJNI}

" ()Ljava/lang/String; "是方法的签名, 如果不知道在怎么写, 可以通过命令获取

find . -name "MainActivity.class"
# ./app/build/intermediates/javac/release/classes/com/test/jnidemo/MainActivity.class
javap -s -p ./app/buid/xxxx/MainActivity.class
#  就能拿到所有的方法签名
#  public native java.lang.String stringFromJNI();
#    descriptor: ()Ljava/lang/String;

src/main/cpp/CMakeLists.txt

cmake_minimum_required(VERSION 3.22.1)
project("jnidemo")
add_compile_options(-fvisibility=hidden) # 添加隐藏符号配置
add_library(${CMAKE_PROJECT_NAME} SHARED
        native-lib.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME})

结果

通过Android Studio运行app的assembleRelease看看结果

jadx-gui ./app/build/outputs/apk/release/app-release-unsigned.apk

image-20231222160036678.png

nm -D ./app/build/intermediates/merged_native_libs/release/out/lib/arm64-v8a/libjnidemo.so
000000000001532c T JNI_OnLoad
000000000002cb44 T _ZNKSt10bad_typeid4whatEv
000000000002ca1c T _ZNKSt13bad_exception4whatEv
000000000002ca7c T _ZNKSt20bad_array_new_length4whatEv
000000000002cae8 T _ZNKSt8bad_cast4whatEv
000000000002ca4c T _ZNKSt9bad_alloc4whatEv

Java_com_test_jnidemo_MainActivity_stringFromJNI也被隐藏, native-lib改为c后, 可以看源码, 反编译后

image-20231222163553094.png

真实的方法全是sub_xxx

大概就是这么多了

缺点是编译时涉及到源码的修改与还原, 大佬们有更好方案希望提供一下, 谢谢

调试源码: Github 源码

注: 方法并非原创, 只是原作者代码时代比较久远, 就总结了一下使用的流程

[Android JNI接口混淆方案]  github.com/qs00019/Mes… 

[​混淆的另一重境界]  www.jianshu.com/p/799e5bc62…

后续的优化版本:

Android JNI混淆 利用llvm的pass机制, 可以不替换源码的方式下进行接口混淆