Hexo
qiling框架学习
Posted on: 2023-07-07 Edited on: 2023-07-07 In:  Views: 

Qiling是一款功能强大的高级代码模拟框架

很早就知道了qiling框架,一直想学但一直都忙于别的事情,这段时间打算开始学习qiling框架,记录一下学习的历程

0x1.运行程序

编写一个简单的hello world程序,交叉编译为mipsel架构的,使用qiling来运行,脚本如下

1
2
3
4
5
6
7
8
9
10
import sys

from qiling.const import QL_VERBOSE
sys.path.append("..")
from qiling import *

if __name__ == "__main__":
ql=Qiling(["./hello"],"/usr/mipsel-linux-gnu",verbose=QL_VERBOSE.DEFAULT)
ql.run()

运行结果如下

RgPj3T.png

执行效果类似于strace

看到Qiling这个类

RgiNrQ.png

参数非常多,我们目前先看到上面的脚本用到的三个参数,剩下的参数啥时候用到了啥时候再分析。

1
ql=Qiling(["./hello"],"/usr/mipsel-linux-gnu",verbose=QL_VERBOSE.DEFAULT)

[]内部的是要执行的程序以及执行所需的参数,我们将程序修改为如下

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char const *argv[])
{
if(argc != 2)
{
printf("wrong\n");
exit(-1);
}
printf("%s\n",argv[1]);
return 0;
}

将脚本修改为

1
2
3
4
5
6
7
8
9
import sys

from qiling.const import QL_VERBOSE
sys.path.append("..")
from qiling import *

if __name__ == "__main__":
ql=Qiling(["./hello","hello"],"/usr/mipsel-linux-gnu",verbose=QL_VERBOSE.DEFAULT)
ql.run()

RgFBeH.png

和正常在终端运行程序一样,将程序名和参数都写入[]即可

第二个参数,"/usr/mipsel-linux-gnu",库文件的路径

第三个参数,输出的详细程度,在我的脚本中使用的是QL_VERBOSE.DEAFAULT,还有其他的参数可供选择

1
2
3
4
5
6
class QL_VERBOSE(IntEnum):
OFF = 0
DEFAULT = 1
DEBUG = 4
DISASM = 10
DUMP = 20

每种的效果怎样我就不说了,师傅们可以自己尝试。

这就是第一个qiling脚本,学习如何使用qiling运行不同架构的程序。

0x2 使用qiling进行调试

只需要在ql.run()之前添加一句ql.debugger=True即可,这样子默认就会监听9999端口,官方给的方法有这些

1
2
3
4
5
You can also customize address & port or type of debugging server
ql.debugger= ":9999" # GDB server listens to 0.0.0.0:9999
ql.debugger = "127.0.0.1:9999" # GDB server listens to 127.0.0.1:9999
ql.debugger = "gdb:127.0.0.1:9999" # GDB server listens to 127.0.0.1:9999
ql.debugger = "idapro:127.0.0.1:9999" # IDA pro server listens to 127.0.0.1:9999

效果如下

RgAQa9.png

感觉还不错

0x3.使用qiling hook程序

hook可以玩的很花,这里就按照官方文档的顺序来讲解,不过只学习几个常用的方法

0x1.ql.hook_address()

使用ql.hook_address()来hook一个特定的地址

RgcIbt.png

目标是hook掉0x4004fd

1
2
3
4
5
6
7
8
9
10
11
12
13
from qiling.const import QL_VERBOSE

from qiling import *

def hook(ql):
print("Hook")
exit(-1)

if __name__ == "__main__":
ql=Qiling(["./hello"],"/",verbose=QL_VERBOSE.DISASM)
ql.hook_address(hook,0x4004fd)
ql.run()

运行结果如下

RggNZt.png

在0x4004fd处执行了我们的hook函数

0x2. ql.hook_code()

ql.hook_code()

hooking every instruction with self defined function

