前前言
- 这个也是老早之前发在看雪上的帖子,原帖地址: https://bbs.kanxue.com/thread-274828.htm
- 以下开始为原贴内容
前言
实际分析嵌入式固件过程中,经常会遇到各种非Linux和非常见系统的固件,这类固件往往就是一个"裸"的二进制程序,而不像ELF和PE这些有特定结构的可执行程序,对于这类固件往往很难对它进行动态调试,而只能静态分析。最近一段时间,学习了很强大的Qiling框架,它除了可以模拟运行各种可执行程序,还提供了Debugger接口,于是乎我就想着能不能用Qiling配合IDA动态调试固件。遗憾的是网上关于Qiling的教程大部分都是模拟ELF和PE这些相对来说比较标准的程序,而模拟像这种就一个"裸"的二进制程序的资料却很少,这里分享下我的摸索过程
关于Qiling和Unicorn这里就不多介绍了,详见官网
Qiling官网: https://qiling.io/
Unicorn官网: https://www.unicorn-engine.org/
下面以我上个帖子 一个简单的STM32固件分析 用到的固件stm32f103RCT6.bin来介绍如何用Qiling框架模拟运行指定函数并启用Debugger,然后使用IDA进行动态调试
开始
首先时qiling官方github的有一个example引起了我的注意,如下图,qiling的示例有一个模拟arm下的uboot。uboot据我所知是一个bootloader,它在固件中就是一个"裸"的二进制程序,于是这里面可能就有我想要的样例
查看hello_arm_uboot.py代码,一直拉到最后,如下图,这部分代码就是模拟运行"裸"的二进制程序的一个例子
接下来就是照猫画虎,仿照着这个来尝试模拟运行stm32f103RCT6.bin固件中的XTEA函数。这里简单介绍下这个stm32f103RCT6.bin固件,它的加载基地址为0x8000000,从前一篇的分析可以知道它在地址0x800E288有个XTEA函数,这个函数就是接下来需要模拟调试运行的函数
首先是导入qiling和unicorn的包
1
2
3
4
5from qiling.core import *
from qiling.const import *
from qiling.os.const import *
from unicorn.arm64_const import *
from unicorn import *对照代码样例,读入需要模拟的固件
1
2
3filepath='stm32f103RCT6.bin'
with open(filepath, 'rb') as fp:
fw = fp.read()接下来看原代码的Qiling对象生成方式,第一个code=uboot_code[0x40:],剔除了前0x40字节的原因应该是,uboot固件的前0x40字节不加载进内存,这里的stm32固件是整个都加载进内存的,所以可以直接传整个读入的fw。有个参数需要注意的是profile="uboot_bin.ql",看起来是还有一个配置文件"uboot_bin.ql"
在同级目录下,可以找到这个uboot_bin.ql,那么接下来,需要简单理解下这个配置文件的各个参数的意义
源码中搜索"heap_size",如下图,可以在qiling/loader/blob.py中找到关于这几个参数的含义
根据上面代码可画出下图内存映射,"entry_point"这里为内存加载地址"load_address"而不是代码入口点,Qiling会根据entry_point和ram_size大小分配一块内存,然后将代码code写入,需要注意的是默认初始栈寄存器SP指向这块内存end_address - 0x1000的位置,如果模拟运行前不做修改,需要将ram_size预留出一定的栈空间的大小,不然往栈内存写数据时会覆盖code内存数据。堆内存heap的起始地址就是entry_point + ram_size,下图虚拟线表示默认不会直接映射堆内存,如果需要使用这块内存,需要先执行
ql.os.heap.alloc(size)
来使用接下来就可以生成配置文件了,代码如下
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# 加载地址
load_addr=0x8000000
# 栈大小
stack_size = 0x20000
# 堆大小
heap_size = 0x20000
# 计算固件大小 0x1000对齐
fw_size = math.ceil(len(fw)/0x1000)*0x1000
# 初始分配的内存大小包括 固件和栈空间大小,+0x1000是为了使可用的栈大小与stack_size保持一致
ram_size = fw_size + stack_size + 0x1000
cfg_str = f"""
[CODE]
ram_size = {ram_size}
entry_point = {load_addr}
heap_size = {heap_size}
[MISC]
current_path = /
"""
# 保存配置文件
with open('ql-config.ql', 'w') as fp:
fp.write(cfg_str)接着,仿照样例生成Qiling对象,代码如下,因为这里模拟运行的用到了thumb指令,所以需要指定参数thumb=True。因为这里默认模拟的是小端序,而固件恰好是小端序固件,所以可以不指定端序,但是如果模拟的为大端序的固件,则需要指定参数endian=QL_ENDIAN.EB。更多参数用法详见官方文档
1
ql = Qiling(code=fw, archtype="arm", ostype="blob", profile="ql-config.ql", thumb=True)
定义模拟运行的起始地址和终止地址,这里因为只模拟运行sub_800E288函数,所以设为sub_800E288函数起始地址和终止地址即可
1
2begin =0x800E288
end = 0x800E296因为模拟的sub_800E288函数有3个参数,所以还需要给函数传参
这里简单介绍下ARM中常见的函数传参规范:对于函数参数不超过4个参数时用r0,r1,r2,r3寄存器来传参,对于函数参数超过4个参数时,前4个依旧用r0,r1,r2,r3寄存器传参,往后的参数以压入栈的方式传参。类似地函数如果有返回值,约定以r0寄存器返回。这些规范主要是为了不同程序或模块之间相互调用各自的函数而不出错,因为是规范,所以也就可以不遵循这个规范,比如说你自己用汇编写的程序的话,想怎么传参就怎么传参,只要你自己不限入混乱,程序不出错即可。
废话不多说,回到正题,这里获取unicorn对象来为函数传参(unicorn对象可以读写各个寄存器)。从上面可知,需要模拟的函数的3个参数都是指针,所以需要给r0,r1,r2 这3个寄存器写入3个内存地址,而初始SP寄存器(栈寄存器)和heap之间有块0x1000的内存没有用到,因此这里可以用sp+0x100,sp+0x200,sp+0x300,这3个地址作为函数参数传入。不过单单将3个地址写入r0,r1,r2寄存器还不够,还要在相应地内存写入数据,这样才是完整的传参过程。因为第3个参数是加密结果的输出,所以可以不往该地址写数据。如下代码,为r0传入密钥"BA 2F 96 A9 BA 2F 96 A9 BA 2F 96 A9 BA 2F 96 A9",为r1传入明文"10 BE 62 F8 E8 DC 34 46"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15sp = ql.arch.regs.sp
uc=ql.uc
#为第1个参数key传参
uc.reg_write(UC_ARM_REG_R0, sp+0x100)
uc.mem_write(sp+0x100, bytes.fromhex("BA 2F 96 A9 BA 2F 96 A9 BA 2F 96 A9 BA 2F 96 A9"))
#为第2个参数in传参
uc.reg_write(UC_ARM_REG_R1, sp+0x200)
uc.mem_write(sp+0x200, bytes.fromhex("10 BE 62 F8 E8 DC 34 46"))
#为第3个参数out传参
uc.reg_write(UC_ARM_REG_R2, sp+0x300)然后是启用debugger,Qiling默认是不启用debugger的,如下代码,启用gdb debugger并监听相应IP和端口。详细说明见官方文档:https://docs.qiling.io/en/latest/debugger/
1
ql.debugger = 'gdb:0.0.0.0:9999'
在qiling-1.4.4中,设置了debugger后且ostype为"blog"的情况下,直接运行ql.run会报"AttributeError: 'QlOsBlob' object has no attribute 'fs_mapper'"错
在源码中找到了相应的描述,如下图,QlOsBlob类中有几个函数还没有实现,详见:qiling/os/blob/blob.py
不过好在,经过测试,在执行ql.run之前,可以按以下方式,规避下这个问题。这段代码作用很简单,就是给个空壳给ql.os.fs_mapper。Python一个非常好的特性就是可以像下面这样,可以很容易地对各种库进行动态修改,而不用去修改库的原文件。下面这部份代码,等以后Qiling有相应的函数实现后就不在需要了
1
2
3
4
5class MyMapper:
def add_fs_mapping(self, ql_path, real_dest):
pass
ql.os.fs_mapper = MyMapper()重新运行后,出现如下信息,说明成功模拟运行,并启用了debugger并监听了相应的IP和端口。需要注意的是,如果在生成Qiling对象是指定了参数verbose=QL_VERBOSE.OFF,那么运行时不会有任何log信息
接下来,介绍IDA中如何连接到这个server进行动态调试
IDA中选择Debugger > Select debugger或者快捷键F9,选择Remote GDB debugger
选择Debugger > Process options
输入运行Qiling机器的IP和端口,如果运行Qiling和IDA是同一机器同一系统内,则IP填127.0.0.1即可
接着选择Debugger > Debugger options
勾选如下两个即可
然后选择Debugger > Manual memory regions
按照下图,新建几个内存映射,否则调试时,IDA可能不能跳转到栈内存中,也不能查看栈内存的数据
这里选择添加两个映射分别是栈内存和栈内存到堆内存之间的一小部分,堆内存(heap)因为这里没有用到,所以可以省略,code因为IDA分析的固件就是这部分内存,所以也可以省略
最后选择Debugger > Attach to process,会出现一个PID为0的进程,点击OK即可
最后如下图可以看到,IDA进入了调试模式,且停在sub_800E28函数开始的位置,接下来就可以使用IDA进行动态调试了
调试结束后,可以回到Qiling中,读取相应地址的结果。从我上一个帖子可知预期的密文为"8C 79 F5 D1 5E A9 46 2D",如下图,输出与预期一致
1
ql.mem.read(sp+0x300, 8).hex()
最后
- 本文只是简单介绍了qiling的一些基本用法,关于qiling更多用法详见官方文档:https://docs.qiling.io/en/latest/
- 本文并没有过多介绍unicorn的用法,关于unicorn的用法可以参看官方文档:https://www.unicorn-engine.org/docs/,还有论坛的这两个帖子:利用unicorn分析固件中的算法 ,Unicorn 在 Android 的应用
- 实际上对于STM32 mcu的模拟可能并不需要那么繁琐,官方有相应的模拟示例,详见:https://github.com/qilingframework/qiling/tree/master/examples/mcu,本文的这种方法主要是用来模拟那些qiling官方还不支持的固件,只不过例子是stm32的固件而已
- 本文完整代码包含在附件之中
2022-10-27更新
qiling模拟thumb时,IDA下断点,停不下来,原因是IDA下的断点地址没有+1,但是qiling判断时却将当前地址+1了
执行ql.run之前加入以下代码,可以动态解决这个问题,下面代码作用相当于注释掉qiling/debugger/gdb/utils.py中dbg_hook函数的前两行代码。同样地,待qiling后续版本修复这个问题后就不再需要这部分代码了
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61import types
def dbg_hook(self, ql, address: int, size: int):
#if ql.arch.type == QL_ARCH.ARM and ql.arch.is_thumb:
# address += 1
# resuming emulation after hitting a breakpoint will re-enter this hook.
# avoid an endless hooking loop by detecting and skipping this case
if address == self.last_bp:
self.last_bp = None
elif address in self.bp_list:
self.last_bp = address
ql.log.info(f'gdb> breakpoint hit, stopped at {address:#x}')
ql.stop()
# # TODO: not sure what this is about
# if address + size == self.exit_point:
# ql.log.debug(f'{PROMPT} emulation entrypoint at {self.entry_point:#x}')
# ql.log.debug(f'{PROMPT} emulation exitpoint at {self.exit_point:#x}')
def run(self, begin: Optional[int] = None, end: Optional[int] = None, timeout: int = 0, count: int = 0):
"""Start binary emulation.
Args:
begin : emulation starting address
end : emulation ending address
timeout : limit emulation to a specific amount of time (microseconds); unlimited by default
count : limit emulation to a specific amount of instructions; unlimited by default
"""
# replace the original entry point, exit point, timeout and count
self.entry_point = begin
self.exit_point = end
self.timeout = timeout
self.count = count
# init debugger (if set)
debugger = select_debugger(self._debugger)
if debugger:
debugger = debugger(self)
debugger.gdb.dbg_hook = types.MethodType(dbg_hook, debugger.gdb)
# patch binary
self.do_bin_patch()
if self.baremetal:
if self.count <= 0:
self.count = -1
self.arch.run(count=self.count, end=self.exit_point)
else:
self.write_exit_trap()
# emulate the binary
self.os.run()
# run debugger
if debugger and self.debugger:
debugger.run()