StringFog插件对Dex字符串加密原理解析
1034379238
8年前
<p>Android应用的加固和逆向一直以来都是大家研究的热点问题之一,加密与破解之间的攻防更是战得如火如荼。虽然其间诞生出了Dex加壳、res混淆等技术,但是实际上应用并不广泛,一是由于大部分防逆向服务都是收费的,二是性能影响较大,三是打包流程操作复杂。市场上大部分的App都是没有做任何的逆向防御,在Jadx、ApkTool等逆向工具面前,几乎同没穿衣服的女人一样毫无隐私。当然,具体的逆向技术我们不再深入讨论,还是切入本篇博客的正题:对Dex中字符串加密。</p> <p>在绝大多数的Android应用当中,很多隐私信息都是以字符串的形式存在的,比如接入的第三方平台的AppId、AppSecret,又比如接口地址字段等等,这些一般都是明文存在的。如果我们能在打包时对Dex中的字符串加密替换,并在运行时调用解密,这样就能够避免字符串明文存在于Dex中。虽然,无法完全避免被破解,但是加大了逆向提取信息的难度,安全性无疑提高了很多。</p> <p style="text-align:center">这一类似技术其实已经有大厂实现并应用了,比如网易云音乐,我们使用Jadx查看应用内容时,发现几乎所有字符串都做了加密处理,情况如下:<br> <img src="https://simg.open-open.com/show/ef5973c65bdede5915500bb0585e663f.png"></p> <p>对于字符串加密的处理,一般来说有两种思路。</p> <p>1、在开发阶段开发者使用加密后的字符串然后手动调用解密。这无疑是最简单的方式,不过维护性差,工作量大,而且对于应用中成千上万的字符串如果全部加密人工耗时巨大。</p> <p>2、编译后修改字节码,动态植入加密后的字符串并自动调用解密。这是最智能的方式,也不影响正常开发,不过实现起来稍有难度。</p> <p>对于第一种方式,大家或多或少可能都使用过,这里不多讲,本文的重点是研究第二种方式,简称StringFog,源码已经开源至Github,供大家参考: <a href="/misc/goto?guid=4959742346949282013" rel="nofollow,noindex">https://github.com/MegatronKing/StringFog</a></p> <p><strong>一、加密方式</strong></p> <p>数据加解密方式有很多种,考虑到性能和实现问题,这里使用对称加密,StringFog使用的是Base64 + XOR算法。</p> <p>先来看下经典的异或算法,这里通过对待加(解)密数据与一个字符串循环异或达到简单加(解)密的处理,代码如下:</p> <pre> <code class="language-java">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; }</code></pre> <p>加密时对数据进行异或得到加密数据,解密时对数据再次进行异或得到解密数据。同时考虑到字符编码的特性,需要使用Base64做编(解)码处理:</p> <pre> <code class="language-java">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)); }</code></pre> <p>这样,既解决了字符编码的问题,又解决了加解密的问题(注意Base64严格意义上来说并非属于加密算法),而且在性能上又得到了可靠的保证。</p> <p><strong>二、字节码植入</strong></p> <p>对Dex中的字符串进行查找和替换不难,但是同时还要植入解密调用就不太容易实现了。但是,如果对编译后Dex前的字节码文件进行操作就相对容易多了,而且对此有强大的ASM包可以使用,著名的热修复框架Nuwa在解决类ISPREVERIFIED标记是也是这样处理的,下面我们来看下实现。</p> <p><strong>1、Gradle Android的transform机制</strong></p> <p>使用Gradle进行Android项目编译和打包时,为了提供更好的自定义任务操作,Gradle Android插件提供了强大的transform机制,可以对字节码文件和资源文件做自定义操作。比如进行Jar包合并、MultiDex拆分、代码混淆等都是通过这种机制来实现的。比较细心的童鞋会发现,执行编译或者打包时能够看到如下任务流:</p> <pre> <code class="language-java">:app:transformClassesWithJarMergingForDebug :app:transformClassesWithMultidexlistForDebug :app:transformClassesWithDexForDebug</code></pre> <p>执行这些任务,会在build/intermediates/transforms目录下看到相应的transform文件夹,具体原理不细说了,感兴趣的自行研究。<br> 所以,我们可以通过自定义transform操作,来对字节码文件使用ASM库进行改写。Gradle Android插件也提供了相应的API给我们进行此类扩展。</p> <pre> <code class="language-java">def android = project.extensions.android android.registerTransform(new StringFogTransform(project))</code></pre> <p>这两行代码是Groovy语言,自定义Gradle插件都会用到,相比Java语言更加简洁和易操作。<br> 第一行代码是获取Android插件的Extension,对应于我们常见的build.gradle脚本里的这种:</p> <pre> <code class="language-java">android { ... }</code></pre> <p>对应的类是com.android.build.gradle.AppExtension,其继承了父类的registerTransform方法,意思就是注册一个transform处理类,这里我们注册的是StringFogTransform。</p> <pre> <code class="language-java">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) } }</code></pre> <p>所有的自定义的处理类都必须继承Transform类,同时需要复写相应的几个方法。<br> 首先,定义Transform的名字,我们使用项目的名字stringFog。<br> 其次,定义输入类型,一共有两种,分别是CLASSES和RESOURCES,我们希望操作的是字节码,所以使用CLASSES。<br> 这样就自动创建并加入了名为transformClassesWithStringFogFor v a r i a n t 的 任 务 , 其 中</p> <p>{variant}指的是buildTypes,一般为Debug或者Release。<br> Transform还有几个待实现的方法,主要定义作用域和模式,这里略过不细说,重点来看一下transform方法的实现。</p> <pre> <code class="language-java">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 ... }</code></pre> <p>需要transform的文件有两类。一类是当前项目Java文件编译后的class字节码文件,路径存放在directoryInputs属性中;一类是通过依赖引用的jar(aar)包,路径存放在jarInputs属性中。我们将其遍历出来放入我们定义的Set集合中,方便后续操作。<br> 在获取到classes和jars的文件路径后,我们就可以通过ASM库来修改字节码文件了,分别调用了下面两个方法:</p> <pre> <code class="language-java">StringFogClassInjector.doFog2Class(fileInput, fileOutput, mKey) StringFogClassInjector.doFog2Jar(jarInputFile, jarOutputFile, mKey)</code></pre> <p>其中mKey就是我们指定的加密key了。</p> <p><strong>2、字节码修改与植入</strong></p> <p>StringFogClassInjector类提供的两个方法doFog2Class和doFog2Jar最终都是调用的processClass方法:</p> <pre> <code class="language-java">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(); }</code></pre> <p>这里就是关于ASM库相关的处理了,我们使用ClassVisitor来操作字节码文件然后重新写入。由于要针对不同的类做不同的处理逻辑,这里使用ClassVisitorFactory静态工厂创建不同的ClassVisitor对象。</p> <pre> <code class="language-java">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) { }; } }</code></pre> <p>工厂会创建三种类型的ClassVisitor。一种是Base64FogClassVisitor,用来修改Base64Fog类的字节码,主要目的是植入我们自定义的加解密key。一种是针对白名单机制的空ClassVisitor,像很多公用和知名的库比如android.support等等,是不需要做字符串加密的,还有像BuildConfig类也不需要做加处理,这里会过滤掉。第三种就是我们要修改的类了,使用StringFogClassVisitor类来处理。</p> <pre> <code class="language-java">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); } }</code></pre> <p>在Base64Fog加解密类中,加解密key是定义在一个名叫DEFAULT_KEY的静态常量中的,通过重写visitField方法然后重写赋值value就达到了修改的目的,这一步非常简单。<br> 下面来看有些复杂的StringFogClassVisitor类,在说这个类之前,我们先来分析下字符串在Java类中有哪些存在形式。<br> - A、静态成员变量<br> - B、普通成员变量<br> - C、局部变量<br> 从广义上来说,分为以上三种。A形式存在于clinit方法中,B形式存在于init方法中,C形式存在于普方法中,相应的我们可以通过重写visitMethod方法来访问到。</p> <pre> <code class="language-java">public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { if ("<clinit>".equals(name)) { ... // 处理静态成员变量 } else if ("<init>".equals(name)) { ... // 处理成员变量 } else { ... // 处理局部变量 } }</code></pre> <p>对于A和B两种形式的成员变量,我们可以先通过visitField方法获取到:</p> <pre> <code class="language-java">@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; } } }</code></pre> <p>由于所有的字符串成员变量最终都要修改成StringFog.decode(“xxxx”)这种静态解密调用,所以value需要全部置null,然后在clinit和init的访问器的visitLdcInsn方法中重写:</p> <pre> <code class="language-java">@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); } }</code></pre> <p>有一点需要注意的是如果字节码中没有clinit方法,我们需要在visitEnd方法中手动植入一个并添加字符串常量的修改:</p> <pre> <code class="language-java">@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(); }</code></pre> <p>到这里,整个字节码的修改就差不多完成了,当然还有些细节处理就不多说了。</p> <p> </p> <p> </p> <p>来自:http://blog.csdn.net/megatronkings/article/details/63252266</p> <p> </p>