记一次混淆算法逆向分析 | WooYun知识库
source link:
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
记一次混淆算法逆向分析
0x00 前言
小弟最近整理之前的资料,偶然发现半年前的混淆对抗研究以及一道CTF练习题目,故分享以作记录。限于水平,难免会有疏漏或者错误之处,望各位读者批评指正。
0x01 基本分析
jeb打开文件,找到方法校验方法。逻辑很简单,校验函数既是Native函数check.
#!vb
public native boolean check(String arg1) {
}
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(2130903040);
this.inputCode = this.findViewById(2131099648);
this.btn_submit = this.findViewById(2131099649);
this.btn_submit.setOnClickListener(new View$OnClickListener() {
public void onClick(View v) {
if(MainActivity.this.check(MainActivity.this.inputCode.getText().toString())) {
MainActivity.this.startActivity(new Intent(MainActivity.this, ResultActivity.class));
}
else {
Toast.makeText(MainActivity.this.getApplicationContext(), "Incorrect Password!",
0).show();
}
}
});
}
直接使用IDA默认Loader打开直接崩溃,存在畸形ELF文件对抗,使用自定义LOADER加载,也是然并卵的节奏。
使用Tracer动态打印check函数地址,挂起进程,dump出对应的代码段加载到IDA,找到check函数。
#!bash
seg000:4561E4E8 check
seg000:4561E4E8 LDR PC, =sub_4561E4EC
seg000:4561E4E8 ; End of function check
seg000:4561E4E8
seg000:4561E4EC ; =============== S U B R O U T I N E seg000:4561E4EC sub_4561E4EC ; CODE XREF: checkj
seg000:4561E4EC ; DATA XREF: seg000:4561E4EC STMFD SP!, {R0-R12,LR}
seg000:4561E4F0 LDR R0, =6
seg000:4561E4F4 B loc_4561E444
通过分析发现,其实为一个汇编stub,通过此stub跳到真正的check函数。
#!bash
seg013:80A0135C sub_80A0135C ; DATA XREF: seg013:80A13F98o
seg013:80A0135C B sub_80A065B8
seg013:80A0135C ; End of function sub_80A0135C
seg013:80A01360
seg013:80A01360 ; =============== S U B R O U T I N E seg013:80A01360 ; Attributes: thunk
seg013:80A01360
seg013:80A01360 sub_80A01360 ; DATA XREF: sub_80A065C4+Co
seg013:80A01360 B sub_80A065F8
seg013:80A01360 ; End of function sub_80A01360
seg013:80A01364 ; =============== S U B R O U T I N E seg013:80A01364 ; Attributes: thunk
seg013:80A01364
seg013:80A01364 sub_80A01364 ; CODE XREF: sub_80A06620j
seg013:80A01364 B sub_80A0663C
seg013:80A01364 ; End of function sub_80A01364
以80A0135C(B sub_80A065B8)为例子,跟进sub_80A065B8,可以看到如下指令:
#!bash
// 0x80A0135C
seg013:80A065B8 BEQ loc_80A0658C
seg013:80A065BC BNE loc_80A0658C
seg013:80A0658C STMFD SP!, {R3-R8,R10,LR} 真实指令
seg013:80A06590 STMFD SP!, {R8,LR}
seg013:80A06594 LDR R8, loc_80A065A4
seg013:80A06598 LDR R8, loc_80A065A8
seg013:80A0659C LDR R8, loc_80A065AC
seg013:80A065A0 LDR R8, locret_80A065B0
seg013:80A065A4 LDR R8, =(sub_80A065C4 - 0x80A065B0)
seg013:80A065A8 ADD R8, PC, R8 ; sub_80A065C4
seg013:80A065AC STR R8, [SP,#4]
seg013:80A065B0 LDMFD SP!, {R8,PC}
seg013:80A065C4 STMFD SP!, {R8,LR}
seg013:80A065C8 LDR R8, =0xFFFFAD25
seg013:80A065CC EOR R8, R8, #0xAD
seg013:80A065D0 ADD R8, PC, R8 ; loc_80A01360 //返回到80A01360
seg013:80A065D4 STR R8, [SP,#8+var_4]
seg013:80A065D8 LDMFD SP!, {R8,PC}
通过分析可以得到真实指令(STMFD SP!, {R3-R8,R10,LR}),其余指令为混淆指令,最终返回到下一条B即80A01360(B sub_80A065F8)指令。通过分析其他B指令,可以得到类似的混淆指令中夹在一条真实指令,只是存在多种混淆的方式。 至此,我们可以得到此混淆的思路:执行"一个B指令"即一条真实的指令,混淆抽象为:
- 执行前跳转混淆
- 真实指令
- 执行后跳转混淆
不难发现,如果仅仅靠一条一条的寻找真实指令,是非常费时费力的。由于执行前后都存在多种模式的混淆,但总的模式是有限的,那么通过提取指令特征匹配即可以自动化实现去混淆,找出真实指令。
0x02 基于指令特征匹配对抗混淆
通过分析找到所有的混淆模式,最后大概几种。限于篇幅,列举一些做说明
#!bash
// 0x80A0135C
seg013:80A065B8 BEQ loc_80A0658C
seg013:80A065BC BNE loc_80A0658C
seg013:80A0658C STMFD SP!, {R3-R8,R10,LR} 真实指令
seg013:80A06590 STMFD SP!, {R8,LR}
seg013:80A06594 LDR R8, loc_80A065A4
seg013:80A06598 LDR R8, loc_80A065A8
seg013:80A0659C LDR R8, loc_80A065AC
seg013:80A065A0 LDR R8, locret_80A065B0
seg013:80A065A4 LDR R8, =(sub_80A065C4 - 0x80A065B0)
seg013:80A065A8 ADD R8, PC, R8 ; sub_80A065C4
seg013:80A065AC STR R8, [SP,#4]
seg013:80A065B0 LDMFD SP!, {R8,PC}
seg013:80A065C4 STMFD SP!, {R8,LR}
seg013:80A065C8 LDR R8, =0xFFFFAD25
seg013:80A065CC EOR R8, R8, #0xAD
seg013:80A065D0 ADD R8, PC, R8 ; loc_80A01360
seg013:80A065D4 STR R8, [SP,#8+var_4]
seg013:80A065D8 LDMFD SP!, {R8,PC}
执行前混淆:B(连续两条条件完全相反的指令) next_jmp
执行后混淆:这里有两组STMFD--LDMFD构成的跳转stub,但其是为一种模式。那如何计算next_jmp
呢?这里我采用取巧的方式,通过从LDMFD所在地址反向找到ADD指令,得到";loc_80a01360"
,再解析出地址80a01360。当然,存在多种prefix,需要作简单处理获取地址。
#!python
def prefix_match(str):
pattern = ['sub_', 'loc_', 'unk_', 'locret_']
for prefix in pattern:
if str.find(prefix) > -1:
substr = str[str.find(prefix) + len(prefix):]
return string.atoi(substr, 16)
return 0xffffffff;
真实指令:通过解析跳转遍历完整个混淆后,通过堆栈平衡原理,提取出真实指令。以上述分析为例,遍历回到下一条指令80a01360后,对指令进行分组即(B)(STMFD SP!, {R3-R8,R10,LR})(STMFD-LDMFD)(STMFD-LDMFD),非常容易获取真实指令。实现时,可将分组过程可融入到指令的遍历即可。
再接着看另一组混淆,以80A01360(B sub_80A065F8)为例。
#!bash
// 0x80A01360
seg013:80A065F8 STMFD SP!, {R0,LR}
seg013:80A065FC LDR R0, loc_80A0660C
seg013:80A06600 LDR R0, loc_80A06610
seg013:80A06604 LDR R0, loc_80A06614
seg013:80A06608 LDR R0, locret_80A06618
seg013:80A0660C LDR R0, =(loc_80A065E0 - 0x80A06618)
seg013:80A06610 ADD R0, PC, R0 ; loc_80A065E0
seg013:80A06614 STR R0, [SP,#4]
seg013:80A06618
seg013:80A06618 LDMFD SP!, {R0,PC}
seg013:80A065E0 LDR R3, [R0] //真实指令
seg013:80A065E4 STMFD SP!, {R0,LR}
seg013:80A065E8 MOV LR, PC
seg013:80A065EC BL loc_80A065F0
seg013:80A065F0
seg013:80A065F0 loc_80A065F0 ; CODE XREF: seg013:80A065ECj
seg013:80A065F0 LDMFD SP!, {R0,LR}
seg013:80A065F4 B sub_80A06620
seg013:80A06620 B sub_80A01364
执行前混淆:STMFD-LDMFD跳转到loc_80A065E0
。获取next_jmp
和上述一致。
执行后混淆:通过STMFD-LDMFD和两次B直接跳转返回到下一条B指令地址sub_80A01364。
真实指令:和上述类似,遍历混淆指令时,对指令进行分组(STMFD-LDMFD)、(LDR R3, [R0])、(B)、(B)。易获取真实指令(LDR R3, [R0])。
通过上述方法,大概分析20个多有的B指令即可找到所有的混淆模式,总的来说混淆的模式是有限的。
通过编写IDAPython脚本,即可实现自动打印真实指令。
#!bash
0x80a0135c PUSH {r3, r4, r5, r6, r7, r8, sl, lr}
0x80a01360 LDR r3, [r0]
0x80a01364 MOV r1, r2
0x80a01368 MOV r6, r2
0x80a0136c LDR r3, [r3, #0x2a4]
0x80a01370 MOV r2, #0
0x80a01374 MOV r4, r0
0x80a01378 BLX r3
0x80a0137c MOV r7, r0
但存在问题,当IDA并没有识别出指令时,无法通过GetMnem等API获取信息。
由于混淆对IDA指令识别的影响,致使IDA无法自动将指令反汇编出来。可能已经有读者意识到,遇到这种情况直接调用MakeCode将数据转化为指令即可。然而,实际使用MakeCode自动处理时,并不能完成手动按'C'识别指令的功能。那么,是否遇到这种情况后,就手动去完成指令反汇编呢?答案是否定的。由于存在很多这种情况,手动转化也很费时(测试环境IDA6.8)。
到这里可以看到,单纯依靠简单的字符串匹配比较的方法,并不能完全满足自动化提取指令对抗混淆的需求。
0x03 基于指令解析执行对抗混淆
通过上述分析,由于IDA存在无法自动反汇编一部分opcode数据,故单纯依靠IDAPython是无法满足指令解析指令的需求的。为了实现对指令的解析,可采用两种途径:
- 对照arm汇编手册,编写常见的opcode解析脚本。以笔者的经验,这部分内容是比较耗时的。
- 引入现有的反汇编引擎,且这种反汇编引擎具备对指令的想尽分析的能力。这里,我选用Capstone。
Capstone是一款支持多种架构的反汇编引擎,支持对汇编指令粗略和详细的分析,支持多种语言。当然,Capstone还有很多其他优点,这里就不赘述了。
3.1 ARM处理器模拟
可能有读者马上会问,模拟arm处理器执行不又是一大工程呢。的确,完全模拟确实包含许多工作量。但结合此混淆的一些特性,整个模拟执行可简化许多。
首先,此混淆并不存在流程分支扁平化(与OLLVM相对比)。结合上述分析也可以看到,所有的混淆执行并不会影响条件标志即CPSR寄存器。
再者,结合堆栈平衡原理,SP寄存器仅仅只需要保存堆栈的变化,比如stmfd仅仅对SP寄存器进行减法操作。
最后,根据上述找到的混淆模式,可以发现使用的指令其实很少,实际编写模拟函数工作量也比较小。
#!python
def do_emulate(code, base, Rx):
ret_addr = 0xffffffff
emu = ARM_emu()
md = Cs(CS_ARCH_ARM, CS_MODE_ARM)
md.detail = True
for i in md.disasm(code, base):
emu.regs[PC] = i.address + 2 * inst_size
dst = i.operands[0]
src = i.operands[1]
if (i.mnemonic).upper() == 'LDR':
if dst.type == ARM_OP_REG and src.type == ARM_OP_MEM:
Rd = conv_reg(dst.value.reg)
Rs = conv_reg(src.value.mem.base)
addr = emu.regs[Rs] + src.value.mem.disp
emu.regs[Rd] = Dword(addr & 0xffffffff)
if Debug:
print ('\t LDR %s :\t0x%x' %(i.op_str, emu.regs[Rd]))
elif (i.mnemonic).upper() == 'ADD':
if i.operands[0].type == ARM_OP_REG and i.operands[1].type == ARM_OP_REG and i.operands[2].type == ARM_OP_REG:
Rd = conv_reg(i.operands[0].value.reg)
R1 = conv_reg(i.operands[1].value.reg)
R2 = conv_reg(i.operands[2].value.reg)
emu.regs[Rd] = (emu.regs[R1] + emu.regs[R2]) & 0xffffffff
if Debug:
print ('\t ADD %s :\t0x%x' %(i.op_str, emu.regs[Rd]))
...
在模拟执行一条真实指令时,首先将所有寄存器的初始值设置为0,通过主流程中的B指令进入到混淆指令。
3.2 真实指令提取
模拟执行时,将混淆中的每条指令都存储到一个指令堆栈中。结合之前直接字符串模式的思路,来实现对真实指令的提取。
以80A01364为例子来说明真实指令的提取方法。
#!bash
seg013:80A01364 sub_80A01364 ; CODE XREF: sub_80A06620j
seg013:80A01364 B sub_80A0663C
seg013:80A0663C BMI loc_80A06648
seg013:80A06640 BPL loc_80A06644
seg013:80A06644
seg013:80A06644 loc_80A06644 ; CODE XREF: sub_80A0663C+4j
seg013:80A06644 ; sub_80A0663C:loc_80A06648j
seg013:80A06644 B loc_80A06624
seg013:80A06648 ;
seg013:80A06648
seg013:80A06648 loc_80A06648 ; CODE XREF: sub_80A0663Cj
seg013:80A06648 B loc_80A06644
seg013:80A06624 loc_80A06624 ; CODE XREF:
seg013:80A06624 MOV R1, R2
seg013:80A06628 STMFD SP!, {R0,LR}
seg013:80A0662C MOV LR, PC
seg013:80A06630 BL loc_80A06634
seg013:80A06634 ;
seg013:80A06634
seg013:80A06634 loc_80A06634 ; CODE XREF: sub_80A0663C-Cj
seg013:80A06634 LDMFD SP!, {R0,LR}
seg013:80A06638 B sub_80A0664C
seg013:80A0664C sub_80A0664C ; CODE XREF: sub_80A0663C-4p
seg013:80A0664C B sub_80A01368
若不在模拟执行中对指令堆栈修正,那么执行完后存储指令如下所示:
#!bash
80A0663C BMI loc_80A06648
80A06648 B loc_80A06644
80A06644 B loc_80A06624
80A06624 MOV R1, R2
80A06628 STMFD SP!, {R0,LR}
80A06630 BL loc_80A06634
80A06634 LDMFD SP!, {R0,LR}
80A06638 B sub_80A0664C
80A0664C B sub_80A01368
对于BMI,虽然形式上和之前分析的(BEQ loc_80A0658C
,BNE loc_80A0658C
)直接跳到next_jmp
,但检测下一条指令即可根据条件相反去处。
对于STM-LDM,当遇到LDM指令时,将STM-LDM及其之间的指令出栈移除。
剩余(B B MOV B)这些指令,根据上述人工分析的结果可知,因为只存在一条真实指令,那么MOV必定是真实指令。另外,存在这种情况(B B BNE B),产生这种情况的根本原因是混淆前这条指令是if或者循环语句的判定点,直接取出BNE指令即可。
3.3 函数识别
不管是基于指令名称匹配还是解析执行,都需要对函数进行识别。先来看一个函数混淆片段:
#!bash
seg013:80A067F0 loc_80A067F0 ; CODE XREF: seg013:loc_80A06838j
seg013:80A067F0 ADR LR, sub_80A06814
seg013:80A067F4 STMFD SP!, {R8,R9,LR}
seg013:80A067F8 LDR R8, loc_80A067FC
seg013:80A067FC
seg013:80A067FC loc_80A067FC ; DATA XREF: seg013:80A067F8r
seg013:80A067FC LDR R9, =0x1A6016A4
seg013:80A06800 ADD R8, R9, R8
seg013:80A06804 ADD R8, PC, R8 ; j_strlen
seg013:80A06808 STR R8, [SP,#8]
seg013:80A0680C LDMFD SP!, {R8,R9,PC}
对于未混淆的指令,函数通常被编译为BL或者BLX(指令模式切换)。由于B指令本身的跳转地址范围很有限,那么混淆后代码膨胀必定需要对其指令修正,有点类似InlineHook指令修正。另外,函数的返回地址需要显式存放到LR寄存器。
这样,上述代码在模拟执行时,当LR寄存器值不为0时,将后续的函数调用转化为'Call sub_xxx'指令,将PC置为next_jmp
(sub_80A06814
)接着模拟。
另外,便于更加清晰的分析,将libc.so加载到和进程一致的基地址,通过IDAPython GetFunctionName获取函数名称。
至此,即可提取出真实指令,check函数流程:
#!bash
0x80a0135c PUSH {r3, r4, r5, r6, r7, r8, sl, lr}
0x80a01360 LDR r3, [r0]
0x80a01364 MOV r1, r2
0x80a01368 MOV r6, r2
0x80a0136c LDR r3, [r3, #0x2a4]
0x80a01370 MOV r2, #0
0x80a01374 MOV r4, r0
0x80a01378 BLX r3
0x80a0137c MOV r7, r0
0x80a01380 call j_strlen
0x80a01384 ADD sl, r0, #1
0x80a01388 MOV r8, r0
0x80a0138c MOV r0, sl
0x80a01390 call j_malloc_0
0x80a01394 MOV r1, r7
0x80a01398 MOV r2, sl
0x80a0139c MOV r5, r0
0x80a013a0 call j_memcpy
0x80a013a4 LDR r3, [r4]
0x80a013a8 MOV r2, #0
0x80a013ac STRB r2, [r5, r8]
0x80a013b0 LDR r3, [r3, #0x2a8]
0x80a013b4 MOV r2, r7
0x80a013b8 MOV r0, r4
0x80a013bc MOV r1, r6
0x80a013c0 BLX r3
0x80a013c4 LDR R0, =0x12BC4
0x80a013c8 MOV r1, #0x80
0x80a013cc LDR R0, [PC,R0]
0x80a013d0 call 0x80a01048
0x80a013d4 ADD r5, r5, r0
0x80a013d8 MOV r0, r5
0x80a013dc call 0x80a010c0
0x80a013e0 MOV r4, r0
0x80a013e4 MOV r0, r5
0x80a013e8 call j_free_1
0x80a013ec MOV r0, r4
0x80a013f0 POP {r3, r4, r5, r6, r7, r8, sl, pc}
0x04 算法逆向分析
通过简单分析check即可看到算法的核心流程在0x80a010c0这个函数,而0x80a01048函数的功能是对指令路径上的断点进行检测,和其他平台的反调试思路类似,这里把重点放在0x80a010c0的逆向上。自动化分析得到0x80a010c0函数:
#!bash
0x80a010c0 PUSH {r4, r5, r6, r7, r8, sb, sl, lr}
0x80a010c4 LDR R7, =0x12EB4
0x80a010c8 SUB sp, sp, #0x308
0x80a010cc ADD r6, sp, #4
0x80a010d0 LDR R7, [PC,R7]
0x80a010d4 LDR r3, [r7]
0x80a010d8 MOV r4, r0
0x80a010dc MOV r1, #0
0x80a010e0 MOV r2, #0x100
0x80a010e4 MOV r0, r6
0x80a010e8 ADD r5, sp, #0x104
0x80a010ec STR r3, [sp, #0x304]
0x80a010f0 call j_memset
0x80a010f4 MOV r1, #0
0x80a010f8 MOV r2, #0x100
0x80a010fc MOV r0, r5
0x80a01100 call j_memset
0x80a01104 MOV r0, r4
0x80a01108 call j_strlen
0x80a0110c SUBS sb, r0, #0
0x80a01110 MOVEQ r0, sb
0x80a01114 BNE #0x80a01130
0x80a01118 LDR r2, [sp, #0x304]
0x80a0111c LDR r3, [r7]
0x80a01120 CMP r2, r3
0x80a01124 BNE #0x80a01334
0x80a01128 ADD sp, sp, #0x308
0x80a0112c POP {r4, r5, r6, r7, r8, sb, sl, pc}
//获取代码段起始256字节作为key
0x80a011bc LDR R0, =0x12DC0 //读取代码段起始地址
0x80a011c0 LDR LR, =0x66666667
0x80a011c4 MOV r4, #0
0x80a011c8 LDR R0, [PC,R0]
0x80a011cc MOV r3, r0
0x80a011d0 SMULL r2, ip, lr, r4
0x80a011d4 ASR r2, r4, #0x1f
0x80a011d8 LDRB r1, [r3]
0x80a011dc RSB r2, r2, ip, asr #1
0x80a011e0 ADD r2, r2, r2, lsl #2
0x80a011e4 RSB r2, r2, r4
0x80a011e8 STRB r1, [r6, r4]
0x80a011ec ADD r4, r4, #1
0x80a011f0 CMP r4, #0x100
0x80a011f4 ADD r3, r3, r2
0x80a011f8 BNE #0x80a011d0
//key变换流程
0x80a01218 MOV r3, #0
0x80a0121c MOV r0, r3
0x80a01220 ADD r4, r4, #1
0x80a01224 ADD r6, sp, #0x308
0x80a01228 AND r4, r4, #0xff
0x80a0122c ADD r1, r6, r4
0x80a01230 LDRB r2, [r1, #-0x304]
0x80a01234 LDRB r8, [r5, r3]
0x80a01238 AND ip, r3, #7
0x80a0123c ADD r0, r2, r0
0x80a01240 AND r0, r0, #0xff
0x80a01244 ADD r6, r6, r0
0x80a01248 LDRB sl, [r6, #-0x304]
0x80a0124c ASR sb, r8, #5
0x80a01250 ORR r8, sb, r8, lsl #3
0x80a01254 STRB sl, [r1, #-0x304]
0x80a01258 STRB r2, [r6, #-0x304]
0x80a0125c LDRB r6, [r1, #-0x304]
0x80a01260 ADD sl, sp, #0x308
0x80a01264 RSB r1, ip, #8 // 8 - [0, 7]
0x80a01268 ADD r2, r2, r6
0x80a0126c AND r2, r2, #0xff
0x80a01270 ADD r2, sl, r2
0x80a01274 LDRB r2, [r2, #-0x304]
0x80a01278 EOR r2, r2, r8
0x80a0127c AND r2, r2, #0xff
0x80a01280 LSL r1, r2, r1
0x80a01284 ORR ip, r1, r2, asr ip // 循环左移(8 - i)位
0x80a01288 STRB ip, [r5, r3]
0x80a0128c ADD r3, r3, #1
0x80a01290 CMP r3, #0x100
0x80a01294 BNE #0x80a01220
for(i = 0; i < 0x100; i++){
left_rotate(right_rotate(mid_code[i], 5) ^ key_stream[i], 8 - (i % 8));
...
限于篇幅,就不在一一分析。其中包括RC4算法。最后得到算法编码主流程:
#!cpp
char gen_mid_code[N];
char key_stream[N];
for(i = 0; i < N; i++){
gen_mid_code[i] = left_rotate(str[gen_index(i, strlen(str))], 8 - (i % 8));
}
gen_key_stream(ori_key, key_stream);
RC4_encrypt(gen_mid_code, key_stream, final_code);
for(i = 0; i < N; i++){
if(final_code[i] != check_code[i]){
...
}
}
最后,得到flag:Hello Tomorrow!
至此,此ctf题目大致分析完毕。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK