Contents

常用模拟执行、符号执行、插桩hook框架概览与比较

常用模拟执行、符号执行、插桩hook框架概览与比较

开篇

前几天巅峰极客的一道题m1_read涉及对白盒AES进行DFA攻击,这里不对密码学部分进行讨论,从二进制的角度来说,就是需要在程序加密过程中动态修改密文,并在加密结束后读取加密结果。而程序本身无法正常调试,此时就需要使用特殊手段。

抽象一下我们的需求,即需要能加载、执行我们的程序,并在执行过程中能随时断下,能够读写寄存器、内存。能做到这些的工具/框架其实很多,我所知道的大致可分为如题几类,针对此题我都尝试了一遍,下面逐一进行介绍。

模拟执行

模拟执行就是在一个沙箱环境中老老实实地把代码跑一遍,期间可以对程序进行控制、对内存进行读写。最著名的模拟执行框架当属unicorn,这里还要介绍另一个基于unicorn的qiling框架。unicorn更底层、注重底层指令控制,一般只能对汇编指令进行模拟。而qiling为我们提供了PE、ELF等高层次的仿真能力,更易于使用。

unicorn

unicorn所做的事就像一个单纯的CPU,只能执行指令序列,仅此而已。要想模拟执行完整的程序,你需要手动实现一个类似loader的功能,包括但不限于划分代码段并拷贝指令序列、划分栈内存并初始化栈指针、划分堆内存、解决动态库的调用。这通常是一个非常痛苦的过程。

API方面,unicorn提供了C API和Python API,C API可用来扩展底层功能,Python API方便写脚本,提高了易用性。unicorn提供了对于指令、代码、代码块、用户数据、内存访问、系统调用等不同粒度的hook方式,足以对指令在模拟过程中进行完整的控制。

综上,unicorn一般被用来模拟执行汇编代码或shellcode,能够保证精确的执行效果和精细的控制。

对于开篇这道题,笔者被折磨了一下午,最终还是没能成功。正是由于unicorn过于底层,所要模拟执行的内容复杂一点,你在编写脚本时要考虑的东西就会多很多,硬要模拟执行PE、ELF这样复杂的东西并不是unicorn的长项。

qiling

qiling框架的开发者想必是受够了unicorn在面对复杂程序模拟时的无力,对unicorn进行了高层次的包装。qiling框架直接支持了PE、ELF文件的模拟,能够自行适配模拟环境,加载动态库,相当于为你完成了loader的部分,让你可以专注程序控制、分析本身。

API方面,qiling是用python编写的,也提供python API,易于编写脚本。同时对unicorn原先API进行了一定程度的封装,使其更丰富、易用。同时,一个很不错的点在于,当模拟执行出错时,qiling会打印出相当完整的错误信息,很容易进行问题锁定。相比于需要自己写hook函数进行错误追踪的unicorn要舒服不少。

qiling的缺点也很明显,执行效率明显低于uncorn,且每次运行都要首先加载大量动态库,但这通常对我们来说问题不大。

对于这道题,使用hook_address进行对特定地址的hook十分方便,使用API在hook函数中也能方便地读写寄存器、内存。脚本如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from qiling import Qiling
from qiling.const import *

ql = Qiling(['./x8664_windows/bin/m1_read.exe'], './x8664_windows', verbose=QL_VERBOSE.OFF)

index = 0
def add_initial_val(ql: Qiling):
    # check_addr = 0x140004BF0
    ql.mem.write(0x500000000, b'\x11' * 16)
    ql.arch.regs.write("rcx", 0x500000000)
    ql.mem.write(0x500000000 + 0x10, b"\x00" * 16)
    ql.arch.regs.write("rdx", 0x500000000 + 0x10)

def insert_fault(ql: Qiling):
    # check_addr = 0x1400052C5
    r12 = ql.arch.regs.read("r12")
    if r12 == 0x8000:
        global index
        ql.mem.write(0x500000000 + index, b'\x00')
        enc = ql.mem.read(0x500000000, 16).hex()
        index += 1

def get_enc(ql:Qiling):
    #check_addr = 0x1400053CA
    enc = ql.mem.read(0x500000000, 16).hex()
    print(enc)

ql.hook_address(add_initial_val, 0x140004BF0)
ql.hook_address(insert_fault, 0x1400052C5)
ql.hook_address(get_enc, 0x1400053CA)

start = 0x140004BF0
end = 0x14000542D
for i in range(16):
    ql.run(start, end)

符号执行

符号执行说到底是执行,重点在符号化。符号执行引擎一样可以执行程序,与模拟执行引擎不同的是,所有变量都会以符号向量的形式存在。