可以hook每一条指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from qiling.const import QL_VERBOSE

from qiling import *
from capstone import *

md = Cs(CS_ARCH_X86,CS_MODE_64)

def print_asm(ql, address, size):
buf = ql.mem.read(address, size)
for i in md.disasm(buf, address):
print(":: 0x%x:\t%s\t%s" %(i.address, i.mnemonic, i.op_str))

def hook(ql, address, size):
print("Hook")


if __name__ == "__main__":
ql=Qiling(["./hello"],"/",verbose=QL_VERBOSE.DEFAULT)
ql.hook_code(hook)
ql.run()

官方文档中给的例子是打印每一条汇编指令,hook函数需要给三个参数,ql对象,指令的地址,指令的长度

RgoAzV.png

这是第一个printf_asm函数

RgTVfI.png这是第二个我们自己写的

0x4.qilinglab-x86

这是一个学习qiling框架的小练习,一共11个挑战,方便我们快速入门qiling,以11个小挑战,Qiling Framework 入门上手跟练Qiling Labs为教程跟随练习
前者的架构为x86_64架构,后者为aarch64,我这里使用x86_64的程序

一共有如下11个挑战

1
2
3
4
5
6
7
8
9
10
11
Challenge 1: Store 1337 at pointer 0x1337
Challenge 2: Make the 'uname' syscall return the correct values.
Challenge 3: Make '/dev/urandom' and 'getrandom' "collide".
Challenge 4: Enter inside the "forbidden" loop.
Challenge 5: Guess every call to rand().
Challenge 6: Avoid the infinite loop.
Challenge 7: Don't waste time waiting for 'sleep'.
Challenge 8: Unpack the struct and write at the target address.
Challenge 9: Fix some string operation to make the iMpOsSiBlE come true.
Challenge 10: Fake the 'cmdline' line file to return the right content.
Challenge 11: Bypass CPUID/MIDR_EL1 checks.

IDA反编译看看

0x1.challenge1

qSDJtH.png
检查0x1337这个地址里面的值是不是1337,因此我们需要往0x1337处写入1337
映射一块内存

1
ql.mem.map(0x1000,0x1000,info="challenge1")

地址从0x1000开始,长度为0x1000,起始地址和长度有如下要求

1
2
3
4
5
6
7
8
9
10
11
Address:

You need to align the memory offset and address for mapping.

addr//size*size -> 0x7fefc9e0//4096*4096

Size:

The amounts of memory that should be mapped

This parameter is OS dependant; If you use a linux system, consider at least a multiple of 4096 for alignment

size需要4k对齐
challenge1的代码如下

1
2
3
4
def challenge1(ql:Qiling):
ql.mem.map(0x1000,0x1000,info="challenge1")
ql.mem.show_mapinfo()
ql.mem.write(0x1337,ql.pack16(1337))

运行后,可以从进程的内存分布查看到映射的内存
qShZSU.png

0x2.challenge2

qS4u38.png

uname这个系统调用用来获得操作系统的一些信息的,其参数name的类型是struct utsname类型,如下

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
struct utsname
{
/* Name of the implementation of the operating system. */
char sysname[_UTSNAME_SYSNAME_LENGTH];

/* Name of this node on the network. */
char nodename[_UTSNAME_NODENAME_LENGTH];

/* Current release level of this implementation. */
char release[_UTSNAME_RELEASE_LENGTH];
/* Current version level of this release. */
char version[_UTSNAME_VERSION_LENGTH];

/* Name of the hardware type the system is running on. */
char machine[_UTSNAME_MACHINE_LENGTH];

#if _UTSNAME_DOMAIN_LENGTH - 0
/* Name of the domain of this node on the network. */
# ifdef __USE_GNU
char domainname[_UTSNAME_DOMAIN_LENGTH];
# else
char __domainname[_UTSNAME_DOMAIN_LENGTH];
# endif
#endif
};
#define _UTSNAME_LENGTH 65

challenge2要使sysname为QilingOS,version为ChallengeStart,如何hook系统调用,在qiling中使用ql.set_syscall,原型如下

1
def set_syscall(self, target_syscall, intercept_function, intercept = None):

target_syscall是目标系统调用,intercept_function是我们自定义的系统调用,intercept定义在什么时候拦截系统调用
首先编写我们自己的系统调用,uname的参数是name结构体的地址,传参为rdi,调用结束后将值写入指针所指的内存中。我们可以在调用结束后,也就是即将退出系统调用的时候拦截uname,修改rdi指向内存的数据,代码如下

1
2
3
4
def hook_uname_on_exit(ql:Qiling,*args,**kw):
rdi=ql.reg.rdi
ql.mem.write(rdi,b"QilingOS\x00")
ql.mem.write(rdi+65*3,b'ChallengeStart\x00')

这个是我们自定义的系统调用,首先获取rdi的值,即name结构体的指针,然后往指针指向的内存中写入数据,像内存中写入数据使用ql.mem.write
根据上面的utsname结构体的定义,sysname是第一个结构体成员,version是第四个每一个成员的长度为65字节

1
2
def challenge2(ql:Qiling):
ql.set_syscall("uname",hook_uname_on_exit,QL_INTERCEPT.EXIT)

使用QL_INTERCEPT.EXIT需要引入头文件from qiling.const import *

0x3.challenge3

qSbbwD.png
从/dev/urandom中读出32个字节的随机数要与用getrandom函数获取的32个随机数全部相同,另外再从/dev/urandom中读出1字节,这1字节不能与上面的32个字节相同。
challenge3涉及到了另一个知识,劫持文件系统。在qiling中,涉及到伪造文件系统的类都需要继承自QlFsMappedObject这个类,此挑战我们可以这样伪造文件系统

1
2
3
4
5
6
7
8
9
class Fake_urandom(QlFsMappedObject):
def read(self,size):
if size==1:
return b'\x41'
else:
return b'\x00'*size

def close(self):
return 0

伪造的文件系统至少需要read和close两个方法,在这个fake_urandom类中,如果读取的长度是1,就返回一个数字1,如果长度不为1,则返回size个空字符。这两种情况对应着challenge3中从/dev/urandom中读取32个字节和1个字节的情况。
再看到getrandom,这也是个系统调用,原型如下

1
ssize_t getrandom(void * buf,size_t buflen,unsigned int标志);

往buf中写入buflen个随机数。

成功后,getrandom()返回复制到缓冲区buf的字节数。如果在标志中指定了GRND_RANDOM,并且随机源中没有足够的熵,或者系统调用被信号中断,则该数目可能小于通过buflen请求的字节

我们hook掉getrandom这个系统调用,只需在我们的hook函数中往buf中写入size*’\x00’即可

1
2
3
4
5
def getrandom_hook(ql:Qiling,buf,size,flags,*args,**kw):
ql.mem.write(buf,b'\x00'*size)
def challenge3(ql:Qiling):
ql.set_syscall("getrandom",getrandom_hook)
ql.add_fs_mapper("/dev/urandom",Fake_urandom())

0x4.challenge4

IDA伪代码有问题,什么都没有,直接看汇编
qSx1AK.png

将a和b的值都设置为0,然后比较a和b的值,如果a小于b就将challenge4要检查的值设为1,否则直接退出。
由于a等于b,所以实际上是不能进入到loc_E35的,我们要做的就是使jl这个指令成立。由于a=0,所以我们只需要在程序运行到0xE43时将eax修改为1,就能满足jl,进入到loc_E35
这里需要用的是ql.hook_address

1
def hook_address(self, callback, address, user_data=None):

callback为执行到address时执行的函数
于是我们这样写

1
2
3
4
5
6
7
8
9
10
11
12
def get_base(ql:Qiling):
for info in ql.mem.map_info:
if info[2]==5 and 'qilinglab-x86_64' in info[3]:
return info[0]

def enter_forbidden_loop(ql:Qiling):
ql.reg.eax=1

def challenge4(ql:Qiling):
base=get_base(ql)
addr=base+0xe43
ql.hook_address(enter_forbidden_loop,addr)

关于这个get_base函数,我用它来获取程序运行基地址,实际上,在qiling中有ql.mem.get_lib_base(ql.path)用来获取基地址的,不过我用起来似乎出了点问题,返回值为-1,就利用ql.mem.map_info写了一个
ql.mem.map_info实际上就是下面这个
qpCUT1.png进程的内存分布,每一行都是一个数组,长度为5,数组下标从0到4分别对应着Start,End,Perm,Label,Image的值。

0x5.challenge5

q9eTs0.png
在一个循环中将v5这个数组的前面一截赋值为0,后面一截赋值为随机数,然后检查这两段的值是否一样。
很明显,我们需要hook掉rand函数,使其返回0
q9ulGQ.png
rand函数的返回值是放在eax的,因此我们在hook函数中将eax设为0,由于rand函数不是系统调用,我们这里用到ql.set_api

1
2
3
4
5
def hook_rand(ql:Qiling):
ql.reg.rax=0

def challenge5(ql:Qiling):
ql.set_api("rand",hook_rand)

比较简单的一个challenge

0x6.challenge6

IDA伪代码寄,还得看汇编
q9Kf00.png

将0和1分别作为初值赋给i和j,然后将判断j是否为0,如果为0即通过挑战
显然,正常走流程是直接结束了。这个挑战和挑战4是类似的,依葫芦画瓢即可,在jnz那条指令处进行hook,将rax设置为0即可

1
2
3
4
5
6
7
def infinite_loop_bypass_hook(ql:Qiling):
ql.reg.rax=0

def challenge6(ql:Qiling):
base=get_base(ql)
addr=base+0xf16
ql.hook_address(infinite_loop_bypass_hook,addr)

0x7.challenge7

q9l79S.png
有一个sleep函数,沉睡时间无限长,很显然我们需要hook掉sleep函数
第一个参数传参为rdi,我们可以修改sleep的参数为0就能退出sleep
还可以在sleep函数中直接return

1
2
3
4
5
6
def hook_sleep(ql:Qiling):
#return
ql.reg.rdi=0

def challenge7(ql:Qiling):
ql.set_api("sleep",hook_sleep)

另外,sleep底层调用的是nanosleep函数,也可以对nanosleep进行hook

0x8.challenge8

q98shV.png
这里借用了JOANSIVION的结构体

1
2
3
4
5
struct random_struct {
char *some_string;
__int64 magic;
char *check_addr;
};

challenge8最后会将要检查的位置的地址赋给check_addr,我们要做的就是在执行这一步之后将check_addr指向的位置赋值为1.我们在0xfb5处进行hook
要做到这点我们首先要获取v2的地址
q9ywBq.png在IDA中我们可以看到各个变量在栈中的偏移,v2这个结构体的地址存在rbp-8处,获取到地址之后,然后根据结构体偏移读取check_addr的值,然后往其中写1即可

1
2
3
4
5
6
7
8
9
10
def hook_struct(ql:Qiling):
struct_addr=ql.unpack64(ql.mem.read(ql.reg.rbp-0x8,8))#unpack64类似于pwnools中的u64,解包数据,8字节
struct_data=ql.mem.read(struct_addr,24)#读取struct_addr指向内存的数据,长度为24字节
some_string_addr,magic,check_addr=struct.unpack('QQQ',struct_data)#QQQ是将读取到的数据按照3个long解包,分别赋值
ql.mem.write(check_addr,b'\01')#往check_addr中写'\01'

def challenge8(ql:Qiling):
base=get_base(ql)
addr=base+0xfb5
ql.hook_address(hook_struct,addr)

