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

 

 本文由用戶 1034379238 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!