StringFog插件对Dex字符串加密原理解析

Android应用的加固和逆向一直以来都是大家研究的热点问题之一,加密与破解之间的攻防更是战得如火如荼。虽然其间诞生出了Dex加壳、res混淆等技术,但是实际上应用并不广泛,一是由于大部分防逆向服务都是收费的,二是性能影响较大,三是打包流程操作复杂。市场上大部分的App都是没有做任何的逆向防御,在Jadx、ApkTool等逆向工具面前,几乎同没穿衣服的女人一样毫无隐私。当然,具体的逆向技术我们不再深入讨论,还是切入本篇博客的正题:对Dex中字符串加密。

在绝大多数的Android应用当中,很多隐私信息都是以字符串的形式存在的,比如接入的第三方平台的AppId、AppSecret,又比如接口地址字段等等,这些一般都是明文存在的。如果我们能在打包时对Dex中的字符串加密替换,并在运行时调用解密,这样就能够避免字符串明文存在于Dex中。虽然,无法完全避免被破解,但是加大了逆向提取信息的难度,安全性无疑提高了很多。

这一类似技术其实已经有大厂实现并应用了,比如网易云音乐,我们使用Jadx查看应用内容时,发现几乎所有字符串都做了加密处理,情况如下:
StringFog插件对Dex字符串加密原理解析_第1张图片

对于字符串加密的处理,一般来说有两种思路。

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 getInputTypes() {
        return ImmutableSet.of(QualifiedContent.DefaultContentType.CLASSES)
      }

}

所有的自定义的处理类都必须继承Transform类,同时需要复写相应的几个方法。
首先,定义Transform的名字,我们使用项目的名字stringFog。
其次,定义输入类型,一共有两种,分别是CLASSES和RESOURCES,我们希望操作的是字节码,所以使用CLASSES。
这样就自动创建并加入了名为transformClassesWithStringFogForvariant{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 ("".equals(name)) {
        ... // 处理静态成员变量
    } else if ("".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, "", "()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

你可能感兴趣的:(Android笔记,android,加密,插件)