0x9.challenge9

q9cfk6.png
将这一串字符串转为小写后,然后和原字符串比较,如果相同则通过检查
两个函数,tolower和strcmp,可以让tolower直接返回,或者让strcmp返回0

1
2
3
4
5
6
7
8
9
def hook_tolower(ql:Qiling):
return

def hook_strcmp(ql:Qiling):
ql.reg.rax=0

def challenge9(ql:Qiling):
ql.set_api("strcmp",hook_strcmp)
ql.set_api("tolower",hook_tolower)

0xA.challenge10

q9gdud.png
从/proc/self/cmdline中读取当前的命令,如果是qilinglab的话就通过检查
这个挑战和challenge2是一样的,劫持文件系统,使其被读取的时候返回qilinglab即可

1
2
3
4
5
6
7
8
9
class Fake_cmdline(QlFsMappedObject):
def read(self,len):
return b'qilinglab'

def close(self):
return 0

def challenge10(ql:Qiling):
ql.add_fs_mapper('/proc/self/cmdline',Fake_cmdline())

0xB.challenge11

q9go5V.png
这个challenge使用cpuid来获取cpu的一些信息,然后检查RBX,RCX,RDX的值是否为特定的值,是的话则通过检查。伪代码中的__PAIR64__应该是IDA自定义的一个,__PAIR64__(_RBX,_RCX) == 0x696C6951614C676ELL实际上检查RBX==0x696C6951,RCX==0X614C676E。
这题需要hook指令,ql.hook_code,hook掉cpuid,不让它正常执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def hook_cpuid(ql:Qiling,address,size):
if ql.mem.read(address,size)==b'\x0f\xa2':#如果当前的指令是cpuid的话(cpuid的机器码是0x0fa2)
ql.reg.rbx=0x696C6951
ql.reg.rcx=0x614C676E
ql.reg.rdx=0x20202062
ql.reg.rip+=size
#就将rbx,rcx,rdx设置为对应的值,然后直接跳过cpuid

def challenge11(ql:Qiling):
begin,end=0,0
for info in ql.mem.map_info:
if info[2]==5 and 'qilinglab-x86_64' in info[3]:
begin,end=info[:2]
ql.hook_code(hook_cpuid,begin=begin,end=end)

另外,除了用hook_code的方式,还可以用hook_address
q94b26.png
在0x1195位置,会把esi,ecx,eax的值赋值到内存中,然后进行if比较,从这里下手,我们在1195处进行hook,设置好esi,ecx和eax的值为正确的值就好

1
2
3
4
5
6
7
8
9
def hook_cpuid(ql:Qiling):
ql.reg.esi=0x696C6951
ql.reg.ecx=0x614C676E
ql.reg.eax=0x20202062

def challenge11(ql:Qiling):
base=get_base(ql)
addr=base+0x1195
ql.hook_address(hook_cpuid,addr)

0x5 qilinglab-aarch64

0x1.challenge1

0x6.使用qiling模拟运行路由器固件

0x1.DIR816A2

http后台程序为/bin/goahead
首先用以下代码尝试运行goahead

1
2
3
4
5
6
7
8
9
10
from qiling.const import *
from qiling import *
from qiling.os.mapper import QlFsMappedObject

if __name__ == '__main__':
rootfs="./squashfs-root"
ql=Qiling(["./squashfs-root/bin/goahead"],rootfs,verbose=QL_VERBOSE.DEFAULT)
#ql.debugger="127.0.0.1:1234"
hook(ql)
ql.run()

得到如下输出
qPusLn.png
缺少/dev/nvram文件,于是我们手动在rootfs下面创建这个文件,继续运行
qPKAfS.png
在IDA中对这个字符进行交叉引用,查找到报错代码
qPMuge.png
qPMGUP.png
缺少/var/run/goahead.pid文件,继续手动创建,接着运行,得到如下报错
qPQ4SS.png
在IDA中定位
qP1g8f.png
问题出在inet_addr转换的时候,看到汇编
qP3XfP.png比较$s1和$v0寄存器的值是否相等,$v0的值是-1,而$s1的值来源于inet_addr的返回值,因此我们需要在这里进行hook,使其不相等。
采用如下代码进行hook

