From 171f0391d76a5b6324db3004ccc1c127797dc5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:31:19 +0300 Subject: [PATCH] runtime: preserve sandbox controls across explicit exec envs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- ...61029T083032Z-security-sandbox-exec-env.md | 4 + runtime/libaether_sandbox_preload.c | 96 ++++++++++++++++++- .../sandbox_exec_env_inherit/launcher.c | 29 ++++++ .../test_sandbox_exec_env_inherit.sh | 78 +++++++++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261029T083032Z-security-sandbox-exec-env.md create mode 100644 tests/integration/sandbox_exec_env_inherit/launcher.c create mode 100644 tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh diff --git a/new_changelogs/20261029T083032Z-security-sandbox-exec-env.md b/new_changelogs/20261029T083032Z-security-sandbox-exec-env.md new file mode 100644 index 000000000..12dd10721 --- /dev/null +++ b/new_changelogs/20261029T083032Z-security-sandbox-exec-env.md @@ -0,0 +1,4 @@ +- **Sandbox controls now survive `execve` with an explicit environment.** The + preload layer rebuilds child environments with its current `LD_PRELOAD` and + grant-source entries, preventing an explicit `envp` from dropping or + replacing cross-process containment. diff --git a/runtime/libaether_sandbox_preload.c b/runtime/libaether_sandbox_preload.c index bbb4aaede..8d1643158 100644 --- a/runtime/libaether_sandbox_preload.c +++ b/runtime/libaether_sandbox_preload.c @@ -392,6 +392,51 @@ FILE* fopen64(const char* path, const char* mode) { #endif // __linux__ (open64 / openat64 / fopen64) +#define MAX_EXEC_ENV_ENTRIES 4096 +extern char** environ; + +static int env_key_is(const char* entry, const char* key) { + size_t n; + if (!entry || !key) return 0; + n = strlen(key); + return strncmp(entry, key, n) == 0 && entry[n] == '='; +} + +static int same_env_key(const char* a, const char* b) { + const char* ae; + const char* be; + if (!a || !b) return 0; + ae = strchr(a, '='); + be = strchr(b, '='); + if (!ae || !be) return 0; + return (ae - a) == (be - b) && strncmp(a, b, (size_t)(ae - a)) == 0; +} + +static int is_sandbox_control_env(const char* entry) { + static const char prefix[] = "AETHER_SANDBOX_"; + if (!entry) return 0; + return env_key_is(entry, "LD_PRELOAD") || + env_key_is(entry, "AETHER_CAPSICUM") || + strncmp(entry, prefix, sizeof(prefix) - 1) == 0; +} + +static int env_has_same_key(char* const env[], size_t count, const char* entry) { + for (size_t i = 0; i < count; i++) { + if (same_env_key(env[i], entry)) return 1; + } + return 0; +} + +static int env_has_nonempty(char* const env[], size_t count, const char* key) { + for (size_t i = 0; i < count; i++) { + if (env_key_is(env[i], key)) { + const char* value = strchr(env[i], '='); + return value && value[1] != '\0'; + } + } + return 0; +} + // execve int execve(const char* pathname, char* const argv[], char* const envp[]) { if (!real_execve) real_execve = dlsym(RTLD_NEXT, "execve"); @@ -399,7 +444,56 @@ int execve(const char* pathname, char* const argv[], char* const envp[]) { errno = EPERM; return -1; } - return real_execve(pathname, argv, envp); + if (!sandbox_active) return real_execve(pathname, argv, envp); + + // An explicit envp is the child's whole environment. Rebuild it on the + // stack so application entries survive while sandbox controls come only + // from this contained process. This avoids allocation in the common + // fork-then-exec path and prevents an explicit environment from dropping + // or replacing the preload and grant source. + char* merged_env[MAX_EXEC_ENV_ENTRIES + 1]; + size_t count = 0; + size_t scanned = 0; + while (envp && envp[scanned]) { + if (scanned >= MAX_EXEC_ENV_ENTRIES) { + errno = E2BIG; + return -1; + } + if (!is_sandbox_control_env(envp[scanned])) { + if (count >= MAX_EXEC_ENV_ENTRIES) { + errno = E2BIG; + return -1; + } + merged_env[count++] = envp[scanned]; + } + scanned++; + } + + scanned = 0; + while (environ && environ[scanned]) { + if (scanned >= MAX_EXEC_ENV_ENTRIES) { + errno = E2BIG; + return -1; + } + if (is_sandbox_control_env(environ[scanned]) && + !env_has_same_key(merged_env, count, environ[scanned])) { + if (count >= MAX_EXEC_ENV_ENTRIES) { + errno = E2BIG; + return -1; + } + merged_env[count++] = environ[scanned]; + } + scanned++; + } + merged_env[count] = NULL; + + if (!env_has_nonempty(merged_env, count, "LD_PRELOAD") || + (!env_has_nonempty(merged_env, count, "AETHER_SANDBOX_SHM") && + !env_has_nonempty(merged_env, count, "AETHER_SANDBOX_GRANTS"))) { + errno = EPERM; + return -1; + } + return real_execve(pathname, argv, merged_env); } // stat/access/fstatat — NOT intercepted. Modern glibc inlines these as diff --git a/tests/integration/sandbox_exec_env_inherit/launcher.c b/tests/integration/sandbox_exec_env_inherit/launcher.c new file mode 100644 index 000000000..c41ab810c --- /dev/null +++ b/tests/integration/sandbox_exec_env_inherit/launcher.c @@ -0,0 +1,29 @@ +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + char* cat_argv[] = { (char*)"/bin/cat", argv[1], NULL }; + char* env_argv[] = { (char*)"/usr/bin/env", NULL }; + char* omitted[] = { (char*)"ONLY=explicit", NULL }; + char* overridden[] = { + (char*)"ONLY=explicit", + (char*)"LD_PRELOAD=", + (char*)"AETHER_SANDBOX_GRANTS=/definitely/missing", + NULL + }; + char** child_env = strcmp(argv[2], "override") == 0 ? overridden : omitted; + const char* child_path = strcmp(argv[2], "env") == 0 + ? "/usr/bin/env" : "/bin/cat"; + char** child_argv = strcmp(argv[2], "env") == 0 ? env_argv : cat_argv; + + execve(child_path, child_argv, child_env); + fprintf(stderr, "execve: %s\n", strerror(errno)); + return 111; +} diff --git a/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh b/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh new file mode 100644 index 000000000..71e2fd08a --- /dev/null +++ b/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# An allowed exec with an explicit environment must retain the preload layer +# and its grant source. Omitting or replacing control entries must not turn the +# child into an unrestricted process. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +LIB="$ROOT/build/libaether_sandbox.so" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_exec_env_inherit: preload is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$LIB" ]; then + echo " [SKIP] sandbox_exec_env_inherit: preload library not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + "$SCRIPT_DIR/launcher.c" -o "$TMP/launcher"; then + echo " [FAIL] sandbox_exec_env_inherit: launcher did not compile" + exit 1 +fi + +printf 'exec sentinel\n' > "$TMP/data.txt" +printf 'exec:/bin/cat\n' > "$TMP/deny-read.grants" +printf 'exec:/bin/cat\nfs_read:%s\n' "$TMP/data.txt" > "$TMP/allow-read.grants" +printf 'exec:/usr/bin/env\n' > "$TMP/env.grants" +fail=0 + +expect_denied() { + mode="$1" + if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/deny-read.grants" \ + AETHER_SANDBOX_LOG=none "$TMP/launcher" "$TMP/data.txt" "$mode" \ + >"$TMP/out" 2>"$TMP/err"; then + echo " [FAIL] explicit env ($mode) dropped containment: child exited zero" + fail=1 + elif grep -qF 'exec sentinel' "$TMP/out"; then + echo " [FAIL] explicit env ($mode) dropped containment: child read the file" + fail=1 + else + echo " [PASS] explicit env ($mode) retains containment" + fi +} + +expect_denied omit +expect_denied override + +if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/allow-read.grants" \ + AETHER_SANDBOX_LOG=none "$TMP/launcher" "$TMP/data.txt" omit \ + >"$TMP/out" 2>"$TMP/err" && grep -qF 'exec sentinel' "$TMP/out"; then + echo " [PASS] inherited controls preserve an allowed exec" +else + echo " [FAIL] inherited controls broke an allowed exec" + sed 's/^/ /' "$TMP/err" | head -8 + fail=1 +fi + +if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/env.grants" \ + AETHER_SANDBOX_LOG=none "$TMP/launcher" ignored env \ + >"$TMP/out" 2>"$TMP/err" && + grep -qxF 'ONLY=explicit' "$TMP/out" && + grep -qxF "LD_PRELOAD=$LIB" "$TMP/out" && + grep -qxF "AETHER_SANDBOX_GRANTS=$TMP/env.grants" "$TMP/out"; then + echo " [PASS] explicit entries and sandbox controls reach the child" +else + echo " [FAIL] merged child environment lost explicit or sandbox entries" + sed 's/^/ /' "$TMP/out" | head -12 + fail=1 +fi + +echo "" +echo "sandbox_exec_env_inherit: $([ "$fail" -eq 0 ] && echo PASS || echo FAIL)" +[ "$fail" -eq 0 ]