#!/usr/bin/env python3 """Astrion Landlock 只读域 launcher(容器内运行,非后端模块)。 由后端 modules/docker_readonly_exec.py 部署到容器内(docker cp)并调用: python3 launcher.py --ro /workspace -- [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-writable(777/o+w)绕 DAC 漏洞本身; - 读/执行不进入 handled 集合,完全交给 DAC(600 权限敏感文件仍不可读); - 域随 fork/exec 继承且不可自行解除(配合 no_new_privs),子进程同受限。 注:不能采用「/ 授全量 + ro 路径授空」的交集写法——landlock_add_rule 对 allowed_access=0 的规则返回 ENOMSG(errno 42),内核拒绝空授权规则。 仅依赖 python3 标准库(ctypes 直调 syscall),x86_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 V3:truncate(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 ", 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()