用Native方法加密APK包所有字符串,安全性拉满
/ 今日科技快讯 /
在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运行时解密等..)等。
推荐阅读:
来自Twitter的17条Compose开发规范和检查工具,帮你避坑
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注