Re:从零开始的容器安全——ruri开发笔记
前言:
ruriv2.0刚发了rc1(现在是3.1-rc1了都),之前一直咕咕咕着的开发笔记差不多也该写写了喵~
笔记主要讲容器及安全原理,使用C语言实现。
头图是项目最早的版本,真是怀念呢喵,那时候咱连数组都不会用,现在ruri代码都突破4k行了。
容器基本原理:
Linux挂载点/设备文件:
众嗦粥汁,Linux下的/proc,/sys与/dev均在开机时由init或其子服务创建,部分系统同时会将/tmp挂载为tmpfs,它们都需要被手动挂载到容器才可保证容器中程序正常运行。
其中,/proc为procfs,/sys为sysfs,/dev为tmpfs。
你还需要在容器中创建/dev下的设备节点文件。
部分文章在创建chroot/unshare容器时都会直接映射宿主机的/dev目录,这是十分危险的,正确的做法是参照docker容器默认创建的设备文件列表去手动创建这些节点。
当然了,docker也会将/sys下部分目录挂载为只读,ruri借鉴了其挂载点,详情可以去看ruri源码或者运行个docker容器看看它的挂载点。
需要注意的是,只读挂载需要先bind-mount再remount-ro,示例写法如下:
1 | // Bind-mount. |
其他容器注意事项:
Android的/data默认为nosuid挂载,/sdcard甚至是noexec,所以在安卓/data下创建容器时请将/data重挂载为suid,不要在/sdcard创建容器。
Archlinux的根目录需要在挂载点上,也就是说需要将容器目录自身bind-mount到自身再进行chroot(2),否则pacman无法运行。
/proc/mounts到/etc/mtab的链接可能需要手动创建。
大部分rootfs的resolv.conf为空需手动创建,安卓系统内容器联网需手动设置,授予需要联网的网络用户组(aid_inet,aid_net_raw)权限。
chroot(2):
chroot可是个老家伙了,从1979年Seventh Edition Unix的开发时便产生了这项技术。
函数调用:
chroot(2)函数来自unistd.h
,原型为
1 | int chroot(const char *path); |
它的作用是改变程序自己认为的根目录为path,需要root权限(其实是CAP_SYS_CHROOT,后面会讲)。
chroot(2)类似chroot(8)命令,但是它在变更完根目录后什么都不会做。貌似我们不是C语言入门教学
不装了,直接上代码吧。
一个完整的chroot程序:
1 |
|
用法为:
1 | cc 文件名.c |
十分的简单,甚至九分的简单。
安全性:
chroot实现了根目录隔离,但是chroot()后的进程会继承父进程特权,而且不幸的是chroot()必须以root(CAP_SYS_CHROOT)特权执行。chroot()后容器仍可访问外部资源,包括但不限于在容器内执行以下操作:
- kill外部进程
- 创建设备节点并挂载磁盘设备
- 当场逃逸出容器
逃逸:
在/proc被挂载的chroot容器中,可能可以通过chroot /proc/1/root
直接逃逸出容器,可行的解决方式是开启PID NS来避免这一问题。
实测wsl1有此逃逸漏洞,wsl2的Linux内核已经修复。
因此不要将chroot容器用于生产,老老实实地pull个docker image吧还是。
unshare(2):
Linux内核自2.4版本引入第一个namespace,即mount ns,当初估计作者没打算再加其他隔离就命名为CLONE_NEWNS了,此宏定义一直被沿用至今。
(Linus:我们不破坏用户空间23333)
Linux父子进程:
众所周知(读者:喵喵喵?我怎么不知道?),Linux下运行的所有进程都是init的子进程,子进程由父进程经fork(2)或clone(2)创建,继承父进程的文件描述符与UID/GID/权限(特权)等。
子进程死亡了若父进程没有对其wait(2)或waitpid(2),则成为僵尸进程。
父进程先走一步的话,子进程被init直接接管。
在pid ns中被执行的第一个进程在该ns中被认为与init等效(具有pid 1),当其死亡时pid ns被内核销毁,其子进程被一同销毁。
函数调用:
unshare(2)函数来自sched.h
,需要先#define _GNU_SOURCE
。
原型为:
1 | int unshare(int flags); |
它的作用是将进程以及其后的子进程的特定flag隔离。
Flags:
1 | CLONE_NEWNS (since Linux 2.4) |
使用unshare(2):
ruri没有开启net和user命名空间,它里面unshare相关的代码为:
1 | pid_t init_unshare_container(bool no_warnings) |
setns(2):
在容器已经运行后,我们可以使用setns()加入容器的namespace,用法如下:
首先我们知道容器的ns所有者pid(ns_pid),然后:
open()打开/proc/{ns_pid}/ns/{namespace}
,获取ns_fd, 其中namespace是想加入的ns名称,
获取到ns_fd后,setns(ns_fd,0);
即可。
注意:mount ns应该始终最后一个被加入,因为加入后/proc下的内容在开启pid ns的情况下会变化导致无法setns(2)。
安全性:
即使在ns全开的设备中,unshare()后容器中的进程中的root权限依然等于宿主系统中的root权限,因此最简单的攻击方式便是直接修改磁盘中的文件,当然也有其它逃逸方式可进行攻击。
pivot_root(2):
pivot_root()类似于chroot(),但更加安全,它的原理是更改当前mount ns的根目录,并将原根目录移动到新的目录。
ruri v3.7正式为unshare容器启用pivot_root替代chroot。
他的使用方式如下:
首先我们需要一个mount ns,可以unshare(CLONE_NEWNS)
后fork()一下自己。
其次,pivot_root()的new_root以及其父挂载点需要是MS_PRIVATE属性。
最后,new_root需要是一个挂载点。
在mount ns的所有者进程中,可以这样使用pivot_root:
1 | // 将根目录挂载为MS_PRIVATE |
在非ns所有者进程中,我们可以通过setns(2)加入已经运行过pivot_root的mount namespace, 然后直接chdir("/")
即可。
Rootless容器:
建议配合咱的另一篇文章阅读:rootless容器开发指北
首先我们需要CLONE_NEWUSER创建一个user ns。
然后,为了使用mount(2),我们还需要CLONE_NEWNS来成为当前mount ns的所有者。
最后,为了挂载procfs,我们需要CLONE_NEWPID,很怪,但实测没有这个不行。
然后我们设置uidmap和gidmap:
1 | uid_t uid = geteuid(); |
最后我们将宿主机的sysfs挂载上:
1 | mount("/sys", "./sys", NULL, MS_BIND | MS_REC, NULL); |
然后是procfs:
1 | mount("proc", "./proc", "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL); |
最后,/dev下的设备需要手动从宿主机映射:
1 | open("./dev/tty", O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, S_IRUSR | S_IWUSR | S_IROTH | S_IWOTH | S_IRGRP | S_IWGRP); |
就可以正常chroot(2)了,还是很简单的。
Cgroup:
cgroup即control group,用于限制进程资源。
目前ruri只支持cpuset和memory这两个cgroup。
首先我们需要挂载cgroup的apifs:
对于cgroup v1:
1 | mkdir("/sys/fs/cgroup", S_IRUSR | S_IWUSR); |
而对于v2:
1 | mkdir("/sys/fs/cgroup", S_IRUSR | S_IWUSR); |
最后,我们在cgroup目录下创建子cgroup,将进程自身加入cgroup.procs然后设置相关限制即可生效。
这部分不细讲了,因为ruri也只是对于cpuset和memory限制简单实现了下。
容器安全:
进程属性查看:
1 | cat /proc/$$/status |
CapEff等表示当前进程capabilities,使用capsh --decode=xxxxx
来解码。
NoNewPrivs,Seccomp和Seccomp_filters后面会讲。
capabilities(7)与libcap(3):
从2.2版本开始,Linux内核将进程root特权分割为可独立控制的部分,称之为capability,capability是线程属性(per-thread attribute),可从父进程继承。
通常所说的root权限其实是拥有相关capability,如chroot(2)其实需要的是CAP_SYS_CHROOT。
目前内核中定义的capability:
1 | CAP_CHOWN 变更文件所有权 |
具体哪些capability需要移除那些保留可直接参照docker。
函数调用:
说实话这东西有点抽象,理论上只移除CapBnd即可生效,Linux 6.6(Archlinux)下其他值会被一并移除,但KernelSU貌似有Bug导致不检查CapBnd。
咱strace追踪了containerd的调用,发现它会同时移除CapAmb和清零CapInh。
首先是CapBnd的移除,使用libcap:
1 | int cap_drop_bound(cap_value_t cap); |
cap值是上面讲到的宏。
CapAmb的移除需要prctl(2):
1 | prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_LOWER, cap, 0, 0); |
最后是CapInh的清除:
1 | // Clear CapInh. |
这段着实有点抽象,hrdp和datap事实上是两个指针,datap需要是一个数组,实测只分配一个datap大小的内存会崩。
seccomp(2)与libseccomp
Secommp (SECure COMPuting,安全计算模式),自Linux 2.6.12被引入,用于对进程的系统调用进行限制,个人理解为:
在启用了Seccomp的设备上:
进程可执行的系统调用 ==(基本系统调用+进程capability所授予的特权调用)∩ seccomp允许的系统调用
Seccomp有strict mode和filter mode两种开启模式。
Strict mode:严格模式,怕是已经被弃用了,只是由于历史原因留着,对咱没啥用处。
Filter mode:BPF过滤器模式,对白名单外的系统调用进行过滤。
函数调用:
用到seccomp.h
中的函数来开启bpf模式,完整操作过程为:
1 | // 初始化规则,ruri自3.1起使用黑名单,因为白名单咱也不会写23333 |
NO_NEW_PRIV位:
也是进程属性,它就像一个奴籍(作者自己都觉得奇怪的比喻,但是不觉得很合理吗?),一旦被设置,进程自身及其子进程均无法主动取消此标志。此属性的作用是限制进程的特权集始终小于等于其父进程,也就是说在设置后进程特权只能减少不能增加。此标志设置后可执行文件suid位以及capability属性均无法生效,就连切换用户为普通用户后执行sudo命令也会失效。
它的设置十分简单,直接使用sys/prctl.h
中的prctl(2)函数:
1 | prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); |
Secure bit:
内核对进程有额外的secure bit属性,可以去man里面了解下,目前作者只加入了SECBIT_NO_CAP_AMBIENT_RAISE:
1 | prctl(PR_SET_SECUREBITS, SECBIT_NO_CAP_AMBIENT_RAISE); |
后记:
文采太差,没有后记,散会。。。
優しさも笑顔も夢の語り方も、
知らなくて全部、
君を真似たよ