复现基于eBPF实现的Docker逃逸

0x00 关于本文

    最近搞毕业设计在研究Docker逃逸,如果只把CDK工具的东西复现一遍(或者照抄),那诚意不足,也失掉了我刻意选我不熟悉领域的题目的意义所在,于是想自己动手做点东西。
    看到seebug上基于eBPF的逃逸ScUpax0s的容器逃逸文章,我意识到基于eBPF的逃逸可以做一做,虽然他们都写了思路贴了部分代码片段,但毕竟没有放完整的代码出来,我自己踩坑实现一遍,可以学点东西,也能算是工作量。
    文章中的代码实现已上传至Github
    在实现的过程中,ScUpax0s给了我许多指点,让我少走了很多弯路,感谢他!

0x01 eBPF为什么能帮助Docker逃逸

    eBPF技术允许用户在用户态编写代码,被verifier扫描鉴定无问题后,送入内核执行。
    eBPF可以在Linux系统的各个地方插桩,在执行到指定位置时,执行用户自定的代码,实现数据搜集和修改。 因此eBPF使得用户可以在用户态高效安全地监控Linux的方方面面。
    能看,还能改,黑客自然也可以拿它来使坏。更妙的是,在Docker环境中,容器和宿主机共享同一个内核,因此如果容器被赋予了CAP_SYS_ADMIN能力,成功在容器中加载了eBPF程序的话,eBPF程序将能够直接在系统的内核中运行,无视容器的各类隔离机制。因此在宿主机环境中作恶的eBPF,在容器中照作不误,它能在宿主机环境里面干上面,那就能在容器里干什么,突破隔离一步到位。

0x02 通过BPF劫持cron进行逃逸

    尽管看起来很容易,但真正实现逃逸还需要一番周折。eBPF的代码仅能在触发插桩点的时候执行,它能够读参数,对指定地址的用户内存读写,但无法直接发起一个系统调用,弹一个shell回来。
    seebug上基于eBPF的逃逸给出了一种思路,即利用cron进行逃逸。
    cron服务在Linux系统上实现了计划任务,它每隔一段时间检查配置文件是否被更改过,如果更改了就读取配置文件,根据配置文件的描述设定定时的命令执行。因此通过劫持cron对配置文件的访问,篡改文件的更改时间和读取内容,即可欺骗cron执行我们预定的命令,而cron是运行在宿主机上的,因此欺骗cron执行了命令相当于在宿主机中执行了命令,也就是逃逸。

0x03 程序整体结构

    在我的实现中,我只监听raw_tracepoint/sys_exit这个点(即系统调用返回的时候)。我看到其它的实现中由于需要获取传入的参数,所以得监听raw_tracepoint/sys_enter(即系统调用进入时)。但是在我实际测试的过程中,发现在返回的时候也能拿到这些信息,所以在我的实现中就只监听sys_exit。
    
    如代码所示,首先通过bpf_get_current_comm获取到使用了这个系统调用的进程名,匹配到如果不是目标进程名(在我们这儿就是cron),那就返回,防止干扰到不该干扰的程序。
    这里体现了通过raw_tracepoint/sys_exit这个点拦截所有系统调用统一处理,而非通过tp/syscalls/sys_exit_read这样的点单个单个地拦截系统调用的好处--对于是否是目标进程的判断,只需要做一次就好了,可以省去很多精力。
    在判断了通过后,对于通过BPF_CORE_READ(regs,orig_ax)获取到的系统调用号,做一个switch case,对于不同的系统调用号,用不同的函数去分门别类地处理,这样想要处理别的调用,只需要增加case语句然后再实现一个函数即可,扩展性较好。

