StringFog插件對Dex字符串加密原理解析
Android應用的加固和逆向一直以來都是大家研究的熱點問題之一,加密與破解之間的攻防更是戰得如火如荼。雖然其間誕生出了Dex加殼、res混淆等技術,但是實際上應用并不廣泛,一是由于大部分防逆向服務都是收費的,二是性能影響較大,三是打包流程操作復雜。市場上大部分的App都是沒有做任何的逆向防御,在Jadx、ApkTool等逆向工具面前,幾乎同沒穿衣服的女人一樣毫無隱私。當然,具體的逆向技術我們不再深入討論,還是切入本篇博客的正題:對Dex中字符串加密。
在絕大多數的Android應用當中,很多隱私信息都是以字符串的形式存在的,比如接入的第三方平臺的AppId、AppSecret,又比如接口地址字段等等,這些一般都是明文存在的。如果我們能在打包時對Dex中的字符串加密替換,并在運行時調用解密,這樣就能夠避免字符串明文存在于Dex中。雖然,無法完全避免被破解,但是加大了逆向提取信息的難度,安全性無疑提高了很多。
這一類似技術其實已經有大廠實現并應用了,比如網易云音樂,我們使用Jadx查看應用內容時,發現幾乎所有字符串都做了加密處理,情況如下:

