本节摘要:这一节把「加固容器」从口号变成四道可验证的边界:文件系统(只读根 + 唯一可写的工作区挂载 + noexec 临时区)、网络(默认断网)、能力(全部裁剪 + 禁提权 + 非 root 用户)、资源(内存、CPU、进程数三限额)。先给一份约二十行的沙箱 Dockerfile,再逐参数拆解运行配置并解释每个参数对应第 0.1 节的哪类攻击面;然后把这一切包进执行包装器 sandbox_run.py——统一超时、统一审计、透传退出码,让上层(2.3 节的网关)只需调用一个函数;最后用四组红队小实验攻击自己的沙箱,确认边界不是纸面的。本节所有代码可拷即用。
| 边界 | 手段 | 对应攻击面(第 0.1 节) |
|---|---|---|
| 文件系统 | --read-only + 唯一可写挂载 workspace + --tmpfs /tmp |
文件系统逃逸 |
| 网络 | --network none |
网络外联 |
| 能力 | --cap-drop ALL + no-new-privileges + 非 root 用户 |
提权与横向移动 |
| 资源 | --memory / --cpus / --pids-limit |
资源耗尽 |
第四列留白的「内核漏洞」不在此节能力范围内——那是 2.2 节的主场。
# Dockerfile —— Agent 沙箱基础镜像:最小 Python 执行环境 FROM python:3.12-slim # 只装任务必需工具;不装 curl/wget/netcat,减小误用与误判面 RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ && rm -rf /var/lib/apt/lists/* # 非 root 用户:容器内一切进程以 uid 1000 运行 RUN useradd --create-home --uid 1000 sandbox USER 1000:1000 WORKDIR /workspace # 入口留空:命令由外部显式传入,镜像不内置任何自动行为 ENTRYPOINT []
构建:docker build -t agent-sandbox:py3.12 .
💡 镜像里不装 curl / wget / nc 是刻意的:即使策略层漏放了一条,攻击者也要先「装」工具——而装依赖这件事会撞上网关的审批门(第 3.1 节)。
# run_sandbox.sh —— 加固容器一次性运行(写法示意,以 Docker 官方 run 参考为准) docker run --rm \ --network none \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=64m \ --cap-drop ALL \ --security-opt no-new-privileges \ --user 1000:1000 \ --pids-limit 128 \ --memory 512m \ --cpus 0.5 \ -v "$PWD/workspace:/workspace:rw" \ -w /workspace \ agent-sandbox:py3.12 \ python analyze.py
| 参数 | 作用 | 说明 |
|---|---|---|
--network none |
网络边界 | 无网卡、无 DNS,外联与回传通道整体消失 |
--read-only |
只读根 | 根文件系统不可写,改 /etc、植入持久化全部失败 |
--tmpfs /tmp:rw,noexec,nosuid,size=64m |
受控临时区 | 唯一根内可写点;noexec 挡「先写后执行」 |
-v 工作区目录:/workspace:rw |
唯一业务可写点 | 读写只发生在工作区;只读 Agent 可改 :ro |
--cap-drop ALL |
能力清零 | 丢弃全部 Linux capabilities,含挂载、网络管理等 |
--security-opt no-new-privileges |
禁提权 | setuid / sudo 提权路径失效 |
--user 1000:1000 |
非 root | 与镜像内用户一致,权限最小化 |
--pids-limit 128 |
进程数上限 | fork 炸弹的硬天花板 |
--memory 512m --cpus 0.5 |
资源限额 | 内存与 CPU 硬顶,防资源耗尽 |
--rm |
用后即焚 | 不留容器残骸与可复用状态 |
⚠️ 两个高危反例,见到就拦:挂载 Docker Socket(
-v /var/run/docker.sock:...)等于交出宿主机 root;挂载宿主根目录(-v /:/host)等于没有沙箱。任何一条出现,前面所有参数归零。
直接敲 docker 命令难以统一超时与审计,全部收进一个包装器:
# sandbox_run.py —— Agent 命令沙箱执行包装器(加固 Docker 落地版) import datetime, json, os, subprocess, sys, time, uuid IMAGE = "agent-sandbox:py3.12" WORKSPACE = os.path.abspath(os.environ.get("SANDBOX_WORKSPACE", "./workspace")) MEMORY, CPUS, PIDS = "512m", "0.5", 128 TIMEOUT = int(os.environ.get("SANDBOX_TIMEOUT", "120")) AUDIT_PATH = os.environ.get("SANDBOX_AUDIT", "audit/audit.jsonl") def audit(event: dict) -> None: os.makedirs(os.path.dirname(AUDIT_PATH), exist_ok=True) with open(AUDIT_PATH, "a", encoding="utf-8") as f: f.write(json.dumps(event, ensure_ascii=False) + "\n") def run_sandboxed(argv: list) -> dict: run_id = uuid.uuid4().hex[:12] cmd = ["docker", "run", "--rm", "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--user", "1000:1000", "--pids-limit", str(PIDS), "--memory", MEMORY, "--cpus", CPUS, "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m", "--name", f"sbx-{run_id}", "-v", f"{WORKSPACE}:/workspace:rw", "-w", "/workspace", IMAGE, *argv] event = {"run_id": run_id, "command": argv, "sandbox": "docker-hardened", "started_at": datetime.datetime.now().isoformat(timespec="seconds")} t0 = time.time() try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT) event.update(status="finished", exit_code=proc.returncode, stdout_tail=proc.stdout[-2000:], stderr_tail=proc.stderr[-2000:]) except subprocess.TimeoutExpired: event.update(status="timeout", exit_code=124, stderr_tail=f"超过 {TIMEOUT}s 已终止") event["duration_ms"] = int((time.time() - t0) * 1000) audit(event) return event if __name__ == "__main__": if len(sys.argv) < 2: sys.exit("用法: python sandbox_run.py <命令> [参数...]") ev = run_sandboxed(sys.argv[1:]) print(json.dumps(ev, ensure_ascii=False, indent=2)) sys.exit(ev["exit_code"] if isinstance(ev["exit_code"], int) else 1)
设计要点:命令以参数数组传递(不做 shell 字符串拼接,堵注入);每次执行自动落一行审计 JSONL(第 3.2 节升级为防篡改审计器);超时兜底防止沙箱内死循环占住资源。
用法示例:python sandbox_run.py python analyze.py、python sandbox_run.py pytest -q。
配置看起来对不等于对,逐组攻击它:
# verify_sandbox.sh —— 边界验证:四组实验应全部失败或被限死(写法示意) python sandbox_run.py sh -c "echo pwned > /etc/evil" # 期望:Read-only file system python sandbox_run.py python -c "print(open('/etc/passwd').read())" # 期望:能读(容器内副本,可接受),但写不了 python sandbox_run.py python -c "import urllib.request;urllib.request.urlopen('http://example.com')" # 期望:网络不可达 / 解析失败
第四组 fork 炸弹(示意):在容器内跑 sh -c ':(){ :|:& };:',预期进程数被 --pids-limit 128 硬顶,宿主机无感。做实验时把 --memory 调小一些可以更直观地看到内存限额触发。
⚠️ 若任一实验「成功」了(写进了 /etc、成功外联、炸穿了进程数),回到第三节对照表格排查参数——十有八九是运行时手敲命令漏了某个 flag,这正是要把执行收进 sandbox_run.py 与网关的原因(2.3 节)。
四道边界挡住了前三类攻击面,但容器与宿主共享内核的结构性风险仍在——runc 历史逃逸漏洞(官方 CVE 记录)随时可能再来一个。下一节给这个缺口打补丁:gVisor 的 runsc 换 runtime 即接入,Firecracker 用独立内核做到最硬。