Skip to content

Commit dea5f1f

Browse files
authored
fix(toolchain): #273 sandbox containment — explicit trust root for post-install rewrites (+ CI wine cache) (#275)
* docs: batch-3 plan — issue #273 sandbox containment + CI wine cache * fix(toolchain): #273 sandbox containment — explicit trust root threaded through every post-install rewriter - containment_root(cfg.registryDir) resolved once at entry, passed down; never re-derived from payload paths (canonicalizing the payload first dissolves the fence) - per-file fence in patchelf_walk covers interior symlinks AND the binutils sibling walks the entry ownership guard never vetted - entry guard rewritten on the shared predicate: fixes error_code reuse masking failures and raw-string prefix matching without a component boundary; fails closed on unresolvable paths - unit tests pin the incident topology (symlinked payload, dot-dot traversal, sibling string prefix, empty-root fail-closed) - 13_toolchain_pin.sh symlink seed kept as a live canary (now harmless) - CI: cache wine .deb closure in cross-build workflow
1 parent 5c33642 commit dea5f1f

6 files changed

Lines changed: 254 additions & 21 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# 批次三:#273 沙盒围栏 + CI wine 缓存 — Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** 修复 #273(patchelf_walk 经符号链接逃逸沙盒、损坏真实 `~/.xlings` payload)+ cross-build CI 的 wine 安装缓存;单 PR → CI 全绿 → bypass squash 合入 → 与批次二一起作为 0.0.104 release → xlings 生态验证。
6+
7+
**Architecture:**(实现中依整体评估修正为非启发式设计)信任根 = `cfg.registryDir` 这一**系统一等事实**,入口 `containment_root()` canonical 化一次后作为显式参数穿透到所有改写者(`patchelf_walk`/`fixup_gcc_specs`/`fixup_clang_cfg`),绝不从 payload 路径反推(先 canonical 化 payload 会把恶意符号链接解析掉,围栏沦为恒真式)。评估中发现入口层其实**已有** ownership guard 与内容指纹 marker(#109 起),本批次修复其三处残余缺口:①guard 复用 error_code(第二次成功清掉第一次失败)、②裸字符串前缀比较无组件边界(`registry-evil` 能穿过)、③binutils 兄弟目录的 walk 完全在 guard 之外。判定谓词独立导出并单测覆盖;`13_toolchain_pin.sh` 保留 symlink 种子作为真实环境金丝雀。
8+
9+
## Global Constraints
10+
11+
- 既有 e2e(含 152–160)与单测全绿;walker 的「补丁副本+原子 rename」行为不变。
12+
- guard 覆盖三个写入者:`patchelf_walk``fixup_gcc_specs``fixup_clang_cfg`
13+
- 信任根解析失败时**fail-closed**(空根 → 一切视为逃逸,拒绝改写)。
14+
- 跳过逃逸文件时 verbose 日志留痕。
15+
- PR body 带 `Fixes #273`;合入后 issue 自动关闭。
16+
17+
## Tasks
18+
19+
- [ ] **T1 围栏谓词 + 三写入者接入**:post_install.cppm 导出 `containment_root(dir)`(拼写路径取 registry 父 + weakly_canonical)与 `escapes_containment(file, root)`(weakly_canonical 后前缀比较);patchelf_walk 入口算根、每候选 ELF 判定;fixup_gcc_specs/fixup_clang_cfg 写文件前同判。
20+
- [ ] **T2 单测**:tests/unit/test_post_install_containment.cpp——真实临时目录 + `std::filesystem::create_directory_symlink` 复刻 issue 逃逸拓扑,断言:直连文件不逃逸、经 symlink 目录前缀的文件逃逸、无 registry 组件 → 无围栏、相对/`..` 路径归一。
21+
- [ ] **T3 13_toolchain_pin.sh 注释更新**:说明 symlink 种子曾是 #273 事故向量、现受围栏保护并有意保留为金丝雀;引用 issue。
22+
- [ ] **T4 CI wine deb 缓存**(已在工作树):首跑下载 .deb 闭包入 cache,命中跑 dpkg -i,apt-get -f 兜底。
23+
- [ ] **T5 PR + CI + 合入**:单 PR(Fixes #273),15 检查全绿后 `gh pr merge --squash --admin`
24+
- [ ] **T6 release 0.0.104 + 生态验证**:按 release.yml 流程出版本(批次二已定版 0.0.104,本批次并入);xlings 安装链路/d2mcpp/d2x 以 release 产物复验;结果回记。

.github/workflows/cross-build-test.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,29 @@ jobs:
205205
restore-keys: |
206206
xlings-${{ runner.os }}-v2-
207207
208+
# wine 的包集固定不变 —— 缓存整个 .deb 依赖闭包,命中时跳过 apt update
209+
# 与下载(每轮省 ~1-2min)。镜像月度更新可能改变依赖缺口,dpkg -i 失败时
210+
# 由 apt-get -f 兜底并重新回填缓存。
211+
- name: Cache wine debs
212+
uses: actions/cache@v4
213+
with:
214+
path: ~/wine-debs
215+
key: wine-debs-${{ runner.os }}-ubuntu24.04-v1
216+
208217
- name: Install wine
209218
run: |
210219
sudo dpkg --add-architecture i386 || true
211-
sudo apt-get update -qq
212-
sudo apt-get install -y wine64 wine || sudo apt-get install -y wine
220+
if ls ~/wine-debs/*.deb >/dev/null 2>&1; then
221+
sudo dpkg -i ~/wine-debs/*.deb 2>/dev/null \
222+
|| { sudo apt-get update -qq; sudo apt-get install -f -y; }
223+
else
224+
sudo apt-get update -qq
225+
sudo apt-get install -y --download-only wine64 wine \
226+
|| sudo apt-get install -y --download-only wine
227+
mkdir -p ~/wine-debs
228+
cp /var/cache/apt/archives/*.deb ~/wine-debs/ 2>/dev/null || true
229+
sudo apt-get install -y wine64 wine || sudo apt-get install -y wine
230+
fi
213231
wine --version
214232
215233
- name: Bootstrap mcpp via xlings

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
### 修复
2727

28+
- **#273 沙盒围栏**:post_install 的所有改写者(`patchelf_walk`/`fixup_gcc_specs`/`fixup_clang_cfg`)以显式信任根(`cfg.registryDir` canonical 化一次后穿透)逐文件设防——物理位置(符号链接解析后)在根外的一律拒绝改写并 fail-closed。修复既有入口 guard 的三处残余缺口:error_code 复用导致的错误掩蔽、裸字符串前缀无组件边界(`registry-evil` 可穿过)、binutils 兄弟目录 walk 不在 guard 覆盖内。`13_toolchain_pin.sh` 的 symlink 种子保留为金丝雀。单测 test_post_install_containment 覆盖事故拓扑。
2829
- **嵌套 mcpp 的 `LD_LIBRARY_PATH` 段错误**:外层 `mcpp run` 为其子进程指向私有 glibc payload 的 loader 路径,子进程再 spawn mcpp 时(如课程 Provider 驱动 `mcpp test`),毒化值继续流入内层 mcpp 的工具子进程——sandbox ninja/gcc 加载错配 libc 后在动态链接器里段错误(残片签名 `__vdso_time`)。现在 `merged_environ` 从继承的 `LD_LIBRARY_PATH`/`DYLD_LIBRARY_PATH`**只剥离** `xim-x-glibc` payload 条目:用户自己的条目保留,per-child 显式 override 一如既往优先。下游(d2x `platform.cppm`、d2mcpp `runner.cppm`)的 `unsetenv` workaround 可随本版删除。
2930

3031
### 备注
@@ -33,6 +34,7 @@
3334
- 首个下游消费者已完成端到端接入:d2mcpp「练习即测试」迁移(104 练习 zh/en 52/52 全绿)+ d2x checker 引导链路,全程使用本分支 musl 静态二进制。
3435
- docs 措辞修正:"(gtest style)" → 框架无关表述;记录合成测试名含 `/` 的命名豁免(zh+en)。
3536
- `BuildOptions::ninjaTargets`(构建计划子集)为支撑性通用接口,空 = 原行为。
37+
- cross-build CI 的 wine 安装改为缓存 .deb 依赖闭包(首跑回填,命中免 apt update/下载)。
3638
- musl 静态构建(`--target x86_64-linux-musl`)已验证:五个新 e2e 全绿;静态 mcpp 本体对 loader 毒化免疫,与上述修复共同覆盖嵌套链路的两端。
3739

3840
## [0.0.103] — 2026-07-22

src/toolchain/post_install.cppm

Lines changed: 86 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,43 @@ import mcpp.xlings;
2323

2424
namespace mcpp::toolchain {
2525

26+
// ── #273 sandbox containment ─────────────────────────────────────────────
27+
// patchelf_walk once escaped its sandbox through a symlinked payload
28+
// directory (an e2e sandbox seeded `registry/data/xpkgs/xim-x-gcc` from the
29+
// user's real ~/.xlings via `ln -s`) and rewrote PT_INTERP/RUNPATH of the
30+
// REAL installation against loader paths inside a soon-deleted mktemp dir.
31+
//
32+
// Containment rule: every rewrite in this module is fenced by ONE explicit
33+
// trust root — the owning sandbox's registry (`cfg.registryDir`), resolved
34+
// once at the entry point via `containment_root` and threaded down as a
35+
// parameter. The root is a first-class fact of the system; it is never
36+
// re-derived from the payload path (canonicalizing the payload first would
37+
// resolve the malicious symlink and collapse the fence into a tautology).
38+
export std::filesystem::path
39+
containment_root(const std::filesystem::path& registryDir) {
40+
std::error_code ec;
41+
auto canon = std::filesystem::weakly_canonical(registryDir, ec);
42+
if (ec) return {}; // empty root → escapes_containment fails closed
43+
return canon;
44+
}
45+
46+
// True when `file`'s physical (symlink-resolved) location falls outside
47+
// `rootCanon`. Fails CLOSED: an empty root or an unresolvable path counts
48+
// as escaping — never patch what cannot be proven contained. Comparison is
49+
// per path component (a `registry-evil` sibling sharing the string prefix
50+
// is outside).
51+
export bool escapes_containment(const std::filesystem::path& file,
52+
const std::filesystem::path& rootCanon) {
53+
if (rootCanon.empty()) return true;
54+
std::error_code ec;
55+
auto real = std::filesystem::weakly_canonical(file, ec);
56+
if (ec) return true;
57+
auto r = rootCanon.generic_string();
58+
auto f = real.generic_string();
59+
if (f == r) return false;
60+
return !(f.size() > r.size() && f.starts_with(r) && f[r.size()] == '/');
61+
}
62+
2663
// Run patchelf on every dynamic ELF in `dir` (recursively):
2764
// - Set PT_INTERP to `loader` (the sandbox-local glibc loader).
2865
// - Set RUNPATH to `rpath` (colon-separated list of sandbox lib dirs).
@@ -36,7 +73,8 @@ namespace mcpp::toolchain {
3673
export void patchelf_walk(const std::filesystem::path& dir,
3774
const std::filesystem::path& loader,
3875
const std::string& rpath,
39-
const std::filesystem::path& patchelfBin)
76+
const std::filesystem::path& patchelfBin,
77+
const std::filesystem::path& fenceRoot)
4078
{
4179
if (!std::filesystem::exists(dir) || !std::filesystem::exists(patchelfBin))
4280
return;
@@ -54,6 +92,14 @@ export void patchelf_walk(const std::filesystem::path& dir,
5492
if (!is || m[0] != 0x7f || m[1] != 'E' || m[2] != 'L' || m[3] != 'F')
5593
continue;
5694
is.close();
95+
// #273 fence: never rewrite files that physically live outside the
96+
// sandbox (payload reached through a symlink → foreign installation).
97+
if (escapes_containment(path, fenceRoot)) {
98+
mcpp::log::verbose("toolchain", std::format(
99+
"patchelf_walk: skip (outside sandbox, #273 fence): {}",
100+
path.string()));
101+
continue;
102+
}
57103
// Probe PT_INTERP — skip static binaries (no interp).
58104
auto probe = std::format("{} --print-interpreter {} 2>/dev/null",
59105
mcpp::platform::shell::quote(patchelfBin.string()),
@@ -152,8 +198,16 @@ export std::string detect_baked_loader(const std::string& specsContent) {
152198

153199
void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot,
154200
const std::filesystem::path& glibcLibDir,
155-
const std::filesystem::path& gccLibDir)
201+
const std::filesystem::path& gccLibDir,
202+
const std::filesystem::path& fenceRoot)
156203
{
204+
// #273 fence: a payload reached through a symlink is a foreign
205+
// installation — its specs file must not be rewritten either.
206+
if (escapes_containment(gccPkgRoot, fenceRoot)) {
207+
mcpp::log::verbose("toolchain",
208+
"fixup_gcc_specs: skip (payload outside sandbox, #273 fence)");
209+
return;
210+
}
157211
std::filesystem::path specsParent;
158212
std::error_code ec;
159213
for (auto it = std::filesystem::directory_iterator(gccPkgRoot / "lib" / "gcc", ec);
@@ -215,7 +269,14 @@ void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot,
215269
// C++ only: -nostdinc++ -stdlib=libc++ + payload libc++ headers/libs
216270
// On macOS the C library comes from the SDK: --sysroot=<sdk> + libc++ headers.
217271
export void fixup_clang_cfg(const std::filesystem::path& payloadRoot,
218-
const std::filesystem::path& glibcLibDir) {
272+
const std::filesystem::path& glibcLibDir,
273+
const std::filesystem::path& fenceRoot) {
274+
// #273 fence — same rule as fixup_gcc_specs above.
275+
if (escapes_containment(payloadRoot, fenceRoot)) {
276+
mcpp::log::verbose("toolchain",
277+
"fixup_clang_cfg: skip (payload outside sandbox, #273 fence)");
278+
return;
279+
}
219280
auto binDir = payloadRoot / "bin";
220281
if (!std::filesystem::exists(binDir)) return;
221282

@@ -344,15 +405,20 @@ void gcc_post_install_fixup(const mcpp::config::GlobalConfig& cfg,
344405

345406
mcpp::log::verbose("toolchain", std::format(
346407
"gcc fixup: patchelf_walk rpath='{}'", rpath));
408+
// Single trust root for every walk below — including the binutils
409+
// SIBLING payloads, which the entry-point ownership guard does not
410+
// cover (it only vets the gcc payload itself).
411+
auto fence = containment_root(cfg.registryDir);
347412
auto binutilsRoot = mcpp::xlings::paths::xim_tool_root(xlEnv, "binutils");
348413
if (std::filesystem::exists(binutilsRoot)) {
349414
for (auto& v : std::filesystem::directory_iterator(binutilsRoot))
350-
patchelf_walk(v.path(), loader, rpath, patchelfBin);
415+
patchelf_walk(v.path(), loader, rpath, patchelfBin, fence);
351416
}
352-
patchelf_walk(payloadRoot, loader, rpath, patchelfBin);
417+
patchelf_walk(payloadRoot, loader, rpath, patchelfBin, fence);
353418

354419
mcpp::log::verbose("toolchain", "gcc fixup: fixup_gcc_specs");
355-
fixup_gcc_specs(payloadRoot, glibcLibDir, gccLibDir);
420+
fixup_gcc_specs(payloadRoot, glibcLibDir, gccLibDir,
421+
containment_root(cfg.registryDir));
356422
} else {
357423
mcpp::ui::warning(
358424
"could not locate sandbox glibc/gcc/patchelf paths; "
@@ -385,10 +451,11 @@ void llvm_post_install_fixup(const mcpp::config::GlobalConfig& cfg,
385451
rpath += llvmLib.string() + ":" + glibcLibDir.string();
386452
mcpp::log::verbose("toolchain", std::format(
387453
"llvm fixup: patchelf_walk lib/ rpath='{}'", rpath));
388-
patchelf_walk(llvmLib, loader, rpath, patchelfBin);
454+
patchelf_walk(llvmLib, loader, rpath, patchelfBin,
455+
containment_root(cfg.registryDir));
389456
}
390457
mcpp::log::verbose("toolchain", "llvm fixup: fixup_clang_cfg");
391-
fixup_clang_cfg(payloadRoot, glibcLibDir);
458+
fixup_clang_cfg(payloadRoot, glibcLibDir, containment_root(cfg.registryDir));
392459
}
393460

394461
// ── the single fixup pipeline entry ──────────────────────────────────────
@@ -416,17 +483,17 @@ export void ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg,
416483
// are not ours to patch — their owner already ran the fixup, and patching
417484
// through the symlink would rewrite the canonical files against OUR
418485
// (possibly ephemeral) paths, bricking the owner's toolchain.
419-
{
420-
std::error_code ec;
421-
auto canonicalRoot = std::filesystem::weakly_canonical(payloadRoot, ec);
422-
auto homeRegistry = std::filesystem::weakly_canonical(cfg.registryDir, ec);
423-
if (!ec && !canonicalRoot.string().starts_with(homeRegistry.string())) {
424-
mcpp::log::verbose("toolchain", std::format(
425-
"skip {} fixup: payload '{}' resolves outside this home ('{}') — "
426-
"inherited payload, owner is responsible for its fixup",
427-
kind, payloadRoot.string(), canonicalRoot.string()));
428-
return;
429-
}
486+
// Rewritten on the shared containment predicate (#273): the old inline
487+
// check reused one error_code across both canonicalizations (the second
488+
// success cleared the first failure) and compared raw strings without a
489+
// component boundary (a `registry-evil` sibling passed). The predicate
490+
// fails closed on any resolution error.
491+
if (escapes_containment(payloadRoot, containment_root(cfg.registryDir))) {
492+
mcpp::log::verbose("toolchain", std::format(
493+
"skip {} fixup: payload '{}' resolves outside this home — "
494+
"inherited payload, owner is responsible for its fixup",
495+
kind, payloadRoot.string()));
496+
return;
430497
}
431498

432499
auto xlEnv = mcpp::config::make_xlings_env(cfg);

tests/e2e/13_toolchain_pin.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ trap "rm -rf $TMP" EXIT
1515

1616
SANDBOX_HOME="$TMP/mcpp-home"
1717
mkdir -p "$SANDBOX_HOME/registry/data/xpkgs"
18+
# #273: this symlink seed was once the incident vector — post-install fixup
19+
# walked through it and rewrote the REAL ~/.xlings gcc against the throwaway
20+
# sandbox's loader paths. Every rewrite is now fenced by the containment
21+
# predicate (physical location must resolve inside cfg.registryDir), so the
22+
# seed is deliberately KEPT: it doubles as a live canary — if the fence ever
23+
# regresses, the machine's gcc breaks loudly and immediately.
1824
if [[ -d "$HOME/.xlings/data/xpkgs/xim-x-gcc/16.1.0" ]]; then
1925
ln -s "$HOME/.xlings/data/xpkgs/xim-x-gcc" \
2026
"$SANDBOX_HOME/registry/data/xpkgs/xim-x-gcc"

0 commit comments

Comments
 (0)