1
2
3
4
5
6
def hook_inet_addr(ql:Qiling):
ql.reg.v0=1

def hook(ql:Qiling):
addr=0x0045CDD8
ql.hook_address(hook_inet_addr,addr)

再次运行
qPG1bQ.png
得到这样的输出,可以直接忽略
netstat查看端口
qPJ6eg.png
在浏览器中输入这个IP:port
qPJOYR.png
成功运行

0x2.DAP1665

这个是个无线扩展器,所有的http处理功能都集成在一个cgibin文件中
qiU3Gj.png
尝试直接运行

1
2
3
4
5
6
7
8
from qiling.const import *
from qiling import *
from qiling.os.mapper import QlFsMappedObject

if __name__ == '__main__':
rootfs="./squashfs-root"
ql=Qiling(["./squashfs-root/htdocs/cgibin"],rootfs,verbose=QL_VERBOSE.DEBUG)
ql.run()

会有如下输出

1
CGI.BIN, unknown command cgibin

调试过cgibin的师傅应该知道,cgibin会根据不同的需求进入到不同分支
qia10K.png
我们运行失败正是因为没有指定进入的分支,如何处理呢?
前面在qilinglab中,我们可以设置寄存器的值,同样的我们可以设置pc的值,使程序流向我们想要的分支。在这里我选择进入到pigwidgeon.cgi(因为需要的参数少一点),我们在main函数入口处进行hook,设置pc寄存器为pigwidgeon分支的入口地址,程序如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from qiling.const import *
from qiling import *
from qiling.os.mapper import QlFsMappedObject

main_addr=0x00402020
pigwidgeon_addr=0x40bc70
def hook_pigwidgeon(ql:Qiling):
ql.reg.arch_pc=pigwidgeon_addr
return

if __name__ == '__main__':
rootfs="./squashfs-root"
ql=Qiling(["./squashfs-root/htdocs/cgibin"],rootfs,verbose=QL_VERBOSE.DEBUG)
ql.hook_address(hook_pigwidgeon,main_addr)
ql.run()

成功运行
qkQR8P.png
进入到pigwidgeon查看
qkQrHH.png
首先会取得请求方式,而且只能为POST,我们继续设置环境变量

1
2
3
4
env_vars={
"REQUEST_METHOD": "POST",
}
ql=Qiling(["./squashfs-root/htdocs/cgibin"],rootfs,env=env_vars,verbose=QL_VERBOSE.DEBUG)

回显发生变化
qkQxrF.png
无法解析HTTP请求,因为我们除了请求方式外什么都没有设置,在查看其他获取环境变量的函数
qk1YSx.png
这里获取了另外两个环境变量,继续按要求设置

1
2
3
4
5
6
env_vars={
"REQUEST_METHOD": "POST",
"REQUEST_URI": "/pigwidgeon.cgi",
"CONTENT_TYPE": "application/",
"CONTENT_LENGTH": "100"
}

qk1g6f.png
可以得到不同的回显。这里只是做一个示例,因为环境变量的设置需要仔细审计完代码才能按照要求设置,就不细看了。
另外提一嘴,qiling的debugger用起来体验并不好,无论是pwdbg还是gef远程连上去后都看不到寄存器,要调试的话还是得用qemu来

0x3.Tenda AC15

直接运行httpd的话会在check_network处卡住
qAKZm4.png
一路追踪check_network,找到了最终调用的函数
qAKJne.png
get_eth_name传入的参数是0,所以check_network是要获取br0的ip,而我的虚拟机中并没有br0这个网卡

--- 本文结束 The End ---