agent-Specialization/modules/landlock_launcher.py
JOJO a3fa5e59c6 feat(sandbox): Docker 只读执行叠加 Landlock 进程级只读域,封死 world-writable 绕 DAC 漏洞
Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-01 15:17:23 +08:00

211 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()