chv-cell: ns 内 setuid 修复 + 单元内 DNS(上游 resolv.conf 绑定)
- 中间进程不再先 setuid(1000) 再 unshare:真机对照实测那样 后续 unshare(CLONE_NEWNS) 得 EPERM;改为保持 root 外层身份 unshare(creator caps 在),孙进程在 ns 内 setgid/setuid(0) 切到内 0(映射到外层 chv 1000)。 - 单元内 DNS:客机 /etc/resolv.conf 是 127.0.0.53 stub,单元 独立 netns 里自己的 lo 上没有 resolver;把上游 /run/systemd/resolve/resolv.conf 绑到 /etc/resolv.conf (解析 symlink 后挂真实目标),只在单元私有 mount ns 里。 - 单测:CellResolvTest(bind 参数/symlink 解析/缺失回落) + 规约 dns_resolv_conf 字段;P2 + 阶段 2 共 73 项全过。
This commit is contained in:
1 parent
afbb7772c5
commit
4b5f2f124f
2 files changed
+104
-13
No files matched your search
+58
-13
@@ -72,6 +72,10 @@ MACHINE = "chv-cell"
|
|||||||
UID_BASE = 1000
|
UID_BASE = 1000
|
||||||
CELL_USER = "chv"
|
CELL_USER = "chv"
|
||||||
CELL_UID = 1000
|
CELL_UID = 1000
|
||||||
|
# 单元内 DNS:客机 /etc/resolv.conf 是 127.0.0.53 stub,单元独立
|
||||||
|
# netns 里自己的 lo 上没有 resolver;上游文件里是真实上游。
|
||||||
|
RESOLV_UPSTREAM = "/run/systemd/resolve/resolv.conf"
|
||||||
|
RESOLV_CONF = "/etc/resolv.conf"
|
||||||
CELL_GID = 1000
|
CELL_GID = 1000
|
||||||
WORKSPACE = "/home/chv/workspace"
|
WORKSPACE = "/home/chv/workspace"
|
||||||
CODEX_HOME = "/home/chv/.codex"
|
CODEX_HOME = "/home/chv/.codex"
|
||||||
@@ -253,6 +257,7 @@ def build_sandbox_spec(command, network="veth",
|
|||||||
"env": display_env,
|
"env": display_env,
|
||||||
"hide": list(INACCESSIBLE_BASE) + [playwright_package_dir],
|
"hide": list(INACCESSIBLE_BASE) + [playwright_package_dir],
|
||||||
"tmpfs": ["/tmp"],
|
"tmpfs": ["/tmp"],
|
||||||
|
"dns_resolv_conf": "upstream-bind",
|
||||||
"network": network,
|
"network": network,
|
||||||
"host_ip": HOST_IP,
|
"host_ip": HOST_IP,
|
||||||
"cell_ip": CELL_IP,
|
"cell_ip": CELL_IP,
|
||||||
@@ -352,9 +357,12 @@ def _write_id_maps_for_pid(pid, uid=CELL_UID, gid=CELL_GID):
|
|||||||
子进程自己写不进——unshare 后它的 euid 变成 overflow,
|
子进程自己写不进——unshare 后它的 euid 变成 overflow,
|
||||||
连 /proc/self/uid_map 的 open 都 EPERM(2026-11-07 真机
|
连 /proc/self/uid_map 的 open 都 EPERM(2026-11-07 真机
|
||||||
实测)。这是标准做法(unshare/bwrap 都是父进程写子进程
|
实测)。这是标准做法(unshare/bwrap 都是父进程写子进程
|
||||||
的映射)。注意:映射的外层 uid 必须是子进程当前的外层
|
的映射)。注意:中间进程保持 root 外层身份 unshare,
|
||||||
uid——中间进程须先 setuid(1000) 再 unshare,否则映射盖
|
映射写上后它在新 ns 里仍是 overflow uid,但 creator
|
||||||
不住它、它在新 ns 里仍是 overflow。失败即抛 RuntimeError。"""
|
caps 在、能继续 unshare 其它 ns;切内 0 由孙进程在
|
||||||
|
ns 内 setuid(0) 完成(见 _grandchild_main)。中间进程
|
||||||
|
绝不能先 setuid(1000) 再 unshare——实测那样后续
|
||||||
|
unshare(CLONE_NEWNS) 得 EPERM。失败即抛 RuntimeError。"""
|
||||||
try:
|
try:
|
||||||
with open("/proc/%d/setgroups" % pid, "w") as fh:
|
with open("/proc/%d/setgroups" % pid, "w") as fh:
|
||||||
fh.write("deny")
|
fh.write("deny")
|
||||||
@@ -377,9 +385,35 @@ def _mount(args, what):
|
|||||||
% (what, (proc.stderr or proc.stdout or "").strip()))
|
% (what, (proc.stderr or proc.stdout or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolv_bind(source=RESOLV_UPSTREAM, target=RESOLV_CONF):
|
||||||
|
"""返回把上游 resolv.conf 绑到单元 /etc/resolv.conf 的 mount
|
||||||
|
参数,或 None(无上游文件/无 nameserver 时跳过,单元内
|
||||||
|
DNS 保持现状)。客机 /etc/resolv.conf 是 127.0.0.53 的
|
||||||
|
stub(systemd-resolved),单元有独立 netns、自己的 lo 上
|
||||||
|
没有 resolver,直接沿用则 DNS 全灭;上游文件
|
||||||
|
/run/systemd/resolve/resolv.conf 里是真实上游(8.8.8.8
|
||||||
|
等),经 veth NAT 可达。target 是 symlink 时挂到真实目标
|
||||||
|
(stub 文件),挂载只在单元的私有 mount ns 里,不动宿主。"""
|
||||||
|
if not os.path.isfile(source):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(source, "r", encoding="utf-8",
|
||||||
|
errors="replace") as f:
|
||||||
|
content = f.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if "nameserver" not in content:
|
||||||
|
return None
|
||||||
|
real_target = os.path.realpath(target)
|
||||||
|
if not os.path.isfile(real_target):
|
||||||
|
return None
|
||||||
|
return ["--bind", source, real_target]
|
||||||
|
|
||||||
|
|
||||||
def _setup_mounts(playwright_package_dir=None):
|
def _setup_mounts(playwright_package_dir=None):
|
||||||
"""单元内挂载:根 rprivate、fresh /proc、/tmp tmpfs、
|
"""单元内挂载:根 rprivate、fresh /proc、/tmp tmpfs、
|
||||||
敏感路径遮蔽(目录盖 tmpfs,文件绑 /dev/null)。
|
敏感路径遮蔽(目录盖 tmpfs,文件绑 /dev/null)、上游
|
||||||
|
resolv.conf 绑到 /etc/resolv.conf(单元内 DNS)。
|
||||||
只在新 mount ns 里做,不动宿主挂载表。"""
|
只在新 mount ns 里做,不动宿主挂载表。"""
|
||||||
if playwright_package_dir is None:
|
if playwright_package_dir is None:
|
||||||
playwright_package_dir = _playwright_package_dir()
|
playwright_package_dir = _playwright_package_dir()
|
||||||
@@ -394,6 +428,12 @@ def _setup_mounts(playwright_package_dir=None):
|
|||||||
else:
|
else:
|
||||||
print("chv-cell: 遮蔽目标不存在,跳过:%s" % path,
|
print("chv-cell: 遮蔽目标不存在,跳过:%s" % path,
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
|
bind = _resolv_bind()
|
||||||
|
if bind:
|
||||||
|
_mount(bind, "DNS resolv.conf")
|
||||||
|
else:
|
||||||
|
print("chv-cell: 无上游 resolv.conf,单元内 DNS 可能不通",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def parse_default_route_iface(ip_route_output):
|
def parse_default_route_iface(ip_route_output):
|
||||||
@@ -547,11 +587,17 @@ def _set_pdeathsig(sig):
|
|||||||
|
|
||||||
|
|
||||||
def _grandchild_main(command, network, ready_w, go_r):
|
def _grandchild_main(command, network, ready_w, go_r):
|
||||||
"""单元 init(新 pid ns 里的 pid 1):挂载、加固、报 READY、
|
"""单元 init(新 pid ns 里的 pid 1):切内 0、挂载、加固、
|
||||||
等放行、exec payload。任何一步失败写 ERR 后退出(父进程
|
报 READY、等放行、exec payload。任何一步失败写 ERR 后退出
|
||||||
收到 ERR 即大声失败,不跑 payload)。"""
|
(父进程收到 ERR 即大声失败,不跑 payload)。"""
|
||||||
try:
|
try:
|
||||||
_set_pdeathsig(9) # SIGKILL:中间进程死即自杀,不留孤儿
|
_set_pdeathsig(9) # SIGKILL:中间进程死即自杀,不留孤儿
|
||||||
|
# 在 ns 内切到内 0(映射到外层 chv 1000):此时进程是
|
||||||
|
# creator、有新 ns 的全部 caps,setgid/setuid(0) 合法;
|
||||||
|
# 必须先 setgid 再 setuid。绝不能在 unshare 之前以外层
|
||||||
|
# 身份 setuid——那会弄丢后续 unshare 的能力(见上)。
|
||||||
|
os.setgid(0)
|
||||||
|
os.setuid(0)
|
||||||
_setup_mounts()
|
_setup_mounts()
|
||||||
_set_no_new_privileges()
|
_set_no_new_privileges()
|
||||||
_apply_seccomp_filter()
|
_apply_seccomp_filter()
|
||||||
@@ -595,13 +641,12 @@ def _intermediate_main(command, network, pid_w, ready_r, ready_w,
|
|||||||
只做命名空间搭建与收割。"""
|
只做命名空间搭建与收割。"""
|
||||||
try:
|
try:
|
||||||
_set_pdeathsig(15) # SIGTERM:父进程死即退出,带走单元
|
_set_pdeathsig(15) # SIGTERM:父进程死即退出,带走单元
|
||||||
# 先以外层 chv 身份再 unshare:映射按外层 uid 翻译,
|
# 只设附加组(让它映射到内 0),不改 uid/gid:中间进程
|
||||||
# 外层不是 1000 时 “0 1000 1” 盖不住进程(2026-11-07
|
# 保持 root 外层身份 unshare(creator caps 在,后续
|
||||||
# 真机实测:root 外层 unshare 后映射写上了、进程在新
|
# unshare 其它 ns 才通)。绝不能先 setuid(1000) 再
|
||||||
# ns 里仍是 overflow)。setuid 前须先清附加组、设 gid。
|
# unshare——2026-11-07 真机对照实测:那样后续
|
||||||
|
# unshare(CLONE_NEWNS) 得 EPERM;不 setuid 则通。
|
||||||
os.setgroups([CELL_GID])
|
os.setgroups([CELL_GID])
|
||||||
os.setgid(CELL_GID)
|
|
||||||
os.setuid(CELL_UID)
|
|
||||||
os.unshare(CLONE_NEWUSER)
|
os.unshare(CLONE_NEWUSER)
|
||||||
# 映射由父进程写(子进程自己写不进,见
|
# 映射由父进程写(子进程自己写不进,见
|
||||||
# _write_id_maps_for_pid);先报“已 unshare”,再等放行。
|
# _write_id_maps_for_pid);先报“已 unshare”,再等放行。
|
||||||
|
|||||||
@@ -111,6 +111,52 @@ class CellArgvTest(unittest.TestCase):
|
|||||||
self.assertNotIn("GITEA_TOKEN", real)
|
self.assertNotIn("GITEA_TOKEN", real)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_resolv_conf_upstream_bind(self):
|
||||||
|
spec = CELL.build_sandbox_spec(["true"])
|
||||||
|
self.assertEqual(spec["dns_resolv_conf"], "upstream-bind")
|
||||||
|
|
||||||
|
|
||||||
|
class CellResolvTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="cell-resolv-")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_upstream_bind_args(self):
|
||||||
|
upstream = os.path.join(self.tmp, "resolv.conf")
|
||||||
|
with open(upstream, "w", encoding="utf-8") as f:
|
||||||
|
f.write("nameserver 8.8.8.8\nnameserver 1.1.1.1\n")
|
||||||
|
target = os.path.join(self.tmp, "target-resolv.conf")
|
||||||
|
with open(target, "w", encoding="utf-8") as f:
|
||||||
|
f.write("nameserver 127.0.0.53\n")
|
||||||
|
self.assertEqual(CELL._resolv_bind(upstream, target),
|
||||||
|
["--bind", upstream, target])
|
||||||
|
|
||||||
|
def test_symlink_target_resolved(self):
|
||||||
|
upstream = os.path.join(self.tmp, "resolv.conf")
|
||||||
|
with open(upstream, "w", encoding="utf-8") as f:
|
||||||
|
f.write("nameserver 8.8.8.8\n")
|
||||||
|
real = os.path.join(self.tmp, "stub-resolv.conf")
|
||||||
|
with open(real, "w", encoding="utf-8") as f:
|
||||||
|
f.write("nameserver 127.0.0.53\n")
|
||||||
|
link = os.path.join(self.tmp, "resolv-link")
|
||||||
|
os.symlink(real, link)
|
||||||
|
self.assertEqual(CELL._resolv_bind(upstream, link),
|
||||||
|
["--bind", upstream, real])
|
||||||
|
|
||||||
|
def test_missing_or_no_nameserver_returns_none(self):
|
||||||
|
target = os.path.join(self.tmp, "target-resolv.conf")
|
||||||
|
with open(target, "w", encoding="utf-8") as f:
|
||||||
|
f.write("nameserver 127.0.0.53\n")
|
||||||
|
self.assertIsNone(CELL._resolv_bind(
|
||||||
|
os.path.join(self.tmp, "no-such-file"), target))
|
||||||
|
empty = os.path.join(self.tmp, "empty-resolv.conf")
|
||||||
|
with open(empty, "w", encoding="utf-8") as f:
|
||||||
|
f.write("search .\n")
|
||||||
|
self.assertIsNone(CELL._resolv_bind(empty, target))
|
||||||
|
|
||||||
|
|
||||||
class CellSeccompTest(unittest.TestCase):
|
class CellSeccompTest(unittest.TestCase):
|
||||||
def test_filter_structure(self):
|
def test_filter_structure(self):
|
||||||
raw = CELL.build_seccomp_filter()
|
raw = CELL.build_seccomp_filter()
|
||||||
|
|||||||
Reference in new issue
Block a user