0x04 劫持cron的读取

    我们的目标是劫持cron程序对/etc/crontab的读取,修改它的读取内容。为了实现这一点,首先我们来看看它是怎么读的。


    如图所示,编辑/etc/crontab(以刷新最后编辑时间),通过strace -p <pid>追踪其系统调用,可以看到cron在读取/etc/crontab的时候,首先用openat打开然后用read读取。需要注意的是,从图的上面可以看到,cron也read了别的文件,因此我们不能一股脑地劫持所有的read然后插入我们命令,而需要针对性地劫持
    想要针对性的劫持,就需要在Hook read调用的时候,知道系统调用号对应的文件,这没法在read调用结束时直接从相关的寄存器中获取。因此需要在oenat调用的时候,读取文件名,在map中做记录。map是bpf提供的一种存放键值对数据的存储方式,我们可以用它来更持久地保存read读出来的东西
    

    如图所示,当openat被调用的时候,代码首先匹配是否是我们感兴趣的文件(在这里是/etc/crontab),如果是的话,就将进程的pid和文件描述符fd打包在结构体中作为键,存入map里,将值设为1。
    


    结构体是我们自己定义的,之所以要用这个结构体把pid和fd打包到一起,是因为pid和fd的组合是唯一确定的,如果我们只记录fd的话,假设有多个进程同时启动将会造成记录的混乱。因此以上的代码实现的功能,用人话来讲就是“当看到/etc/crontab被打开的时候,记录好打开者的pid和它拿到的fd”。
    当然,文件会开也会关,所以假设我们感兴趣的文件读完后关闭了,结果程序再打开别的文件时拿着相同的文件描述符fd号,我们的eBPF程序就会错误地认为这是我们感兴趣的文件并予以篡改,这样就会造成各种错误,所以我们也需要在关闭文件的系统调用close返回的时候,记录被关闭的文件,方式和上面的handle_exit_openat大同小异,感兴趣的读者可以去看Github上handle_exit_close实现。
    在记录好了后,我们就可以去放心大胆地劫持read调用,篡改返回结果了。
    
    如代码所示,在read返回的时候,先用bpf_map_lookup_elem检查键是否存在,如果存在且对应的值为1(也就是是我们感兴趣的文件,并且没有被关闭),那么判断返回结果(即读出的长度)是否长于我们即将写入的PAYLOAD,如果是的话,就用bpf_probe_write_user写入用户空间,也就是read函数读出去的地址,最终实现修改读出来的值。
    

0x05 劫持cron对文件修改时间的判断

    
    依然是这张strace的结果,它展示了cron是如何判断文件是否被修改的。它通过newfstatat调用判断了两次,一次filename就是/etc/crontab,另一次filename为空但是dfd是/etc/crontab的文件描述符,经过实际测试,必须要两个都骗过去才能让它重新读/etc/crontab(然后被我们骗)。
    


    于是代码首先对比文件fd,再对比文件名,如果有一个表名是/etc/crontab文件,那就对把返回的结构体中的修改时间设置为一个随机数。
    (写这篇blog的时候发现代码忘了加if(thisistarget)了,紧急修复了下,计算器还能弹出来,但是可能还有别的bug,不过总之大体思路是不会错的)

0x06 Docker逃逸&弹计算器

    最激动人心的环节就是最后的逃逸&计算器弹出的环节,想要实现这个事情,还有一些小细节要处理。
    首先在makefile的CFLAGS选项中加入-static,这个是静态编译选项,要求在编译的时候把各种库都打包进来,之所以要这么做是因为Docker上面往往库不够全,直接编译无法在上面正确运行。
    接着就是弹计算器的问题。毕竟执行是用root权限执行的,首先得切换成当前登录的用户,而且得设置DISPLAY变量让GUI程序能够正确显示已登录的会话上。于是在Crontab里面可以弹计算器的命令是sudo -i -u <用户名>  "/bin/bash" -c "DISPLAY=:0 gnome-calculator"&
    解决了这些问题后,编译出二进制文件
    用docker run -ti --cap-add SYS_ADMIN ubuntu:latest /bin/bash 命令启动一个具有CAP_SYS_ADMIN能力的Docker,把文件拷进去后执行,等待一分钟,就能看到计算器弹出
    
    

    

评论

此博客中的热门博文

局域网监控软件WFilter ICF 鸡肋0day RCE漏洞挖掘

别想偷我源码:通用的针对源码泄露利用程序的反制(常见工具集体沦陷)