这样做的好处是你可以无需做多余的初始化操作,而是仅仅当作未约束的符号向量即可。另一方面,符号执行便于直接约束求解,这在CTF赛事中经常用到。但是,编写和执行的自由性同时也意味着精度的丢失,符号执行框架注定无法像模拟执行那样确保指令级别的准确性。

angr

大名鼎鼎的符号执行引擎,逆向壬应该都用过。应该是目前最成熟、使用最广泛的符号执行引擎。python api写起来比较爽,但同时也有一些小bug,以及维护人员比较佛系有时会让人苦恼。

针对这道题来说,我自己写起来angr应该是最舒服的,贴个脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import angr 

proj = angr.Project('./m1_read.exe')
start = 0x140004BF0
init_state = proj.factory.blank_state(addr=start)

enc_addr = 0x500000000
enc = init_state.solver.BVV(b'\x11'*16, 16*8)
init_state.memory.store(enc_addr, enc)
init_state.regs.rcx = enc_addr

mask = 0x00ffffffffffffffffffffffffffffff

@proj.hook(0x1400052C5)
def insert_fault(state):
    round = state.regs.r12
    round = state.solver.eval(round)

    if round == 0x8000:
        global mask
        enc = state.memory.load(enc_addr, 16)
        enc = state.solver.eval(enc)
        enc &= mask
        enc = state.solver.BVV(enc, 16*8)
        state.memory.store(enc_addr, enc)
        mod = 2**(16*8) - 1
        mask = ((mask >> 8) & mod) | (mask << (16*8-8) & mod)

res = open('./tracefile', 'w')
for i in range(16):
    simgr = proj.factory.simgr(init_state)
    simgr.explore(find=0x1400053CA)

    if simgr.found:
        state = simgr.found[0]
        ans = state.memory.load(enc_addr, 16)
        ans = state.solver.eval(ans)
        res.write(f"{ans:032x}" + '\n')

radius2

rust开发的符号执行引擎(rust已经是时代潮流辣),不过目前还并不完善,只提供rust api并且文档比较少,有个Cli工具可以玩一玩,对付某些题目或有奇效。目前可以关注一下这个项目的状态,或许未来能解决angr的小毛病甚至完全替代angr的地位也未可知。

尝试用rust api写这道题,但无奈rust写脚本体验实在太差,尤其是文档不足,遂放弃。

插桩、hook

从模拟执行、符号执行到这里的插桩、hook框架,我们的着眼点是在逐步变高的,hook框架大多是直接作用于应用层的,且hook框架实际上并不能执行程序,而是在程序原本正常执行中进行操作,和模拟执行、符号执行有很大的区别。

frida

frida最广泛的使用场景就是安卓平台,能够针对Java等高级语言进行插桩、hook、注入,而无需关心底层的指令细节,功能强大且易于使用。并且提供多平台、多架构的frida-server,便于脚本注入。

只不过基本只能使用js进行注入代码的编写,对于习惯用python的人可能稍有不适。

对于这道题,frida自然也可以使用,贴个脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const baseAddr = Module.findBaseAddress('m1_read.exe')
const idaBase = 0x140000000
let f = new NativeFunction(baseAddr.add(0x140004bf0 - idaBase), 'pointer', ['pointer', 'pointer'])
let a1 = Memory.alloc(16)
let a2 = Memory.alloc(16)

let index = 0
Interceptor.attach(baseAddr.add(0x1400052C5 - idaBase), {
    onEnter(args) {
       let round = this.context.r12
       if (round == 0x8000) {
            let enc = a1.readByteArray(16)
            enc = new Uint8Array(enc)
            enc[index] = 0
            index += 1
            a1.writeByteArray(enc)
       }
    }
})

Interceptor.attach(baseAddr.add(0x1400053CA - idaBase), {
    onEnter(args) {
        let enc = hexdump(a1.readByteArray(16), {header: false}).replace('/\s/g', '')
        console.info(enc)
    }
})

for (let i = 0; i < 16; i++) {
    let data = new Uint8Array(16).fill(0x11)
    a1.writeByteArray(data)
    f(a1, a2)
}

Intel Pin

功能强大的动态插桩工具,主要支持Intel架构,与frida一样依赖于现有二进制程序进行代码/指令注入,但较frida更加底层。一般用来输出程序信息而很难修改内存,特点鲜明,一般用于专业性较强的研究。

对于这道题,由于难以运行时动态修改内存,所以并不适用。对于Intel Pin,笔者也是刚接触,在这里提一句,其他就不多说了。