前言: 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 2 3 4 mount(source, target, NULL , mountflags | MS_BIND, NULL ); mount(source, target, NULL , mountflags | MS_BIND | MS_REMOUNT, NULL );
其他容器注意事项: 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 #include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/dir.h> #include <unistd.h> int main (int argc, char **argv) { if (getuid() != 0 ) { fprintf (stderr , "\033[31mError: this program should be run with root privileges !\033[0m\n" ); exit (1 ); } if (argc <= 1 ) { fprintf (stderr , "\033[31mError: too few arguments !\033[0m\n" ); exit (1 ); } char *ld_preload = getenv("LD_PRELOAD" ); if (ld_preload != NULL ) { fprintf (stderr , "\033[31mError: please unset $LD_PRELOAD before running this program or use su -c `COMMAND` to run.\033[0m\n" ); exit (1 ); } char *container_dir = argv[1 ]; char *command[1024 ] = {0 }; if (argc == 2 ) { command[0 ] = "/bin/su" ; command[1 ] = "-" ; command[2 ] = NULL ; } else { int i = 0 ; for (int j = 2 ; j < argc; j++) { command[i] = argv[j]; i++; } command[i + 1 ] = NULL ; } DIR *direxist; if ((direxist = opendir(container_dir)) == NULL ) { fprintf (stderr , "\033[31mError: container directory does not exist !\033[0m\n" ); exit (1 ); } else { closedir(direxist); } chroot(container_dir); chdir("/" ); if (execv(command[0 ], command) == -1 ) { fprintf (stderr , "\033[31mFailed to execute %s\n" , command[0 ]); fprintf (stderr , "execv() returned: %d\n" , errno); fprintf (stderr , "error reason: %s\033[0m\n" , strerror(errno)); exit (1 ); } }
用法为:
1 2 cc 文件名.c ./a.out NEWROOT [COMMAND [ARG]...]
十分的简单,甚至九分的简单。
安全性: chroot实现了根目录隔离,但是chroot()后的进程会继承父进程特权,而且不幸的是chroot()必须以root(CAP_SYS_CHROOT)特权执行。chroot()后容器仍可访问外部资源,包括但不限于在容器内执行以下操作:
kill外部进程
创建设备节点并挂载磁盘设备
当场逃逸出容器
因此不要将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
。 原型为:
它的作用是将进程以及其后的子进程的特定flag隔离。 Flags:
1 2 3 4 5 6 7 8 9 CLONE_NEWNS (since Linux 2.4) CLONE_NEWUTS (since Linux 2.6.19) CLONE_NEWIPC (since Linux 2.6.19) CLONE_NEWNET (since Linux 2.6.24) CLONE_SYSVSEM (since Linux 2.6.26) CLONE_NEWUSER (since Linux 3.8) CLONE_NEWPID (since Linux 3.8) CLONE_NEWCGROUP (since Linux 4.6) CLONE_NEWTIME (since Linux 5.6)
使用unshare(2): ruri没有开启net和user命名空间,它里面unshare相关的代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 pid_t init_unshare_container (bool no_warnings) { pid_t unshare_pid = INIT_VALUE; unshare(CLONE_NEWNS); unshare(CLONE_NEWUTS); unshare(CLONE_NEWIPC); unshare(CLONE_NEWPID); unshare(CLONE_NEWCGROUP); unshare(CLONE_NEWTIME); unshare(CLONE_SYSVSEM); unshare(CLONE_FILES); unshare(CLONE_FS); unshare_pid = fork(); if (unshare_pid > 0 ) { usleep(200000 ); waitpid(unshare_pid, NULL , 0 ); } else if (unshare_pid < 0 ) { error("Fork error QwQ?" ); } return unshare_pid; }
安全性: 即使在ns全开的设备中,unshare()后容器中的进程中的root权限依然等于宿主系统中的root权限,因此最简单的攻击方式便是直接修改磁盘中的文件,当然也有其它逃逸方式可进行攻击,于是来到我们的下一节,容器安全。
Rootless容器: 这部分咱研究不深,简单讲讲Rootless容器的创建过程(su等命令实测在容器中无法执行)。 首先我们需要CLONE_NEWUSER创建一个user ns。 然后,为了使用mount(2),我们还需要CLONE_NEWNS来成为当前mount ns的所有者。 最后,为了挂载procfs,我们需要CLONE_NEWPID,很怪,但实测没有这个不行。 然后我们设置uidmap和gidmap:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 uid_t uid = geteuid();gid_t gid = getegid();char uid_map[32 ] = { "\0" };sprintf (uid_map, "0 %d 1\n" , uid);int uidmap_fd = open("/proc/self/uid_map" , O_RDWR | O_CLOEXEC);write(uidmap_fd, uid_map, strlen (uid_map)); close(uidmap_fd); int setgroups_fd = open("/proc/self/setgroups" , O_RDWR | O_CLOEXEC);write(setgroups_fd, "deny" , 5 ); close(setgroups_fd); char gid_map[32 ] = { "\0" };sprintf (gid_map, "0 %d 1\n" , gid);int gidmap_fd = open("/proc/self/gid_map" , O_RDWR | O_CLOEXEC);write(gidmap_fd, gid_map, strlen (gid_map)); close(gidmap_fd); setuid(0 ); setgid(0 );
最后我们将宿主机的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 2 open("./dev/tty" , O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, S_IRUSR | S_IWUSR | S_IROTH | S_IWOTH | S_IRGRP | S_IWGRP); mount("/dev/tty" , "./dev/tty" , NULL , MS_BIND, NULL );
就可以正常chroot(2)了,还是很简单的。
Cgroup: cgroup即control group,用于限制进程资源。 目前ruri只支持cpuset和memory这两个cgroup。 首先我们需要挂载cgroup的apifs: 对于cgroup v1:
1 2 3 4 5 6 7 8 mkdir("/sys/fs/cgroup" , S_IRUSR | S_IWUSR); mount("tmpfs" , "/sys/fs/cgroup" , "tmpfs" , MS_NOSUID | MS_NODEV | MS_NOEXEC | MS_RELATIME, NULL ); mkdir("/sys/fs/cgroup/memory" , S_IRUSR | S_IWUSR); mkdir("/sys/fs/cgroup/cpuset" , S_IRUSR | S_IWUSR); mount("none" , "/sys/fs/cgroup/memory" , "cgroup" , MS_NOSUID | MS_NODEV | MS_NOEXEC | MS_RELATIME, "memory" ); mount("none" , "/sys/fs/cgroup/cpuset" , "cgroup" , MS_NOSUID | MS_NODEV | MS_NOEXEC | MS_RELATIME, "cpuset" );
而对于v2:
1 2 mkdir("/sys/fs/cgroup" , S_IRUSR | S_IWUSR); mount("none" , "/sys/fs/cgroup" , "cgroup2" , MS_NOSUID | MS_NODEV | MS_NOEXEC | MS_RELATIME, NULL );
最后,我们在cgroup目录下创建子cgroup,将进程自身加入cgroup.procs然后设置相关限制即可生效。 这部分不细讲了,因为ruri也只是对于cpuset和memory限制简单实现了下。
容器安全: 进程属性查看:
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 CAP_CHOWN 变更文件所有权 CAP_DAC_OVERRIDE 忽略文件的DAC访问限制 CAP_DAC_READ_SEARCH 忽略文件读及目录搜索的DAC访问限制 CAP_FOWNER 忽略文件属主ID必须和进程用户ID相匹配的限制 CAP_FSETID 允许设置文件的setuid位 CAP_IPC_LOCK 允许锁定共享内存片段 CAP_IPC_OWNER 忽略IPC所有权检查 CAP_KILL 向非当前用户进程发送信号(杀死) CAP_LEASE 允许修改文件锁的FL_LEASE标志 CAP_LINUX_IMMUTABLE 允许修改文件的IMMUTABLE和APPEND属性标志 CAP_NET_ADMIN 允许执行网络管理任务 CAP_NET_BIND_SERVICE 允许绑定到小于1024的端口 CAP_NET_BROADCAST 允许网络广播和多播访问 CAP_NET_RAW 使用原始套接字 CAP_SETGID 设置gid CAP_SETPCAP 设置其他进程capability CAP_SETUID 设置uid CAP_SYS_ADMIN 允许执行系统管理任务,如加载或卸载文件系统、设置磁盘配额等 CAP_SYS_BOOT 重启设备 CAP_SYS_CHROOT 调用chroot CAP_SYS_MODULE 加载/删除内核模块 CAP_SYS_NICE 允许提升优先级及设置其他进程的优先级 CAP_SYS_PACCT 允许执行进程的BSD式审计 CAP_SYS_PTRACE 允许对任意进程进行ptrace CAP_SYS_RAWIO 访问原始块设备 CAP_SYS_RESOURCE 忽略资源限制 CAP_SYS_TIME 设置系统时间 CAP_SYS_TTY_CONFIG 设置tty设备 CAP_MKNOD (since Linux 2.4) 创建设备节点 CAP_AUDIT_CONTROL (since Linux 2.6.11) 启用和禁用内核审计;改变审计过滤规则;检索审计状态和过滤规则 CAP_AUDIT_WRITE (since Linux 2.6.11) 将记录写入内核审计日志 CAP_SETFCAP (since Linux 2.6.24) 允许为文件设置任意的capabilities CAP_MAC_ADMIN (since Linux 2.6.25) 允许MAC配置或状态更改 CAP_MAC_OVERRIDE (since Linux 2.6.25) 覆盖MAC设置 CAP_SYSLOG (since Linux 2.6.37) 允许使用syslog()系统调用 CAP_WAKE_ALARM (since Linux 3.0) 允许触发一些能唤醒系统的东西 CAP_BLOCK_SUSPEND (since Linux 3.5) 使用可以阻止系统挂起的特性 CAP_AUDIT_READ (since Linux 3.16) 允许通过 multicast netlink 套接字读取审计日志 CAP_BPF (since Linux 5.8) 使用bpf() CAP_PERFMON (since Linux 5.8) 使用perf_event_open() CAP_CHECKPOINT_RESTORE (since Linux 5.9) 调用checkpoint/restore
具体哪些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 2 3 4 5 6 7 8 9 10 cap_user_header_t hrdp = (cap_user_header_t )malloc (sizeof *hrdp);cap_user_data_t datap = (cap_user_data_t )malloc (sizeof *datap);hrdp->pid = getpid(); hrdp->version = _LINUX_CAPABILITY_VERSION_3; syscall(SYS_capget, hrdp, datap); datap->inheritable = 0 ; syscall(SYS_capset, hrdp, datap); free (hrdp);free (datap);
这段着实有点抽象,hrdp和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 2 3 4 5 6 7 8 9 10 scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW); seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(系统调用), 0 ); seccomp_attr_set(ctx, SCMP_FLTATR_CTL_NNP, 0 ); seccomp_load(ctx);
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);
后记: 文采太差,没有后记,散会。。。
優しさも笑顔も夢の語り方も、
知らなくて全部、
君を真似たよ