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逆向工具破解。

相关推荐
闲暇部落15 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
长亭外的少年1 天前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
JIAY_WX1 天前
kotlin
开发语言·kotlin
麦田里的守望者江2 天前
KMP 中的 expect 和 actual 声明
android·ios·kotlin
菠菠萝宝2 天前
【YOLOv8】安卓端部署-1-项目介绍
android·java·c++·yolo·目标检测·目标跟踪·kotlin
恋猫de小郭2 天前
Kotlin Multiplatform 未来将采用基于 JetBrains Fleet 定制的独立 IDE
开发语言·ide·kotlin
枫__________2 天前
kotlin 协程 job的cancel与cancelAndJoin区别
android·开发语言·kotlin
鸠摩智首席音效师3 天前
如何在 Ubuntu 上配置 Kotlin 应用环境 ?
linux·ubuntu·kotlin
jikuaidi6yuan4 天前
Java与Kotlin在鸿蒙中的地位
java·kotlin·harmonyos
liulanba4 天前
Kotlin的data class
前端·微信·kotlin