對于字符串加密的處理,一般來說有兩種思路。
1、在開發階段開發者使用加密后的字符串然后手動調用解密。這無疑是最簡單的方式,不過維護性差,工作量大,而且對于應用中成千上萬的字符串如果全部加密人工耗時巨大。
2、編譯后修改字節碼,動態植入加密后的字符串并自動調用解密。這是最智能的方式,也不影響正常開發,不過實現起來稍有難度。
對于第一種方式,大家或多或少可能都使用過,這里不多講,本文的重點是研究第二種方式,簡稱StringFog,源碼已經開源至Github,供大家參考: https://github.com/MegatronKing/StringFog
一、加密方式
數據加解密方式有很多種,考慮到性能和實現問題,這里使用對稱加密,StringFog使用的是Base64 + XOR算法。
先來看下經典的異或算法,這里通過對待加(解)密數據與一個字符串循環異或達到簡單加(解)密的處理,代碼如下:
private static byte[] xor(byte[] data, String key) {
int len = data.length;
int lenKey = key.length();
int i = 0;
int j = 0;
while (i < len) {
if (j >= lenKey) {
j = 0;
}
data[i] = (byte) (data[i] ^ key.charAt(j));
i++;
j++;
}
return data;
}
加密時對數據進行異或得到加密數據,解密時對數據再次進行異或得到解密數據。同時考慮到字符編碼的特性,需要使用Base64做編(解)碼處理:
public static String encode(String data, String key) {
return new String(Base64.encode(xor(data.getBytes(), key), Base64.NO_WRAP));
}
public static String decode(String data, String key) {
return new String(xor(Base64.decode(data, Base64.NO_WRAP), key));
}
這樣,既解決了字符編碼的問題,又解決了加解密的問題(注意Base64嚴格意義上來說并非屬于加密算法),而且在性能上又得到了可靠的保證。
二、字節碼植入
對Dex中的字符串進行查找和替換不難,但是同時還要植入解密調用就不太容易實現了。但是,如果對編譯后Dex前的字節碼文件進行操作就相對容易多了,而且對此有強大的ASM包可以使用,著名的熱修復框架Nuwa在解決類ISPREVERIFIED標記是也是這樣處理的,下面我們來看下實現。
1、Gradle Android的transform機制
使用Gradle進行Android項目編譯和打包時,為了提供更好的自定義任務操作,Gradle Android插件提供了強大的transform機制,可以對字節碼文件和資源文件做自定義操作。比如進行Jar包合并、MultiDex拆分、代碼混淆等都是通過這種機制來實現的。比較細心的童鞋會發現,執行編譯或者打包時能夠看到如下任務流:
:app:transformClassesWithJarMergingForDebug
:app:transformClassesWithMultidexlistForDebug
:app:transformClassesWithDexForDebug
執行這些任務,會在build/intermediates/transforms目錄下看到相應的transform文件夾,具體原理不細說了,感興趣的自行研究。
所以,我們可以通過自定義transform操作,來對字節碼文件使用ASM庫進行改寫。Gradle Android插件也提供了相應的API給我們進行此類擴展。
def android = project.extensions.android
android.registerTransform(new StringFogTransform(project))
這兩行代碼是Groovy語言,自定義Gradle插件都會用到,相比Java語言更加簡潔和易操作。
第一行代碼是獲取Android插件的Extension,對應于我們常見的build.gradle腳本里的這種:
android {
...
}
對應的類是com.android.build.gradle.AppExtension,其繼承了父類的registerTransform方法,意思就是注冊一個transform處理類,這里我們注冊的是StringFogTransform。
class StringFogTransform extends Transform {
private static final String TRANSFORM_NAME = 'stringFog'
@Override
String getName() {
return TRANSFORM_NAME
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return ImmutableSet.of(QualifiedContent.DefaultContentType.CLASSES)
}
}
所有的自定義的處理類都必須繼承Transform類,同時需要復寫相應的幾個方法。
首先,定義Transform的名字,我們使用項目的名字stringFog。
其次,定義輸入類型,一共有兩種,分別是CLASSES和RESOURCES,我們希望操作的是字節碼,所以使用CLASSES。
這樣就自動創建并加入了名為transformClassesWithStringFogFor v a r i a n t 的 任 務 , 其 中
{variant}指的是buildTypes,一般為Debug或者Release。
Transform還有幾個待實現的方法,主要定義作用域和模式,這里略過不細說,重點來看一下transform方法的實現。
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
def dirInputs = new HashSet<>()
def jarInputs = new HashSet<>()
// Collecting inputs.
transformInvocation.inputs.each { input ->
input.directoryInputs.each { dirInput ->
dirInputs.add(dirInput)
}
input.jarInputs.each { jarInput ->
jarInputs.add(jarInput)
}
}
// transform classes and jars
...
}
需要transform的文件有兩類。一類是當前項目Java文件編譯后的class字節碼文件,路徑存放在directoryInputs屬性中;一類是通過依賴引用的jar(aar)包,路徑存放在jarInputs屬性中。我們將其遍歷出來放入我們定義的Set集合中,方便后續操作。
在獲取到classes和jars的文件路徑后,我們就可以通過ASM庫來修改字節碼文件了,分別調用了下面兩個方法:
StringFogClassInjector.doFog2Class(fileInput, fileOutput, mKey)
StringFogClassInjector.doFog2Jar(jarInputFile, jarOutputFile, mKey)
其中mKey就是我們指定的加密key了。
2、字節碼修改與植入
StringFogClassInjector類提供的兩個方法doFog2Class和doFog2Jar最終都是調用的processClass方法:
private static void processClass(InputStream classIn, OutputStream classOut, String key) throws IOException {
ClassReader cr = new ClassReader(classIn);
ClassWriter cw = new ClassWriter(0);
ClassVisitor cv = ClassVisitorFactory.create(cr.getClassName(), key, cw);
cr.accept(cv, 0);
classOut.write(cw.toByteArray());
classOut.flush();
}
這里就是關于ASM庫相關的處理了,我們使用ClassVisitor來操作字節碼文件然后重新寫入。由于要針對不同的類做不同的處理邏輯,這里使用ClassVisitorFactory靜態工廠創建不同的ClassVisitor對象。
public final class ClassVisitorFactory {
public static ClassVisitor create(String className, String key, ClassWriter cw) {
if (Base64Fog.class.getName().replace('.', '/').equals(className)) {
return new Base64FogClassVisitor(key, cw);
}
if (WhiteLists.inWhiteList(className, WhiteLists.FLAG_PACKAGE) || WhiteLists.inWhiteList(className, WhiteLists.FLAG_CLASS)) {
return createEmpty(cw);
}
return new StringFogClassVisitor(key, cw);
}
public static ClassVisitor createEmpty(ClassWriter cw) {
return new ClassVisitor(Opcodes.ASM5, cw) {
};
}
}
工廠會創建三種類型的ClassVisitor。一種是Base64FogClassVisitor,用來修改Base64Fog類的字節碼,主要目的是植入我們自定義的加解密key。一種是針對白名單機制的空ClassVisitor,像很多公用和知名的庫比如android.support等等,是不需要做字符串加密的,還有像BuildConfig類也不需要做加處理,這里會過濾掉。第三種就是我們要修改的類了,使用StringFogClassVisitor類來處理。
public class Base64FogClassVisitor extends ClassVisitor {
private static final String CLASS_FIELD_KEY_NAME = "DEFAULT_KEY";
private String mKey;
public Base64FogClassVisitor(String key, ClassWriter cw) {
super(Opcodes.ASM5, cw);
this.mKey = key;
}
@Override
public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
if (CLASS_FIELD_KEY_NAME.equals(name)) {
value = mKey;
}
return super.visitField(access, name, desc, signature, value);
}
}
在Base64Fog加解密類中,加解密key是定義在一個名叫DEFAULT_KEY的靜態常量中的,通過重寫visitField方法然后重寫賦值value就達到了修改的目的,這一步非常簡單。
下面來看有些復雜的StringFogClassVisitor類,在說這個類之前,我們先來分析下字符串在Java類中有哪些存在形式。
- A、靜態成員變量
- B、普通成員變量
- C、局部變量
從廣義上來說,分為以上三種。A形式存在于clinit方法中,B形式存在于init方法中,C形式存在于普方法中,相應的我們可以通過重寫visitMethod方法來訪問到。
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
if ("<clinit>".equals(name)) {
... // 處理靜態成員變量
} else if ("<init>".equals(name)) {
... // 處理成員變量
} else {
... // 處理局部變量
}
}
對于A和B兩種形式的成員變量,我們可以先通過visitField方法獲取到:
@Override
public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
if (ClassStringField.STRING_DESC.equals(desc) && name != null && !mIgnoreClass) {
// static final, in this condition, the value is null or not null.
if ((access & Opcodes.ACC_STATIC) != 0 && (access & Opcodes.ACC_FINAL) != 0) {
mStaticFinalFields.add(new ClassStringField(name, (String) value));
value = null;
}
// static, in this condition, the value is null.
if ((access & Opcodes.ACC_STATIC) != 0 && (access & Opcodes.ACC_FINAL) == 0) {
mStaticFields.add(new ClassStringField(name, (String) value));
value = null;
}
// final, in this condition, the value is null or not null.
if ((access & Opcodes.ACC_STATIC) == 0 && (access & Opcodes.ACC_FINAL) != 0) {
mFinalFields.add(new ClassStringField(name, (String) value));
value = null;
}
// normal, in this condition, the value is null.
if ((access & Opcodes.ACC_STATIC) != 0 && (access & Opcodes.ACC_FINAL) != 0) {
mFields.add(new ClassStringField(name, (String) value));
value = null;
}
}
}
由于所有的字符串成員變量最終都要修改成StringFog.decode(“xxxx”)這種靜態解密調用,所以value需要全部置null,然后在clinit和init的訪問器的visitLdcInsn方法中重寫:
@Override
public void visitLdcInsn(Object cst) {
if (cst != null && cst instanceof String && !TextUtils.isEmptyAfterTrim((String) cst)) {
super.visitLdcInsn(Base64Fog.encode((String) cst, mKey));
super.visitMethodInsn(Opcodes.INVOKESTATIC, BASE64_FOG_CLASS_NAME, "decode", "(Ljava/lang/String;)Ljava/lang/String;", false);
}
}
有一點需要注意的是如果字節碼中沒有clinit方法,我們需要在visitEnd方法中手動植入一個并添加字符串常量的修改:
@Override
public void visitEnd() {
if (!mIgnoreClass && !isClInitExists && !mStaticFinalFields.isEmpty()) {
MethodVisitor mv = super.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
mv.visitCode();
// Here init static final fields.
for (ClassStringField field : mStaticFinalFields) {
if (field.value == null) {
continue; // It could not be happened
}
mv.visitLdcInsn(Base64Fog.encode(field.value, mKey));
mv.visitMethodInsn(Opcodes.INVOKESTATIC, BASE64_FOG_CLASS_NAME, "decode", "(Ljava/lang/String;)Ljava/lang/String;", false);
mv.visitFieldInsn(Opcodes.PUTSTATIC, mClassName, field.name, ClassStringField.STRING_DESC);
}
mv.visitInsn(Opcodes.RETURN);
mv.visitMaxs(1, 0);
mv.visitEnd();
}
super.visitEnd();
}
到這里,整個字節碼的修改就差不多完成了,當然還有些細節處理就不多說了。
來自:http://blog.csdn.net/megatronkings/article/details/63252266