这篇文章上次修改于 1187 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
原文 -> https://blog.csdn.net/qq_38851536/article/details/117828334
前言
久违了,这是SO逆向实战教程的第五篇,最近忙于即将开讲的Unidbg课程内容的设计,所以疏忽了博客的更新,这篇的重点是一个MD5的炫技操作,需要对哈希算法原理有较深理解,本篇中不讲算法原理(可以自己看文档,或者看我在SO基础课里对MD5算法的手算),不懂算法原理的话,看起来一头雾水
这是SO逆向入门实战教程的第五篇,总共会有十三篇,十三个实战。有以下几个注意点:
- 主打入门级的实战,适合有一定基础但缺少实战的朋友(了解JNI,也上过一些Native层逆向的课,但感觉实战匮乏,想要壮壮胆,入入门)。
- 侧重新工具、新思路、新方法的使用,算法分析的常见路子是Frida Hook + IDA ,在本系列中,会淡化Frida 的作用,采用Unidbg Hook + IDA 的路线。
- 主打入门,但并不限于入门,你会在样本里看到有浅有深的魔改加密算法、以及OLLVM、SO对抗等内容。
- 共十三篇,1-2天更新一篇。每篇的资料放在文末的百度网盘中。
准备
只有两个参数,context和明文,结果是一长串
输入与输出示例
- input1 -> context
- input2 -> r0ysue
- output -> nonce=32DAB5DB-A036-4B83-8884-1E95A552C4B2×tamp=1623412271283&devicetoken=r0ysue&sign=5B0FF50A89C8704E3B3149A9E0EF2679
可以发现,输出的就是devicetoken,在输出中,有nonce和sign两个未知的键值对,timestamp应该就只是时间戳
unidbg模拟执行
package com.lession5;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.memory.Memory;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
public class qxs extends AbstractJni{
private final AndroidEmulator emulator;
private final VM vm;
private final Module module;
qxs() throws FileNotFoundException {
emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.qxs").build(); // 创建模拟器实例,要模拟32位或者64位,在这里区分
final Memory memory = emulator.getMemory(); // 模拟器的内存操作接口
memory.setLibraryResolver(new AndroidResolver(23)); // 设置系统类库解析
vm = emulator.createDalvikVM(new File("unidbg-android\\src\\test\\java\\com\\lession5\\轻小说.apk")); // 创建Android虚拟机
vm.setVerbose(true); // 设置是否打印Jni调用细节
DalvikModule dm = vm.loadLibrary(new File("unidbg-android\\src\\test\\java\\com\\lession5\\libsfdata.so"), false); // 加载libttEncrypt.so到unicorn虚拟内存,加载成功以后会默认调用init_array等函数
module = dm.getModule(); //
// 先把JNI Onload跑起来,里面做了大量的初始化工作
vm.setJni(this);
dm.callJNI_OnLoad(emulator);
}
public static void main(String[] args) throws Exception {
qxs test = new qxs();
System.out.println(test.getSFsecurity());
}
public String getSFsecurity(){
List<Object> list = new ArrayList<>(10);
list.add(vm.getJNIEnv()); // 第一个参数是env
list.add(0); // 第二个参数,实例方法是jobject,静态方法是jclazz,直接填0,一般用不到
Object custom = null;
DvmObject<?> context = vm.resolveClass("android/content/Context").newObject(custom);// context
list.add(vm.addLocalObject(context));
list.add(vm.addLocalObject(new StringObject(vm, "F1517503-9779-32B7-9C78-F5EF501102BC")));
Number number = module.callFunction(emulator, 0xA944 + 1, list.toArray())[0];
String result = vm.getObject(number.intValue()).getValue().toString();
return result;
}
}
运行异常补环境
@Override
public DvmObject<?> callStaticObjectMethodV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) {
switch (signature) {
case "java/util/UUID->randomUUID()Ljava/util/UUID;":{
return dvmClass.newObject(UUID.randomUUID());
}
}
return super.callStaticObjectMethodV(vm, dvmClass, signature, vaList);
};
运行异常补环境
@Override
public DvmObject<?> callObjectMethodV(BaseVM vm, DvmObject<?> dvmObject, String signature, VaList vaList) {
switch (signature) {
case "java/util/UUID->toString()Ljava/lang/String;":{
String uuid = dvmObject.getValue().toString();
return new StringObject(vm, uuid);
}
}
return super.callObjectMethodV(vm, dvmObject, signature, vaList);
}
出结果
验证可以发现,nonce即uuid生成的随机数,timestamp就是当前时间戳,deviceToken就是传入的参数,sign就是生成的结果
问题来了,sign哪来的
算法还原
这个SO存在一定的保护,没办法通过F5反编译,findHash插件也没有给出结果,那该怎么办?
这让人不禁思考一个问题
unidbg已经把加密结果正确跑出来了,那么,这个算法的所有细节应该尽收眼底,可是为什么现在emmm
我们连它用了什么加密都一无所知呢?这显然是不合理的呀!
让我们打开unidbg 的traceCode功能,追踪一下汇编指令流
参数是起始地址和终止地址,此处指只追踪SO内的汇编流程,我们并不想跟着去libc里
运行代码,这次足足跑了一两分钟才出结果
我们将trace的汇编执行流保存到文件中,这样更直观,也好分析
// 保存的path
String traceFile = "unidbg-android\\src\\test\\java\\com\\lession5\\qxstrace.txt";
PrintStream traceStream = new PrintStream(new FileOutputStream(traceFile), true);
emulator.traceCode(module.base, module.base+module.size).setRedirect(traceStream);
运行完成后,查看trace文件
trace文件共11w行,但我们对这个trace其实并不满意,相比较IDA trace,它少了非常关键的寄存器值信息
在指令trace这方面,ExAndroidNativeemu做的非常好,我们后面会做一下分析,现在我们先简单实现一下unidbg的指令trace(ARM32)
找到代码文件 src/main/java/com/github/unidbg/arm/AbstractARMEmulator.java
// 添加值显示
private void printAssemble(PrintStream out, Capstone.CsInsn[] insns, long address, boolean thumb) {
StringBuilder sb = new StringBuilder();
for (Capstone.CsInsn ins : insns) {
sb.append("### Trace Instruction ");
sb.append(ARM.assembleDetail(this, ins, address, thumb));
// 打印每条汇编指令里参与运算的寄存器的值
Set<Integer> regset = new HashSet<Integer>();
Arm.OpInfo opInfo = (Arm.OpInfo) ins.operands;
for(int i = 0; i<opInfo.op.length; i++){
regset.add(opInfo.op[i].value.reg);
}
String RegChange = ARM.SaveRegs(this, regset);
sb.append(RegChange);
sb.append('\n');
address += ins.size;
}
out.print(sb);
}
src/main/java/com/github/unidbg/arm/ARM.java中,新建SaveRegs方法
实际上就是showregs的代码,只不过从print改成return回来而已
public static String SaveRegs(Emulator<?> emulator, Set<Integer> regs) {
Backend backend = emulator.getBackend();
StringBuilder builder = new StringBuilder();
builder.append(">>>");
Iterator it = regs.iterator();
while(it.hasNext()) {
int reg = (int) it.next();
Number number;
int value;
switch (reg) {
case ArmConst.UC_ARM_REG_R0:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r0=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R1:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r1=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R2:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r2=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R3:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r3=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R4:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r4=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R5:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r5=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R6:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r6=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R7:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r7=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R8:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " r8=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R9: // UC_ARM_REG_SB
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " sb=0x%x", value));
break;
case ArmConst.UC_ARM_REG_R10: // UC_ARM_REG_SL
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " sl=0x%x", value));
break;
case ArmConst.UC_ARM_REG_FP:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " fp=0x%x", value));
break;
case ArmConst.UC_ARM_REG_IP:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " ip=0x%x", value));
break;
case ArmConst.UC_ARM_REG_SP:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " SP=0x%x", value));
break;
case ArmConst.UC_ARM_REG_LR:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " LR=0x%x", value));
break;
case ArmConst.UC_ARM_REG_PC:
number = backend.reg_read(reg);
value = number.intValue();
builder.append(String.format(Locale.US, " PC=0x%x", value));
break;
}
}
return builder.toString();
}
代码存在一些小bug,但勉强能用,让我们来看一下结果
Sign是三十二位十六进制数,这让人想到MD5
MD5在前面的篇幅中已经讲了很多了,它有两组标志性的数可以用于确认自身身份
1.是0x67452301 0xefcdab89 等四个魔术,但单靠这四个数证明不了是MD5,也可能是别的哈希算法,除此之外,算法可能魔改常数
2.MD5的64个K,K1-K64是MD5独特的标志,简单的魔改也不会改K值(其实K表也可以随便改,但一般的开发人员也不懂K的意义,不敢乱改)
# 魔数
A = 0x67452301
B = 0xefcdab89
C = 0x98badcfe
D = 0x10325476
# K表
Ktable = [
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf,
0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af,
0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e,
0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
0xd62f105d, 0x2441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6,
0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8,
0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122,
0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x4881d05, 0xd9d4d039,
0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, 0x432aff97,
0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d,
0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391
]
看一下汇编trace文件
可以搜索到K表中的值以及魔数,所以可以断定是一个MD5或者MD5的魔改版本
考虑一个问题,我们是否可以直接从汇编中"析出"明文和密文?实际上对于标准算法来说是完全可以的,接下来的思路需要对MD5算法具有较深的了解
首先,我们明确了样本算法中使用到了MD5
接下来我们做两件事
- 从汇编trace中析出MD5的结果——用于确认输出是否与MD5有直接关系
- 从汇编trace中析出MD5的输入——用于确认函数的输入和MD5的输入的关系
首先做第一件事
找0x67452301最后和谁相加
计算两者相加的结果(如果大于0xffffffff则取低的32比特) 即 E8D87616
如果输入小于512比特,那么调整一下端序,1676D8E8,这就是MD5前8个数字的结果
我们搜索一下 E8D87616,发现后面还有它参与的运算,这说明明文长度超过一个分组长,需要进行第二个分组的运算
同样找0xE8D87616最后和谁相加
0xE8D87616 + 0xda40fcd8
取C319 72EE
倒转端序 即EE72 19C3
我们发现这就是加密结果的前8个数,读者可以自行验证第二第三第四部分,同理
我们通过这种方式确认了,MD5的结果就是加密的结果
那么做另一件事——Trace汇编中析出MD5的明文,这不是一件简单的事
在MD5具体流程中,每轮运算都需要64步,每步的第三个操作是选取明文的一截进行加法运算,第四个操作是和K相加
我们无法定位第三个操作,但因为第四个操作的K都是已知的
所以可以这样描述
- 第四个操作上方第一个add运算就是明文的一截+中间结果
但是呢...
这前四步其实并没有硬性的顺序要求,生成的汇编代码常常不遵照顺序
但好在第一个F(B,C,D)的结果是固定的0xffffffff,它是一个很好的锚点
基于K值和这个锚点,我们可以在汇编trace中准确的析出明文——仅依靠trace汇编
不管OLLVM或者花指令将指令流变成10w行还是100w行,还是SO做了保护,明文不会完整出现在内存中,都不影响这个分析过程
红框即定位的明文块1的小端序
所以明文就是34413545,cyberchef中看一下
依照着所述锚点,不断往下追
- 第一个明文块:4A5E
- 第二个明文块:9A20
- 第三个明文块:-893
- 第四个明文块:3-4E
- 第五个明文块:2D-8
- 第六个明文块:39A-
- 第七个明文块:0DFC
- 第八个明文块:F9EA
- 第九个明文块:7247
- 第十个明文块:1623
- 第十一个明文块:4180
- 第十二个明文块:1589
- 第十三个明文块:1r0y
- 第十四个明文块:suet
- 第十五个明文块:d9#K
- 第十六个明文块:n_p7
开始第二个分组
- 第十七个明文块:vUw.(.即0x80填充开始)
更严谨些,通过K15确认明文长0x218比特,即512比特+ 24比特,所以明文到此结束,合并起来就是
- 4A5E9A20-8933-4E2D-839A-0DFCF9EA72471623418015891r0ysuetd9#Kn_p7vUw
首先迫不及待求一下MD5,验证结果
完全正确
接下来仔细瞧瞧明文的组成
这是我们的输出
nonce=4A5E9A20-8933-4E2D-839A-0DFCF9EA7247×tamp=1623418015891&devicetoken=r0ysue&sign=EE7219C352A74B6058B22CE8A5FB282E
这是明文
4A5E9A20-8933-4E2D-839A-0DFCF9EA72471623418015891r0ysuetd9#Kn_p7vUw
即
- nonce+timestamp+devicetoken+(固定的salt)td9#Kn_p7vUw
大功告成!
尾声
这不是一篇简单的文章,放第五篇有些偏前,而且其方法内核基于加密算法的深度理解,看不懂或者看不下去都没关系,下一篇恢复正常,和此篇没有关联性
但笔者必须要强调,文中所述的这种方法,是一种强大的、无视混淆流程的,真正意义上深入底层的标准算法还原技术
尤其在加盐哈希算法中分析盐格外强大,甚至存在编写代码自动化完成相关工作的可能性
资源链接:https://pan.baidu.com/s/1b24egt-FEbcRlQYeOwpNYQ
提取码:1t4l
没有评论