diff --git a/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md b/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md new file mode 100644 index 000000000..969b6b8c5 --- /dev/null +++ b/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md @@ -0,0 +1,5 @@ +- **Sandbox grant transport is isolated and validated.** + Linux and FreeBSD now allocate an exclusive shared-memory object per + `spawn_sandboxed` call. Empty grant lists run as deny-all policies, while + malformed or oversized lists are rejected instead of being skipped or + truncated during serialization. diff --git a/runtime/sandbox/aether_sandbox_grants.h b/runtime/sandbox/aether_sandbox_grants.h new file mode 100644 index 000000000..541fc5fb0 --- /dev/null +++ b/runtime/sandbox/aether_sandbox_grants.h @@ -0,0 +1,58 @@ +#ifndef AETHER_SANDBOX_GRANTS_H +#define AETHER_SANDBOX_GRANTS_H + +#include +#include +#include +#include + +#define AETHER_SANDBOX_GRANTS_SIZE 8192 + +extern int list_size(void*); +extern void* list_get_raw(void*, int); +extern const char* aether_string_data(const void*); + +static int aether_sandbox_serialize_grants(void* grant_list, char* buf, + size_t buf_size, size_t* data_size) { + if (!grant_list || !buf || !data_size || buf_size == 0) { + errno = EINVAL; + return -1; + } + + int n = list_size(grant_list); + if (n < 0 || n % 2 != 0) { + errno = EINVAL; + return -1; + } + + size_t pos = 0; + buf[0] = '\0'; + for (int i = 0; i < n; i += 2) { + void* category_item = list_get_raw(grant_list, i); + void* pattern_item = list_get_raw(grant_list, i + 1); + if (!category_item || !pattern_item) { + errno = EINVAL; + return -1; + } + const char* category = aether_string_data(category_item); + const char* pattern = aether_string_data(pattern_item); + if (strchr(category, ':') || + strpbrk(category, "\r\n") || strpbrk(pattern, "\r\n")) { + errno = EINVAL; + return -1; + } + + int written = snprintf(buf + pos, buf_size - pos, "%s:%s\n", + category, pattern); + if (written < 0 || (size_t)written >= buf_size - pos) { + errno = E2BIG; + return -1; + } + pos += (size_t)written; + } + + *data_size = pos + 1; + return 0; +} + +#endif diff --git a/runtime/sandbox/aether_sandbox_shm.h b/runtime/sandbox/aether_sandbox_shm.h new file mode 100644 index 000000000..9e2615891 --- /dev/null +++ b/runtime/sandbox/aether_sandbox_shm.h @@ -0,0 +1,77 @@ +#ifndef AETHER_SANDBOX_SHM_H +#define AETHER_SANDBOX_SHM_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define AETHER_SANDBOX_SHM_NAME_SIZE 64 +#define AETHER_SANDBOX_SHM_CREATE_ATTEMPTS 128 + +static atomic_uint aether_sandbox_shm_sequence = ATOMIC_VAR_INIT(0); + +static int aether_sandbox_shm_create(char* name, size_t name_size) { + for (int attempt = 0; attempt < AETHER_SANDBOX_SHM_CREATE_ATTEMPTS; attempt++) { + unsigned sequence = atomic_fetch_add_explicit( + &aether_sandbox_shm_sequence, 1, memory_order_relaxed); + int written = snprintf(name, name_size, "/aether_sandbox_%ld_%u", + (long)getpid(), sequence); + if (written < 0 || (size_t)written >= name_size) { + errno = ENAMETOOLONG; + return -1; + } + + int fd = shm_open(name, O_CREAT | O_EXCL | O_RDWR, 0600); + if (fd >= 0) return fd; + if (errno != EEXIST) return -1; + } + errno = EEXIST; + return -1; +} + +static int aether_sandbox_shm_write(char* name, size_t name_size, + const void* data, size_t data_size) { + int fd = aether_sandbox_shm_create(name, name_size); + if (fd < 0) return -1; + + if (ftruncate(fd, (off_t)data_size) != 0) { + int saved = errno; + close(fd); + shm_unlink(name); + errno = saved; + return -1; + } + + void* mem = mmap(NULL, data_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (mem == MAP_FAILED) { + int saved = errno; + close(fd); + shm_unlink(name); + errno = saved; + return -1; + } + + memcpy(mem, data, data_size); + if (munmap(mem, data_size) != 0) { + int saved = errno; + close(fd); + shm_unlink(name); + errno = saved; + return -1; + } + if (close(fd) != 0) { + int saved = errno; + shm_unlink(name); + errno = saved; + return -1; + } + return 0; +} + +#endif diff --git a/runtime/sandbox/spawn_sandboxed_bsd.c b/runtime/sandbox/spawn_sandboxed_bsd.c index 797e6dbe7..aae1f5173 100644 --- a/runtime/sandbox/spawn_sandboxed_bsd.c +++ b/runtime/sandbox/spawn_sandboxed_bsd.c @@ -40,6 +40,8 @@ extern const char* aether_string_data(const void*); // The preload library's lookup, shared with the contrib process hosts. #include "aether_sandbox_preload_path.h" +#include "aether_sandbox_grants.h" +#include "aether_sandbox_shm.h" // The close-on-exec pipe, the fork, and what a child execs with. #include "../utils/aether_spawn.h" @@ -84,40 +86,14 @@ static int sandbox_report(int errfd) { // Serialize grants from a list to shared memory // Format: "category:pattern\n" lines, null-terminated -static char shm_name[64]; - -static int serialize_grants(void* grant_list) { - int n = list_size(grant_list); - if (n <= 0 || n % 2 != 0) return -1; - - // Build the grant string - char buf[8192]; - int pos = 0; - for (int i = 0; i < n && pos < 8000; i += 2) { - const char* cat = aether_string_data(list_get_raw(grant_list, i)); - const char* pat = aether_string_data(list_get_raw(grant_list, i + 1)); - if (!cat || !pat) continue; - pos += snprintf(buf + pos, sizeof(buf) - pos, "%s:%s\n", cat, pat); - } - - // Write to shared memory - snprintf(shm_name, sizeof(shm_name), "/aether_sandbox_%d", getpid()); - int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0600); - if (fd < 0) return -1; - - if (ftruncate(fd, pos + 1) != 0) { - close(fd); - shm_unlink(shm_name); - return -1; - } - void* mem = mmap(NULL, pos + 1, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); - if (mem == MAP_FAILED) { close(fd); return -1; } - - memcpy(mem, buf, pos + 1); - munmap(mem, pos + 1); - close(fd); - - return 0; +static int serialize_grants(void* grant_list, char* shm_name, size_t shm_name_size) { + char buf[AETHER_SANDBOX_GRANTS_SIZE]; + size_t data_size = 0; + if (aether_sandbox_serialize_grants( + grant_list, buf, sizeof buf, &data_size) != 0) return -1; + + return aether_sandbox_shm_write( + shm_name, shm_name_size, buf, data_size); } // Spawn a sandboxed child process @@ -132,7 +108,8 @@ int aether_spawn_sandboxed(void* grant_list, const char* program, const char* ar } // Serialize grants to shared memory - if (serialize_grants(grant_list) < 0) { + char shm_name[AETHER_SANDBOX_SHM_NAME_SIZE]; + if (serialize_grants(grant_list, shm_name, sizeof shm_name) < 0) { fprintf(stderr, "[aether] cannot create shared memory for grants\n"); return -1; } diff --git a/runtime/sandbox/spawn_sandboxed_linux.c b/runtime/sandbox/spawn_sandboxed_linux.c index fd07b2c00..2b4cbbf7a 100644 --- a/runtime/sandbox/spawn_sandboxed_linux.c +++ b/runtime/sandbox/spawn_sandboxed_linux.c @@ -36,6 +36,8 @@ extern const char* aether_string_data(const void*); // The preload library's lookup, shared with the contrib process hosts. #include "aether_sandbox_preload_path.h" +#include "aether_sandbox_grants.h" +#include "aether_sandbox_shm.h" // The close-on-exec pipe, the fork, and what a child execs with. #include "../utils/aether_spawn.h" @@ -89,40 +91,14 @@ static int sandbox_report(int errfd) { // Serialize grants from a list to shared memory // Format: "category:pattern\n" lines, null-terminated -static char shm_name[64]; - -static int serialize_grants(void* grant_list) { - int n = list_size(grant_list); - if (n <= 0 || n % 2 != 0) return -1; - - // Build the grant string - char buf[8192]; - int pos = 0; - for (int i = 0; i < n && pos < 8000; i += 2) { - const char* cat = aether_string_data(list_get_raw(grant_list, i)); - const char* pat = aether_string_data(list_get_raw(grant_list, i + 1)); - if (!cat || !pat) continue; - pos += snprintf(buf + pos, sizeof(buf) - pos, "%s:%s\n", cat, pat); - } - - // Write to shared memory - snprintf(shm_name, sizeof(shm_name), "/aether_sandbox_%d", getpid()); - int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0600); - if (fd < 0) return -1; - - if (ftruncate(fd, pos + 1) != 0) { - close(fd); - shm_unlink(shm_name); - return -1; - } - void* mem = mmap(NULL, pos + 1, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); - if (mem == MAP_FAILED) { close(fd); return -1; } - - memcpy(mem, buf, pos + 1); - munmap(mem, pos + 1); - close(fd); - - return 0; +static int serialize_grants(void* grant_list, char* shm_name, size_t shm_name_size) { + char buf[AETHER_SANDBOX_GRANTS_SIZE]; + size_t data_size = 0; + if (aether_sandbox_serialize_grants( + grant_list, buf, sizeof buf, &data_size) != 0) return -1; + + return aether_sandbox_shm_write( + shm_name, shm_name_size, buf, data_size); } // Is the `fork` category granted in the grant list? The LD_PRELOAD layer @@ -338,7 +314,8 @@ int aether_spawn_sandboxed(void* grant_list, const char* program, const char* ar } // Serialize grants to shared memory - if (serialize_grants(grant_list) < 0) { + char shm_name[AETHER_SANDBOX_SHM_NAME_SIZE]; + if (serialize_grants(grant_list, shm_name, sizeof shm_name) < 0) { fprintf(stderr, "[aether] cannot create shared memory for grants\n"); return -1; } diff --git a/tests/integration/sandbox_grant_transport/harness.c b/tests/integration/sandbox_grant_transport/harness.c new file mode 100644 index 000000000..6175cad98 --- /dev/null +++ b/tests/integration/sandbox_grant_transport/harness.c @@ -0,0 +1,78 @@ +#include +#include +#include + +extern int aether_spawn_sandboxed(void* grant_list, const char* program, const char* arg); +extern void* list_new(void); +extern int list_add_raw(void* list, void* item); +extern void list_free(void* list); + +static int expect_result(const char* name, int got, int want) { + if (got == want) return 0; + fprintf(stderr, "FAIL %s: got %d, want %d\n", name, got, want); + return 1; +} + +static void* pair(const char* category, const char* pattern) { + void* grants = list_new(); + if (!grants || !list_add_raw(grants, (void*)category) || + !list_add_raw(grants, (void*)pattern)) { + fprintf(stderr, "FAIL cannot create grant pair\n"); + exit(2); + } + return grants; +} + +int main(void) { + int failures = 0; + + void* empty = list_new(); + if (!empty) return 2; + failures += expect_result("empty deny-all list", + aether_spawn_sandboxed(empty, "/bin/true", NULL), 0); + list_free(empty); + + failures += expect_result("null list", + aether_spawn_sandboxed(NULL, "/bin/true", NULL), -1); + + void* odd = list_new(); + if (!odd || !list_add_raw(odd, (void*)"exec")) return 2; + failures += expect_result("odd list", + aether_spawn_sandboxed(odd, "/bin/true", NULL), -1); + list_free(odd); + + void* null_item = pair(NULL, "*"); + failures += expect_result("null grant item", + aether_spawn_sandboxed(null_item, "/bin/true", NULL), -1); + list_free(null_item); + + void* multiline = pair("fs_read", "/tmp/allowed\n*:*"); + failures += expect_result("multiline pattern", + aether_spawn_sandboxed(multiline, "/bin/true", NULL), -1); + list_free(multiline); + + void* delimited_category = pair("fs_read:*", "*"); + failures += expect_result("category containing delimiter", + aether_spawn_sandboxed(delimited_category, + "/bin/true", NULL), -1); + list_free(delimited_category); + + char* oversized_pattern = malloc(9001); + if (!oversized_pattern) return 2; + memset(oversized_pattern, 'a', 9000); + oversized_pattern[9000] = '\0'; + void* oversized = pair("fs_read", oversized_pattern); + failures += expect_result("oversized transport", + aether_spawn_sandboxed(oversized, "/bin/true", NULL), -1); + list_free(oversized); + free(oversized_pattern); + + void* allow = pair("*", "*"); + failures += expect_result("ordinary grant pair", + aether_spawn_sandboxed(allow, "/bin/true", NULL), 0); + list_free(allow); + + if (failures) return 1; + printf("PASS: sandbox grant transport validates complete input\n"); + return 0; +} diff --git a/tests/integration/sandbox_grant_transport/test_sandbox_grant_transport.sh b/tests/integration/sandbox_grant_transport/test_sandbox_grant_transport.sh new file mode 100755 index 000000000..2320d0313 --- /dev/null +++ b/tests/integration/sandbox_grant_transport/test_sandbox_grant_transport.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Grant transport accepts an empty deny-all policy, while rejecting malformed +# or unrepresentable lists before starting a child. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_grant_transport: spawn_sandboxed is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$ROOT/build/libaether.a" ] || + [ ! -f "$ROOT/build/libaether_sandbox.so" ]; then + echo " [SKIP] sandbox_grant_transport: sandbox runtime not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT + +if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" -lpthread -lm 2>"$TMP/cc.err"; then + if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" \ + -lpthread -ldl -lm -lrt -lz -lssl -lcrypto 2>"$TMP/cc.err"; then + echo " [FAIL] sandbox_grant_transport: harness did not link" + sed 's/^/ /' "$TMP/cc.err" | head -8 + exit 1 + fi +fi + +out="$(AETHER_SANDBOX_PRELOAD="$ROOT/build/libaether_sandbox.so" \ + AETHER_SANDBOX_LOG=none "$TMP/harness" 2>&1 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern int aether_spawn_sandboxed(void* grant_list, const char* program, const char* arg); +extern void* list_new(void); +extern int list_add_raw(void* list, void* item); + +static pthread_barrier_t fork_barrier; +static char collision_name[128]; + +static void remove_collision(void) { + if (collision_name[0]) shm_unlink(collision_name); +} + +pid_t __wrap_aether_fork(void) { + int rc = pthread_barrier_wait(&fork_barrier); + if (rc != 0 && rc != PTHREAD_BARRIER_SERIAL_THREAD) { + errno = rc; + return -1; + } + return fork(); +} + +struct sandbox_call { + void* grants; + const char* script; + const char* output; + int rc; +}; + +static void* run_sandbox(void* raw) { + struct sandbox_call* call = (struct sandbox_call*)raw; + call->rc = aether_spawn_sandboxed(call->grants, call->script, call->output); + return NULL; +} + +static int write_script(const char* path) { + static const char text[] = + "#!/bin/sh\n" + "printf '%s\\n' \"$AETHER_SANDBOX_SHM\" > \"$1\"\n"; + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0755); + if (fd < 0) return -1; + ssize_t written = write(fd, text, sizeof text - 1); + int saved = errno; + close(fd); + errno = saved; + if (written != (ssize_t)(sizeof text - 1)) return -1; + return chmod(path, 0755); +} + +static int read_name(const char* path, char* name, size_t size) { + FILE* f = fopen(path, "r"); + if (!f) return -1; + if (!fgets(name, (int)size, f)) { + fclose(f); + return -1; + } + fclose(f); + name[strcspn(name, "\r\n")] = '\0'; + return name[0] ? 0 : -1; +} + +static int expect_removed(const char* name) { + int fd = shm_open(name, O_RDONLY, 0); + if (fd < 0 && errno == ENOENT) return 0; + if (fd >= 0) close(fd); + fprintf(stderr, "FAIL shared memory remains after spawn: %s\n", name); + return 1; +} + +int main(int argc, char** argv) { + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + char script[4096]; + char output_a[4096]; + char output_b[4096]; + snprintf(script, sizeof script, "%s/record-shm.sh", argv[1]); + snprintf(output_a, sizeof output_a, "%s/a.txt", argv[1]); + snprintf(output_b, sizeof output_b, "%s/b.txt", argv[1]); + if (write_script(script) != 0) { + perror("write script"); + return 1; + } + + void* grants = list_new(); + if (!grants || !list_add_raw(grants, (void*)"*") || + !list_add_raw(grants, (void*)"*")) { + fprintf(stderr, "FAIL cannot create grant list\n"); + return 1; + } + if (pthread_barrier_init(&fork_barrier, NULL, 2) != 0) { + fprintf(stderr, "FAIL cannot create fork barrier\n"); + return 1; + } + + snprintf(collision_name, sizeof collision_name, + "/aether_sandbox_%ld_0", (long)getpid()); + int collision_fd = shm_open(collision_name, O_CREAT | O_EXCL | O_RDWR, 0600); + if (collision_fd < 0) { + perror("create collision object"); + return 1; + } + close(collision_fd); + atexit(remove_collision); + + struct sandbox_call a = { grants, script, output_a, -1 }; + struct sandbox_call b = { grants, script, output_b, -1 }; + pthread_t thread_a; + pthread_t thread_b; + if (pthread_create(&thread_a, NULL, run_sandbox, &a) != 0 || + pthread_create(&thread_b, NULL, run_sandbox, &b) != 0) { + fprintf(stderr, "FAIL cannot create caller threads\n"); + return 1; + } + pthread_join(thread_a, NULL); + pthread_join(thread_b, NULL); + pthread_barrier_destroy(&fork_barrier); + + if (a.rc != 0 || b.rc != 0) { + fprintf(stderr, "FAIL concurrent spawns returned %d and %d\n", a.rc, b.rc); + return 1; + } + + char name_a[128]; + char name_b[128]; + if (read_name(output_a, name_a, sizeof name_a) != 0 || + read_name(output_b, name_b, sizeof name_b) != 0) { + fprintf(stderr, "FAIL children did not record shared-memory names\n"); + return 1; + } + if (strcmp(name_a, name_b) == 0) { + fprintf(stderr, "FAIL concurrent spawns shared one name: %s\n", name_a); + return 1; + } + if (expect_removed(name_a) || expect_removed(name_b)) return 1; + + collision_fd = shm_open(collision_name, O_RDONLY, 0); + if (collision_fd < 0) { + fprintf(stderr, "FAIL spawn did not preserve an existing name: %s\n", + collision_name); + return 1; + } + close(collision_fd); + + printf("PASS: concurrent spawn_sandboxed calls use separate shared memory\n"); + return 0; +} diff --git a/tests/integration/sandbox_unique_shm/test_sandbox_unique_shm.sh b/tests/integration/sandbox_unique_shm/test_sandbox_unique_shm.sh new file mode 100755 index 000000000..76e5528be --- /dev/null +++ b/tests/integration/sandbox_unique_shm/test_sandbox_unique_shm.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Two concurrent spawn_sandboxed calls must not publish the same grant +# transport. The harness holds both callers at aether_fork until both shared +# memory objects have been created, then compares the names seen by the children. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_unique_shm: spawn_sandboxed is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$ROOT/build/libaether.a" ]; then + echo " [SKIP] sandbox_unique_shm: build/libaether.a not built" + exit 0 +fi +if [ ! -f "$ROOT/build/libaether_sandbox.so" ]; then + echo " [SKIP] sandbox_unique_shm: build/libaether_sandbox.so not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT + +if ! ${CC:-cc} -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" -Wl,--wrap=aether_fork \ + -lpthread -lm 2>"$TMP/cc.err"; then + if ! ${CC:-cc} -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" -Wl,--wrap=aether_fork \ + -lpthread -ldl -lm -lrt -lz -lssl -lcrypto 2>"$TMP/cc.err"; then + echo " [FAIL] could not link the concurrent spawn harness" + sed 's/^/ /' "$TMP/cc.err" | head -8 + exit 1 + fi +fi + +mkdir -p "$TMP/work" +out="$(AETHER_SANDBOX_PRELOAD="$ROOT/build/libaether_sandbox.so" \ + env -u AETHER_SANDBOX_VERBOSE "$TMP/harness" "$TMP/work" 2>&1