# 39-实例可见面收窄(/etc 白名单 + 宿主访问封锁)(2026-09-11 落地) > **TL;DR**|**结论**:按用户要求收窄实例可见面:`/etc` 改**白名单**,并**封 `127.0.0.0/8` 等四类目的地址**(宿主 loopback、内网卡、docker0、公网 EIP)。 > **关键**:nft H5 规则;`/usr` **刻意保留**(运行时就住在 `/usr/local`,去掉实例起不来)。 > **状态**:✅ 已落地(其引致的 python3 回归由档案 42 修复) ## 背景与动机 用户要求:**「用户就只能访问(含读取)dsh 服务用户 id 对应的那个文件夹,不能读取其他路径(连读都不要读取)」**。 (用户明确澄清「用户路径」= `/users/<该用户id>/`,**不是** `/usr`。) 起因:档案 38a 发现 `bundled-skills` 未挂载;进一步审计暴露两个问题: 1. `/etc` 整体 `--ro-bind` → 实例能读走 **576 个 others-readable 文件**,其中含**平台情报**。 2. bwrap 只 `--unshare-pid` 不 `--unshare-net` → 实例与宿主**共享网络命名空间**,可枚举/连接宿主服务。 ## 用户决策 | 决策点 | 用户选择 | 理由 | |---|---|---| | 执行 `/etc` 白名单化 | ✅ 执行 | 已实测零功能损失 | | 一并收窄网络面(宿主访问封锁) | ✅ 执行 | 才是真正的越权读取通道 | | 移除 `/usr` 可见面 | ❌ 不执行(采纳建议) | 运行时就住在 `/usr/local`,去掉 = 实例起不来;`/usr/bin` 的 1151 个 CLI 是 agent 唯一手脚;审计证实 `/usr` **零凭据、零用户数据** | ## 实现 ### A. bwrap `/etc` 收窄(`src/supervisor/orchestrator.ts`,`spawnAsUser`) `'--ro-bind', '/etc', '/etc'` → `'--tmpfs', '/etc'` + 逐文件 `--ro-bind-try`。 白名单 14 项(代码内 allow 列表):`ld.so.cache`、`ld.so.conf`、`passwd`、`group`、`nsswitch.conf`、 `hosts`、`resolv.conf`、`host.conf`、`services`、`localtime`、`os-release`、`machine-id`、 `pki/tls/certs`、`pki/ca-trust`。 > ⚠️ **symlink 必须 `realpathSync()` 后绑「目标 → symlink 原路径」**,否则实例内是断链 > (`/etc/nsswitch.conf` → `/etc/authselect/nsswitch.conf`;`/etc/localtime` → `/usr/share/zoneinfo/Asia/Shanghai`)。 > 新增 `realpathSync` 导入。 ### B. nft H5 宿主访问封锁(`/etc/nftables-dsh-egress.nft`) 在 `ip dsh_egress` 表的 output 链新增,仅作用于 skuid `100000-199999`: ``` meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \ meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn counter reject with tcp reset meta skuid 100000-199999 ip daddr { …同上… } meta l4proto udp ct state new counter reject ``` 封 4 类目的地址:`127.0.0.0/8`(宿主全部 loopback 服务 + 其他实例的 127.0.0.1 监听)、 `172.18.16.212`(eth0 内网卡)、`172.17.0.1`(docker0 网关)、`47.77.182.89`(公网 EIP)。 与平台既有 `src/supervisor/firewall.ts` 的 portGuard(iptables `-m owner ! --uid-owner 0 -j REJECT`) **语义一致、范围更全**,且不依赖 `config.portGuard` 开关。portGuard 的存在反证了 「实例进程自身不需要任何 loopback 连接」。 ## 验证记录 ### 修改前对照实验(同用户 admin,仅 `/etc` 变量不同) | 探针 | C0(整体绑定) | C1(白名单) | |---|---|---| | `node -v` | v22.23.2 | v22.23.2 | | `os.userInfo()` | OK | OK | | `/etc/passwd` 可读 | OK | OK | | DNS `lookup` | OK | OK | | node TLS → registry.npmjs.org | 200 | 200 | | `curl https://www.baidu.com` | 200 | 200 | | **`dsh --profile web --dump-config`** | **544 行 / 170 插件** | **544 行 / 170 插件(逐字一致,exit=0)** | ### 修改后终验(guest uid 100002,收窄后沙箱) | 项 | 结果 | |---|---| | `/etc` 可见项 | **222 → 13** | | `/etc` 可读文件 | **576 → 27** | | 平台情报 systemd / nft 护栏 / cron / letsencrypt | **4/1/2/8 → 0/0/0/0** | | 其他用户目录可见 | **1 项(仅自己)** | | 数据库可读 | **NO** | | node / userInfo / DNS / 出网 TLS / curl 公网 | 全 OK | | `dsh --profile web --dump-config` | **549 行 / 176 插件,exit=0,stderr 空** | | `dsh --version` | 0.1.2-rc.1 | | loopback :3080 / :22 / :888 / :58888 | **全部 BLOCKED** | | 宿主 IP :443 / 内网卡 :80 / 元数据端点 | **全部 BLOCKED** | | nft HOST 规则计数器 | **命中 1 包** | | 门户健康 | `systemctl is-active` = active,`3080 → 200` | ### 真实启动 E2E(orchestrator 原样命令 + 收窄后沙箱) ``` [workspace-scoped-picker] loaded root=…/ws (cwd) dsh web: http://127.0.0.1:41999/?token=… ← 打印 launch token(= orchestrator 成功判据) ``` - root(模拟 nginx 回源)→ 实例端口:**TCP 连接成功 + `HTTP/1.1 303 See Other` + `set-cookie: dsh-auth-…`**(正常登录跳转,实例完全可用) - 实例内(uid 100002)→ 自己端口:**BLOCKED ECONNREFUSED**(H5 生效) ## 事故 / 踩坑记录 ### ★ P0 级自伤:H5 按「目的地址」一刀切会打断实例回包(实测踩到并修复) - **现象**:H5 首版只写 `ip daddr { 127.0.0.0/8, … } reject`,部署后 root 连实例端口 **超时**(curl `000`、`node net.connect` timeout),TCP 层就不通。 - **根因**:实例是「先收后回」的服务端。nginx(root) → 实例的 SYN 合法,但**实例回的 SYN-ACK 与后续数据包,目的地址同样是 `127.0.0.1`**(客户端就在本机)→ 被同一条 daddr 规则拒掉 → 握手永远完不成。 - **修正**:只匹配**实例主动发起**的连接 —— TCP 加 `tcp flags & (fin|syn|rst|ack) == syn`(**纯 SYN**;SYN-ACK 带 ack 位故不匹配),UDP 加 `ct state new`。修后 root→实例 303 正常,实例内自连仍 BLOCKED。 - **教训(通用)**:在 OUTPUT 链按**目的地址**封本机服务时,必须区分「主动发起」与「回包」;仅靠 daddr 会连回包一起封。判定特征是 **timeout(丢包)而非 refused**——refused 说明规则生效且只在客户端方向;timeout 往往意味着双向都被打到。 - **幸运点**:修复时**无实例在跑**(`systemctl list-units "dsh-*scope"` = 0),线上用户零影响。 ### 其他 - ⚠️ 分析脚本漏设 `DSH_HOME` 会让 `dsh --dump-config` 输出 1 行(找不到 profile),**不是回归**——对比测试必须与基线同 env。 - ⚠️ 档案号二次撞号已从「串行取号」升级为「**原子预留**」(`mkdir .lock-`),本次占号 39 一次成功。 ## 回滚 / 注意 - **代码**:`cp src/supervisor/orchestrator.ts.bak- src/supervisor/orchestrator.ts && npm run build && systemctl restart dshs` - **nft**:`cp /etc/nftables-dsh-egress.nft.bak- /etc/nftables-dsh-egress.nft && systemctl restart dsh-egress` (或整体停用:`systemctl disable --now dsh-egress`) - **副作用**:`/etc` 在白名单外**改为 tmpfs(可写、临时)**,实例退出即消失,无跨租户影响。 - **未做(残留)**:`--unshare-net`(会断外网,不建议);`/usr` 收窄(判定无收益)。 - **后续**:`bundled-skills` 挂载(档案 38a P0)仍未修,需在本改动之上追加 `--ro-bind-try`。 --- ## 事后修正(2026-09-11 17:15):白名单漏了 `/etc/alternatives` → 21 个命令静默断链 **发现途径**:分析 guest「代码库功能与权限探索」会话时,agent 独立报告「`python3` 不存在」。 我起初以为是 agent 误报(宿主上 `/usr/bin/python3` 明明存在),复核后**确认是我引入的回归**。 **根因**:`/usr/bin/python3` 是**两跳软链**—— `/usr/bin/python3 → /etc/alternatives/python3 → /usr/bin/python3.6` 中间一跳在 `/etc`,而白名单只绑了「叶子文件的 realpath 目标到原路径」, **没有绑 `/etc/alternatives` 这个「枢纽目录」** → 沙箱内该目录不存在 → 软链断裂。 **受损清单(21 条,通用探测器枚举)**: `python3` `python` `pip3` `pip-3` `pydoc3` `pydoc-3` `python3-config` `pyvenv-3` `easy_install-3` `unversioned-python` `ld`→`ld.bfd` `pax` `print-*`(`lp` `lpr` `lpq` `lprm` `lpstat` `cancel`)`ifup` `ifdown` `lpc` 其中对我方场景关键的是 **`python3`、`pip3`(装技能依赖)、`ld`(node-gyp 编译原生模块)**。 **修法**:白名单追加 `/etc/alternatives` 整目录。安全性已核:其 33 项**全部指向 `/usr` 或 `/lib64` 下的程序/man**(已只读挂载),**不含任何凭据或平台情报** → 不扩大实质可见面。实测修后 `/etc` 可见项 13→14、可读文件 27、平台情报仍 0/0/0/0、其他用户目录仍 1 项、DB 不可读。 **⚠️ 通用教训(已写进 skill)**:`/etc` 白名单化时,**必须枚举所有"符号链接链条会穿过 /etc"的条目**,不能只看直接指向 /etc 的那些。探测器: ```bash find /usr/bin /usr/sbin /usr/libexec /usr/local/bin -maxdepth 1 | while read -r f; do [ -L "$f" ] || continue; c="$f"; n=0 while [ -L "$c" ] && [ $n -lt 10 ]; do t=$(readlink "$c") case "$t" in /*) c="$t";; *) c="$(dirname "$c")/$t";; esac case "$c" in /etc/*) echo "$f -> $c";; esac; n=$((n+1)); done done | sort -u ``` 另:这类回归**不会报错、只会静默 `command not found`**,只能靠"改动前后对比工具清单"或会话取证发现 —— 佐证了「会话取证」作为验收手段的价值。