WEBKT

cgroups 限制 Linux 共享内存 shm 防止 OOM 攻击实战

3 0 0 0

在多租户环境、容器云平台或向外提供公共 API 服务的 Linux 主机上,共享内存(Shared Memory,简称 shm)常常是一个容易被安全人员忽略的资源漏洞。

由于默认情况下 POSIX 共享内存(挂载在 /dev/shmtmpfs)或 System V IPC 共享内存没有针对单用户的严苛物理内存额度限制,恶意攻击者或写出 Bug 的程序可以通过持续向共享内存写入数据,迅速耗尽宿主机的物理内存,触发系统级的 OOM(Out of Memory)机制,导致核心服务被系统内核无差别杀掉。

虽然通过 mount -o remount,size=XX 可以全局限制 /dev/shm 的大小,但这种方法是全局粗粒度的,无法针对特定的恶意进程或用户组进行精细化限制。

本文将演示如何利用 Linux 内核的 cgroups(Control Groups) 机制,精确限制特定进程组所能消耗的最大 shm 物理内存,从根本上防御针对共享内存的 OOM 攻击。


一、 核心原理解析:cgroups 是如何统计 shm 的?

在 Linux 中,共享内存(无论是 /dev/shm 中的文件,还是通过 shmget 分配的 System V 共享内存段)在内核底层都是由 tmpfs(临时文件系统)支撑的。

tmpfs 的特殊之处在于:

  1. 它没有实际的磁盘介质,所有写入的数据都直接暂存在**内核页缓存(Page Cache)**中。
  2. 只要数据未被释放,这部分页缓存就属于“不可回收的页”(Unevictable Pages),会一直占用物理内存(或 Swap)。

在 cgroups(特别是主流的 cgroups v2)的内存控制器中,这部分由 tmpfs 和共享内存产生的内存开销,会被精确地统计在 shmem 指标下,并归属到首次向该共享内存写入数据(触发 Page Fault 物理页分配)的进程所在的 cgroup 节点

这意味着:

  • 只要我们将目标进程限制在特定的 cgroup 中,并设定 memory.max
  • 当该进程写入共享内存(/dev/shm)的物理内存大小与该进程自身消耗的匿名内存之和超过限制时,cgroups 就会直接介入。
  • 内核会优先在该 cgroup 内部触发 OOM 杀掉该进程,而不会波及宿主机上的其他无关业务。

二、 实战演练:利用 cgroups v2 限制 shm 内存分配

目前主流的 Linux 发行版(如 Ubuntu 22.04+、CentOS Stream 9、Debian 11+)默认已全面启用 cgroups v2。下面我们将基于 cgroups v2 演示完整的限制与防御过程。

1. 准备工作:确认 cgroups v2 已启用

在终端执行以下命令,若输出为 cgroup2fs,则代表系统已启用 v2 版本的 cgroup:

stat -f /sys/fs/cgroup

(如果是 v1 版本,控制路径在 /sys/fs/cgroup/memory/ 下,逻辑类似,但参数名为 memory.limit_in_bytes。)

2. 创建受限控制组

在 cgroup 根目录下创建一个名为 sandbox 的子控制组:

sudo mkdir /sys/fs/cgroup/sandbox

内核会自动在此目录下生成相应的控制文件。

3. 配置内存上限值

我们限制该控制组最多只能使用 100MB 的物理内存(包括进程匿名内存和共享内存)。同时为了防止进程通过 Swap 空间规避限制,我们将 Swap 限制也设为 0:

# 限制物理内存最大为 100MB
echo "100M" | sudo tee /sys/fs/cgroup/sandbox/memory.max

# 禁用该控制组的 Swap(可选,视具体安全策略而定)
echo "0" | sudo tee /sys/fs/cgroup/sandbox/memory.swap.max

三、 攻击防御测试

为了验证限制是否生效,我们编写一个简单的 Python 脚本。该脚本会持续向 /dev/shm 写入大文件,模拟共享内存耗尽攻击(OOM 攻击)。

1. 编写模拟攻击脚本 shm_attack.py

在宿主机中创建 shm_attack.py

import os
import time

shm_path = "/dev/shm/oom_trigger_file"
chunk_size = 10 * 1024 * 1024  # 每次写入 10MB
total_written = 0

print(f"[+] 准备向 {shm_path} 持续写入数据...")

try:
    # 以二进制写模式打开 shm 中的文件
    with open(shm_path, "wb") as f:
        while True:
            # 写入 10MB 的全零数据,触发内核分配物理页
            f.write(b'\x00' * chunk_size)
            f.flush()
            os.fsync(f.fileno())  # 强制刷盘到 tmpfs 内存中
            total_written += chunk_size
            print(f"[*] 已写入共享内存: {total_written / (1024*1024):.1f} MB")
            time.sleep(0.2)
except Exception as e:
    print(f"[-] 发生异常: {e}")
finally:
    # 异常退出时清理现场
    if os.path.exists(shm_path):
        os.remove(shm_path)

2. 测试场景一:不带 cgroups 限制运行(危险,随时准备终止进程)

