提到字符串混淆,就要解释下为什么要做字符串混淆,之前文章有介绍过基于smali的字符串混淆,但基于smali就必须先反编译,使用成本太大,而混淆往往用于开发层面,目前Android开发的主流语言还是java(kotlin),使用NDK开发的偏少,如果编译时不进行处理,那么使用一些反编译工具反编译后就相当于在阅读源码,市面上主流的proguard混淆工具并没有提供字符串混淆功能,而其它混淆工具如DashO,Zelix,DexGuard,Allatori等等均属于收费工具,做字符串混淆的初衷主要是为了提高逆向门槛,或是从另一方面减少硬编码的危害。
0x1 介绍
看一例简单字符串混淆,如下示例:
对于对称算法来说,泄漏密钥则意味数据包可被篡改(此处未考虑数据包做了完整性校验),站在逆向的角度来说,对于数据包的逆向分析一般会从url地址、参数类型、猜测加密类型、堆栈跟踪等多个方面去寻找加解密方法,而前三种方式是用的较多的,比如http://helloworld.com/keyword=d3Fld3FlMTExc2QuLg==
,反编译后则可以通过搜索keyword
关键字去查找值内容的加密,亦或是通过猜测加密类型,如上述值内容其实仅做了个base64编码,在尝试解码后就可以拿到明文,当如果解码后依然是密文,一方面就可以通过查找函数引用,而我自己使用的方式直接去靠猜(= =),常规的加密方式无非是AES,DES,RSA(这三种是用的最多的),此处考虑的是加解密算法在java层实现,较大型的app一般已经使用openssl在jni层实现了,这里不做讨论,通过搜索AES,DES,RSA关键字再稍加分析一般就可以找到真正的加解密函数。
字符串混淆就是为了增大直接搜索关键字的难度,逆向人员在遇到字符串混下后首先要解决的就是反混淆,可通过静态反混淆或是动态代码插桩查看明文,此处针对一项简单的字符串混淆介绍下反混淆。
这里每次会调用a类中的c方法对加密字符串进行解密,这里有两种还原方式,一种是借助jeb写脚本遍历c方法的引用进行还原,但不是永久性还原,另一种就是基于smali做反混淆,将app反编译smali后,上图的代码如下
可以看到在声明了一个const-string后紧接着就是解密方法,根据这个特性,写个方法遍历下所有包含这个特性的字符串进行还原然后编码回去即可,需要注意的就是smali里中文显示的是unicode形式。
0x2 实现
那么在处理过字符串后以字符串的形式编码是不可靠的,静态还原难度太低,于是有了以数组形式展现的方式,目前效果如下:
展示过效果图接下来就是介绍原理,本工具是基于StringFog进行的二次开发,无论是gradle插件还是jar包混淆,处理的对象都是class字节码,那这里就引用了著名的asm字节码工具,遍历每个class文件,通过重写ClassVisitor中的visitField、visitMethod、visitEnd方法,对于全局变量,需要考虑它的修饰符是否为static和final,在visitField时根据其不同的修饰区分出三种情形:
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;
}
此处用于拿到Field的名称和值用于后面的加密,而在visitMethod时,需要对
if ("<clinit>".equals(name)) {
...
for (ClassStringField field : mStaticFinalFields) {
if (field.value == null) {
continue;
}
startEncode(super.mv, field.value);
...
@Override
public void visitLdcInsn(Object cst) {
if (cst != null && cst instanceof String && !TextUtils.isEmptyAfterTrim((String) cst)) {
lastStashCst = (String) cst;
startEncode(super.mv, lastStashCst);
}
}
@Override
public void visitFieldInsn(int opcode, String owner, String name, String desc) {
if (mClassName.equals(owner) && lastStashCst != null) {
boolean isContain = false;
for (ClassStringField field : mStaticFields) {
if (field.name.equals(name)) {
isContain = true;
break;
}
...
}
};
} else if ("<init>".equals(name)) {
mv = new MethodVisitor(Opcodes.ASM5, mv) {
@Override
public void visitLdcInsn(Object cst) {
if (cst != null && cst instanceof String && !TextUtils.isEmptyAfterTrim((String) cst)) {
startEncode(super.mv, (String) cst);
}
}
};
}
而真正的加密重点即在startEncode方法
private void startEncode(MethodVisitor mv, String str) {
boolean special = false;
char[] charArray = str.toCharArray();
int len = charArray.length;
if (len <= 0) {
return;
}
for (char c : charArray) {
if (c > 255) {
special = true;
break;
}
}
if (special) {
encode3(mv, str);
} else {
encode2(mv, str);
}
}
这里做了个判断,当char值大于255时,进行了另一种混淆,原因在于使用的自定义混淆算法根据c版本翻译而来,c版本类型为unsigned char 在实验过程中碰到了很多的问题,即当出现中文字符或者其它特殊字符时,造成运行崩溃或是乱码,故当遇到大于255的字符时,则使用常规的异或进行处理
private static final String hexString = "0123456789ABCDEF";
public static String encode3(String str, String key) {
//根据默认编码获取字节数组
byte[] bytes;
try {
bytes = str.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
bytes = str.getBytes();
}
int len = bytes.length;
int keyLen = key.length();
for (int i = 0; i < len; i++) {
//对每个字节进行异或
bytes[i] = (byte) (bytes[i] ^ key.charAt(i % keyLen));
}
StringBuilder sb = new StringBuilder(bytes.length * 2);
//将字节数组中每个字节拆解成2位16进制整数
for (byte aByte : bytes) {
sb.append(hexString.charAt((aByte & 0xf0) >> 4));
sb.append(hexString.charAt((aByte & 0x0f) >> 0));
}
String res = sb.toString();
return res;
}
为避免出现乱码问题,在将string转为byte时优先指定utf-8编码,而针对0-255的范围则使用的自定义加密方式,下面看一个c版本的简单自定义解密方法
unsigned char s[] =
{
0xa4, 0xbc, 0xb8, 0xfc, 0x81
};
for (unsigned int m = 0; m < sizeof(s); ++m) {
unsigned char c = s[m];
c = (c >> 0x2) | (c << 0x6);
c -= 0x60;
c = ~c;
c -= 0x5a;
c ^= 0xa5;
s[m] = c;
}
printf("%s\n", s);
运行后显示ysrc
。
不过仅有解密方法,如何生成加密方法呢?这就需要我们自己进行逆推,其中取反,加减乘除异或都不存在难度,唯一需要注意的就是一个移位运算,翻译成java后需要对每次移位后的结果&0xff,防止越界,逆推后的java代码如下:
private static char U(int ch) {
return (char) (ch & 0x0ff);
}
private static void encode(String str) {
char[] array = str.toCharArray();
int len = array.length;
for (int m = 0; m < len; ++m) {
char c = U(array[m]);
c = U((c << 0x2) | (c >> 0x6));
c = U(c + 0x60);
c = U(~c);
c = U(c + 0x5a);
c = U(c ^ 0xa5);
array[m] = U(c);
}
}
由于java中没有unsigned char 这种类型,所以需要保证每次计算后的结果都在0-255以内。
有了加密方式就可以对字符串进行加密了,在使用asm时也碰到了不少问题,借助class字节码分析工具也成功解决了这些问题,如下为针对char[]的加密方式
private void encode2(MethodVisitor mv, String str) {
String mKey = UUID.randomUUID().toString().replace("-", "").trim().substring(0, 6);
char[] enc = Encode2.Encode(str, mKey);
int len = enc.length;
mv.visitIntInsn(Opcodes.SIPUSH, len);
mv.visitIntInsn(Opcodes.NEWARRAY, Opcodes.T_CHAR);
for (int i = 0; i < len; i++) {
mv.visitInsn(Opcodes.DUP);
mv.visitIntInsn(Opcodes.SIPUSH, i);
mv.visitIntInsn(Opcodes.SIPUSH, enc[i]);
mv.visitInsn(Opcodes.BASTORE);
}
mv.visitLdcInsn((String) mKey);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, Xor_FLAG2, "Decode", "([CLjava/lang/String;)Ljava/lang/String;", false);
}
上述代码中使用一个randomUUID生成一个随机异或因子用于对字符的异或,在调用加密方法后使用asm填充到class文件中,根据字节码生成数组的结构
声明数组大小
声明数组类型
第0个
第0个字符
保存
第1个
第1个字符
保存
这里使用for循环将所有字符填充进去后再声明调用方法即完成了字符串加密,这里有个坑就是在添加数组时可能导致堆栈不平衡,可以通过手动去修正但asm也为我们考虑到了这一点可以让程序自动帮我们进行计算,只需要在new ClassWriter(int)
时传入1即可,具体细节可以参见asm文档:
完成了以上所有的加密工作后就需要封装成一键工具来使用,这里介绍下gradle的使用方式,在build.gradle中添加如下代码
android.libraryVariants.all{ variant ->
variant.javaCompile.doLast{
println ("start classes obfuscation "+"${variant.javaCompile.destinationDir}")
javaexec {
main = "-jar";
args = [
"../obfuseJarString.jar",
project.name,
variant.javaCompile.destinationDir
]
}
}
}
如果是module模块,则需要修改applicationVariants
为libraryVariants
,其中的javaCompile.destinationDir指向目录为build\intermediates\classes\debug
,正式版指向的是release,里面包含了当前已经编译的class文件,那混淆思路即为:遍历所有class文件->忽略白名单文件->混淆class文件,生成重命名文件->删除原始文件->重命名为原始文件名
。
0x3 总结
asm是一款非常强大的class字节码框架,但学习难度也很高(个人觉得),一部分的混淆工具也是基于asm开发的,包括强大的jadx反编译套件也是基于asm,使用好asm可以帮助我们解决很多难以手工解决的技术问题。
往期文章
Eat.Hack.Sleep.Repeat - YSRC 第二期夏日安全之旅
Django的两个url跳转漏洞分析:CVE-2017-7233&7234
A BLACK PATH TOWARD THE SUN - HTTP Tunnel 工具简介