最近在学习 IoT 相关漏洞利用,打算先学习一下 Qiling 框架,为后续的漏洞利用文章做铺垫。 Qiling 框架是基于 unicorn 的多架构平台模拟执行框架,提供的仿真环境很全面,能够在模拟执行的基础上提供统一的分析 API,这个A PI 包括插桩分析、快照、系统调用和API劫持等操作。在2021年JOANSIVION提供了两个QilingLab能够针对Qiling框架各种操作进行学习的程序,并提供了相应的writeup。他提供的 writeup 是 arm 架构的,因此本文以 x86_64 架构为基础,在他的文章上进行相应补充。程序下载地址放在了文末参考链接中。
时隔两年,Qiling 框架进行了很多更新与改动,我在完成 QilingLab 的过程中发现高版本的 Qiling 存在问题,因此从代码审计的角度入手,分析并提出解决方案,如果曾经分析过 QilingLab 的朋友可以直接跳到 Challenge 10 部分。本文将以最为基础的视角来陪着大家从 0 到 1 学习 Qiling 框架,希望大家看完能有所收获。
首先下载Qiling:
1 |
|
使用版本为最新版:
1 2 3 4 |
|
可以看到 QilingLab 提供的11个挑战如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
运行一下程序,可以看到输出了挑战的所有题目:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
先调用Qiling仿真环境运行一下:
1 2 3 4 5 6 7 |
|
发现报错:
1 |
|
接下来进行分析无法运行的原因,按照出题人所说需要解决第一个挑战才能正常进入。首先查看一下文件信息,可以看到程序使用小端顺序,符号表未裁剪:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
1 2 |
|
拖到IDA里看一下函数流程,main函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
可以看到跳转到start,查看一下start伪代码:
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 61 62 63 64 |
|
查看调用的checker函数伪代码,可以知道当检查到问题已解决时返回1:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
我们梳理一下函数逻辑:循环初始化数组v13所有元素为0,如果完成对应挑战就会改变v13对应数组的值,再用checker进行一次检查v13数组元素是否为0.再将checker返回值传递给变量,再加到下一次的检查结果上。这个过程不断重复,最后将累加起来的值输出到"\nYou solved %d/%d of the challenges\n"
。
拖到IDA里看一下 challenge 1 汇编代码:
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 |
|
可以看出我们需要让内存地址1337h处的值为1337才能让cmp判断相等,rdi传入的是数组地址对应数组首个元素地址,相等后调整rdi地址处的值为1,这也符合后续被checker函数检查数组后进行的一系列操作。
1 2 3 4 5 6 |
|
challenge 1 对应的伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
在Qiling中编写字节序列常用有以下方式,我选用的是pack16。
1 2 3 4 5 6 |
|
根据前面的分析可以得到 challenge 1 writeup:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
运行上面的脚本,可以看到 challenge 1 已经解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
如果我们想使用Qiling进行远程调试的话,应该怎么做?
我们在使用qemu配合IDA进行远程调试时,需要使用下面的qemu-arm-static:
1 |
|
查了一下qiling文档,调试qiling的仿真程序需要在脚本中添加下面的代码:
1 |
|
接着设置一下IDA的gdb debugger,断点在入口函数处,
运行添加dbg后的脚本:
运行后报错:
1 |
|
Qiling对于报错的提示都挺好的,这里需要将qilinglab-x86_64放置到指定的rootfs目录(我这里是使用的rootfs/x8664_linux作为rootfs目录)下运行,复制后更改一下程序运行的路径,更改后的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
可以看到程序已经可以远程调试了:
Challenge 2: Make the 'uname' syscall return the correct values.
查看challenges 2 汇编代码:
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
|
正确的运行逻辑: challenge2 -> loc_555555554BFC -> loc_555555554C6D -> loc_555555554CB8 -> loc_555555554D13 -> loc_555555554D27
对应伪代码:
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 |
|
第一个if判断:
1 2 3 4 |
|
uname(&name)
是一个系统调用函数,它的作用是获取当前系统的名称和版本信息,并将这些信息存储到 struct utsname
类型的结构体变量 name
中,name
使用struct utsname name;
声明 ,如果成功获取的话函数返回值为0,就可以进入else了。else中我们最后需要执行*a1 = 1
,这里面有两个循环进行字符串逐个判断,如果相同就能在最后的if判断中让*a1 = 1
。此处我们需要了解uname的结构体,uname的结构为:
1 2 3 4 5 6 7 8 |
|
我们需要让name.sysname等于"QilingOS",name.version等于"ChallengeStart"。
出题人提供了源码,可以看到源码的逻辑跟分析的一致:
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 |
|
根据上述分析,我们需要hook系统调用uname。在Qiling中有四种hook方式,这四个方式都是常量:
为了使用上面的hook方式,需要使用 from qiling.const import *
导入Qiling模拟器中的常量。
系统调用返回结构体型数据时,会将结构体的地址存放在寄存器rdi中,因此我们需要使用 ql.arch.regs.rdi
得到rdi中存储的uname地址。
writeup:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
用IDA看一下运行脚本后rdi指向的sysname和version,可以看到已经按照我们的脚本进行了修改:
运行脚本,challenge2已解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
Challenge 3: Make '/dev/urandom' and 'getrandom' "collide".
IDA 看一下 Challenge 3:
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
|
Challenge 3 伪代码:
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 |
|
分析程序逻辑,主要关注点在buf,v5与v7三者之间的关系:
1 2 3 4 |
|
read(fd, buf, 0x20uLL)
:从文件描述符fd读取32(0x20)字节的数据,并将其存储在字符数组buf中。原函数:ssize_t read(int fd, void *buf, size_t count);
read(fd, &v5, 1uLL)
:从文件描述符fd读取1字节的数据,并将其存储在字符变量v5中。getrandom(v7, 32LL, 1LL)
:系统调用,从系统提供的随机数源获取随机数据。代码含义是从系统熵池中获取32字节的随机数据,并将其存储在字符数组v7中。
我们需要在i循环中让buf[i]
与v7[i]
相等,且buf[i]
不等于v5
。getrandom是利用系统调用获取随机数,urandom是利用文件读写操作获取随机数,要解决这道题需要让两者一样。关于 /dev/urandom
与 getrandom
的相关知识:
/dev/urandom
是一个 Unix/Linux 系统中的特殊文件,它是一个伪随机数发生器设备文件,用于生成随机数。
在Qiling中使用 ql.add_fs_mapper("/dev/urandom", "/dev/urandom")
将宿主机中的 /dev/urandom (后面的)
设备文件映射到 Qiling 虚拟机中的 /dev/urandom (前面的)
文件上,以便为虚拟机中的程序提供随机数服务。
getrandom使用方法:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
我们可以使用QlFsMappedObject自定义文件对象,实现"/dev/urandom":
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
我们可以通过hook系统调用自定义一个"getrandom",例如:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
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 |
|
运行后,可以看到challenge 3已解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Challenge 4: Enter inside the "forbidden" loop.
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 |
|
IDA 查看伪代码发现什么都看不到:
1 2 3 4 |
|
根据汇编逻辑,我们最终需要实现 mov byte ptr [rax], 1
,但是在 loc_555555554E40 中 :
1 2 3 4 5 6 7 8 |
|
实际上就是 0比0,我们需要让[rbp+var_4]
小于[rbp+var_8]
,jl指令才会跳转到loc_555555554E35函数中,执行*a=1
,然后再通过 add [rbp+var_4], 1
跳出循环。
我们根据逻辑patch汇编使得 [rbp+var_8], 1
,就可以得到正确的伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
可以看到我们的分析是正确的,接着就来编写 Qiling challenge 4 writeup:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
运行后,可以看到 challenge 4 已经被解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Challenge 5: Guess every call to rand().
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
|
challenge 5 伪代码如下:
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 |
|
分析代码逻辑,我们想要最后得到*a1 = 1
的话,需要绕过第二个for循环。让rand()得到的值都为0就可以了。hook方法在官方手册 Hijacking OS API (POSIX) 。
1 2 3 4 5 |
|
运行后没有显示第五个挑战是否被解决,因为被第六题的死循环卡住了。
Challenge 6: Avoid the infinite loop.
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 |
|
查看伪代码,可以看到跟第四题一样:
1 2 3 4 5 |
|
我们需要执行loc_555555554F0B函数,然后再退出循环。我们需要让ZF=0,jnz才能跳转loc_555555554F0B函数。
主要关注点在test al, al
上,这条指令的含义是将EAX的低8位(al)与自身与运算,主要是用于修改ZF标志位。
1 2 3 4 5 |
|
因此我们需要将eax设置为0,对mov [rbp+var_5], 1
进行patch,改为 mov [rbp+var_5], 0
,就可以正确生成伪代码了,得到的和我们预期的一致。
1 2 3 4 |
|
1 2 3 4 5 6 |
|
运行后可以看到第五个挑战已经解决,但是第六个还没显示,应该是需要解出第七题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Challenge 7: Don't waste time waiting for 'sleep'.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
伪代码如下:
1 2 3 4 5 |
|
我们可以利用之前学的方法hook sleep函数,还是比较简单的。
1 2 3 4 |
|
运行后,可以看到1-7题已解决:
1 2 3 4 |
|
Challenge 8: Unpack the struct and write at the target address.
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 |
|
伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
v2是一个指针,指向malloc分配的内存块,相当于结构体的头指针,结构体整理后的内容如下:
1 2 3 4 5 6 |
|
我们需要修改*a1
为1,我使用的是以下方法对程序运行时存在的魔数进行搜索来判断结构体头指针位置,进而根据对应地址进行修改:
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 |
|
运行后可以看到第八题已解出:
1 2 3 |
|
Challenge 9: Fix some string operation to make the iMpOsSiBlE come true.
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 61 62 63 64 65 66 67 68 69 70 71 72 73 |
|
伪代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
根据代码,我们需要让 strcmp(src, dest) == 0
,strcmp(str1,str2) 如果str1 == str2
,则返回 0。
tolower函数是将给定的字符转换为小写形式。我们可以通过hook tolower让tolower失效,strcmp自然能够顺利对比一致。
1 2 3 4 |
|
运行后顺利通过:
1 2 3 |
|
Challenge 10: Fake the 'cmdline' line file to return the right content.
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
|
对应伪代码为:
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 |
|
根据代码,我们需要让 buf 等于 "qilinglab"。
fd:open失败为-1,open成功为0。
/proc/self/cmdline是一个在Linux系统中的特殊文件,它提供了当前进程(即访问/proc/self/cmdline的进程)的命令行参数信息。
假如我们使用命令./my_program arg1 arg2 arg3
启动一个程序时,读取/proc/self/cmdline的过程在 ./my_program
中,/proc/self/cmdline文件的内容将是:
1 |
|
按照第三个挑战的方法,我们可以使用自定义文件对象来更改读取文件时的返回值:
1 2 3 4 5 6 7 8 9 |
|
在运行后并没有解题成功,好像是在hook /proc/self/cmdline
这里存在问题,我写了个脚本单独调试以下cmdline读取程序:
c 程序:用来读取cmdline,文件名:test_cmdline
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 |
|
python程序:用来hook test_cmdline 中的文件操作
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 |
|
运行结果如下,可以看到确实在读取操作的过程中存在问题。
1 2 3 4 5 6 7 8 |
|
切换到 Qilinglab当时使用的版本看一下是否存在这个问题,Qilinglab的文章是21年7月发的,对应Qiling版本应该是1.2.4,使用pip指定版本安装方法:
1 |
|
运行后存在版本不兼容问题,因此我将从这个版本开始直到最新版的每个版本都进行了测试。结果如下:
1.4.3 可以正确进行文件映射:
1 2 |
|
1.4.4 无法正确进行文件映射:
1 2 |
|
我们使用的最新版是 1.4.5,也就是说在1.4.3到1.4.4这个版本更新中可能存在问题,因此接下来进行代码审计。文件映射在源码对应的 qiling/os/mapper.py
中,我们对比一下文件改动:
改动如下:
第1点:
在之前,Qiling只支持字符串和对象做为文件系统映射。而QlFsMappedCallable允许用户传入一个类对象real_dest,然后在调用时实例化这个类对象。
用于 add_fs_mapping(self, ql_path: Union[os.PathLike, str], real_dest: Union[str, QlFsMappedObject, QlFsMappedCallable])
。
第2点:
第3点:
第4点:
我们替换1.4.4的mapper.py为1.4.3版本,
1 |
|
更改后依然没变化,报错。接着分析是否是针对 /proc/self/cmdline
有新的处理方式,加载1.4.4的源码到vscode中,发现在 linux.py
文件中新增以下代码:
看一下更新对比:
1.4.4版本的linux.py文件与1.4.3版本的linux.py文件相比,有以下主要改动:
之前在1.4.3版本中模拟/proc目录的方式有两个:
这两种方式存在一定缺点:
所以总的来说1.4.4版本对/proc处理做了封装,使得它更模块化、易用。但是在新增的几个模块中对 '/proc/self/auxv' 等文件做出了额外的限制,我们在映射这几个文件时可能存在问题。因此我怀疑 cmdline 的问题就是在这个地方。
接着看run(self)函数:
1 2 3 4 |
|
这段代码的含义是:
只有在加载ELF文件或可执行程序的时候,才设置/proc文件系统。
self.ql.code
)的情况,不设置/proc文件系统self.ql.loader.is_driver
) 的情况,也不设置/proc文件系统1 |
|
因此将此处判断代码注释后,就应该可以成功运行了。注释后运行:
1 2 3 4 5 6 7 8 9 |
|
运行成功!
这个问题应该在1.4.4和1.4.5中都存在,我们可以通过新增一个选项 ql = Qiling(path, rootfs, enable_procfs=False)
来控制是否设置/proc文件系统,当enable_procfs=False
时关闭,默认值为True。我们需要修改 core.py
文件:
1 |
|
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 |
|
修改 linux.py
中的代码新增对参数enable_procfs的判断:
1 2 3 4 |
|
这样我们只需要将ql改成下面的代码即可:
1 |
|
然后修改我们的challenge10测试一下,运行成功!
1 2 3 4 5 6 7 8 9 |
|
Challenge 11: Bypass CPUID/MIDR_EL1 checks.
伪代码如下:
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 |
|
关键部分汇编代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
我们需要让*a=1
,就要让if的判断顺利通过,if ( __PAIR64__(_RBX, _RCX) == 0x696C6951614C676ELL && (_DWORD)_RDX == 538976354 )
的含义为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
最后可以看到全部题目均已解决:
1 2 3 |
|
最后完整的代码为:
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
|
跟着上面的文章思路走一遍,我相信大家已经了解了 Qiling 框架的使用方法。通过攥写本文时分析 Qiling 的源码,也加强了我对 Qiling框架的理解。
如果在文中发现问题,欢迎大家在下面留言。
Qiling 官方文档
Qiling lab
Qiling Framework入门,11个挑战快速上手@PJXRocks
浅尝qiling框架-qilinglab [email protected]
11个小挑战,Qiling Framework 入门上手跟练@Cr0ssx2
最后于 5小时前 被bwner编辑 ,原因: 补全图片