直接运行脚本,你会发现它会一直无限制地写入,直到把主机的物理内存榨干:

python3 shm_attack.py

(在控制台看到数值快速攀升时,请立刻按 Ctrl+C 强行终止,避免系统死机。)

3. 测试场景二:置入 cgroups 限制运行

现在,我们利用 cgroups v2 的进程附着机制,将测试进程放入我们刚刚创建的 sandbox 控制组中。

可以使用 systemd-run 命令便捷地在特定 cgroup 下启动程序,或者直接通过 shell 写入:

# 将当前的 Bash Shell 进程 PID 写入 sandbox 的 cgroup.procs
echo $$ | sudo tee /sys/fs/cgroup/sandbox/cgroup.procs

# 此时,当前 Shell 派生出的所有子进程都将自动继承该 cgroup 限制
python3 shm_attack.py

观察输出结果:

[+] 准备向 /dev/shm/oom_trigger_file 持续写入数据...
[*] 已写入共享内存: 10.0 MB
[*] 已写入共享内存: 20.0 MB
[*] 已写入共享内存: 30.0 MB
[*] 已写入共享内存: 40.0 MB
[*] 已写入共享内存: 50.0 MB
[*] 已写入共享内存: 60.0 MB
[*] 已写入共享内存: 70.0 MB
[*] 已写入共享内存: 80.0 MB
[*] 已写入共享内存: 90.0 MB
已杀死 (Killed)

进程在写入到大约 90MB 左右时,由于加上 Python 虚拟机自身占用的十几兆基础内存,累积触碰了 100MB 的硬限制,当场被内核硬性杀掉(Killed)

4. 验证内核日志 (dmesg)

此时在宿主机上执行 dmesg -T 检索内核日志,能清晰地看到是 cgroups 的 memory controller 触发了 OOM-killer,而不是整机 OOM:

[Mon Oct 28 14:15:20 2024] shm_attack.py Invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
[Mon Oct 28 14:15:20 2024] CPU: 2 PID: 4521 Comm: python3 Not tainted 5.15.0-generic #1
[Mon Oct 28 14:15:20 2024] Hardware name: QEMU Standard PC
[Mon Oct 28 14:15:20 2024] Memory cgroup out of memory: Killed process 4521 (python3) total-vm:124536kB, anon-rss:14324kB, file-rss:0kB, shmem-rss:81920kB

注意看日志细节:

  • Memory cgroup out of memory:指明这是 cgroup 级别触发的 OOM,非全局 OOM。
  • shmem-rss:81920kB:内核清晰地统计出,该进程在被杀掉时,有大约 80MB 的开销是属于 shmem(即 /dev/shm 里的共享内存文件)。

四、 生产环境中的高阶配置建议

在实际生产部署中,我们通常不会通过手动写入 cgroup.procs 的方式去限制进程。以下是两种推荐的工业级配置方案。

方案 A:配合 Systemd Service 进行限制

如果你的服务是由 Systemd 管理的,直接在服务的 .service 配置文件中加入 MemoryMax 参数即可。Systemd 会在底层自动将其转化为 cgroups v2 控制器规则。

编辑 /etc/systemd/system/vulnerable-app.service

[Unit]
Description=可能会耗尽共享内存的高危业务服务
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/app/shm_attack.py
User=www-data
Group=www-data

# 限制该服务及其所有子进程的总物理内存占用(包含 shm)
MemoryMax=500M
# 防止通过 Swap 规避
MemorySwapMax=0

[Install]
WantedBy=multi-user.target

保存后重载并启动服务:

sudo systemctl daemon-reload
sudo systemctl start vulnerable-app.service

方案 B:容器化场景下的限制 (Docker / Kubernetes)

容器本身就是基于 cgroups 实现的隔离。在默认情况下,Docker 容器的 /dev/shm 大小被硬性限制为 64MB。

如果你因为业务需要(例如运行 PyTorch 深度学习多进程训练或高性能 Redis 实例),使用 --shm-size 放大了共享内存,务必同时利用 -m 参数对容器的物理内存做出硬性物理限制:

docker run -d \
  --name app-container \
  --shm-size=2g \
  -m 2.5g \
  vulnerable-image:latest

在 Kubernetes 中,可通过设置 resources.limits.memory 来确保即便 Pod 内向 emptyDir.medium: Memory (即 K8s 的 shm 挂载方式) 写入超量数据,也会在 Pod 级别触发 OOM,而不至于拖垮宿主机 Node。

五、 总结

Linux 的共享内存虽然带来了极高的 IPC 通信性能,但由于其本质是 Page Cache 形式的物理内存占用,一旦缺乏监控与隔离,就容易沦为整机崩溃的导火索。

通过 cgroups v2memory.max 指标,我们可以将进程自身运行所需的 Anonymous Memory 与其分配的 shmem 进行统一合并统计。这种精细化的资源边界控制,不仅能有效遏制恶意 OOM 攻击,更是构建多租户安全沙箱与高可用生产系统不可或缺的关键一环。

KernelDevOps Linuxcgroups安全防御

评论点评