说明
这篇文章是上周六本人在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.a
和y5.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逆向工具破解。