作者论坛账号:金罡
在windows流行的时候,虚拟机保护是多人不敢碰的东西现在依然也是如此,pc的性能比移动端性能要高出不少,虚拟化和变异的代码多到令人发指,因此在加密保护强度上要比移动端要强很多很多,为了移动端App更好的体验(ANR率)移动端加密强度短时间内不会达到pc上的强度,随着移动cpu性能越来越好相信加密强度会逐年加强。
早年兴趣使然分析研究过windows端VMProtect、Safengine Shielden、Themida、VProtect、Enigma Protector等等虚拟机,最近发现国内流行的短视频也有虚拟机加密同时也比较感兴趣,便开始了我的分析之旅。
分析任何虚拟机必须要扣汇编指令级细节。
安卓诞生这么多年了至今没有像windows端olldbg、x64dbg那样友好的调试器,IDA PRO虽然自带了安卓调试器总是没有相像中的稳定。lldb作为移动端iOS和android开发的御用调试器,带源码调试在开发环境中还算比较友好,而汇编级调试只能输入命令行了,这是很多用惯了gui调试器的人接受不了的,但是个人发现lldb调试稳定性出奇的好,功能上比IDA Pro的安卓调试器强大太多了。
静态分析工具
IDA Pro
IDA Pro中的变量和标签命名约定: 1. 无下线为精确的名字,已经非常了解代码的功能和含义,例如:"xxx"。2. "_"单下划线定义为代码的功能和含义比较模糊,例如:"_xxx"。3. 双下划线为分析的代码模糊不清,由于地址不好记忆但需要命名以区分,例如:"__xxx"。
动态分析工具
lldb
ARM参考文档
《Arm Architecture Reference Manual》
《ARM Cortex-A Series Programmer's Guide for ARMv8-A》
其他工具
CyberChef
libEncryptor.so一共包含了三套虚拟机,三套虚拟机各自独立并且代码一模一样,本文重点只分析vm2虚拟机。
虚拟机指令编解码参考借鉴了arm64的一部分规则,并实现了自己的一套规则,在后面的解码分析中会有很多和arm64解码相似的地方。
另外虚拟机并没有像VMProtect那样将一条指令分割成多条"微指令"的方式,此虚拟机没有把当前真实的上下文放到虚拟机上下文去模拟执行,而是运行了一套自己单独的上下文。
vm1
: 0xDA0
在JNI_OnLoad中被调用,该虚拟机起始位置在0xDA0,主要作用解密是注册java native所需要的字符串和调用RegisterNatives进行注册。
vm2
: 0x2AC4
虚拟机起始位置在0x2AC4,是java注册的原生接口com.bytedance.frameworks.encryptor.EncryptorUtil.ttEncrypt(byte[] buff, int size),用来加密buff字节数组。
vm3
: 0x444
代码位置在0x444,用于生成aes-128 cbc算法的key和iv。
在函数中调用虚拟机时会传入一个指针数组类型参数变量,这是传入到虚拟机入口的唯一参数。
void vm_entry(*int args)
c伪代码来表示函数调用虚拟机入口
void ttEncrypt(char* buff, int buff_size) { int dst_size = size +0x76; char* pDstBuff = malloc(size +0x76); vm_entry(buff, size, pDstBuff, &dst_size);}
反汇编版本:
IDA Pro查看入口的cfg图,复杂程序看似很难其实一点都不简单,话说回来cfg看起来和ollvm的混淆平坦化非常相似,其实和ollvm混淆关系不大,只不过一部分的switch被拉平了,在了解调度逻辑后分析也不算复杂。
在代码中依然能看到两个ollvm swtich var变量,其作用没有详细分析,但整个cfg图确定与ollvm关系不是很大,猜测开启ollvm后性能会大幅下降影响app启动速度了。
在进入虚拟机运行时前,在入口需要准备虚拟机所需的内存和参数。对虚拟机内存布局情况必须了解如指掌,这样在动态和静态分析时才不会迷失方向。
SP
类型
变量名
注释
0x0
未使用
0x8
char *
pSrcBuffer
0x10
int
srcSize
0x18
char *
pDstBuffer
0x20
int *
pDstBufferSize
0x28
void *
pCall_register_trampoline
0x30
void *
pVMMemoryEnd
偏移0x510
0x38
vmMemoryStart
虚拟机运行时专用内存起始位置
...
0x510
vmMemoryEnd
虚拟机运行时内存结束位置
0x518
0x520
x28
0x528
x19
0x530
x29
上一个栈桢地址
0x538
x30
参数
在进入虚拟机正式执行前,vm2_entry在进入虚拟机运行时前会对5个参数进行初始化。
pOpcode
:opcode指针指向虚拟机要执行的代码
pArgs
:传入虚拟机的参数
pReserve
:未使用,用途暂时未知
pExternalFunc
:调用虚拟机外的函数列表,此列表是加密的。
pVmData
:一个结构体指针,结构体存储了两指针分别是函数跳板地址另一个是虚拟机内存结束地址,{0x0: pCallRegisterTrampoline, 0x8: pVMMemoryEnd}
vm2_run(void* pOpcode, void* pArgs, void* pReserve, void* pExternalFunc, void* pVmData);
opcode
vm2的opcode起始地址在0xB090,共占用0x2D8字节,每个opcode占用四个字节。
跳板函数
跳板函数是衔接适配虚拟机和外部函数以及参数的桥梁,它包含来自虚拟机传递的两个参数, 参数1:x0是调用跳转的一个外部函数地址,参数2:x1是外部函数需要使用的参数指针。
外部函数
外部地址函数目标地址是被简单加密过的,在虚拟机指令中使用加法解密这8个地址,例如:0xDDD2D0(加密地址数据) + FF225870(key) = 0x2B40(目标地址)。
截图中IDA Pro以默认0基址的地址,在调试时看到的地址数据是被代码手动重定位后的数据。
vm2_run仅仅只分配了保存被调用者寄存器的堆栈内存空间,并没有分配空闲的堆栈内存,在虚拟机真实开始之前会将传递进来的5个参数即0x-x4对虚拟机中的虚拟寄存器和真实专用寄存器进行初始化。
vm_run还初始化了解码opcode的switch表,在初始化时发现一共初始化了6张switch表,当然在handler中还存在其他switch表,这么多表是如何来的?猜测编写时只有1-2张表,在编译器优化后表就被分割成多块了。
虚拟寄存器初始化
vm2_run初始化时会将传递的5个参数赋值给虚拟寄存器,其中包括PC和SP的值。
虚拟寄存器
参数
注释
x0
x0始终为0,XZR寄存器?
x4
arg2: pArgs
x5
arg3: pReserve
x6
arg4: pExternalFunc
x7
arg4: pVmData->pCallRegisterTrampolineFunction
x29(SP)
arg5: pVmData->pVmMemoryLimit - 0x150
SP的初始值
x31(LR)
初始值为0,寄存器名不确定,vm退出时保存退出代码编号
PC
arg1: pOpcode
真实专用寄存器
虚拟机运行时使用了真实虚拟器,其中包括临时寄存器和专用寄存器,临时寄存器保存多种类型的值,而专用寄存器在虚拟机从开始到退出只保存一种指定类型数据或恒定不变。
opcode位域伪代码:
w12保存了4位32字节的opcode,在opcode首次解码时,位域中的变量会放到真实寄存器。
位域伪代码示例:
w12[26]
: 取第26位到放到入目标的26位
w12[26->0]
: 取第26位并放入到目标的指定位
w12[27-26->1-0]
: 取27位和26位放入到目标第1位和第0位
`
`: 按位或
真实寄存器
注释
x0
temp/pcode
x1
temp,保存[x19-0x20]的值某种流程控制
x2
temp
x3
temp
x4
虚拟寄存x4,初始化时保存pBufferInfo(x1),虚拟机中保存跳板函数地址
x5
虚拟寄存x5,初始化时保存数值为0,虚拟机运行时跳板函数的参数指针
x6
ollvm混淆switch_var,初始值:0x400000b,这个应该是llvm混淆的switch var
w7
ollvm混淆switch_var,初始值:0x200fff,这个应该是llvm混淆的switch var
w8
w12[20-16] operand1 Xt/Xm,det register index?
w9
w12[25-21] operand2 Xn,src register index?
w10
w12[15->4] | w12[14->3] | w12[13->2] | w12[12->1] | w12[31->0],5个位合并到低5位
w11
w12[30->4] | w12[29->3] | w12[28->2] | w12[27-26->0-1],组成的低5位
w12
32位的opcode
w13
w12[31]
w14
w12[30]
w15
w12[29]
w16
w12[28]
w17
w12[27]
w18
w12[26]
x19
虚拟机下文负偏移指针指向可使用内存最高上限的地址与vmMemoryLimit值相同,使用负偏移对上下文进行访问。
x20
call_register_trampoline 跳板地址
x21
Context,上下文指针
x22
switch_table7
x23
switch_table6
w24
默认为1
x25
switch_table5
x26
switch_table3
x27
switch_table_main
x28
switch_table2
x29(fp)
未使用
x30(lr)
switch_table_4(3A)
虚拟机context
虚拟机中也有专用寄存器,在调用外部函数时,其中x4
虚拟机中保存外部函数地址,x5
虚拟机中保存参数指针,x25
虚拟机中调用外部函数时的跳板地址。另外虚拟寄存器和aarch64中的寄存器并不是一一对应的,在这里只是对每个虚拟寄存器启了一个相应的名字方便理解和记忆。
负偏移
虚拟寄存器
注释
-0x150
虚拟机堆栈SP初始位置
-0x148
-0x140
-0x138
pc
pc指针,真实寄存器x21中的值指向此地址,handler使用
-0x130
x0
初始化值:0,虚拟机中开始到结束始终为0,XZR寄存器?
-0x128
x1
-0x120
x2
-0x118
x3
-0x110
x4
初始化值:pBufferInfo,专用寄存器:虚拟机中调用外部函数时保存外部函数地址
-0x108
x5
初始化值: 0,专用寄存器:虚拟机中调用外部函数时保存参数指针
-0x100
x6
初始化值: vm2_external_func_list
-0xF8
x7
初始化值: call_register_trampoline_function
-0xF0
x8
-0xE8
x9
-0xE0
x10
-0xD8
x11
-0xD0
x12
-0xC8
x13
-0xC0
x14
-0xB8
x15
-0xB0
x16
-0xA8
x17
-0xA0
x18
-0x98
x19
-0x90
x20
-0x88
x21
-0x80
x22
-0x78
x23
-0x70
x24
-0x68
x25
虚拟机中的专用寄存器:虚拟机中调用外部函数时的跳板地址
-0x60
x26
-0x58
x27
-0x50
x28
-0x48
x29(SP)
虚拟机堆栈SP, 初始化时指向-0x150
-0x40
x30
-0x38
x31(LR)
当值为0时退出虚拟机
-0x30
-0x28
-0x20
0
流程控制状态标记,值范围0-3,0:正常执行状态,2和3:程序中包含分支跳转某种流程,3和1:call某个地址函数
-0x18
new pc/call某个地址函数的地址/为0时退出虚拟机
-0x10
LR,在调用调用函数结束后返回虚拟机地址
-0x8
起始pc指针,一部分分支跳转指令参考的起始基址,例如: 跳转目标地址=pc起始地址+offset
pOpcode取出4个字节的opcode并取得低5位解码出op1。
# 初始化LDP X20, X19, [X4] ;x20:call_register_trampoline,0x19:pVmMemoryLimitSUB X21, X19, #0x138 ;x19-0x138=x21计算出pc指针在负偏移指针的内存位置STR X0, [X21] ;保存pc到x21,此时x0和x21中的值相同并被关联。# 取opcodeLDR W12, [X0] ;取出4字节opcodeAND W10, W12, #0x3F ;opcode的低5位为op1CMP W10, #0x3F ;op1的值最大只能小于63,说明op1只有0-63一共64个B.HI next_pc ;下一条指令
在opcode首次解码时会解码4个寄存器操作数,从中提取4个位域的字段,分别保存到真实专用寄存器w8、w9、w10、w11,在arm64指令集中当指令是MADD、MSUB、UMADDL、UMSUBL、SMADDL、SMADDL等会有4个寄存器操作数的情况,在这里同样也是如此。
首次解码时位域布局:w12[31,30-26,25-21,20-16,15-12,11-6,5-0]
w12[5-0:6] = op1
w12[11-6:6] = op2
w12[15-12:4]|[31:1] = Xm/Wm
w12[20-16:5] = Xt/Wt
w12[25-21:5] = Xn/Wn
w12[30-26:5] =Xa/Wa
w8
: Rd(Xt/Wt)目标寄存器操作数,w12[20-16->5-0],取出16-20位保存最低位,5位可以表示0-31个寄存器。
w9
: Rn(Xn/Wn)(第一个源寄存器操作数,w12[25-21->5-0],取出21-25位保存最低位,5位可以表示0-31个寄存器。
w10
: Rm(Xm/Wm)第二个源寄存器操作数,w12[15->4] | w12[14->3] | w12[13->2] | w12[12->1] | w12[31->0],5位可以表示0-31个寄存器。
w11
: Ra(Xa/Wa)第三个源寄存器操作数,w12[30->4] | w12[29->3] | w12[28->2] | w12[27-26->1-0],5位可以表示0-31个寄存器。
寄存器操作数伪代码表示如下:
操作数大小
: X为64位操作数、W为32位操作数。
目标操作数
: d=destination register, t=target register。
源寄存器
:第一个源寄存器为n,第二个寄存器为m,第三个寄存器为a。
R
: 表示寄存器64或32位操作数,X是64位操作数、W代表32位操作数。
在高级语言中如果一个switch太多,在某些编译器编译优化后出现在多张switch子表和子表的子表的情况,对于一些相同代码的多个case会合并到一个case中再次switch分发的情况。
根据编译器的优化编译的特性,在处理switch的case为了减少查找次数找到最终的case,在代码中会经常看到大于(GT)、小于(LE)等分支跳转,看到这个不要迷惑,这是编译器优化case的结果,这样做的目的是减少查找次数使用了类似二分查找的算法,当看到B.EQ的跳转目标和B.NE的下一条指令就是匹配到了case常量了。
在首次解码后分发过程中通常还会有二次解码,除了MADD和MSUB等等指令有4个寄存器操作数,指令只需一般指令只有2-3个寄存器操作数,一条完整的arm64指令至少需要二个寄存器操作数,这里也同样如此,当指令只有二个寄存器操作数时即一个目标操作数Xt和第一个源操作数Xn,其他的位域字段就会空闲下来,例如:op2、Xm、Xa,在二次解码时这些空闲的位域字段原有的值就会覆盖被再次利用组成其他寻址方式例如:shift、extend、imm等等。
在虚拟机指令op1的值是11(0xb)时,分发处理会解码op2,解码op2时Xt、Xn、Xm等操作数位置会发生变化,原有的x10/w10第三个源操作数在op2中变成目标操作数,第一个源操作数变成x8/w8,第二个源操作数变成x9/w9,各操作数的位域解码方式不变,变的只是操作数角色。
Xt
:Rd(Xt/Wt)目标寄存器操作数(x10/w10),w12[15->4] | w12[14->3] | w12[13->2] | w12[12->1] | w12[31->0],5位可以表示0-31个寄存器。
Xn
: Rn(Xn/Wn)(第一个源寄存器操作数(x8/w8),w12[20-16->5-0],取出16-20位保存最低位,5位可以表示0-31个寄存器。
Xm
: Rm(Xm/Wm)第二个源寄存器操作数(x9/w9),w12[25-21->5-0],取出21-25位保存最低位,5位可以表示0-31个寄存器。
从op1的取值范围为0-63一共64条指令,当op1是11时还有存在op2和op3的情况,由于虚拟机的handler太过庞大分析所有的指令太过耗时,我们目的是还原代码逻辑,只需分析执行过的handler,对于没有执行过的handler放弃分析。
在了解了op1解码方式后,通过脚本得到执行次数最多的指令并优先分析:
import structpcode_start = 0xB090pcode_size = 0x2D8with open("libEncryptor.so", "rb") as f: content = f.read()bytes_code = content[pcode_start : pcode_start + pcode_size]insts_sets = {}for pc in range(0, pcode_size, 4): word = struct.unpack("<I", bytes_code[pc : pc + 4]) if len(word) > 0: opcode = word[0] op1 = opcode & 0x3F if insts_sets.get(op1, None) == None: insts_sets[op1] = 1 else: insts_sets[op1] += 1sorted_dict = dict(sorted(insts_sets.items(), key=lambda item: item[1], reverse=True))for inst, count in sorted_dict.items(): print(f'op1: {inst}, count: {count}')
得到执行次数最多的指令:
op1: 23, count: 56op1: 11, count: 51op1: 40, count: 38op1: 21, count: 27op1: 24, count: 2op1: 12, count: 2op1: 17, count: 2op1: 48, count: 2op1: 52, count: 1op1: 7, count: 1
从python打印的结果来看23、11、40前面三个指令使用最为频繁,因篇幅关系只分析这三条指令,其中op1值是11的还存在第二个操作码op2,这里选择op1=11 & op2=12的指令进行分析。
取指
在循环的开始,首先判断一个虚拟机状态控制码,指示了当前指令执行完成是否进入某些分支跳转指令流程,接着取出4个字节并判断指令op1的合法性,如果不合法忽略当前指令直接跳转B.HI next_pc执行一条指令。
解码
同样首次解码出四个寄存器操作数,w8=目标操作数Xt,w9=第一源操作数Xn,w10=第二个源操作数Xm,w11=第三个源操作数Xa。
分发 & 解码2
既然op1=23,那么switch会跳转到主分发表的case 23的位置,从图中看出20,23,27,35,36,48,54,58多个case合并的到一个的case,可能是这些的代码是相同的被合并到一个地方了。
2E50
: 代码开头4行代码是判断op1的范围是否20-58之间来确定case的合法性,代码中使用了首次解码的寄存器w10并被直接重新赋值使用了,首次解码时是第二个源寄存器Xm,说明此条指令可能没有第二和第三个源寄存器。
2E60-2E8C
: 首次解码的寄存器w11是第三个源寄存器,同样被重新赋值给予新的含义了,此时有效的寄存器有w12(opcode)、w8(Xt)、w9(Xn)、x21(pContext上下文指针),这段代码主要是做了三件事:
获取Imm16:
获取一个16位的立即数:w11 = w12[12-15] | w12[31->11] | w12[30->10] | w12[29->9] | w12[28->8] | w12[27->7] | w12[26->6] | w12[6-11->0-5],解码过程的高级语言版本方便理解 (opcode & 0xF000) | (opcode >> 20 & 0xFC0) | (opcode >> 6 & 0x3F)
.text:0000000000002E60 060 UBFX W11, W12, #6, #6 ; w12[6-11->0-5].text:0000000000002E64 060 AND W12, W12, #0xF000 ; w12[12-15].text:0000000000002E68 060 BFXIL W12, W18, #20, #12 ; w12[12-15] | w12[26->6].text:0000000000002E6C 060 ORR W11, W12, W11 ; w12[12-15] | w12[26->6] | w12[6-11->0-5].text:0000000000002E78 060 ORR W11, W11, W17,LSR#20 ; w12[27->7].text:0000000000002E80 060 ORR W11, W11, W16,LSR#20 ; w12[28->8].text:0000000000002E84 060 ORR W11, W11, W15,LSR#20 ; w12[29->9].text:0000000000002E88 060 ORR W11, W11, W14,LSR#20 ; w12[30->10].text:0000000000002E8C 060 ORR W11, W11, W13,LSR#20 ; w12[31->11]
获取源操作数Xn的值:
2E70
:x21是pContext指针,w9是第一个源寄存器是一个偏移索引值,x9 = x21 + w9 8得到相对的偏移植。
2E7C
: 从上下文中取出第一个寄存器的值,x9 = x21 + w9 8 + 8。
.text:0000000000002E70 060 ADD X9, X21, W9,UXTW#3.text:0000000000002E7C 060 LDR X9, [X9,#8]
计算出一个偏移量:
第一个寄存器的值加上imm16扩展到32位的立即数得到一个偏移量。
2E94 060 ADD X9, X9, W11,SXTH ; w9=address,取出半字16立即数再相加
执行
再次分发后来到case 23的最终目标地:
x9:此时是一个偏移量
w8:是一个目标寄存器,它的值是一个索引值
3380
: x21是pContext指针,w8是目标源寄存器是一个偏移索引值,x9 = x21 + w9 8得到相对的偏移植。
3384
: 取出目标寄存器的值,x8 = x21 + w8 8 + 8。
3388
: 将目标寄存器存储到偏移量地址中,[Xn + imm]=Xt。
.text:0000000000003380 case_23__STR_Xt_Mem_Xn_simm.text:0000000000003380 060 ADD X8, X21, W8,UXTW#3 ; jumptable 0000000000002E98 case 23.text:0000000000003384 060 LDR X8, [X8,#8].text:0000000000003388 060 STR X8, [X9]
准备下一条指令: pc指针地址加4
338C 060 B next_pc
指令还原
储存指令:STR Xt, [Xn + imm16]
当imm为0时: STR Xt, [Xn]
当op1值等于11时会解码第二个或第三个操作码。
取指
这里不再重复参考op: 23取指部分
解码
这里不再重复参考op: 23解码部分
分发 & 解码2
11号case首先会提取出op2对op2进行再次switch case分发处理。
经过分发跳转跳转来到这里,op2的多个case 1,6,8,12,17,26,36,58也被合并到一个位置。
经过多次跳转来吧op2=8的最终目的地case 8,上文提到过op2与op1的操作数位置不一样,Xn(w10),Xn(w8),Xm(w9)
3D60
: 获取上下文中的寄存器偏移
3D64
:取第二次源操作数值,x9=[x11+ w9 8]
3D68
:取第一次源操作数值,x8=[x11+ w8 8]
3D6C
:减法运算
3D70
:将结果写入到目标寄存器,[x11+ w10 * 8] = x8
指令还原
SUB Xt, Xn, Xm
取指
这里不再重复参考op: 23取指部分
解码
这里不再重复参考op: 23解码部分
分发 & 解码2
在指令的开头3条指令中第二个(w10)、第三个源寄存器(w11)的值被重新赋值并使用,说明指令中有效寄存器操作数只有Xt和Xn。
经过分析二次解码和op: 23一模一样,获取立即数、获取源操作数的值、获取一个偏移值
获取imm
opcode提取imm16位立即数:
w11 = w12[12-15] | w12[31->11] | w12[30->10] | w12[29->9] | w12[28->8] | w12[27->7] | w12[26->6] | w12[6-11->0-5]
.text:0000000000002D50 060 UBFX W11, W12, #6, #6 ; w12[6-11->0-5].text:0000000000002D54 060 AND W12, W12, #0xF000 ; w12[12-15].text:0000000000002D58 060 BFXIL W12, W18, #20, #12 ; w12[12-15] | w12[26->6].text:0000000000002D5C 060 ORR W11, W12, W11 ; w12[12-15] | w12[28->6] | w12[6-11->0-5].text:0000000000002D68 060 ORR W11, W11, W17,LSR#20 ; w12[27->7].text:0000000000002D70 060 ORR W11, W11, W16,LSR#20 ; w12[28->8].text:0000000000002D74 060 ORR W11, W11, W15,LSR#20 ; w12[29->9].text:0000000000002D78 060 ORR W11, W11, W14,LSR#20 ; w12[30->10].text:0000000000002D7C 060 ORR W11, W11, W13,LSR#20 ;
获取源操作数的值
.text:0000000000002D60 060 ADD X9, X21, W9,UXTW#3.text:0000000000002D6C 060 LDR X9, [X9,#8] ; << Xn
计算一个偏移量
.text:0000000000002D84 060 ADD X9, X9, W11,SXTH ; X9 = Xn + imm16
执行
经过二次解码分析得出目前有效的真实寄存器有:x9是源寄存器加上立即的一个偏移量,w8是目标操作数索引值。
3318
:偏移量的内存中取值
3A70
:计算目标寄存器在pContext的偏移地址,x8= x21 + w8 * 8
3A74
:将内存值放入目标寄存器
.text:0000000000003318 060 LDR X9, [X9] .text:0000000000003A70 060 ADD X8, X21, W8,UXTW#3.text:0000000000003A74 060 STR X9, [X8,#8]
指令还原
加载指令:LDR Xt, [Xn + imm16]
当imm为0时:LDR Xt, [Xn]
分析完取指、解码、执行后大体得到了一个模糊的虚拟机执行框架,为了方便记忆和理解使用c伪代码来描述。
使用脚本解析opcode的op1和op2打印所有需要分析的handler。
import struct pcode_start = 0xB090 pcode_size = 0x2D8 with open("libEncryptor.so", "rb") as f: content = f.read() bytes_code = content[pcode_start : pcode_start + pcode_size] insts_op1_sets = {} insts_op2_sets = {} for pc in range(0, pcode_size, 4): word = struct.unpack("<I", bytes_code[pc : pc + 4]) if len(word) > 0: opcode = word[0] op1 = opcode & 0x3F if op1 == 11: op2 = (opcode >> 6) & 0x3F if insts_op2_sets.get(op2, None) == None: insts_op2_sets[op2] = 1 else: insts_op2_sets[op2] += 1 else: if insts_op1_sets.get(op1, None) == None: insts_op1_sets[op1] = 1 else: insts_op1_sets[op1] += 1 print("op1指令统计:") op1_sorted_dict = dict(sorted(insts_op1_sets.items(), key=lambda item: item[1], reverse=True)) for inst, count in op1_sorted_dict.items(): print(f'op1: {inst}, count: {count}') print("op2指令统计:") op2_sorted_dict = dict(sorted(insts_op2_sets.items(), key=lambda item: item[1], reverse=True)) for inst, count in op2_sorted_dict.items(): print(f'op1: 11, op2: {inst}, count: {count}')
一共打印出15个handler,好在需要分析还原的指令不多:
op1指令统计:op1: 23, count: 56op1: 40, count: 38op1: 21, count: 27op1: 24, count: 2op1: 12, count: 2op1: 17, count: 2op1: 48, count: 2op1: 52, count: 1op1: 7, count: 1op2指令统计:op1: 11, op2: 7, count: 25op1: 11, op2: 43, count: 14op1: 11, op2: 12, count: 8op1: 11, op2: 25, count: 2op1: 11, op2: 39, count: 1op1: 11, op2: 62, count: 1
在15个handler并还原后,现在重新编写脚本decode_opcode.py
解码opcode,将打印还原的指令打印出伪汇编代码并保存文件ttencryptor.asm
和generate_aes_key_iv.asm
。
def decode(pcode_start, pcode_size, liner_disasm = False): with open("libEncryptor.so", "rb") as f: content = f.read() print(f"{'pc':^8} {'指令':^8} {'op1':^8} {'op2':^8}\t{'助记符':^30}") print(f"{'-'*8} {'-'*8} {'-'*8} {'-'*8} MOV\tx0, #0\t\t\t;x0始终为0,XZR寄存器?") print(f"{'-'*8} {'-'*8} {'-'*8} {'-'*8} MOV\tx4, pArgs\t\t;参数列表指针") print(f"{'-'*8} {'-'*8} {'-'*8} {'-'*8} MOV\tx5, #0") print(f"{'-'*8} {'-'*8} {'-'*8} {'-'*8} MOV\tx6, pfn_external_func_list\t\t;外部函数列表指针") print(f"{'-'*8} {'-'*8} {'-'*8} {'-'*8} MOV\tx7, pCallRegisterTrampolineFunction\t;保存跳转函数地址") print(f"{'-'*8} {'-'*8} {'-'*8} {'-'*8} MOV\tx29, pVirualStackBottom;\t\t;虚拟机堆栈栈底") print(f"{'-'*8} {'-'*8} {'-'*8} {'-'*8} MOV\tlr, #0\t\t\t;x31=0") # 已解析的指令 known_insns_op1 = {} # dcode_insns_status=1 known_insns_op2 = {} # dcode_insns_status=2 # 未解析的指令 unknown_insts_op1 = set() # dcode_insns_status=3 unknown_insts_op2 = set() # dcode_insns_status=4 bytes_code = content[pcode_start : pcode_start + pcode_size] for pc in range(0, pcode_size, 4): word = struct.unpack("<I", bytes_code[pc : pc + 4]) asm = "" dcode_insns_status = 0 if len(word) > 0: opcode = word[0] op1 = get_op1(opcode) Xt = get_openand1(opcode) # x8/w8 Xn = get_operand2(opcode) # x9/w9 Xm = get_operand3(opcode) # x10/w10 X4 = get_operand4(opcode) # x11/w11 match op1: case 11: Xt = get_operand3(opcode) # x10/w10 Xn = get_openand1(opcode) # x8/w8 Xm = get_operand2(opcode) # x9/w9 op2 = get_op2(opcode) match op2: case 7: dcode_insns_status=2 print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tORR\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, {get_regsiter_name(Xm)}") case 12: dcode_insns_status=2 print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tADD\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, {get_regsiter_name(Xm)}") case 25: dcode_insns_status=2 if X4 == 0: print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tNOP\t\t\t\t;LSL\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, #{X4}") else: print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tLSL)\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, #{X4}") case 39: dcode_insns_status=2 print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tCMP\t{get_regsiter_name(Xm)}, {get_regsiter_name(Xn)}") print(f"{' '*8} {' '*8} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tCSET\t{get_regsiter_name(Xt)}, CC") case 43: dcode_insns_status=2 if liner_disasm: print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tBR\t{get_regsiter_name(Xm)}\t\t\t;LR={get_regsiter_name(Xt)}") else: asm = f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tBR\t{get_regsiter_name(Xm)}\t\t\t;LR={get_regsiter_name(Xt)}" set_branch_control(asm) case 62: dcode_insns_status=2 if liner_disasm: print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tExitVm\t0\t\t\t;{get_regsiter_name(Xm)}") else: asm = f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\tExitVm\t0\t\t\t;{get_regsiter_name(Xm)}" set_branch_control(asm) case _: dcode_insns_status=4 print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {op2:02d}(0x{op2:02X})\t>> op2_xxx\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, {get_regsiter_name(Xm)}") case 7: dcode_insns_status = 1 imm16 = get_imm16(opcode) print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tORR\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, #{hex(imm16)}") case 12: dcode_insns_status = 1 imm26 = get_imm26(opcode) offset = imm26 * 4 if liner_disasm: print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tB\t{hex(offset)}") else: asm = f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tB\t{hex(offset)}" set_branch_control(asm) case 17: dcode_insns_status = 1 imm16 = get_imm16(opcode) print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tADD\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, #{hex(imm16)}") case 21: dcode_insns_status = 1 imm16 = get_imm16(opcode) print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tADD\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, #{hex(imm16)}") case 23: dcode_insns_status = 1 imm16 = get_imm16(opcode) print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tSTR\t{get_regsiter_name(Xt)}, [{get_regsiter_name(Xn)}, #{hex(imm16)}]") case 24: dcode_insns_status = 1 imm16 = get_imm16(opcode) offset = pc + imm16 * 4 + 4 if liner_disasm: print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tB.HS\t#{hex(offset)}\t\t\t;{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, ${hex(imm16 * 4)}") else: asm = f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tB.HS\t#{hex(offset)}\t\t\t;{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, ${hex(imm16 * 4)}" set_branch_control(asm) case 40: dcode_insns_status = 1 imm16 = get_imm16(opcode) print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tLDR\t{get_regsiter_name(Xt)}, [{get_regsiter_name(Xn)}, #{hex(imm16)}]") case 48: dcode_insns_status = 1 imm16 = get_imm16(opcode) print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tSTR\t{get_regsiter_name(Xt, 32)}, [{get_regsiter_name(Xn)}, #{hex(imm16)}]") case 52: dcode_insns_status = 1 imm16 = get_imm16(opcode, False) print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tMOVZ\t{get_regsiter_name(Xt, 32)}, #{hex(imm16)}, LSL#16") print(f"{' '*8} {' '*8} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\tSXTW\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xt, 32)}") case _: dcode_insns_status=3 print(f"{pc:08X} {opcode:08X} {opcode & 0x3F:02d}(0x{opcode & 0x3F:02X}) {'-'*8}\t>> op1_xxx\t{get_regsiter_name(Xt)}, {get_regsiter_name(Xn)}, {get_regsiter_name(Xm)}") if not liner_disasm: branch_pipeline_process() record_insns(dcode_insns_status, known_insns_op1, known_insns_op2, unknown_insts_op1, unknown_insts_op2, opcode) else: print("error") decode_statistics(known_insns_op1, known_insns_op2, unknown_insts_op1, unknown_insts_op2) def main(): # 解码vm2 pcode_start = 0xB090 pcode_size = 0x2D8 decode(pcode_start, pcode_size) # # 解码vm3,获取aes的key和iv pcode_start = 0xBDE0 pcode_size = 0x1C8 decode(pcode_start, pcode_size) # 解码vm1,JNI_OnLoad pcode_start = 0x85C0 pcode_size = 0xCC decode(pcode_start, pcode_size) if __name__ == "__main__": main()
vm2还原的伪汇编代码,经过分析主要做了这些事件:
保存寄存器环境
解密外部函数地址
生成随机数
以随机数为入参生成aes加密的key和iv
生成pSrcBuffer的hash
拼接数据hash+scrBuffer
aes加密拼接的数据
拼接加密结果: magic(0x6字节) + randNumber(0x20字节) + aesOut
恢复寄存器环境
由于vm2会调用vm3生成aes的key和iv,因此vm3的代码也需要解析还原generate_aes_key_iv.asm:
保存寄存器环境
解密外部函数地址
对随机数生成hash
解密种子数据
对随机数生成hash和种子数据再次生成hash
再次生成的hash数据中截取key和iv
恢复寄存器环境
这就是libEncryptor.so中ttEncrypt函数的加密算法了。
import secretsimport hashlibfrom Crypto.Cipher import AES # pip install pycryptodomefrom Crypto.Util.Padding import pad, unpaddef generate_rand_number(): return secrets.token_bytes(32)def decrypt_seeds(): key1 = [0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB] key1 += [0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB] key1 += [0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E] key1 += [0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25] key2 = [0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F] key2 += [0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF] key2 += [0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61] key2 += [0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D] results = bytearray() for i in range(len(key1)): results.append(key1[i] ^ key2[i]) return resultsdef sha512(buff): sha512_hash = hashlib.sha512() sha512_hash.update(buff) return sha512_hash.digest()def get_aes_key_iv(rand_num): rand_num_hash = sha512(rand_num) seeds = decrypt_seeds() data = rand_num_hash + seeds key_iv_hash = sha512(data) print(f"rand_num: {rand_num.hex()}") print(f"rand_num_hash: {rand_num_hash.hex()}") print(f"seeds: {seeds.hex()}") print(f"rand_num_hash + seeds: {data.hex()}") print(f"key_iv_hash: {key_iv_hash.hex()}") print(f"aes key: {key_iv_hash[0:0x10].hex()}") print(f"aes iv: {key_iv_hash[0x10:0x20].hex()}") return key_iv_hash[0:0x10], key_iv_hash[0x10:0x20]def aes_128_cbc_encrypt(plaintext, key, iv): # 创建一个 AES 加密器对象 cipher = AES.new(key, AES.MODE_CBC, iv) # 添加填充并加密数据 padded_data = pad(plaintext, AES.block_size) ciphertext = cipher.encrypt(padded_data) return ciphertextdef get_magic(): return b"\x74\x63\x05\x10\x00\x00"def ttEncrypt(buff): magic = get_magic() rand_number = generate_rand_number() aes_key, aes_iv = get_aes_key_iv(rand_number) buff_hash = sha512(buff) aes_plaintext = buff_hash + buff aes_ciphertext = aes_128_cbc_encrypt(aes_plaintext, aes_key, aes_iv) print(f"plaintext: {buff.hex()}") print(f"rand_number: {rand_number.hex()}") print(f"buff hash: {buff_hash.hex()}") print(f"aes_plaintext: {aes_plaintext.hex()}") print(f"ciphertext: {aes_ciphertext.hex()}") return magic + rand_number + aes_ciphertextdef main(): buff = b'aabbccddeeffgg' result = ttEncrypt(buff) print(f"ttEncrypt: {result.hex()}")def test_aes(): buff = b"\x61\x02\xbe\x54\xa6\x2a\x73\xe7\x65\xba\x38\xc9\x87\x34\x09\xbd" +\ b"\xeb\xb6\xb0\xd3\x7e\xa0\x60\x40\x3d\x0c\x26\xfe\xa5\xeb\xb6\xba" +\ b"\x5a\x0c\x7f\x36\xec\xb7\x58\xc7\x7e\x19\x37\x50\x5f\xa8\x5b\x4e" +\ b"\x77\xce\x82\x7a\x70\x09\xd2\x2b\x2f\xaf\xc4\x68\x00\xd7\xa9\xff" +\ b"\x62\x69\x61\x6e\x66\x65\x6e\x67" aes_key = b"\xe8\xaf\x6e\x91\xde\x99\x7e\xf0\xfa\xfb\xcd\xbe\x97\x73\xb2\xc5" aes_iv = b"\x03\x7e\xed\x97\x4e\x1e\xc5\x19\xdc\xc2\xb4\x35\x5b\x26\xf0\x1b" ciphertext = aes_128_cbc_encrypt(buff, aes_key, aes_iv) print(f"ciphertext: {ciphertext.hex()}")if __name__ == "__main__": main() # test_aes()
为了验证算法是否有效,这里使用了dy模块中的ttEncrypt算和tt的设备注册代码:
import secretsimport uuidimport timeimport jsonimport hashlibimport gzipimport requests # pip install requestsimport ttEncryptorUtil# 注: 协议来自Tiktokdef UUID(): return str(uuid.uuid4())def md5(message): md5_hash = hashlib.md5() md5_hash.update(message) return md5_hash.hexdigest()def get_timestamp_in_millisecond(): return int(time.time() * 1000)def get_timestamp_in_second(): return int(time.time())def generate_android_id(): return secrets.token_bytes(8).hex()def http_post(url, headers, payload): response = requests.request("POST", url, headers=headers, data=payload) return response.textdef gzip_compress(buff): return gzip.compress(buff)def get_post_data(android_id, cdid, google_aid, clientudid): openudid = android_id postDataObj = { "magic_tag": "ss_app_log", "header": { "display_name": "TikTok", "update_version_code": 2023205030, "manifest_version_code": 2023205030, "app_version_minor": "", "aid": 1233, "channel": "googleplay", "package": "com.zhiliaoapp.musically", "app_version": "32.5.3", "version_code": 320503, "sdk_version": "3.9.17-bugfix.9", "sdk_target_version": 29, "git_hash": "3e93151", "os": "Android", "os_version": "11", "os_api": 30, "device_model": "Pixel 2", "device_brand": "google", "device_manufacturer": "Google", "cpu_abi": "arm64-v8a", "release_build": "e7cd5de_20231207", "density_dpi": 420, "display_density": "mdpi", "resolution": "1794x1080", "language": "en", "timezone": -5, "access": "wifi", "not_request_sender": 1, "rom": "6934943", "rom_version": "RP1A.201005.004.A1", "cdid": cdid, "sig_hash": "194326e82c84a639a52e5c023116f12a", # md5(packageInfo.signatures[0]) "gaid_limited": 0, "google_aid": google_aid, "openudid": openudid, "clientudid": clientudid, "tz_name": "America\\/New_York", "tz_offset": -18000, "req_id": UUID(), "device_platform": "android", "custom": { "is_kids_mode": 0, "filter_warn": 0, "web_ua": "Mozilla\\/5.0 (Linux; Android 11; Pixel 2 Build\\/RP1A.201005.004.A1; wv) AppleWebKit\\/537.36 (KHTML, like Gecko) Version\\/4.0 Chrome\\/116.0.0.0 Mobile Safari\\/537.36", "user_period": 0, "screen_height_dp": 683, "user_mode": -1, "apk_last_update_time": 1702363135217, "screen_width_dp": 411 }, "apk_first_install_time": 1697783355395, "is_system_app": 0, "sdk_flavor": "global", "guest_mode": 0 }, "_gen_time": get_timestamp_in_millisecond() } return gzip_compress(json.dumps(postDataObj).encode(encoding='utf-8'))def get_headers(md5Hash): headers = { 'log-encode-type': 'gzip', 'x-tt-request-tag': 't=0;n=1', 'sdk-version': '2', 'X-SS-REQ-TICKET': f'{get_timestamp_in_millisecond()}', 'passport-sdk-version': '19', 'x-tt-dm-status': 'login=0;ct=1;rt=4', 'x-vc-bdturing-sdk-version': '2.3.4.i18n', 'Content-Type': 'application/octet-stream;tt-data=a', 'X-SS-STUB': md5Hash, 'Host': 'log-va.tiktokv.com' } return headersdef get_device_register_url(openudid, cdid): url = 'https://log-va.tiktokv.com/service/2/device_register/?' + \ "tt_data=a" + \ "ac=wifi" + \ "channel=googleplay" + \ "aid=1233" + \ "app_name=musical_ly" + \ "version_code=320503" + \ "version_name=32.5.3" + \ "device_platform=android" + \ "os=android" + \ "ab_version=32.5.3" + \ "ssmix=a" + \ "device_type=Pixel+2" + \ "device_brand=google" + \ "language=en" + \ "os_api=30" + \ "os_version=11" + \ f"openudid={openudid}" + \ "manifest_version_code=2023205030" + \ "resolution=1080*1794" + \ "dpi=420" + \ "update_version_code=2023205030" + \ f"_rticket={get_timestamp_in_millisecond()}" + \ "is_pad=0" + \ "current_region=TW" + \ "app_type=normal" + \ "timezone_name=America%2FNew_York" + \ "residence=TW" + \ "app_language=en" + \ "ac2=wifi5g" + \ "uoo=0" + \ "op_region=TW" + \ "timezone_offset=-18000" + \ "build_number=32.5.3" + \ "host_abi=arm64-v8a" + \ "locale=en" + \ f"ts={get_timestamp_in_second()}" + \ f"cdid={cdid}" return urldef device_register(): android_id = generate_android_id() cdid = UUID() google_aid = UUID() clientudid = UUID() gzip_post_data = get_post_data(android_id, cdid, google_aid, clientudid) ttencrypt_post_data = ttEncryptorUtil.ttEncrypt(gzip_post_data) headers = get_headers(md5(ttencrypt_post_data)) url = get_device_register_url(android_id, cdid) response = http_post(url, headers, ttencrypt_post_data) print(f"设备注册结果:\n{response}")if __name__ == "__main__": device_register()
结果:
设备注册结果:{"server_time":1719390270,"device_id":7384724147647088134,"install_id":7384724761467832069,"new_user":1,"device_id_str":"7384724147647088134","install_id_str":"7384724761467832069"}
在分析完之后虚拟机保护的强度没有想像中的那么好,在分析过的虚拟化加密强度非要分10个等级的话,VMProtect为10级、Safengine Shielden强度为9级、Themida强度9级、VProtect强度7级、Enigma Protector强度3级,而它的强度等级仅为1级。
-官方论坛
👆👆👆
公众号设置“星标”,您不会错过新的消息通知
如开放注册、精华文章和周边活动等公告