cgroups 限制 Linux 共享内存 shm 防止 OOM 攻击实战
在多租户环境、容器云平台或向外提供公共 API 服务的 Linux 主机上,共享内存(Shared Memory,简称 shm)常常是一个容易被安全人员忽略的资源漏洞。
由于默认情况下 POSIX 共享内存(挂载在 /dev/shm 的 tmpfs)或 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 的特殊之处在于:
- 它没有实际的磁盘介质,所有写入的数据都直接暂存在**内核页缓存(Page Cache)**中。
- 只要数据未被释放,这部分页缓存就属于“不可回收的页”(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 v2 的 memory.max 指标,我们可以将进程自身运行所需的 Anonymous Memory 与其分配的 shmem 进行统一合并统计。这种精细化的资源边界控制,不仅能有效遏制恶意 OOM 攻击,更是构建多租户安全沙箱与高可用生产系统不可或缺的关键一环。