feat(sandbox): Docker 只读执行叠加 Landlock 进程级只读域,封死 world-writable 绕 DAC 漏洞

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
This commit is contained in:
JOJO 2026-09-01 15:17:23 +08:00
parent 8da53635c4
commit a3fa5e59c6
6 changed files with 370 additions and 17 deletions

View File

@ -412,10 +412,14 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
只读权限的强制由各平台原生机制兜底;`config/limits.py` 的命令文本白名单(`_is_readonly_run_command_allowed`)只是**审批决策的启发式**,不再是安全边界(已知可绕过,如 `find . -delete`;绕过后果只是多走一次审批)。
- **docker/web 模式 = 非特权 uid 执行角色**`modules/docker_readonly_exec.py`
- **docker/web 模式 = 非特权 uid 执行角色 + Landlock 进程级只读域**`modules/docker_readonly_exec.py`
- 容器主进程保持 root可写执行不变`sandbox_write_access=False` 的执行通道(`terminal_ops/run.py`、`background_command_manager.py`、只读语境创建的持久终端)以 `-u 10001:10001` 运行,`DOCKER_READONLY_EXEC_UID/GID` 可覆盖
- 强制力 = 内核 DAC工作区属主为宿主机 root非属主无写权600 权限文件(如 .env不可读逃逸需提权setuid/内核漏洞),无 umount 类捷径
- 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock云端已验证macOS Docker Desktopvirtiofs fakeowner不执行 uid 权限,仅 Linux 宿主生效
- 第一层强制力 = 内核 DAC工作区属主为宿主机 root非属主无写权600 权限文件(如 .env不可读逃逸需提权setuid/内核漏洞),无 umount 类捷径
- **第二层 = Landlock 只读域**2026-09 新增,云端实测 kernel 6.8 / ABI V4 / Docker 28 默认 seccomp 放行):纯 DAC 的残留漏洞是工作区内历史遗留的 world-writable777/o+w路径对只读 uid 仍可写;只读执行时命令再经 `modules/landlock_launcher.py`(首次执行时 docker cp 进容器并自检)进入「工作区写类操作全拒」的内核域,最终权限 = DAC ∩ Landlock该洞封死。要点
- launcher 规则handled 只含写类操作ro 路径(工作区挂载点)不加规则(无覆盖即拒绝);/tmp、/var/tmp、/dev/shm 显式授写权以对齐纯 DAC 现状行为(只读身份 HOME=/tmp读/执行不进 handled仍由 DAC 管。注意不能写「/ 授全量 + ro 授空」的交集规则——空授权规则被内核拒绝ENOMSG/errno 42
- 自检失败(内核 <5.13 / seccomp 拦截 / 容器无 python3自动降级纯 DACwarning 日志标注 enforcement level`DOCKER_READONLY_LANDLOCK=0` 可整体停用
- 语义边界Landlock 不管 chmod/chown 等元数据修改,也不管未配置的网络——因此非特权 uid 层必须保留root+Landlock 的进程可 chmod 放宽权限位让域外进程受益)
- 前提:工作区属主与该 uid 不碰撞、容器未挂 docker.sock云端已验证macOS Docker Desktop 双重不适用virtiofs fakeowner 不按 uid 检查linuxkit 内核未编译 Landlock仅 Linux 宿主生效
- 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`terminal_readonly_enabled` 判定,`terminal_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道权限跨界切换受限档⇄unrestricted时销毁现有终端会话重建`_apply_restricted_execution_mode_link` → `close_all()`
- Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益
- **macOS 宿主机 = Seatbelt 白名单读模型**`modules/host_sandbox_runner.py`

View File

@ -19,7 +19,7 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan,
host_sandbox_enabled,
)
from modules.docker_readonly_exec import docker_readonly_exec_args
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_wrap_inner
from modules.i18n import tr
@ -225,9 +225,12 @@ class BackgroundCommandManager:
if relative:
container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [docker_bin, "exec"]
inner_cmd = ["/bin/bash", "-lc", command]
if not sandbox_write_access:
# 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py
exec_cmd += docker_readonly_exec_args()
# Landlock 加固:可用时再以进程级只读域封住工作区写;失败自动降级纯 DAC。
inner_cmd = docker_readonly_wrap_inner(container_name, mount_path, inner_cmd, docker_bin)
exec_cmd += [
"-e",
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@ -236,9 +239,7 @@ class BackgroundCommandManager:
"-w",
container_workdir,
container_name,
"/bin/bash",
"-lc",
command,
*inner_cmd,
]
use_shell = False

View File

@ -19,11 +19,37 @@
权限检查本机制仅在 Linux 宿主机云端/ Linux 桌面生效属预期差异
持久终端在 approval/auto_approval 模式下同为只读身份写入命令请走
run_command审批通过后以 root 重跑
Landlock 加固2026-09
- DAC 的残留漏洞工作区内历史遗留的 world-writable777/o+w路径
对只读 uid 仍开放写权限为此在只读执行时额外用 Landlock 给进程套上
工作区写类操作全拒的内核域最终权限 = DAC Landlock封死该洞
- launcher 为本目录 landlock_launcher.py首次只读执行时 docker cp 进容器
并以只读身份自检本进程+子进程写工作区都必须被拒通过后才启用
任何一步失败都静默降级为纯 DAC并以 warning 日志标注 enforcement level
- 可用环境变量 DOCKER_READONLY_LANDLOCK=0 整体停用运维逃生门
- 语义刻意对齐纯 DAC 现状仅工作区写类操作被拒/tmp 等其余路径写
全部读/执行行为不变读保护仍由 DAC 承担
"""
from __future__ import annotations
import logging
import os
from typing import List, Tuple
import shutil
import subprocess
import threading
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("docker_readonly_exec")
LANDLOCK_LAUNCHER_SRC = Path(__file__).resolve().with_name("landlock_launcher.py")
LANDLOCK_LAUNCHER_CONTAINER_PATH = "/opt/astrion-landlock/launcher.py"
# 容器名 -> "available" / "unavailable:<reason>"None 表示尚未探测
_landlock_states: Dict[str, str] = {}
_landlock_states_lock = threading.Lock()
def docker_readonly_uid_gid() -> Tuple[str, str]:
@ -54,3 +80,105 @@ def docker_readonly_exec_args() -> List[str]:
"-e", "GIT_CONFIG_KEY_0=safe.directory",
"-e", "GIT_CONFIG_VALUE_0=*",
]
def _landlock_enabled() -> bool:
return os.environ.get("DOCKER_READONLY_LANDLOCK", "1").strip().lower() not in (
"0", "false", "off", "no",
)
def _docker_run(docker_bin: str, args: List[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(
[docker_bin] + args, capture_output=True, timeout=timeout,
)
def _deploy_and_selftest(container_name: str, mount_path: str,
docker_bin: str) -> Tuple[bool, str]:
"""把 launcher 部署进容器并以只读身份自检。返回 (是否可用, 失败原因)。"""
mount_path = (mount_path or "/workspace").rstrip("/") or "/"
try:
# 1. 容器内 python3 可用性
r = _docker_run(docker_bin, ["exec", container_name, "sh", "-c",
"command -v python3"], timeout=10)
if r.returncode != 0:
return False, "no-python3-in-container"
# 2. 部署 launcher 文件
r = _docker_run(docker_bin, ["exec", container_name, "mkdir", "-p",
os.path.dirname(LANDLOCK_LAUNCHER_CONTAINER_PATH)],
timeout=10)
if r.returncode != 0:
return False, f"mkdir-failed:{r.stderr.decode(errors='replace')[:200]}"
r = _docker_run(docker_bin, ["cp", str(LANDLOCK_LAUNCHER_SRC),
f"{container_name}:{LANDLOCK_LAUNCHER_CONTAINER_PATH}"],
timeout=30)
if r.returncode != 0:
return False, f"docker-cp-failed:{r.stderr.decode(errors='replace')[:200]}"
# 3. 以只读身份自检(与真实只读执行同一 uid/环境)
r = _docker_run(
docker_bin,
["exec", *docker_readonly_exec_args(), container_name,
"python3", LANDLOCK_LAUNCHER_CONTAINER_PATH,
"--selftest", "--ro", mount_path],
timeout=30,
)
out = (r.stdout + r.stderr).decode(errors="replace")
# 4. 清理自检意外成功时的残留(此时说明 Landlock 未生效,但仍要扫尾)
try:
_docker_run(docker_bin, ["exec", container_name, "rm", "-f",
f"{mount_path}/.landlock_selftest_probe"],
timeout=10)
except Exception:
pass
if r.returncode == 0 and "LANDLOCK_SELFTEST_OK" in out:
return True, ""
return False, f"selftest-failed:{out.strip()[:300]}"
except subprocess.TimeoutExpired:
return False, "probe-timeout"
except Exception as e: # noqa: BLE001 - 部署探测必须兜底为降级而非异常
return False, f"{type(e).__name__}:{e}"
def ensure_landlock_ready(container_name: str, mount_path: str,
docker_bin: Optional[str] = None) -> bool:
"""确保容器内 Landlock 只读域可用(部署+自检,按容器缓存结果)。
任何失败都返回 False调用方回退纯 DAC不会抛出
"""
if not _landlock_enabled():
return False
with _landlock_states_lock:
state = _landlock_states.get(container_name)
if state is not None:
return state == "available"
docker_bin = docker_bin or shutil.which("docker") or "docker"
ok, reason = _deploy_and_selftest(container_name, mount_path, docker_bin)
with _landlock_states_lock:
_landlock_states[container_name] = "available" if ok else f"unavailable:{reason}"
if ok:
logger.info("landlock readonly ready: container=%s mount=%s",
container_name, mount_path)
else:
logger.warning(
"landlock unavailable: container=%s reason=%s"
"readonly enforcement falls back to DAC-only (world-writable "
"paths remain writable by the readonly uid)",
container_name, reason,
)
return ok
def docker_readonly_wrap_inner(container_name: str, mount_path: str,
inner_cmd: List[str],
docker_bin: Optional[str] = None) -> List[str]:
"""只读执行时包装容器内命令Landlock 可用则经 launcher 进入只读域。
可用 [python3, launcher, --ro, mount, --] + inner_cmd
不可用原样返回 inner_cmd DAC 降级
"""
if ensure_landlock_ready(container_name, mount_path, docker_bin):
mount = (mount_path or "/workspace").rstrip("/") or "/"
return ["python3", LANDLOCK_LAUNCHER_CONTAINER_PATH,
"--ro", mount, "--", *inner_cmd]
return list(inner_cmd)

View File

@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Astrion Landlock 只读域 launcher容器内运行非后端模块
由后端 modules/docker_readonly_exec.py 部署到容器内docker cp并调用
python3 launcher.py --ro /workspace -- <cmd> [args...]
python3 launcher.py --selftest --ro /workspace
语义2026-09 云端实测kernel 6.8 / Landlock ABI V4 / Docker 28 默认 seccomp 放行
- handled 集合只含写类操作写文件/建删文件目录/创建设备节点/符号链接/
rename-link 跨越/截断不授权的路径一律被内核拒绝
- ro 路径通常即工作区挂载点不加任何规则 写类操作全拒
- /tmp/var/tmp/dev/shm 显式授予写类权限 与纯 DAC 只读的历史行为对齐
只读身份 HOME=/tmp常见临时写入不受影响其余路径写权限收紧这正是
要修复的 world-writable777/o+w DAC 漏洞本身
- /执行不进入 handled 集合完全交给 DAC600 权限敏感文件仍不可读
- 域随 fork/exec 继承且不可自行解除配合 no_new_privs子进程同受限
不能采用/ 授全量 + ro 路径授空的交集写法landlock_add_rule
allowed_access=0 的规则返回 ENOMSGerrno 42内核拒绝空授权规则
仅依赖 python3 标准库ctypes 直调 syscallx86_64/aarch64 通用
"""
import ctypes
import os
import sys
libc = ctypes.CDLL(None, use_errno=True)
PR_SET_NO_NEW_PRIVS = 38
# x86_64 与 aarch64 编号一致
SYS_CREATE_RULESET = 444
SYS_ADD_RULE = 445
SYS_RESTRICT_SELF = 446
LANDLOCK_RULE_PATH_BENEATH = 1
LANDLOCK_CREATE_RULESET_VERSION = 1 << 0
# access_fs 位ABI V1 全集 + V2/V3 增量)
FS_EXECUTE = 1 << 0
FS_WRITE_FILE = 1 << 1
FS_READ_FILE = 1 << 2
FS_READ_DIR = 1 << 3
FS_REMOVE_DIR = 1 << 4
FS_REMOVE_FILE = 1 << 5
FS_MAKE_CHAR = 1 << 6
FS_MAKE_DIR = 1 << 7
FS_MAKE_REG = 1 << 8
FS_MAKE_SOCK = 1 << 9
FS_MAKE_FIFO = 1 << 10
FS_MAKE_BLOCK = 1 << 11
FS_MAKE_SYM = 1 << 12
FS_REFER = 1 << 13 # ABI V2跨目录 rename/link
FS_TRUNCATE = 1 << 14 # ABI V3truncate(2)
# 只读域要管的写类操作(读/执行刻意排除,留给 DAC 决定)
WRITE_OPS_V1 = (FS_WRITE_FILE | FS_REMOVE_DIR | FS_REMOVE_FILE |
FS_MAKE_CHAR | FS_MAKE_DIR | FS_MAKE_REG | FS_MAKE_SOCK |
FS_MAKE_FIFO | FS_MAKE_BLOCK | FS_MAKE_SYM)
class RulesetAttr(ctypes.Structure):
_fields_ = [("handled_access_fs", ctypes.c_uint64)]
class PathBeneathAttr(ctypes.Structure):
_fields_ = [("allowed_access", ctypes.c_uint64),
("parent_fd", ctypes.c_int32),
("_pad", ctypes.c_int32)]
def probe_abi() -> int:
"""返回内核 Landlock ABI 版本(>=1不支持/被拦返回 <1。"""
ctypes.set_errno(0)
ret = libc.syscall(SYS_CREATE_RULESET, None, 0,
LANDLOCK_CREATE_RULESET_VERSION, 0, 0, 0)
return ret
def handled_for_abi(abi: int) -> int:
handled = WRITE_OPS_V1
if abi >= 2:
handled |= FS_REFER
if abi >= 3:
handled |= FS_TRUNCATE
return handled
def install_readonly_domain(ro_paths, rw_paths=("/tmp", "/var/tmp", "/dev/shm")):
"""安装只读域。成功返回 None失败返回错误描述字符串。
ro_paths写类操作全拒的路径不加规则无覆盖即拒绝生效
rw_paths显式授予写类权限的路径对齐纯 DAC 只读下的常用可写区
"""
abi = probe_abi()
if abi < 1:
return f"kernel-unsupported(errno={ctypes.get_errno()})"
handled = handled_for_abi(abi)
attr = RulesetAttr(handled)
ctypes.set_errno(0)
ruleset_fd = libc.syscall(SYS_CREATE_RULESET, ctypes.byref(attr),
ctypes.sizeof(attr), 0, 0, 0)
if ruleset_fd < 0:
e = ctypes.get_errno()
return f"create_ruleset(errno={e}:{os.strerror(e)})"
# 只给存在的 rw 路径加授权规则ro 路径刻意不加规则(无覆盖 → 拒绝)
for path in rw_paths:
if not os.path.exists(path):
continue
try:
fd = os.open(path, os.O_PATH)
except OSError as e:
return f"open({path})(errno={e.errno}:{e.strerror})"
pba = PathBeneathAttr(handled, fd, 0)
ctypes.set_errno(0)
ret = libc.syscall(SYS_ADD_RULE, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
ctypes.byref(pba), 0, 0, 0)
os.close(fd)
if ret < 0:
e = ctypes.get_errno()
return f"add_rule({path})(errno={e}:{os.strerror(e)})"
if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0:
return "prctl(PR_SET_NO_NEW_PRIVS) failed"
ctypes.set_errno(0)
ret = libc.syscall(SYS_RESTRICT_SELF, ruleset_fd, 0, 0, 0, 0, 0)
if ret < 0:
e = ctypes.get_errno()
return f"restrict_self(errno={e}:{os.strerror(e)})"
return None
def selftest(ro_paths) -> int:
"""自检:安装域后本进程与子进程写 ro 路径都必须被拒。"""
err = install_readonly_domain(ro_paths)
if err:
print(f"SELFTEST_FAIL install: {err}", file=sys.stderr)
return 2
probe = os.path.join(ro_paths[0], ".landlock_selftest_probe")
try:
with open(probe, "w") as f:
f.write("x")
print("SELFTEST_FAIL write-not-denied", file=sys.stderr)
return 3 # 残留文件由部署方以容器 root 清理
except OSError as e:
if e.errno != 13: # EACCES
print(f"SELFTEST_FAIL unexpected errno={e.errno} ({e.strerror})",
file=sys.stderr)
return 4
import subprocess
code = f"open({probe!r},'w').write('x')"
r = subprocess.run([sys.executable, "-c", code], capture_output=True)
if r.returncode == 0:
print("SELFTEST_FAIL child-not-denied", file=sys.stderr)
return 5
# 白名单区(/tmp必须仍可写否则说明规则误配会造成行为回归
try:
tmp_probe = f"/tmp/.landlock_selftest_rw.{os.getpid()}"
with open(tmp_probe, "w") as f:
f.write("x")
os.remove(tmp_probe)
except OSError as e:
print(f"SELFTEST_FAIL tmp-not-writable: errno={e.errno} ({e.strerror})",
file=sys.stderr)
return 6
print("LANDLOCK_SELFTEST_OK")
return 0
def main() -> None:
args = sys.argv[1:]
ro_paths = []
selftest_mode = False
cmd = []
i = 0
while i < len(args):
a = args[i]
if a == "--ro" and i + 1 < len(args):
ro_paths.append(os.path.normpath(args[i + 1]))
i += 2
elif a == "--selftest":
selftest_mode = True
i += 1
elif a == "--":
cmd = args[i + 1:]
break
else:
i += 1
if not ro_paths:
print("launcher: missing --ro <path>", file=sys.stderr)
sys.exit(2)
if selftest_mode:
sys.exit(selftest(ro_paths))
if not cmd:
print("launcher: missing command after --", file=sys.stderr)
sys.exit(2)
err = install_readonly_domain(ro_paths)
if err:
print(f"launcher: install readonly domain failed: {err}", file=sys.stderr)
sys.exit(2)
os.execvp(cmd[0], cmd)
if __name__ == "__main__":
main()

View File

@ -62,7 +62,7 @@ except ImportError:
TERMINAL_SANDBOX_REQUIRE,
)
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid, docker_readonly_wrap_inner
from modules.i18n import tr
@ -268,7 +268,8 @@ class StartMixin:
"exec",
"-i",
]
if self.sandbox_options.get("docker_readonly_exec"):
readonly_exec = bool(self.sandbox_options.get("docker_readonly_exec"))
if readonly_exec:
# 只读身份会话:与 run_command 只读执行同一非特权 uid内核 DAC 强制)
cmd += docker_readonly_exec_args()
if container_workdir:
@ -284,10 +285,15 @@ class StartMixin:
for key, value in envs.items():
cmd += ["-e", f"{key}={value}"]
cmd.append(container_name)
cmd.append(shell_path)
inner_cmd = [shell_path]
if shell_path.endswith("sh"):
cmd.append("-i")
inner_cmd.append("-i")
if readonly_exec:
# Landlock 加固:可用时 shell 及其子进程全程处于工作区只读域;失败自动降级纯 DAC。
inner_cmd = docker_readonly_wrap_inner(container_name, mount_path, inner_cmd, docker_path)
cmd.append(container_name)
cmd.extend(inner_cmd)
env = os.environ.copy()
process = subprocess.Popen(

View File

@ -40,7 +40,7 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan,
host_sandbox_enabled,
)
from modules.docker_readonly_exec import docker_readonly_exec_args
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_wrap_inner
from modules.i18n import tr
if TYPE_CHECKING:
@ -187,9 +187,15 @@ class RunMixin:
if relative:
container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [docker_bin, "exec"]
inner_cmd = ["/bin/bash", "-lc", command]
if not sandbox_write_access:
# 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py
exec_cmd += docker_readonly_exec_args()
# Landlock 加固可用时再以进程级只读域封住工作区写777/o+w 绕 DAC 的洞);
# 部署/自检有阻塞 docker 调用,放线程池;失败自动降级纯 DAC。
inner_cmd = await asyncio.to_thread(
docker_readonly_wrap_inner, container_name, mount_path, inner_cmd, docker_bin
)
exec_cmd += [
"-e",
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@ -198,9 +204,7 @@ class RunMixin:
"-w",
container_workdir,
container_name,
"/bin/bash",
"-lc",
command,
*inner_cmd,
]
use_shell = False