查看原文
其他

用Native方法加密APK包所有字符串,安全性拉满

赶码人 郭霖
2024-07-19



/   今日科技快讯   /


在6月21日-23日的华为开发者大会2024上,新一代鸿蒙操作系统HarmonyOS Next(“纯血鸿蒙”)发布,其不再兼容安卓应用。余承东提到,鸿蒙原生应用已进入全面冲刺阶段,目前,TOP 5000应用已加入鸿蒙生态,满足用户使用时长99.9%,1500+应用已完成上架。当天,“纯血鸿蒙”正式开启开发者Beta(测试),并将于今年第四季度正式投入商用。


/   作者简介   /


本篇文章来自赶码人的投稿,文章主要分享了字符串解密和Kotlin字符串加密到Native层方法,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。


原文地址:

https://www.bilibili.com/read/cv35706656/


/   开始   /


StringFog可能是最知名的、开源的 Android Kotlin/Java层编译期字符串加密插件,只需几十秒接入到项目,便可抵挡静态分析一瞬;也有些团队是为降低马甲包被应用商店关联的概率而使用它。它的作者Github用户名是MegatronKing,他的另一个知名作品是抓包工具"小黄鸟",现在在独立做项目创业。


几个月前听说,现在有些工具已经可以一键还原纯Kotlin/Java层的字符串加密。


最近成功注销了王者55星的王者荣耀账号,因此有时间来分享一波对抗纯Kotlin/Java的字符串加密的思路,以及如何扩展StringFog来对抗"对静态分析的对抗"。可以参考Github页面的教程把StringFog接入到项目中。我使用的配置如下:


/   StringFog默认加密效果   /


可以参考Github页面的教程把StringFog接入到项目中。我使用的配置如下:


stringfog {
    implementation 'com.github.megatronking.stringfog.xor.StringFogImpl'
    kg new RandomKeyGenerator(16)
    enable true
    fogPackages = ['com.example', 'androidx.appcompat']


把打包出的Apk拖入到随便一个逆向工具,观察我们代码中字符串的位置,节选如下图:



try {
  fn.b(fn.a, e80.a("P+QA6o7uGflO\n", "XoV1jueBL80Rw0KJ+E8aoA==\n") + new File(e80.a("xrE6VR2k8+2t/yl/tZS0/YujIlMNqPHssvk=\n", "6cJDJmnBnsLBlktJgbvYlA==\n")).exists());
  fn.a(e80.a("qSnTn49py/Li\n", "yEim++YG+MC9/LotAVUWxw==\n") + new File(e80.a("HyHcEiTFskKOyL2w8EaN9lEnwQg/jqwC\n", "MFKlYVCg323iod+fnC/vlw==\n")).exists(), null);
  fn.a(e80.a("7TF1R2SNd3E=\n", "hEJDcybkAy4g42y1O5NQbw==\n") + Process.is64Bit(), null);
} catch (Exception e3) {
  Log.e(e80.a("SWSZ\n", "CBTpCmUw5TWaAD8Hd15N5Q==\n"), e80.a("KqpEDONKjGKa/A==\n", "RcQHfoYr+Aeg3HYQnEar+A==\n"), e3);
}
try {
  strArr = Build.SUPPORTED_ABIS;
  wx.b(strArr);
} catch (Exception e4) {
  Log.e(e80.a("iGSv\n", "yRTfE0FJLQYCNub41+h/Ng==\n"), e80.a("zzKlD5yvGTj6/g==\n", "oFzmffnObV3A3oSpY9tz4g==\n"), e4);
}
if (!c5.J(strArr, e80.a("eQY8\n", "AT4Kw9YJenj06t+Ecx7+LQ==\n")) && !c5.J(strArr, e80.a("HFMYf4wr\n", "ZGsuILofWy1NtQXvOdC3uQ==\n"))) {
  fn.b(fn.a, e80.a("/RXljZ4EohfmH3LlV1sZUqoo6p+tFKg=\n", "nHeM/sFnzXmSfhuLJARhag==\n"));
  es0Var = f2;


Kotlin源代码:



对照源代码,每处字符串都变成了调用e80.a(xx, yy)的形式,从Jadx看此函数实现:


public static String a(String str, String str2) {
  byte[] a2 = y5.a(str);
  byte[] a3 = y5.a(str2);
  int length = a2.length;
  int length2 = a3.length;
  int i = 0;
  int i2 = 0;
  while (i < length) {
    if (i2 >= length2) {
      i2 = 0;
    }
    a2[i] = (byte) (a2[i] ^ a3[i2]);
    i++;
    i2++;
  }
  return new String(a2, StandardCharsets.UTF_8);
}


它内部调用的函数除了java自己的String类构造函数, 只有y5.a,继续看y5.a函数实现。由于此函数过长,不完整贴出了,只需要注意到它内部未调用任何非JRE的其他函数(下段依此脱离Android环境运行)。


package defpackage;

public final class y5 {
// ... 


/   如何还原纯Kotlin/Java层的字符串加密   /


正如上一段的简单分析,完全可以把e80.a和y5.a这两个函数复制到Java工程中,然后主调调用这两个函数,就可以解出具体的字符串。但这样手动去做毫无意义,因为一个仅仅10M的App产品中就可能有几万个字符串。


众所周知Android项目的Kotlin/Java代码都会被编译为Java字节码(基于栈),然后再编译为Smali指令(基于寄存器),形成dex文件。从Jadx看到的伪代码,正是通过将dex文件转为smali指令,再进行分析的。回到Jadx工具,切换为smali视图:



可以明显看到,Java伪代码对应的smali代码,其中调用e80.a函数总是这样三条指令(还有move-result-object):


const-string A, "xx"
const-string B, "yy"
invoke-static {A, B}, Le80;->a(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;


那么我们完全可以写一段代码,解析一个项目中所有的smali指令。


首先通过baksmali工具将dex文件转化为smali代码。为了方便在Kotlin/Java工程中调用e80.a函数,还可以顺便转一份jar。



此时就可以创建一个脱离Android的项目了。转一份jar,是为了可以直接用类加载器加载e80.a函数,这样就不用手工把e80相关类复制到工程中。


import java.net.URL
import java.net.URLClassLoader

fun main(){
    val classLoader = URLClassLoader.newInstance(arrayOf(
            URL("file", null, "/home/k3x1n/Documents/demo/classes2-dex2jar.jar"),
            URL("file", null, "/home/k3x1n/Documents/demo/classes-dex2jar.jar")))

    val res = classLoader.loadClass("e80")
        .getDeclaredMethod("a", String::class.java, String::class.java)
        .invoke(null, "P+QA6o7uGflO\n", "XoV1jueBL80Rw0KJ+E8aoA==\n")

    println("res = $res")



成功输出了加密前的字符串:



接下来,写一段代码,找出所有e80#a调用,并且调用e80类的a函数:


import java.io.File
import java.net.URL
import java.net.URLClassLoader

fun main(){

    val classLoader = URLClassLoader.newInstance(arrayOf(
        URL("file", null, "/home/k3x1n/Documents/demo/classes2-dex2jar.jar"),
        URL("file", null, "/home/k3x1n/Documents/demo/classes-dex2jar.jar")))

    val smaliDir = File("/home/k3x1n/Documents/demo/out")
    val register = HashMap<String, String>()
    smaliDir.walk().forEach { f->
        if(f.isFile){
            f.readLines().forEach {
                val inst = it.trim()
                if(inst.startsWith("const-string")){
                    val part = inst.split(Regex("\\s"))
                    val reg = part[1].replace(",", "")
                    val value = part[2].replace("\"", "").replace("\\n", "\n")
                    register[reg] = value

                }else if(inst.endsWith("Le80;->a(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")){
                    val param = inst.substring(inst.indexOf("{") + 1, inst.indexOf("}")).split(", ")

                    try{
                        val res = classLoader.loadClass("e80")
                            .getDeclaredMethod("a", String::class.java, String::class.java)
                            .invoke(null, register[param[0]], register[param[1]])
                        println("res = $res")
                    }catch (e:Exception){
                        println("file: $f $inst \n${e.message}")
                    }

                }
            }
        }
    }





如图所示,成功自动解出了所有直接调用e80类加密的字符串,接下来为了方便使用Jadx静态分析,再写一小段代码,把move-result-object指令直接替换为const-string,然后重新把smali转换为dex。完整代码如下:


import java.io.File
import java.net.URL
import java.net.URLClassLoader

fun main(){
    val classLoader = URLClassLoader.newInstance(arrayOf(
        URL("file", null, "/home/k3x1n/Documents/demo/classes2-dex2jar.jar"),
        URL("file", null, "/home/k3x1n/Documents/demo/classes-dex2jar.jar")))

    val i = 1

    val smaliDir = File("/home/k3x1n/Documents/demo/out$i")
    val register = HashMap<String, String>()

    var nextConstValue : String? = null

    smaliDir.walk().forEach { f->
        if(f.isFile){
            val outputFile = File(f.absolutePath.replace("/out$i/", "/smali$i/"))
            if(outputFile.exists()){
                outputFile.delete()
            }
            outputFile.parentFile.mkdirs()

            f.readLines().forEach {
                val inst = it.trim()
                if(inst.startsWith("move-result-object") && nextConstValue != null){
                    val reg = inst.split(" ")[1]

                    val rawStr = nextConstValue!!
                        .replace("\\", "\\\\")
                        .replace("\n", "\\n")
                        .replace("\"", "\\\"")
                        .replace("\t", "\\t") //...

                    outputFile.appendText("    const-string $reg, \"$rawStr\"\n")
                    nextConstValue = null

                }else{
                    if(inst.startsWith("const-string")){
                        outputFile.appendText("$it\n")

                        val part = inst.split(Regex("\\s"))
                        val reg = part[1].replace(",", "")
                        val value = part[2].replace("\"", "").replace("\\n", "\n")
                        register[reg] = value

                    }else if(inst.endsWith("Le80;->a(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")){
                        val param = inst.substring(inst.indexOf("{") + 1, inst.indexOf("}")).split(", ")

                        try{
                            val res = classLoader.loadClass("e80")
                                .getDeclaredMethod("a", String::class.java, String::class.java)
                                .invoke(null, register[param[0]], register[param[1]]) as String
                            nextConstValue = res
                            println("res = $res")
                        }catch (e:Exception){
                            outputFile.appendText("$it\n")
                            println("file: $f $inst \n${e.message}")
                        }

                    }else{
                        outputFile.appendText("$it\n")

                    }

                }

            }
        }
    }




成功去除字符串加密,效果如图:



/   知名安全大厂的字符串加密效果   /


通过前文可以看到,StringFog默认的字符串加密效果,只能稍微阻挡不了解smali的普通码农,如果了解过一些JVM基本概念,就可以完美还原。它还把字符串变长了,仿佛白白增大了包体积。


给读者看张图,参考下某安全大厂旗下的某分身产品,他们的Kotlin/Java层字符串加密是如何做的,这也是我们最终要实现的效果。



/   扩展StringFog实现Java/Kotlin层字符串加密到Native   /


按照官方的自定义加密算法流程,要新建一个插件模块,实现IKeyGenerator和IStringFog接口。前者可以用一用,但后者我并不满意,因为它设计的方法,对我有些繁琐了。


/**
 * Interface of how to encrypt and decrypt a string.
 *
 * @author Megatron King
 * @since 2018/9/20 16:15
 */
public interface IStringFog {

    /**
     * Encrypt the data by the special key.
     *
     * @param data The original data.
     * @param key Encrypt key.
     * @return The encrypted data.
     */
    byte[] encrypt(String data, byte[] key);

    /**
     * Decrypt the data to origin by the special key.
     *
     * @param data The encrypted data.
     * @param key Encrypt key.
     * @return The original data.
     */
    String decrypt(byte[] data, byte[] key);

    /**
     * Whether the string should be encrypted.
     *
     * @param data The original data.
     * @return If you want to skip this String, return false.
     */
    boolean shouldFog(String data);



这里我们也直接新建buildSrc模块。这里我直接给出我IKeyGenerator的实现:NativeKeyGenerator,它把项目中的字符串生成对应的int值。也要同步修改主模块build.gradle的stringfog配置中的kg值。


package safe.string

import com.github.megatronking.stringfog.IKeyGenerator

class NativeKeyGenerator : IKeyGenerator {

    companion object{
        val map = HashMap<String, Int>()

    }

    @Synchronized
    override fun generate(text: String): ByteArray {
        var i = map[text]
        if(i == null){
            i = map.size
            map[text] = i
        }
        return i.toString().toByteArray()
    }



既然不想用IStringFog类,那么需要修改StringFog插件的核心源码了。StringFog分为多个模块,可能会把我们项目结构变得复杂,但实际上可以利用类加载机制相关原理动态替换需要修改的类(只需要相同包名类名直接放入buildSrc模块即可,它会比StringFog插件先被类加载器加载,从而实现替换的效果),而不需要拉取完整代码放入项目中。


我们的解密类如下,也就是StringFog通过ASM插入的要调用的函数:


package safe.string

import androidx.annotation.Keep

@Keep
object NativeStringFog {

    const val NATIVE_NAME = "string_safe"

    @Keep
    @JvmStatic
    external fun decrypt(key: Int): String



对应的native代码:这里直接让GPT造一份把UTF-8数组转Java字符串的代码,然后稍微改改。为了测试简单,直接使用静态注册了:


#include "string_safe.h"

#include <jni.h>
#include <string.h>
#include <stdlib.h>

JNIEXPORT jstring JNICALL
Java_safe_string_NativeStringFog_decrypt(JNIEnv *env, jobject thiz, jint key) {

    // UTF-8 byte array
    char* utf8Str = (char*)__string_safe_list[key];

    // Calculate length of the byte array
    jsize length = strlen(utf8Str);

    // Create a Java byte array
    jbyteArray javaBytes = (*env)->NewByteArray(env, length);

    // Set the Java byte array region with utf8Str
    (*env)->SetByteArrayRegion(env, javaBytes, 0, length, (jbyte *) utf8Str);

    // Find the Java String class and its constructor (byte[] -> String)
    jclass stringClass = (*env)->FindClass(env, "java/lang/String");
    jmethodID ctor = (*env)->GetMethodID(env, stringClass, "<init>", "([B)V");

    // Create the Java String object by calling the constructor
    jstring javaString = (*env)->NewObject(env, stringClass, ctor, javaBytes);

    // Clean up local references
    (*env)->DeleteLocalRef(env, stringClass);
    (*env)->DeleteLocalRef(env, javaBytes);

    return javaString;




然后在所有字符串使用前,尽早加载so。参考StringFog源码,复制一份

StringFogClassVisitor(以及它依赖的类)到buildSrc模块,先修改canEncrypted函数不加密"string_safe"(以免后面忘了..),因为我们调用System.loadLibrary("string_safe")之前无法解密字符串:


private boolean canEncrypted(String value) {
    return (value != null && value.trim().length() != 0)
            && value.length() < 65536 >> 2 && mStringFogImpl.shouldFog(value)
            && !value.equals("string_safe");


在主模块build.gradle编写在StringFog执行完ASM操作之后的任务:生成刚刚的c代码对应的头文件,只需要读NativeKeyGenerator的map成员:


tasks.register('makeSafeStringNativeHeader') {
    println("file = " + projectDir.parentFile)

    def headerFile = new File(projectDir.parentFile, "app/src/main/cpp/string_safe.h")
    if(!headerFile.exists()){
        headerFile.setText("const char* __string_safe_list[];")
    }

    doLast {
        def fileWriter = new FileWriter(headerFile)

        NativeKeyGenerator.map.forEach{k,v->
            // println("---> v: " + v + ", k: " + k)
            fileWriter.write("const char __string_safe_$v[] = {${k.getBytes().join(",")}, 0};\n")
        }

        fileWriter.write("const char* __string_safe_list[] = {\n")
        for(int i = 0; i < NativeKeyGenerator.map.size() ; i++){
            fileWriter.write("        __string_safe_$i,\n")
        }
        fileWriter.write("};\n")
        fileWriter.flush()

    }
}

afterEvaluate{
    transformClassesWithStringFogForDebug.finalizedBy makeSafeStringNativeHeader


复制StringFogClassGenerator到buildSrc模块,直接注释掉它generate方法所有内容,这里是用来在项目中插入StringFogWrapper类的,我们已经用不到它了。


刚刚修改过的StringFogClassVisitor类,注意要把它改为public修饰的,因为默认的包可见性只有同一类加载器加载的类才有效,否则过不去JVM的校验。然后简单起见,直接删掉对mode的判断,构造方法注释掉的代码如下,代码中其他位置顺着这个改动去修改:


/* package */ public StringFogClassVisitor(IStringFog stringFogImpl, StringFogMappingPrinter mappingPrinter,
                                    String fogClassName, ClassWriter cw, IKeyGenerator kg, StringFogMode mode) {
    super(Opcodes.ASM7, cw);
    this.mStringFogImpl = stringFogImpl;
    this.mMappingPrinter = mappingPrinter;
    this.mKeyGenerator = kg;
    /*fogClassName = fogClassName.replace('.', '/');
    mInstructionWriter = new NativeInstructionWriter(fogClassName);

    if (mode == StringFogMode.base64) {
        this.mInstructionWriter = new Base64InstructionWriter(fogClassName);
    } else if (mode == StringFogMode.bytes) {
        this.mInstructionWriter = new ByteArrayInstructionWriter(fogClassName);
    } else {
        throw new IllegalArgumentException("Unknown stringfog mode: " + mode);
    }*/
}


然后修改encryptAndWrite方法如下:


private void encryptAndWrite(String value, MethodVisitor mv) {
    /*byte[] key = mKeyGenerator.generate(value);
    byte[] encryptValue = mStringFogImpl.encrypt(value, key);
    String result = mInstructionWriter.write(key, encryptValue, mv);
    mMappingPrinter.output(getJavaClassName(), value, result);*/

    String nativeKey = new String(mKeyGenerator.generate(value));

    pushNumber(mv, Integer.parseInt(nativeKey));

    mv.visitMethodInsn(Opcodes.INVOKESTATIC, "safe/string/NativeStringFog",
            "decrypt", "(I)Ljava/lang/String;", false);

    mMappingPrinter.output(getJavaClassName(), value, nativeKey);



这里pushNumber方法哪里来的?它是ByteArrayInstructionWriter的内部方法,加上static直接放到外面就好了,需要用它给NativeStringFog的decrypt方法传入int类型参数。


完成这些步骤,重新打包运行,效果如图:



/   其他   /


上文介绍了将Kotlin/Java字符串加密到native层的关键步骤,以及涉及到的一些技巧。


还有一些坑要注意,比如如果在attachBaseContext加载so,参数要加问号(如下下图),否则kotlin会插入用于检查null抛出异常的指令(含字符串,风险如下图)



注意“checkNotNullParameter”。



其他坑就不替踩了。

如果想投入到正式项目中,还需要自行做一些事情:


  • 便于后续维护的整理(例如目前为了简单,破坏了StringFog的"mode"参数)
  • 字符串池优化(防止创建过多的String对象)
  • Native层防护(函数改为动态注册+strip符号、防动态调试、SO运行时解密等..)等。


推荐阅读:

我的新书,《第一行代码 第3版》已出版!

来自Twitter的17条Compose开发规范和检查工具,帮你避坑

解读Compose的项目中的知识点


欢迎关注我的公众号

学习技术或投稿



长按上图,识别图中二维码即可关注

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存