Skip to content

Commit afba6f0

Browse files
committed
fix(runtime): keep the private libc out of the process environment (#401)
`mcpp run` put the private glibc directory on LD_LIBRARY_PATH. That variable is inherited by every process the program ever spawns, and a child like /bin/sh is loaded by the HOST loader — PT_INTERP is baked in and no environment variable overrides it. glibc's libc.so.6 and its ld.so are version-locked through GLIBC_PRIVATE, so the child dies during relocation, before main: sh: symbol lookup error: …/xim-x-glibc/2.44/lib64/libc.so.6: undefined symbol: __pointer_chk_guard, version GLIBC_PRIVATE The reporter hit it through an application probing the desktop theme with gsettings: popen() returned nothing, the app silently used the wrong theme, and mcpp said nothing. Verified locally — the 2.44 payload kills /bin/sh, the 2.39 payload does not, which is why this survived as long as it did and why "it works here" proved nothing. The directory was published to serve a dlopen() the executable's DT_NEEDED closure does not cover, and the artifact's RUNPATH already covers exactly that: the link model emits -Wl,-rpath,<glibc> next to --dynamic-linker, and the built binary's RUNPATH is byte-identical before and after this change. So the environment entry bought nothing and cost every child process. It is gone. Measured against the released mcpp with the same fixture: before LDLP=[…/runtime:…/xim-x-glibc/2.39/lib64] after LDLP=[…/runtime] The decision lives in mcpp.platform.runtime_env_contract as a scope, not a condition: no build-level predicate can make an inherited variable safe for a process mcpp did not launch and cannot see. mcpp#291 was the same mistake one hop closer in. 208 pins all three halves — the dir is in the artifact RUNPATH, it is not in the program's environment, and both the /bin/sh child and the dlopen still work. The RUNPATH assertion deliberately checks coverage mcpp does not itself emit, so losing it is a red test rather than a silent loss of resolution.
1 parent 4c0b7b7 commit afba6f0

4 files changed

Lines changed: 252 additions & 19 deletions

File tree

src/build/flags.cppm

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,14 @@ CompileFlags compute_flags(const BuildPlan& plan) {
804804
// Dependency/runtime provider search comes from LinkIntent and is
805805
// rendered separately. In particular runtimeSearchDirs contributes
806806
// RUNPATH only; it must never become a link-time -L path.
807+
//
808+
// The private libc directory is NOT emitted here: the link model
809+
// already puts it in the artifact's RUNPATH wherever a payload exists
810+
// (`-L<glibc> -Wl,-rpath,<glibc>` next to --dynamic-linker), and this
811+
// link line has a hard 128KiB ceiling that real workspaces already
812+
// spend 43% of. `208_private_libc_stays_in_the_binary.sh` asserts that
813+
// coverage, so if a toolchain ever stops providing it the failure is a
814+
// red test rather than a silent loss of dlopen() resolution.
807815
}
808816

809817
// For Clang with payload paths: the payload C runtime — -B so the driver

src/build/plan.cppm

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import mcpp.toolchain.fingerprint;
1616
import mcpp.toolchain.triple;
1717
import mcpp.platform;
1818
import mcpp.platform.runtime_binding;
19+
import mcpp.platform.runtime_env_contract;
1920
import mcpp.xlings.subos_info;
2021

2122
export namespace mcpp::build {
@@ -887,29 +888,36 @@ make_plan(const mcpp::manifest::Manifest& manifest,
887888
for (auto const& dir : tc.linkRuntimeDirs) {
888889
append_unique_path(plan.runtimeLibraryDirs, dir);
889890
}
890-
// The private glibc payload is the ONE entry that is not also in the
891-
// executable's RUNPATH (flags.cppm excludes it deliberately, so static and
892-
// musl links stay clean). It is here purely so a dlopen()'d library — whose
893-
// own DT_NEEDED closure never consults the main executable's RUNPATH — can
894-
// still resolve the same libc the executable was linked against.
891+
// The private glibc payload exists here for ONE reason: a dlopen()'d
892+
// library, whose own DT_NEEDED closure never consults the main
893+
// executable's RUNPATH, must still resolve the same libc the executable
894+
// was linked against. So it is only published when this build actually has
895+
// such a library.
895896
//
896-
// So add it ONLY when this build actually has such a library. mcpp#291:
897+
// It is published through the ARTIFACT — the link model already emits
898+
// `-Wl,-rpath,<glibc>` alongside --dynamic-linker wherever a payload
899+
// exists — and never through the environment.
897900
// LD_LIBRARY_PATH is inherited by the whole process subtree, and a child
898-
// that is a HOST binary (/bin/sh, reached via a provider's popen()) loads
899-
// the HOST loader — PT_INTERP is baked into the executable and no
900-
// environment variable can override it — while this variable hands it the
901-
// payload libc.so.6. libc and ld.so are version-locked to each other
902-
// through GLIBC_PRIVATE, so on any host whose glibc differs from the
903-
// payload's the shell dies of SIGSEGV inside the dynamic linker, before
904-
// main, with empty stdout and no diagnostic. (It does NOT reproduce when
905-
// host and payload glibc happen to match, which is why this survived.)
901+
// that is a HOST binary (/bin/sh, reached via popen()) loads the HOST
902+
// loader — PT_INTERP is baked in and no environment variable overrides it
903+
// — while the variable hands it the payload libc.so.6. libc and ld.so are
904+
// version-locked through GLIBC_PRIVATE, so the shell dies during
905+
// relocation, before main: glibc 2.44's libc.so.6 needs
906+
// `__pointer_chk_guard` from its own loader (mcpp#401), and older payloads
907+
// segfault in the linker instead (mcpp#291). It does NOT reproduce when
908+
// host and payload glibc happen to match, which is how it survived.
906909
//
907910
// process.cppm's strip_private_glibc already removes this entry from
908-
// mcpp's OWN children. It cannot help one hop further out: mcpp sets the
909-
// variable for the target deliberately, and what the target then spawns is
910-
// beyond mcpp's reach. Not emitting it unless it is needed is.
911-
if (tc.payloadPaths && !plan.depRuntimeLibraryDirs.empty()) {
912-
append_unique_path(plan.runtimeLibraryDirs, tc.payloadPaths->glibcLib);
911+
// mcpp's OWN children. It cannot help one hop further out — what the
912+
// target spawns is beyond mcpp's reach. Putting the directory in the
913+
// artifact's RUNPATH instead is: DT_RUNPATH reaches the object that
914+
// carries it and the dlopen() it performs, and nothing else.
915+
if constexpr (mcpp::platform::publishes_via_environment(
916+
mcpp::platform::kPrivateLibcSearchScope)) {
917+
if (tc.payloadPaths && !plan.depRuntimeLibraryDirs.empty()) {
918+
append_unique_path(plan.runtimeLibraryDirs,
919+
tc.payloadPaths->glibcLib);
920+
}
913921
}
914922

915923
// 1a. Object addressing.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// mcpp.platform.runtime_env_contract — where a runtime search directory is
2+
// allowed to be published.
3+
//
4+
// There are two ways to tell a dynamic loader where to look, and they have
5+
// very different blast radii:
6+
//
7+
// DT_RUNPATH baked into ONE object. Reaches that object's own
8+
// DT_NEEDED closure and any dlopen() it performs. Nothing
9+
// else on the machine can observe it.
10+
//
11+
// LD_LIBRARY_PATH an environment variable. Reaches the target process AND
12+
// every process it ever spawns, transitively, forever —
13+
// including binaries mcpp never built, loaded by a loader
14+
// mcpp never chose.
15+
//
16+
// That second property is not a detail. mcpp ships a PRIVATE glibc, and a
17+
// glibc's `libc.so.6` and its `ld.so` are version-locked to each other through
18+
// GLIBC_PRIVATE symbols: 2.44's libc.so.6 carries an undefined reference to
19+
// `__pointer_chk_guard`, which only 2.44's own `ld-linux-x86-64.so.2` exports.
20+
// An mcpp-built program is fine — its PT_INTERP names the private loader. But
21+
// a program that calls popen()/system() spawns `/bin/sh`, whose PT_INTERP
22+
// names the HOST loader and cannot be overridden by any environment variable.
23+
// If LD_LIBRARY_PATH points that host loader at the private libc, the shell
24+
// dies during relocation, before main:
25+
//
26+
// sh: symbol lookup error: …/xim-x-glibc/2.44/lib64/libc.so.6:
27+
// undefined symbol: __pointer_chk_guard, version GLIBC_PRIVATE
28+
//
29+
// mcpp#401. The same shape, one hop closer in, was mcpp#291 (a nested mcpp's
30+
// own host tools). Both are the same mistake: publishing a private-libc search
31+
// path through a channel that does not stop at the process that needs it.
32+
//
33+
// So the rule is scoped, not conditional — "only export it when a dependency
34+
// might dlopen" still exports it, and the failing child does not care why. A
35+
// private libc directory is BINARY-scoped: it is written into the artifacts
36+
// mcpp itself links, and never into the environment. dlopen() from the program
37+
// still resolves, because the loader consults the calling object's DT_RUNPATH.
38+
//
39+
// Note the asymmetry with ordinary dependency runtime directories: those are
40+
// plain shared libraries with no loader coupling, so a host binary that
41+
// stumbles onto them is at worst confused, not killed. They keep their
42+
// existing environment scope.
43+
44+
export module mcpp.platform.runtime_env_contract;
45+
46+
import std;
47+
48+
export namespace mcpp::platform {
49+
50+
enum class RuntimeSearchScope {
51+
Binary, // DT_RUNPATH on the objects mcpp links
52+
Environment, // LD_LIBRARY_PATH, inherited by the whole process subtree
53+
};
54+
55+
// The private libc payload. See the module comment for why this is not a
56+
// tunable: no build-level condition can make an inherited variable safe for a
57+
// process that mcpp did not launch and cannot see.
58+
inline constexpr RuntimeSearchScope kPrivateLibcSearchScope =
59+
RuntimeSearchScope::Binary;
60+
61+
constexpr bool publishes_via_environment(RuntimeSearchScope scope) {
62+
return scope == RuntimeSearchScope::Environment;
63+
}
64+
65+
constexpr bool publishes_via_binary(RuntimeSearchScope scope) {
66+
return scope == RuntimeSearchScope::Binary;
67+
}
68+
69+
} // namespace mcpp::platform
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env bash
2+
# requires: gcc elf unix-shell
3+
# 208_private_libc_stays_in_the_binary.sh — mcpp#401.
4+
#
5+
# mcpp ships a private glibc. Its libc.so.6 and its ld.so are version-locked to
6+
# each other through GLIBC_PRIVATE symbols — glibc 2.44's libc.so.6 carries an
7+
# undefined reference to `__pointer_chk_guard`, exported only by 2.44's own
8+
# loader. An mcpp-built program is unaffected: PT_INTERP names the private
9+
# loader. `/bin/sh` is not: its PT_INTERP names the HOST loader, and no
10+
# environment variable can override it.
11+
#
12+
# So the moment the private libc directory reaches LD_LIBRARY_PATH — which is
13+
# inherited by every process the program ever spawns — any popen()/system() in
14+
# the program dies during relocation, before main:
15+
#
16+
# sh: symbol lookup error: …/xim-x-glibc/2.44/lib64/libc.so.6:
17+
# undefined symbol: __pointer_chk_guard, version GLIBC_PRIVATE
18+
#
19+
# The reporter hit it through an application probing the desktop theme with
20+
# `gsettings`: the child never ran, popen() returned nothing, and the app
21+
# silently used the wrong theme. Nothing in the mcpp output said anything.
22+
#
23+
# The directory is still needed — a dlopen()'d library's own DT_NEEDED closure
24+
# does not consult the executable's RUNPATH — so it is published through the
25+
# ARTIFACT (DT_RUNPATH, which reaches exactly the object carrying it) instead
26+
# of the ENVIRONMENT. This test pins both halves: the child survives, and the
27+
# library still loads.
28+
set -e
29+
30+
TMP=$(mktemp -d)
31+
trap 'rm -rf "$TMP"' EXIT
32+
33+
cd "$TMP"
34+
mkdir -p app/src app/runtime
35+
36+
# A dlopen-only plugin. Its presence is what makes mcpp publish the private
37+
# libc directory at all, so the test would not exercise the bug without it.
38+
cat > app/runtime/plugin.c <<'EOF'
39+
int runtime_plugin_answer(void) { return 42; }
40+
EOF
41+
gcc -shared -fPIC app/runtime/plugin.c -o app/runtime/libruntime_plugin.so
42+
43+
cat > app/src/main.cpp <<'EOF'
44+
#include <cstdio>
45+
#include <cstdlib>
46+
#include <cstring>
47+
#include <dlfcn.h>
48+
49+
using answer_fn = int (*)();
50+
51+
int main() {
52+
// 1. What the program was handed. Printed so the test can assert on the
53+
// real environment rather than on how it was constructed.
54+
const char* ldlp = std::getenv("LD_LIBRARY_PATH");
55+
std::printf("LDLP=[%s]\n", ldlp ? ldlp : "");
56+
57+
// 2. A child that the HOST loader loads. This is the #401 failure.
58+
std::FILE* pipe = ::popen("/bin/sh -c 'echo mcpp-child-ok'", "r");
59+
if (!pipe) { std::puts("CHILD=popen-failed"); return 10; }
60+
char buf[64] = {0};
61+
const bool got = std::fgets(buf, sizeof buf, pipe) != nullptr;
62+
const int status = ::pclose(pipe);
63+
buf[strcspn(buf, "\n")] = '\0';
64+
std::printf("CHILD=[%s] status=%d\n", got ? buf : "", status);
65+
if (!got || std::strcmp(buf, "mcpp-child-ok") != 0) return 11;
66+
67+
// 3. …and the dlopen the private libc directory exists to serve must still
68+
// work, or the fix traded one breakage for another.
69+
void* handle = ::dlopen("libruntime_plugin.so", RTLD_NOW);
70+
if (!handle) { std::printf("DLOPEN=[%s]\n", ::dlerror()); return 12; }
71+
auto answer = reinterpret_cast<answer_fn>(
72+
::dlsym(handle, "runtime_plugin_answer"));
73+
const int value = answer ? answer() : -1;
74+
::dlclose(handle);
75+
std::printf("DLOPEN=ok answer=%d\n", value);
76+
return value == 42 ? 0 : 13;
77+
}
78+
EOF
79+
80+
cat > app/mcpp.toml <<'EOF'
81+
[package]
82+
name = "app"
83+
version = "0.1.0"
84+
85+
[build]
86+
sources = ["src/*.cpp"]
87+
ldflags = ["-ldl"]
88+
89+
[runtime]
90+
library_dirs = ["runtime"]
91+
92+
[targets.app]
93+
kind = "bin"
94+
main = "src/main.cpp"
95+
EOF
96+
97+
cd app
98+
"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; }
99+
100+
NINJA=$(find target -name build.ninja | head -1)
101+
GLIBC_LIB=$(grep -oE '/[^ ",]*/xim-x-glibc/[0-9.]+/lib(64)?' "$NINJA" | head -1 || true)
102+
if [[ -z "$GLIBC_LIB" ]]; then
103+
echo "SKIP: this toolchain has no private glibc payload"
104+
exit 0
105+
fi
106+
echo "private libc payload: $GLIBC_LIB"
107+
108+
# ── 1. published through the artifact ───────────────────────────────
109+
# The link model emits this next to --dynamic-linker. mcpp does not add a
110+
# second copy — the link line has a hard 128KiB ceiling — so this asserts the
111+
# coverage it relies on instead of duplicating it. If a toolchain ever stops
112+
# providing it, dlopen() resolution would silently lose the payload libc, and
113+
# this is the test that says so.
114+
grep -F -- "-Wl,-rpath,$GLIBC_LIB" "$NINJA" >/dev/null || {
115+
echo "FAIL: private libc directory is not in the artifact RUNPATH"
116+
grep -nF -- "$GLIBC_LIB" "$NINJA" || true
117+
exit 1
118+
}
119+
120+
run_out=$("$MCPP" run 2>&1) || { echo "$run_out"; echo "run failed"; exit 1; }
121+
122+
# ── 2. the program's own environment must not carry it ──────────────
123+
ldlp_line=$(printf '%s\n' "$run_out" | grep '^LDLP=' || true)
124+
[[ -n "$ldlp_line" ]] || { echo "$run_out"; echo "FAIL: program printed no LD_LIBRARY_PATH"; exit 1; }
125+
case "$ldlp_line" in
126+
*"$GLIBC_LIB"*)
127+
echo "$ldlp_line"
128+
echo "FAIL: private libc directory reached LD_LIBRARY_PATH — every child"
129+
echo " process the program spawns now loads it under the host loader"
130+
exit 1
131+
;;
132+
esac
133+
134+
# ── 3. the child the host loader loads must survive ─────────────────
135+
printf '%s\n' "$run_out" | grep -q 'CHILD=\[mcpp-child-ok\] status=0' || {
136+
echo "$run_out"
137+
echo "FAIL: a /bin/sh child did not run cleanly under mcpp run (mcpp#401)"
138+
exit 1
139+
}
140+
141+
# ── 4. …and the dlopen it exists for still resolves ─────────────────
142+
printf '%s\n' "$run_out" | grep -q 'DLOPEN=ok answer=42' || {
143+
echo "$run_out"
144+
echo "FAIL: dlopen through the runtime library dir regressed"
145+
exit 1
146+
}
147+
148+
echo "OK"

0 commit comments

Comments
 (0)