反混淆:字符串加密

字符串与常量加密也是常规的混淆方案,很常见。

这一章会介绍常见的字符串加密场景,以及静态对抗字符串加密混淆的常用方案

通用的字符串加密对抗

字符串加密有很多种小情况:解密的方式,什么时候解密,解密数据放到哪里,等等

总的来说,有这么几种通用的方法:

  • Dump内存:如果解密逻辑都放在初始化中(比如JNI_Onload 或者 init_func),程序执行后Dump内存是一种很棒的方法。但是,现在复杂的混淆都会边执行函数边解密,这就使得dump内存的方法不那么好用了;还存在一些其它的情况,比如解密之后把数据加密回去。

  • 模拟执行:模拟执行也是很通用的方法,通常用于对抗将字符串解密放到函数开头的场景。

还有一些小技巧,比如在IDA中,我们可以将__DATA段设置为只读的,这样IDA通过常量计算的能力,在F5中能看到部分的数据。

独立的字符串解密函数

比较早期的字符串加密方案,将字符串全部加密存储后,运行时调用解密函数。

分析解密函数的逻辑后,通过XREF找到所有的调用点,分析传入的参数就能很容易的写出解密逻辑。

样例:

如上图,decrypt_str_buffer_1CE30函数将会传入四个参数,进行解密;我们可以简单的编写脚本,进行解密,解密的结果如下:

下面是我写的一个简单的脚本

import flare_emu
import idc
import idaapi
import ida_bytes
import idautils
import keypatch


func_addr = 0
wait_string_addr = []
if func_addr == 0:
    func_addr = idaapi.get_screen_ea()
do_function = idaapi.get_func(func_addr)
flowchart = idaapi.FlowChart(do_function)
# 遍历流图中的所有function-call
for block in flowchart:
    # 打印当前basic-block的起始与结束地址
    # print("Basic-block: 0x%x - 0x%x" % (block.start_ea, block.end_ea))
    for head in idautils.Heads(block.start_ea, block.end_ea):
        if not idaapi.is_call_insn(head):
            continue
        call_name = idc.GetDisasm(head)
        if "decrypt_str_buffer_1CE30" not in call_name: 
            continue
        
        # 直接模拟获得返回值
        eh = flare_emu.EmuHelper()
        # 传入待选参数 endAddr,和第一个参数共同标识一段地址区间
        eh.emulateRange(block.start_ea, endAddr=head, skipCalls=True)
        p0 = eh.getRegVal("X0")
        p1 = eh.getRegVal("X1")
        p2 = eh.getRegVal("W2")
        p3 = eh.getRegVal("W3")
        if p0 == 0 or p1 == 0 or p2 ==0 or p3 == 0:
            continue
        print(f"call at {hex(head)}, x0={hex(p0)}, x1={hex(p1)}, x2={hex(p2)}, x3={hex(p3)}") 
        #wait_string_addr.append(ret)
        before_bytes = idaapi.get_bytes(p1, p3)
        result = ""
        xor_data = p2
        for each in before_bytes:
            result = result + chr(xor_data ^ each)
            xor_data = xor_data+3
        
        ida_bytes.patch_bytes(p0 ,result.encode()+ b"\x00")
        idc.create_strlit(p0, p0+p3)

        set_type = idc.parse_decl(f"const char[{p3}];",idc.PT_SILENT)
        idc.apply_type(p0, set_type)

配合着脚本,解释一些对抗中可能会遇到一些小问题:

  • 如何获得函数传入的参数:静态还原工具不太好直接提取出,为了通用性上面的脚本使用了模拟执行,还有一种做法是向上解析汇编。

  • 解密的结果在动态分配的内存:有的时候,解密的结果会放到malloc的内存中,通过一个全局或者局部的指针使用,这种情况解密的结果需要可能要处理内存对象,不太好处理,比较偷懒的办法就是加注释。

解密逻辑在每个函数头部

也是很常见的字符串加密,通常来说会有个原子atomic全局变量包裹,整个逻辑inline在每个函数的头部,

案例:

我通常使用模拟执行对抗。模拟执行头部,监控所有的__data段写入;

参考下面我这个通用的代码:

https://github.com/wINfOG/IDA_Easy_Life/blob/main/emu_fast_string.py

https://github.com/wINfOG/IDA_Easy_Life/blob/main/unicorn_fast_string.py

解密逻辑在支配树父节点

可以说是上面的升级版本,这个方案会使得字符串加密逻辑更加的复杂,更加的细碎。

原理是:支配树中的父节点是当前basic-block的必经节点,因此只要将当前使用的字符串解密逻辑放到父节点里面去,就确保解密逻辑一定执行。

我没有找到比较合适的案例,因此这部分不再展开了,依照类似的思路应该是能够完成一个较为通用的方案:找到所有的basic-block块 ;判断里面的字符串解密部分;模拟执行或者模式匹配每个basic-block中的字符串解密;把数据填回去。

混合其他混淆的字符串加密

还有一个点不得不说,字符串加密通常不单独出现,他只是作为混淆的基坐。

最典型的场景是和OLLVM一起出现。这导致逻辑更加的难看懂。

Last updated