Android字符串安全(一):如何逆向破解纯Kotlin/Java的字符串加密/混淆

说明

这篇文章是上周六本人在B站专栏发的,然后投稿到了一个Android领域知名公众号,现在再次编辑完善,发到掘金。以后就优先在这里写博客,主要原因是B站专栏在PC端网页中,和动态图文混在了一起,很难检索。其次是评论区敏感,触发了某些关键词不是被删,就是仅自己可见。最后是编辑器不支持markdown,甚至新版连插入代码的按钮都没了。

前言

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

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

最近正好有时间,来探索和分享对抗纯字符串加密的思路,以及如何扩展StringFog,让字符串变得更安全。

这个系列,后面预计还有两篇文章。

StringFog默认加密效果

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

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

用于演示的Kotlin源代码:

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

java 复制代码
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;
}

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

java 复制代码
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环境运行)。

JAVA 复制代码
package defpackage;

public final class y5 {
// ...

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

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

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

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

smali 复制代码
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相关类复制到工程中。

kotlin 复制代码
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函数:

kotlin 复制代码
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。完整代码如下:

kotlin 复制代码
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")

                    }

                }

            }
        }
    }


}

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

其他

这一篇先演示了纯Kotlin/Java字符串加密存在的风险,是如何被逆向的,下一篇将介绍如何扩展StringFog,来避免Kotlin/Java字符串被轻易被Java逆向工具破解。

相关推荐
Kapaseker12 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
A0微声z2 天前
Kotlin Multiplatform (KMP) 中使用 Protobuf
kotlin
alexhilton3 天前
使用FunctionGemma进行设备端函数调用
android·kotlin·android jetpack
lhDream3 天前
Kotlin 开发者必看!JetBrains 开源 LLM 框架 Koog 快速上手指南(含示例)
kotlin
RdoZam3 天前
Android-封装基类Activity\Fragment,从0到1记录
android·kotlin
Kapaseker4 天前
研究表明,开发者对Kotlin集合的了解不到 20%
android·kotlin
糖猫猫cc4 天前
Kite:两种方式实现动态表名
java·kotlin·orm·kite
如此风景4 天前
kotlin协程学习小计
android·kotlin
Kapaseker5 天前
你搞得懂这 15 个 Android 架构问题吗
android·kotlin
zh_xuan5 天前
kotlin 高阶函数用法
开发语言·kotlin