149 lines
9.3 KiB
Markdown
149 lines
9.3 KiB
Markdown
# 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 对应的那个文件夹,不能读取其他路径(连读都不要读取)」**。
|
|||
|
|
(用户明确澄清「用户路径」= `<dataRoot>/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-<n>`),本次占号 39 一次成功。
|
|||
|
|
|
|||
|
|
## 回滚 / 注意
|
|||
|
|
|
|||
|
|
- **代码**:`cp src/supervisor/orchestrator.ts.bak-<ts> src/supervisor/orchestrator.ts && npm run build && systemctl restart dshs`
|
|||
|
|
- **nft**:`cp /etc/nftables-dsh-egress.nft.bak-<ts> /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`**,只能靠"改动前后对比工具清单"或会话取证发现 —— 佐证了「会话取证」作为验收手段的价值。
|