From c01d3ed7246e198cdf70674a256c59c18bc90381 Mon Sep 17 00:00:00 2001 From: Colton Willey Date: Fri, 10 Jul 2026 09:50:59 -0700 Subject: [PATCH 1/4] wp_seed_src: read /dev/urandom via raw fd to avoid seccomp SIGSYS SEED-SRC opened /dev/urandom with XFOPEN (buffered fopen). The first fread pulls read-ahead into the stdio buffer. sshd primes its RNG before the privsep fork, so the preauth child inherits that buffered FILE*; at child exit glibc stdio cleanup calls lseek() to drop the unread read-ahead. lseek() isn't in OpenSSH's preauth seccomp filter, so the child is killed with SIGSYS. Use a raw fd (open/read/close, O_CLOEXEC) instead: no buffer, no read-ahead, no exit-time lseek, matching OpenSSL's own device seeding. The fd and wolfSSL seed callback are shared and reference counted across provider contexts; the last unload closes the fd and restores the seed callback to wc_GenerateSeed (not NULL, which fails wc_InitRng with DRBG_NO_SEED_CB under FIPS). Also fixes a re-init leak that orphaned the prior buffered handle. --- .github/workflows/README.md | 2 +- .github/workflows/seed-src.yml | 10 +- include/wolfprovider/internal.h | 18 +- src/wp_seed_src.c | 296 +++++----- src/wp_wolfprov.c | 16 +- test/test_rand_seed.c | 392 +++++++++++++ test/test_seccomp_sandbox.c | 980 ++++++++++++++++++++++++++------ test/unit.c | 59 ++ test/unit.h | 22 + 9 files changed, 1486 insertions(+), 309 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index a706a656..b199923d 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -31,7 +31,7 @@ ready_for_review) and on every push to `master`, `main`, or | `smoke-test.yml` | Minimal end-to-end: build, load the provider into stock OpenSSL, run `openssl list -providers` and a handful of `openssl` subcommands. Catches link-time and provider-registration regressions. | | `cmdline.yml` | Runs `scripts/cmd_test/do-cmd-tests.sh` — exercises every `openssl` CLI verb (genrsa, pkeyutl, enc, dgst, …) through wolfProvider. | | `fips-ready.yml` | Same as `simple` but builds wolfSSL with `--enable-fips=ready`. Sanity check that FIPS-ready compiles and basic tests pass without the full FIPS bundle. | -| `seed-src.yml` | Builds with `--enable-seed-src` (entropy seed source variant) and runs the unit tests. | +| `seed-src.yml` | Builds with `--enable-seed-src` (entropy seed source variant) and `-DWP_TEST_SECCOMP_SANDBOX`, then runs the unit tests including the OpenSSH fork+seccomp-sandbox regression suite. | | `multi-compiler.yml` | Cross-compiler sweep: gcc-9 through gcc-14 and clang-12 through latest. Catches toolchain-specific warnings / UB. | | `codespell.yml` | Spell-check on tracked source. `*.patch` is excluded because OSP patches mirror upstream source whose original spelling we shouldn't silently rewrite. | | `sanitizers.yml` | Builds wolfProvider with `-fsanitize=address,undefined` (one job) and `-fsanitize=thread` (separate job — TSan and ASan can't coexist in one binary), runs `make test` + `cmd_test/do-cmd-tests.sh` under each. Caches OpenSSL + wolfSSL source/install to avoid the ~15 min rebuild on every push. | diff --git a/.github/workflows/seed-src.yml b/.github/workflows/seed-src.yml index 6a774cba..969813bc 100644 --- a/.github/workflows/seed-src.yml +++ b/.github/workflows/seed-src.yml @@ -51,9 +51,13 @@ jobs: - name: Build and test wolfProvider with SEED-SRC run: | - # Force wolfSSL to not use getrandom syscall via ac_cv_func_getrandom=no. - # This ensures /dev/urandom is used as the entropy source, which is - # required to test the SEED-SRC feature's fork-safe caching behavior. + # ac_cv_func_getrandom=no forces wolfSSL onto /dev/urandom, the + # entropy path the SEED-SRC fork-safe caching test needs. + # WOLFPROV_CONFIG_CFLAGS reaches the unit.test compile via configure + # CFLAGS; -DWP_TEST_SECCOMP_SANDBOX compiles in the OpenSSH preauth + # seccomp suite (else it builds to a skip stub). ubuntu-22.04 runners + # are full VMs where PR_SET_SECCOMP works. + WOLFPROV_CONFIG_CFLAGS="-DWP_TEST_SECCOMP_SANDBOX" \ WOLFSSL_CONFIG_OPTS="--enable-all-crypto --with-eccminsz=192 --with-max-ecc-bits=1024 --enable-opensslcoexist --enable-sha ac_cv_func_getrandom=no" \ OPENSSL_TAG=${{ matrix.openssl_ref }} \ WOLFSSL_TAG=${{ matrix.wolfssl_ref }} \ diff --git a/include/wolfprovider/internal.h b/include/wolfprovider/internal.h index e0660520..21242eb1 100644 --- a/include/wolfprovider/internal.h +++ b/include/wolfprovider/internal.h @@ -36,6 +36,10 @@ #include #include +/* WP_HAVE_* feature macros: keep the config-guarded WOLFPROV_CTX fields below + * consistent across every translation unit that includes this header. */ +#include + #include "wp_params.h" #ifndef AES_BLOCK_SIZE @@ -146,16 +150,18 @@ typedef struct WOLFPROV_CTX { wolfSSL_Mutex rng_mutex; #endif BIO_METHOD *coreBioMethod; +#if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) + /** Set once this context holds a urandom reference, so a failed init is not + * balanced by a cleanup that releases a reference it never took. */ + int urandomInitialized; +#endif } WOLFPROV_CTX; #if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) /* - * Global /dev/urandom subsystem functions. - * - * These manage a cached file handle to /dev/urandom that is opened lazily - * on first entropy request (matching OpenSSL's default provider behavior). - * The file stays open so child processes inherit it and can read even - * in sandboxed environments that block openat(). + * Shared /dev/urandom entropy subsystem (cached fd + wolfSSL seed callback), + * reference counted: balance each wp_urandom_init() with one + * wp_urandom_cleanup(). See wp_seed_src.c. */ int wp_urandom_init(void); void wp_urandom_cleanup(void); diff --git a/src/wp_seed_src.c b/src/wp_seed_src.c index 49268853..491f103e 100644 --- a/src/wp_seed_src.c +++ b/src/wp_seed_src.c @@ -24,6 +24,8 @@ #include #include +#include +#include #include #include @@ -33,25 +35,26 @@ #include #include -/* Include wolfSSL port header for XFOPEN/XFREAD/XFCLOSE macros */ +/* Include wolfSSL port header for XREAD/XCLOSE macros. */ #include -/* - * /dev/urandom file caching for fork-safe entropy. - * - * These functions manage a cached file handle to /dev/urandom that is - * opened lazily on first entropy request and kept open. This matches - * OpenSSL's default provider behavior. The file stays open so child - * processes after fork() can inherit the underlying fd and read from it - * without needing to call openat(), which may be blocked by seccomp sandboxes. - */ +/* SEED-SRC reads /dev/urandom through wolfSSL's XREAD/XCLOSE, which wc_port.h + * only defines when file/directory support is enabled. NO_WOLFSSL_DIR builds + * are intentionally unsupported for SEED-SRC: fail with a clear message here + * rather than a cryptic "XREAD undefined" later. */ +#if defined(NO_WOLFSSL_DIR) + #error "wolfProvider SEED-SRC requires wolfSSL file support; NO_WOLFSSL_DIR builds are unsupported. Rebuild wolfSSL without NO_WOLFSSL_DIR, or disable SEED-SRC." +#endif + +#ifndef XBADFD + #define XBADFD -1 +#endif +#ifndef O_CLOEXEC + #define O_CLOEXEC 0 +#endif #define URANDOM_PATH "/dev/urandom" -/* - * Helper macros for thread-safe urandom access. - * These expand to no-ops when WP_SINGLE_THREADED is defined. - */ #ifndef WP_SINGLE_THREADED #define WP_URANDOM_LOCK() wc_LockMutex(wp_get_urandom_mutex()) #define WP_URANDOM_UNLOCK() wc_UnLockMutex(wp_get_urandom_mutex()) @@ -60,115 +63,161 @@ #define WP_URANDOM_UNLOCK() (void)0 #endif -/* - * Global cached /dev/urandom file handle. - * Opened lazily on first entropy request, kept open for the lifetime of the - * provider. This matches OpenSSL's model where random devices are opened - * on-demand and cached. - */ -static XFILE g_urandom_file = XBADFILE; +/* Cached /dev/urandom fd, shared across provider contexts and reference + * counted. Kept open so forked children inherit it and can read under a + * seccomp sandbox that blocks open()/openat(). */ +static int g_urandom_fd = XBADFD; + +/* Live provider contexts referencing the shared fd/callback; mutex-guarded. */ +static int g_urandom_ref_count = 0; -/* - * Flag indicating whether the seed callback has been registered. - */ #ifdef WC_RNG_SEED_CB static int g_seed_cb_registered = 0; #endif /** - * wolfSSL seed callback that uses the cached /dev/urandom file. + * Open the cached /dev/urandom fd if not already open. Idempotent; caller must + * hold the urandom mutex. * - * This callback is registered with wc_SetSeed_Cb() and is called by wolfSSL's - * DRBG when it needs entropy (including after fork detection). + * @return 0 on success. + * @return -1 if /dev/urandom cannot be opened. + */ +static int wp_urandom_open_locked(void) +{ + if (g_urandom_fd == XBADFD) { + do { + g_urandom_fd = open(URANDOM_PATH, O_RDONLY | O_CLOEXEC); + } while (g_urandom_fd == XBADFD && errno == EINTR); + + if (g_urandom_fd == XBADFD) { + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, + "wp_urandom_open: failed to open " URANDOM_PATH); + return -1; + } + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, + "wp_urandom_open: opened " URANDOM_PATH); + } + + return 0; +} + +/** + * Read exactly len bytes from the cached /dev/urandom fd, retrying on EINTR. + * Caller must hold the urandom mutex. * - * @param [in] os OS_Seed structure (unused) - * @param [out] seed Buffer to fill with seed data - * @param [in] sz Number of bytes to generate - * @return 0 on success - * @return -1 on failure + * @param [out] buf Buffer to fill. + * @param [in] len Number of bytes to read; must not exceed INT_MAX. + * @return len on success. + * @return -1 on failure, or if len exceeds INT_MAX. */ -#ifdef WC_RNG_SEED_CB -static int wp_wolfssl_seed_cb(OS_Seed* os, byte* seed, word32 sz) +static int wp_urandom_read_locked(unsigned char* buf, size_t len) { - size_t bytesRead; size_t total = 0; - (void)os; - - /* Lock before checking/opening file to prevent race conditions. - * The urandom mutex is initialized via constructor at library load, - * so it's guaranteed to be ready for use here. - */ - if (WP_URANDOM_LOCK() != 0) { + if (len > (size_t)INT_MAX) { + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, + "wp_urandom_read: requested length too large"); return -1; } - /* Lazy open: open file on first entropy request */ - if (g_urandom_file == XBADFILE) { - g_urandom_file = XFOPEN(URANDOM_PATH, "rb"); - if (g_urandom_file == XBADFILE) { - WP_URANDOM_UNLOCK(); - return -1; - } + if (wp_urandom_open_locked() != 0) { + return -1; } - /* Read until we have all the bytes we need */ - while (total < sz) { - bytesRead = XFREAD(seed + total, 1, sz - total, g_urandom_file); + while (total < len) { + size_t toRead = len - total; + ssize_t bytesRead; + + bytesRead = XREAD(g_urandom_fd, buf + total, toRead); + if (bytesRead > 0) { - total += bytesRead; + total += (size_t)bytesRead; + } + else if (bytesRead < 0 && errno == EINTR) { + continue; } else { - /* EOF or error */ - WP_URANDOM_UNLOCK(); + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, + "wp_urandom_read: XREAD failed"); return -1; } } + return (int)total; +} + +/** + * wolfSSL seed callback: fills seed from the cached /dev/urandom fd. Called by + * wolfSSL's DRBG when it needs entropy (including after fork). + * + * @param [in] os Unused. + * @param [out] seed Buffer to fill. + * @param [in] sz Number of bytes to generate. + * @return 0 on success. + * @return -1 on failure. + */ +#ifdef WC_RNG_SEED_CB +static int wp_wolfssl_seed_cb(OS_Seed* os, byte* seed, word32 sz) +{ + int rc; + + (void)os; + + if (WP_URANDOM_LOCK() != 0) { + return -1; + } + + rc = wp_urandom_read_locked(seed, sz); + WP_URANDOM_UNLOCK(); - return 0; + return (rc >= 0 && (size_t)rc == (size_t)sz) ? 0 : -1; } #endif /* WC_RNG_SEED_CB */ /** - * Initialize the urandom subsystem. - * - * This performs lazy initialization - the file handle is not opened until - * first entropy request. This matches OpenSSL's default provider behavior. - * The seed callback is registered here so wolfSSL can use our entropy source. + * Take one reference on the shared urandom subsystem. The seed callback is + * re-registered on every call because provider init resets wolfSSL's global + * callback. Balance each successful call with one wp_urandom_cleanup(). * * @return 0 on success. * @return -1 on failure. */ int wp_urandom_init(void) { - /* Lock to ensure thread-safe initialization. - * The urandom mutex is initialized via constructor at library load. - */ +#ifdef WC_RNG_SEED_CB + int firstRef; +#endif + if (WP_URANDOM_LOCK() != 0) { return -1; } - /* Initialize global file handle to invalid - will be opened lazily */ - g_urandom_file = XBADFILE; + if (g_urandom_ref_count == INT_MAX) { + WP_URANDOM_UNLOCK(); + return -1; + } + +#ifdef WC_RNG_SEED_CB + firstRef = (g_urandom_ref_count == 0); +#endif + g_urandom_ref_count++; #ifdef WC_RNG_SEED_CB - /* Register our seed callback with wolfSSL. - * This is critical for fork safety - wolfSSL will call this callback - * instead of wc_GenerateSeed() when it needs to reseed after fork. - * The callback will open the file lazily on first use. - */ - if (!g_seed_cb_registered) { - if (wc_SetSeed_Cb(wp_wolfssl_seed_cb) != 0) { - WOLFPROV_MSG_DEBUG_RETCODE(WP_LOG_LEVEL_DEBUG, - "wc_SetSeed_Cb failed", -1); - /* Non-fatal - continue without callback */ + if (wc_SetSeed_Cb(wp_wolfssl_seed_cb) != 0) { + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, + "wc_SetSeed_Cb failed to register seed callback"); + /* Non-fatal - continue without callback */ + } + else { + g_seed_cb_registered = 1; + if (firstRef) { + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, + "wp_urandom_init: registered wolfSSL seed callback"); } else { - g_seed_cb_registered = 1; - WOLFPROV_MSG_DEBUG_RETCODE(WP_LOG_LEVEL_DEBUG, - "wp_urandom_init: registered wolfSSL seed callback", 0); + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, + "wp_urandom_init: re-registered wolfSSL seed callback"); } } #endif @@ -179,57 +228,58 @@ int wp_urandom_init(void) } /** - * Clean up the urandom subsystem. - * - * Closes the cached file handle if it was opened, and unregisters - * the seed callback. + * Release one reference taken by wp_urandom_init(). On the last reference, + * close the cached fd and restore wolfSSL's default seed callback. No-op if the + * reference count is already zero. */ void wp_urandom_cleanup(void) { - /* Lock to ensure thread-safe cleanup. - * The urandom mutex is initialized via constructor at library load. - */ if (WP_URANDOM_LOCK() != 0) { return; } + if (g_urandom_ref_count == 0) { + WP_URANDOM_UNLOCK(); + return; + } + + g_urandom_ref_count--; + if (g_urandom_ref_count > 0) { + WP_URANDOM_UNLOCK(); + return; + } + #ifdef WC_RNG_SEED_CB - /* Unregister seed callback */ + /* Restore wolfSSL's default seed callback rather than NULL: a later + * wc_InitRng() in the same process would otherwise fail with + * DRBG_NO_SEED_CB. wc_GenerateSeed is the baseline provider init installs. */ if (g_seed_cb_registered) { - wc_SetSeed_Cb(NULL); + wc_SetSeed_Cb(wc_GenerateSeed); g_seed_cb_registered = 0; } #endif - /* Close global file if it was opened */ - if (g_urandom_file != XBADFILE) { - XFCLOSE(g_urandom_file); - g_urandom_file = XBADFILE; - WOLFPROV_MSG_DEBUG(WP_LOG_LEVEL_DEBUG, + if (g_urandom_fd != XBADFD) { + XCLOSE(g_urandom_fd); + g_urandom_fd = XBADFD; + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, "wp_urandom_cleanup: closed " URANDOM_PATH); } WP_URANDOM_UNLOCK(); - - /* Note: global urandom mutex is managed via constructor/destructor */ } /** - * Read random bytes from /dev/urandom. + * Read random bytes from /dev/urandom (public entry; takes the urandom mutex). * - * Opens /dev/urandom lazily on first call, then keeps it open for subsequent - * reads. This matches OpenSSL's default provider behavior. The file stays open - * so child processes can inherit it and read even in sandboxed environments. - * - * @param [out] buf Buffer to fill with random bytes. - * @param [in] len Number of bytes to read. + * @param [out] buf Buffer to fill. + * @param [in] len Number of bytes to read. * @return Number of bytes read on success. * @return -1 on failure. */ int wp_urandom_read(unsigned char* buf, size_t len) { - size_t bytesRead; - size_t total = 0; + int rc; if (buf == NULL || len == 0) { return -1; @@ -239,37 +289,11 @@ int wp_urandom_read(unsigned char* buf, size_t len) return -1; } - /* Lazy open: open file on first entropy request */ - if (g_urandom_file == XBADFILE) { - g_urandom_file = XFOPEN(URANDOM_PATH, "rb"); - if (g_urandom_file == XBADFILE) { - WOLFPROV_MSG_DEBUG(WP_LOG_LEVEL_DEBUG, - "wp_urandom_read: failed to open " URANDOM_PATH); - WP_URANDOM_UNLOCK(); - return -1; - } - WOLFPROV_MSG_DEBUG(WP_LOG_LEVEL_DEBUG, - "wp_urandom_read: opened " URANDOM_PATH); - } - - /* Read until we have all the bytes we need */ - while (total < len) { - bytesRead = XFREAD(buf + total, 1, len - total, g_urandom_file); - if (bytesRead > 0) { - total += bytesRead; - } - else { - /* EOF or error - shouldn't happen with /dev/urandom */ - WOLFPROV_MSG_DEBUG(WP_LOG_LEVEL_DEBUG, - "wp_urandom_read: XFREAD failed"); - WP_URANDOM_UNLOCK(); - return -1; - } - } + rc = wp_urandom_read_locked(buf, len); WP_URANDOM_UNLOCK(); - return (int)total; + return rc; } @@ -467,8 +491,8 @@ static int wp_seed_src_generate(wp_SeedSrcCtx* ctx, unsigned char* out, * processes can inherit the fd and read even in seccomp sandboxes. */ rc = wp_urandom_read(buf, outLen); - if (rc != (int)outLen) { - WOLFPROV_MSG_DEBUG_RETCODE(WP_LOG_LEVEL_DEBUG, + if (rc < 0 || (size_t)rc != outLen) { + WOLFPROV_MSG_DEBUG_RETCODE(WP_LOG_COMP_RNG, "wp_urandom_read failed", rc); ok = 0; } @@ -654,8 +678,8 @@ static size_t wp_seed_src_get_seed(wp_SeedSrcCtx* ctx, unsigned char** pSeed, * processes can inherit the fd and read even in seccomp sandboxes. */ rc = wp_urandom_read(buffer, minLen); - if (rc != (int)minLen) { - WOLFPROV_MSG_DEBUG_RETCODE(WP_LOG_LEVEL_DEBUG, + if (rc < 0 || (size_t)rc != minLen) { + WOLFPROV_MSG_DEBUG_RETCODE(WP_LOG_COMP_RNG, "wp_urandom_read failed", rc); OPENSSL_clear_free(buffer, minLen); buffer = NULL; diff --git a/src/wp_wolfprov.c b/src/wp_wolfprov.c index d92618ef..0110b2e1 100644 --- a/src/wp_wolfprov.c +++ b/src/wp_wolfprov.c @@ -255,8 +255,11 @@ static WOLFPROV_CTX* wolfssl_prov_ctx_new(void) if (ctx != NULL) { if (wp_urandom_init() != 0) { /* Non-fatal - fall back to normal RNG on platforms without urandom */ - WOLFPROV_MSG_DEBUG_RETCODE(WP_LOG_LEVEL_DEBUG, - "wp_urandom_init failed, will use WC_RNG instead", 0); + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_PROVIDER, + "wp_urandom_init failed, will use WC_RNG instead"); + } + else { + ctx->urandomInitialized = 1; } } #endif @@ -272,8 +275,11 @@ static WOLFPROV_CTX* wolfssl_prov_ctx_new(void) static void wolfssl_prov_ctx_free(WOLFPROV_CTX* ctx) { #if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) - /* Clean up urandom subsystem */ - wp_urandom_cleanup(); + /* Release a reference only if this context took one; pairing a failed init + * with a cleanup would tear down the shared subsystem other contexts use. */ + if (ctx->urandomInitialized) { + wp_urandom_cleanup(); + } #endif BIO_meth_free(ctx->coreBioMethod); @@ -1463,6 +1469,8 @@ int wolfssl_provider_init(const OSSL_CORE_HANDLE* handle, if (ok) { #ifdef WC_RNG_SEED_CB + /* Required under FIPS: the global seedCb defaults to NULL, so the + * wc_InitRng() in wolfssl_prov_ctx_new() would fail DRBG_NO_SEED_CB. */ wc_SetSeed_Cb(wc_GenerateSeed); #endif /* FIPS CAST tests are now run lazily per-algorithm via wp_init_cast() */ diff --git a/test/test_rand_seed.c b/test/test_rand_seed.c index e2c62cdc..51ed2042 100644 --- a/test/test_rand_seed.c +++ b/test/test_rand_seed.c @@ -33,12 +33,28 @@ #include "unit.h" +/* test_seed_src_refcount / test_seed_src_reload are declared in unit.h and + * defined in both configurations below (real tests when SEED-SRC is enabled, + * otherwise skip stubs). */ + #if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) +#include +#include +#include +#include +#include + #include #include #include +#include +#include + +/* wpUnitProviderDir/wpUnitProviderName are declared in unit.h for every + * SEED-SRC build (see the WP_HAVE_SEED_SRC && WP_HAVE_RANDOM block there). */ + /** * Test that we can fetch a SEED-SRC and generate random bytes from it. * @@ -721,6 +737,368 @@ static int test_drbg_reseed_helper(OSSL_LIB_CTX *libCtx, const char *propq) return err; } +/** + * Non-seccomp coverage for reference counting of the shared /dev/urandom fd and + * wolfSSL seed callback: load wolfProvider into two library contexts, draw + * entropy from both, unload the first, and confirm the survivor still gets + * entropy via both the OpenSSL RAND path and the wolfSSL seed callback. The main + * suite holds a wolfProvider reference throughout, so the fd is not closed here; + * the seccomp T3 test covers the decrement-to-zero close path. + */ +int test_seed_src_refcount(void *data) +{ + int err = 0; + const char *providerDir = wpUnitProviderDir; + const char *providerName = wpUnitProviderName; + OSSL_LIB_CTX *ctx1 = NULL; + OSSL_LIB_CTX *ctx2 = NULL; + OSSL_PROVIDER *provider1 = NULL; + OSSL_PROVIDER *provider2 = NULL; + EVP_RAND_CTX *rctx = NULL; + unsigned char buf[32]; + WC_RNG rng; + int rngInit = 0; + + (void)data; + + if (providerDir == NULL) { + providerDir = ".libs"; + } + if (providerName == NULL) { + providerName = wolfprovider_id; + } + + PRINT_MSG("Testing SEED-SRC shared urandom fd/callback refcount lifecycle"); + + /* First provider context: forces wp_urandom_init (refcount increments). */ + ctx1 = OSSL_LIB_CTX_new(); + if (ctx1 == NULL) { + PRINT_ERR_MSG("Failed to create first library context"); + err = 1; + goto cleanup; + } + if (OSSL_PROVIDER_set_default_search_path(ctx1, providerDir) != 1) { + PRINT_ERR_MSG("Failed to set search path for first context: %s", + providerDir); + err = 1; + goto cleanup; + } + provider1 = OSSL_PROVIDER_load(ctx1, providerName); + if (provider1 == NULL) { + PRINT_ERR_MSG("Failed to load provider %s into first context", + providerName); + err = 1; + goto cleanup; + } + if (RAND_bytes_ex(ctx1, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("First-context RAND_bytes_ex failed"); + err = 1; + goto cleanup; + } + + /* Second provider context: shares the fd/callback (refcount increments). */ + ctx2 = OSSL_LIB_CTX_new(); + if (ctx2 == NULL) { + PRINT_ERR_MSG("Failed to create second library context"); + err = 1; + goto cleanup; + } + if (OSSL_PROVIDER_set_default_search_path(ctx2, providerDir) != 1) { + PRINT_ERR_MSG("Failed to set search path for second context: %s", + providerDir); + err = 1; + goto cleanup; + } + provider2 = OSSL_PROVIDER_load(ctx2, providerName); + if (provider2 == NULL) { + PRINT_ERR_MSG("Failed to load provider %s into second context", + providerName); + err = 1; + goto cleanup; + } + if (RAND_bytes_ex(ctx2, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("Second-context RAND_bytes_ex failed"); + err = 1; + goto cleanup; + } + + /* Unload the first context (one cleanup, one decrement). The second context + * and the main suite still hold references, so the fd/callback must survive. */ + OSSL_PROVIDER_unload(provider1); + provider1 = NULL; + OSSL_LIB_CTX_free(ctx1); + ctx1 = NULL; + + /* Survivor must still draw entropy; force a fresh seed pull so an fd wrongly + * closed on the first teardown would surface here. */ + rctx = RAND_get0_public(ctx2); + if (rctx == NULL) { + PRINT_ERR_MSG("Survivor RAND_get0_public failed after first unload"); + err = 1; + goto cleanup; + } + if (EVP_RAND_reseed(rctx, 0, NULL, 0, NULL, 0) != 1) { + PRINT_ERR_MSG("Survivor EVP_RAND_reseed failed after first unload"); + err = 1; + goto cleanup; + } + if (RAND_bytes_ex(ctx2, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("Survivor RAND_bytes_ex failed after first unload"); + err = 1; + goto cleanup; + } + + /* The wolfSSL seed callback must also still be registered for the survivor. */ + if (wc_InitRng(&rng) != 0) { + PRINT_ERR_MSG("wc_InitRng failed after first unload"); + err = 1; + goto cleanup; + } + rngInit = 1; + if (wc_RNG_GenerateBlock(&rng, buf, sizeof(buf)) != 0) { + PRINT_ERR_MSG("wc_RNG_GenerateBlock failed after first unload"); + err = 1; + goto cleanup; + } + + PRINT_MSG("Survivor context still produces entropy after first unload"); + +cleanup: + if (rngInit) { + wc_FreeRng(&rng); + } + /* Unload the second context too. The main suite's reference keeps the count + * above zero, so the fd stays open for the remaining tests. */ + OSSL_PROVIDER_unload(provider2); + OSSL_LIB_CTX_free(ctx2); + OSSL_PROVIDER_unload(provider1); + OSSL_LIB_CTX_free(ctx1); + + return err; +} + +/** + * Fresh-process worker for the SEED-SRC full teardown -> reload cycle. Runs + * after a re-exec (see test_seed_src_reload) so the refcount starts at zero and + * can be driven to zero and back: load reopens the shared fd and registers + * wp_wolfssl_seed_cb; a full unload closes the fd and restores the seed callback + * to wc_GenerateSeed (not NULL, which would yield DRBG_NO_SEED_CB); reload must + * reopen and re-register. + * + * Returns 0 on success, non-zero on failure. + */ +int test_seed_src_reload_helper(void) +{ + int err = 0; + const char *providerDir = wpUnitProviderDir; + const char *providerName = wpUnitProviderName; + OSSL_LIB_CTX *ctx1 = NULL; + OSSL_LIB_CTX *ctx2 = NULL; + OSSL_PROVIDER *provider1 = NULL; + OSSL_PROVIDER *provider2 = NULL; + unsigned char buf[32]; + WC_RNG rng; + + if (providerDir == NULL) { + providerDir = ".libs"; + } + if (providerName == NULL) { + providerName = wolfprovider_id; + } + + PRINT_MSG("Testing SEED-SRC teardown-to-zero then reload in fresh process"); + + /* + * Load wolfProvider into the first context. This is the first reference, so + * wp_urandom_init lazily opens the shared /dev/urandom fd (refcount 0 -> 1) + * and registers wp_wolfssl_seed_cb. + */ + ctx1 = OSSL_LIB_CTX_new(); + if (ctx1 == NULL) { + PRINT_ERR_MSG("Failed to create first library context"); + err = 1; + } + if (err == 0 && + OSSL_PROVIDER_set_default_search_path(ctx1, providerDir) != 1) { + PRINT_ERR_MSG("Failed to set search path for first context: %s", + providerDir); + err = 1; + } + if (err == 0) { + provider1 = OSSL_PROVIDER_load(ctx1, providerName); + if (provider1 == NULL) { + PRINT_ERR_MSG("Failed to load provider %s into first context", + providerName); + err = 1; + } + } + + /* Draw entropy so the shared fd is actually opened. */ + if (err == 0 && RAND_bytes_ex(ctx1, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("First-context RAND_bytes_ex failed"); + err = 1; + } + + /* Fully unload: releases the last reference, so cleanup decrements to zero, + * closes the fd, and restores the seed callback to wc_GenerateSeed. */ + if (provider1 != NULL) { + OSSL_PROVIDER_unload(provider1); + provider1 = NULL; + } + OSSL_LIB_CTX_free(ctx1); + ctx1 = NULL; + + /* With the callback restored (not NULL), a direct wolfCrypt RNG must still + * seed and generate; a NULL callback would surface as DRBG_NO_SEED_CB. */ + if (err == 0) { + if (wc_InitRng(&rng) != 0) { + PRINT_ERR_MSG("wc_InitRng failed after teardown to zero"); + err = 1; + } + else { + if (wc_RNG_GenerateBlock(&rng, buf, sizeof(buf)) != 0) { + PRINT_ERR_MSG( + "wc_RNG_GenerateBlock failed after teardown to zero"); + err = 1; + } + wc_FreeRng(&rng); + } + } + + /* Reload into a second context (refcount 0 -> 1): the fd must lazily reopen + * and wp_wolfssl_seed_cb must re-register. */ + if (err == 0) { + ctx2 = OSSL_LIB_CTX_new(); + if (ctx2 == NULL) { + PRINT_ERR_MSG("Failed to create second library context"); + err = 1; + } + } + if (err == 0 && + OSSL_PROVIDER_set_default_search_path(ctx2, providerDir) != 1) { + PRINT_ERR_MSG("Failed to set search path for second context: %s", + providerDir); + err = 1; + } + if (err == 0) { + provider2 = OSSL_PROVIDER_load(ctx2, providerName); + if (provider2 == NULL) { + PRINT_ERR_MSG("Failed to load provider %s into second context", + providerName); + err = 1; + } + } + + /* Entropy through the reopened fd must succeed. */ + if (err == 0 && RAND_bytes_ex(ctx2, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("Second-context RAND_bytes_ex failed after reload"); + err = 1; + } + + /* The wolfSSL seed callback path must work after reload too. */ + if (err == 0) { + if (wc_InitRng(&rng) != 0) { + PRINT_ERR_MSG("wc_InitRng failed after reload"); + err = 1; + } + else { + if (wc_RNG_GenerateBlock(&rng, buf, sizeof(buf)) != 0) { + PRINT_ERR_MSG("wc_RNG_GenerateBlock failed after reload"); + err = 1; + } + wc_FreeRng(&rng); + } + } + + if (err == 0) { + PRINT_MSG("SEED-SRC reload after full teardown succeeded"); + } + + OSSL_PROVIDER_unload(provider2); + OSSL_LIB_CTX_free(ctx2); + OSSL_PROVIDER_unload(provider1); + OSSL_LIB_CTX_free(ctx1); + + return err; +} + +/** + * Non-seccomp coverage for the SEED-SRC shared fd/seed callback being fully torn + * down (refcount to zero) and reloaded. The main suite always holds a + * wolfProvider reference, so this re-execs a fresh unit.test as + * --seed-src-reload-helper (refcount starts at zero) and passes iff that child + * completes the teardown-then-reload sequence and exits 0. + */ +int test_seed_src_reload(void *data) +{ + const char *providerDir = wpUnitProviderDir; + const char *providerName = wpUnitProviderName; + char exePath[PATH_MAX]; + ssize_t exeLen; + pid_t pid; + int status; + int err = 0; + + (void)data; + + if (providerDir == NULL) { + providerDir = ".libs"; + } + if (providerName == NULL) { + providerName = wolfprovider_id; + } + + PRINT_MSG("Testing SEED-SRC full teardown then reload via re-exec"); + + /* This test re-execs the unit binary via /proc/self/exe to get a fresh, + * zero-based refcount. On platforms without /proc/self/exe (non-Linux) + * there is no portable way to re-exec, so skip rather than hard-fail. */ + exeLen = readlink("/proc/self/exe", exePath, sizeof(exePath) - 1); + if (exeLen < 0 || exeLen >= (ssize_t)sizeof(exePath) - 1) { + PRINT_MSG("SEED-SRC reload test skipped - /proc/self/exe unavailable"); + return 0; + } + exePath[exeLen] = '\0'; + + pid = fork(); + if (pid == -1) { + PRINT_ERR_MSG("fork() failed: %s", strerror(errno)); + return 1; + } + + if (pid == 0) { + execl(exePath, exePath, "--seed-src-reload-helper", providerDir, + providerName, (char *)NULL); + PRINT_ERR_MSG("execl reload helper failed: %s", strerror(errno)); + _exit(127); + } + + if (waitpid(pid, &status, 0) == -1) { + PRINT_ERR_MSG("waitpid(reload helper) failed: %s", strerror(errno)); + return 1; + } + + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { + PRINT_MSG("SEED-SRC reload helper passed"); + } + else if (WIFEXITED(status)) { + PRINT_ERR_MSG("SEED-SRC reload helper exited with status %d", + WEXITSTATUS(status)); + err = 1; + } + else if (WIFSIGNALED(status)) { + PRINT_ERR_MSG("SEED-SRC reload helper killed by signal %d", + WTERMSIG(status)); + err = 1; + } + else { + PRINT_ERR_MSG("SEED-SRC reload helper exited abnormally"); + err = 1; + } + + return err; +} + #else /* !(WP_HAVE_SEED_SRC && WP_HAVE_RANDOM) */ int test_rand_seed(void *data) @@ -731,6 +1109,20 @@ int test_rand_seed(void *data) return 0; } +int test_seed_src_refcount(void *data) +{ + (void)data; + PRINT_MSG("SEED-SRC refcount lifecycle test skipped - not enabled"); + return 0; +} + +int test_seed_src_reload(void *data) +{ + (void)data; + PRINT_MSG("SEED-SRC reload lifecycle test skipped - not enabled"); + return 0; +} + #endif /* WP_HAVE_SEED_SRC && WP_HAVE_RANDOM */ int test_drbg_reseed(void *data) diff --git a/test/test_seccomp_sandbox.c b/test/test_seccomp_sandbox.c index 9e43a64c..50f05b56 100644 --- a/test/test_seccomp_sandbox.c +++ b/test/test_seccomp_sandbox.c @@ -19,41 +19,37 @@ */ /* - * This test mimics OpenSSH's seccomp sandbox behavior to verify that - * wolfProvider's DRBG can operate correctly after fork() when file - * descriptor operations are restricted. + * Seccomp coverage for wolfProvider SEED-SRC under OpenSSH's preauth sandbox. * - * OpenSSH Flow (sshd-session.c privsep_preauth): - * 1. Parent forks child for privilege separation - * 2. Child calls reseed_prngs() BEFORE sandbox (RAND_poll, RAND_bytes) - * 3. Child applies seccomp sandbox (blocks open/openat syscalls) - * 4. Child performs crypto operations under sandbox restrictions - * - * The problem: After sandbox is applied, if DRBG needs to reseed, - * it cannot open /dev/urandom because openat() returns EACCES. - * - * This test verifies: - * - Default OpenSSL provider works under these conditions (baseline) - * - wolfProvider should eventually work (currently expected to fail) - * - * NOTE: This test is disabled by default because it uses seccomp which - * can interfere with other tests and debugging. Enable it by defining - * WP_TEST_SECCOMP_SANDBOX in CFLAGS: - * ./configure CFLAGS="-DWP_TEST_SECCOMP_SANDBOX" + * These are passing regression tests. The original bug held /dev/urandom as a + * buffered stdio FILE*: OpenSSH's preauth seccomp filter does not allow + * lseek(), so glibc stdio cleanup could kill the child with SIGSYS while + * rewinding fread read-ahead. The SEED-SRC path now uses a raw /dev/urandom fd + * (open/read/close, no stdio buffering), so libc exit performs no lseek(). The + * tests here would catch a reintroduction of the buffered-stream SIGSYS bug. */ #include "unit.h" -#if defined(WP_TEST_SECCOMP_SANDBOX) && defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) +#if defined(WP_TEST_SECCOMP_SANDBOX) && defined(WP_HAVE_SEED_SRC) && \ + defined(WP_HAVE_RANDOM) #include #include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include /* Check for seccomp support */ #ifdef __has_include @@ -70,9 +66,11 @@ #include #include #include +#include +#include #include -/* Determine the correct audit architecture */ +/* Determine the correct audit architecture. */ #if defined(__x86_64__) #define SECCOMP_AUDIT_ARCH AUDIT_ARCH_X86_64 #elif defined(__i386__) @@ -82,7 +80,7 @@ #elif defined(__arm__) #define SECCOMP_AUDIT_ARCH AUDIT_ARCH_ARM #else - /* Unsupported architecture - disable test */ + /* Unsupported architecture - disable test. */ #undef WP_HAVE_SECCOMP #endif @@ -90,111 +88,357 @@ #ifdef WP_HAVE_SECCOMP -/* BPF macros for seccomp filter - mirrors OpenSSH sandbox-seccomp-filter.c */ +#ifdef SECCOMP_RET_KILL_PROCESS +#define SECCOMP_FILTER_FAIL SECCOMP_RET_KILL_PROCESS +#else +#define SECCOMP_FILTER_FAIL SECCOMP_RET_KILL +#endif + +/* OpenSSH 9.9p1 sandbox-seccomp-filter.c preauth filter body. */ +#if __BYTE_ORDER == __LITTLE_ENDIAN +# define ARG_LO_OFFSET 0 +# define ARG_HI_OFFSET sizeof(uint32_t) +#elif __BYTE_ORDER == __BIG_ENDIAN +# define ARG_LO_OFFSET sizeof(uint32_t) +# define ARG_HI_OFFSET 0 +#else +#error "Unknown endianness" +#endif + +/* Simple helpers to avoid manual errors (but larger BPF programs). */ #define SC_DENY(_nr, _errno) \ BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, (_nr), 0, 1), \ BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ERRNO|(_errno)) - #define SC_ALLOW(_nr) \ BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, (_nr), 0, 1), \ BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW) +#define SC_ALLOW_ARG(_nr, _arg_nr, _arg_val) \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, (_nr), 0, 6), \ + /* load and test syscall argument, low word */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, args[(_arg_nr)]) + ARG_LO_OFFSET), \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, \ + ((_arg_val) & 0xFFFFFFFF), 0, 3), \ + /* load and test syscall argument, high word */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, args[(_arg_nr)]) + ARG_HI_OFFSET), \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, \ + (((uint32_t)((uint64_t)(_arg_val) >> 32)) & 0xFFFFFFFF), 0, 1), \ + BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW), \ + /* reload syscall number; all rules expect it in accumulator */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, nr)) +/* Allow if syscall argument contains only values in mask */ +#define SC_ALLOW_ARG_MASK(_nr, _arg_nr, _arg_mask) \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, (_nr), 0, 8), \ + /* load, mask and test syscall argument, low word */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, args[(_arg_nr)]) + ARG_LO_OFFSET), \ + BPF_STMT(BPF_ALU+BPF_AND+BPF_K, ~((_arg_mask) & 0xFFFFFFFF)), \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, 0, 0, 4), \ + /* load, mask and test syscall argument, high word */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, args[(_arg_nr)]) + ARG_HI_OFFSET), \ + BPF_STMT(BPF_ALU+BPF_AND+BPF_K, \ + ~(((uint32_t)((uint64_t)(_arg_mask) >> 32)) & 0xFFFFFFFF)), \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, 0, 0, 1), \ + BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW), \ + /* reload syscall number; all rules expect it in accumulator */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, nr)) +/* Deny unless syscall argument contains only values in mask */ +#define SC_DENY_UNLESS_ARG_MASK(_nr, _arg_nr, _arg_mask, _errno) \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, (_nr), 0, 8), \ + /* load, mask and test syscall argument, low word */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, args[(_arg_nr)]) + ARG_LO_OFFSET), \ + BPF_STMT(BPF_ALU+BPF_AND+BPF_K, ~((_arg_mask) & 0xFFFFFFFF)), \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, 0, 0, 3), \ + /* load, mask and test syscall argument, high word */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, args[(_arg_nr)]) + ARG_HI_OFFSET), \ + BPF_STMT(BPF_ALU+BPF_AND+BPF_K, \ + ~(((uint32_t)((uint64_t)(_arg_mask) >> 32)) & 0xFFFFFFFF)), \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, 0, 1, 0), \ + BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ERRNO|(_errno)), \ + /* reload syscall number; all rules expect it in accumulator */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, nr)) +/* Special handling for futex(2) that combines a bitmap and operation number */ +#if defined(__NR_futex) || defined(__NR_futex_time64) +#define SC_FUTEX_MASK (FUTEX_PRIVATE_FLAG|FUTEX_CLOCK_REALTIME) +#define SC_ALLOW_FUTEX_OP(_nr, _op) \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, (_nr), 0, 8), \ + /* load syscall argument, low word */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, args[1]) + ARG_LO_OFFSET), \ + /* mask off allowed bitmap values, low word */ \ + BPF_STMT(BPF_ALU+BPF_AND+BPF_K, ~(SC_FUTEX_MASK & 0xFFFFFFFF)), \ + /* test operation number, low word */ \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ((_op) & 0xFFFFFFFF), 0, 4), \ + /* load syscall argument, high word */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, \ + offsetof(struct seccomp_data, args[1]) + ARG_HI_OFFSET), \ + /* mask off allowed bitmap values, high word */ \ + BPF_STMT(BPF_ALU+BPF_AND+BPF_K, \ + ~(((uint32_t)((uint64_t)SC_FUTEX_MASK >> 32)) & 0xFFFFFFFF)), \ + /* test operation number, high word */ \ + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, \ + (((uint32_t)((uint64_t)(_op) >> 32)) & 0xFFFFFFFF), 0, 1), \ + BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW), \ + /* reload syscall number; all rules expect it in accumulator */ \ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, nr)) -/* - * Minimal seccomp filter that mimics OpenSSH's preauth sandbox. - * Key restrictions: - * - Deny open/openat with EACCES (prevents opening /dev/urandom) - * - Allow essential syscalls for the test to run - */ -static const struct sock_filter naomi_insns[] = { - /* Verify architecture */ - BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, arch)), - BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, SECCOMP_AUDIT_ARCH, 1, 0), - BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL), +/* Use this for both __NR_futex and __NR_futex_time64 */ +# define SC_FUTEX(_nr) \ + SC_ALLOW_FUTEX_OP(_nr, FUTEX_WAIT), \ + SC_ALLOW_FUTEX_OP(_nr, FUTEX_WAIT_BITSET), \ + SC_ALLOW_FUTEX_OP(_nr, FUTEX_WAKE), \ + SC_ALLOW_FUTEX_OP(_nr, FUTEX_WAKE_BITSET), \ + SC_ALLOW_FUTEX_OP(_nr, FUTEX_REQUEUE), \ + SC_ALLOW_FUTEX_OP(_nr, FUTEX_CMP_REQUEUE) +#endif /* __NR_futex || __NR_futex_time64 */ + +#if defined(__NR_mmap) || defined(__NR_mmap2) +# ifdef MAP_FIXED_NOREPLACE +# define SC_MMAP_FLAGS MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED|MAP_FIXED_NOREPLACE +# else +# define SC_MMAP_FLAGS MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED +# endif /* MAP_FIXED_NOREPLACE */ +/* Use this for both __NR_mmap and __NR_mmap2 variants */ +# define SC_MMAP(_nr) \ + SC_DENY_UNLESS_ARG_MASK(_nr, 3, SC_MMAP_FLAGS, EINVAL), \ + SC_ALLOW_ARG_MASK(_nr, 2, PROT_READ|PROT_WRITE|PROT_NONE) +#endif /* __NR_mmap || __NR_mmap2 */ - /* Load syscall number */ - BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, nr)), +/* Syscall filtering set for preauth. */ +static const struct sock_filter preauth_insns[] = { + /* Ensure the syscall arch convention is as expected. */ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, + offsetof(struct seccomp_data, arch)), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, SECCOMP_AUDIT_ARCH, 1, 0), + BPF_STMT(BPF_RET+BPF_K, SECCOMP_FILTER_FAIL), + /* Load the syscall number for checking. */ + BPF_STMT(BPF_LD+BPF_W+BPF_ABS, + offsetof(struct seccomp_data, nr)), - /* Deny file open syscalls with EACCES - this is the key restriction */ + /* Syscalls to non-fatally deny */ +#ifdef __NR_lstat + SC_DENY(__NR_lstat, EACCES), +#endif +#ifdef __NR_lstat64 + SC_DENY(__NR_lstat64, EACCES), +#endif +#ifdef __NR_fstat + SC_DENY(__NR_fstat, EACCES), +#endif +#ifdef __NR_fstat64 + SC_DENY(__NR_fstat64, EACCES), +#endif +#ifdef __NR_fstatat64 + SC_DENY(__NR_fstatat64, EACCES), +#endif #ifdef __NR_open SC_DENY(__NR_open, EACCES), #endif #ifdef __NR_openat SC_DENY(__NR_openat, EACCES), #endif +#ifdef __NR_newfstatat + SC_DENY(__NR_newfstatat, EACCES), +#endif +#ifdef __NR_stat + SC_DENY(__NR_stat, EACCES), +#endif +#ifdef __NR_stat64 + SC_DENY(__NR_stat64, EACCES), +#endif +#ifdef __NR_shmget + SC_DENY(__NR_shmget, EACCES), +#endif +#ifdef __NR_shmat + SC_DENY(__NR_shmat, EACCES), +#endif +#ifdef __NR_shmdt + SC_DENY(__NR_shmdt, EACCES), +#endif +#ifdef __NR_ipc + SC_DENY(__NR_ipc, EACCES), +#endif +#ifdef __NR_statx + SC_DENY(__NR_statx, EACCES), +#endif - /* Allow syscalls needed for the test to function */ -#ifdef __NR_read - SC_ALLOW(__NR_read), + /* Syscalls to permit */ +#ifdef __NR_brk + SC_ALLOW(__NR_brk), #endif -#ifdef __NR_write - SC_ALLOW(__NR_write), +#ifdef __NR_clock_gettime + SC_ALLOW(__NR_clock_gettime), +#endif +#ifdef __NR_clock_gettime64 + SC_ALLOW(__NR_clock_gettime64), #endif #ifdef __NR_close SC_ALLOW(__NR_close), #endif -#ifdef __NR_exit_group - SC_ALLOW(__NR_exit_group), -#endif #ifdef __NR_exit SC_ALLOW(__NR_exit), #endif -#ifdef __NR_brk - SC_ALLOW(__NR_brk), +#ifdef __NR_exit_group + SC_ALLOW(__NR_exit_group), #endif -#ifdef __NR_mmap - SC_ALLOW(__NR_mmap), +#ifdef __NR_futex + SC_FUTEX(__NR_futex), #endif -#ifdef __NR_munmap - SC_ALLOW(__NR_munmap), +#ifdef __NR_futex_time64 + SC_FUTEX(__NR_futex_time64), #endif -#ifdef __NR_mprotect - SC_ALLOW(__NR_mprotect), +#ifdef __NR_geteuid + SC_ALLOW(__NR_geteuid), #endif -#ifdef __NR_futex - SC_ALLOW(__NR_futex), +#ifdef __NR_geteuid32 + SC_ALLOW(__NR_geteuid32), #endif -#ifdef __NR_getrandom - SC_ALLOW(__NR_getrandom), +#ifdef __NR_getpgid + SC_ALLOW(__NR_getpgid), #endif #ifdef __NR_getpid SC_ALLOW(__NR_getpid), #endif +#ifdef __NR_getrandom + SC_ALLOW(__NR_getrandom), +#endif #ifdef __NR_gettid SC_ALLOW(__NR_gettid), #endif -#ifdef __NR_rt_sigprocmask - SC_ALLOW(__NR_rt_sigprocmask), +#ifdef __NR_gettimeofday + SC_ALLOW(__NR_gettimeofday), #endif -#ifdef __NR_rt_sigaction - SC_ALLOW(__NR_rt_sigaction), +#ifdef __NR_getuid + SC_ALLOW(__NR_getuid), #endif -#ifdef __NR_clock_gettime - SC_ALLOW(__NR_clock_gettime), +#ifdef __NR_getuid32 + SC_ALLOW(__NR_getuid32), #endif -#ifdef __NR_nanosleep - SC_ALLOW(__NR_nanosleep), +#ifdef __NR_madvise + SC_ALLOW_ARG(__NR_madvise, 2, MADV_NORMAL), +# ifdef MADV_FREE + SC_ALLOW_ARG(__NR_madvise, 2, MADV_FREE), +# endif +# ifdef MADV_DONTNEED + SC_ALLOW_ARG(__NR_madvise, 2, MADV_DONTNEED), +# endif +# ifdef MADV_DONTFORK + SC_ALLOW_ARG(__NR_madvise, 2, MADV_DONTFORK), +# endif +# ifdef MADV_DONTDUMP + SC_ALLOW_ARG(__NR_madvise, 2, MADV_DONTDUMP), +# endif +# ifdef MADV_WIPEONFORK + SC_ALLOW_ARG(__NR_madvise, 2, MADV_WIPEONFORK), +# endif + SC_DENY(__NR_madvise, EINVAL), +#endif +#ifdef __NR_mmap + SC_MMAP(__NR_mmap), +#endif +#ifdef __NR_mmap2 + SC_MMAP(__NR_mmap2), #endif -#ifdef __NR_sched_yield - SC_ALLOW(__NR_sched_yield), +#ifdef __NR_mprotect + SC_ALLOW_ARG_MASK(__NR_mprotect, 2, PROT_READ|PROT_WRITE|PROT_NONE), #endif #ifdef __NR_mremap SC_ALLOW(__NR_mremap), #endif -#ifdef __NR_madvise - SC_ALLOW(__NR_madvise), +#ifdef __NR_munmap + SC_ALLOW(__NR_munmap), +#endif +#ifdef __NR_nanosleep + SC_ALLOW(__NR_nanosleep), +#endif +#ifdef __NR_clock_nanosleep + SC_ALLOW(__NR_clock_nanosleep), +#endif +#ifdef __NR_clock_nanosleep_time64 + SC_ALLOW(__NR_clock_nanosleep_time64), +#endif +#ifdef __NR__newselect + SC_ALLOW(__NR__newselect), +#endif +#ifdef __NR_ppoll + SC_ALLOW(__NR_ppoll), +#endif +#ifdef __NR_ppoll_time64 + SC_ALLOW(__NR_ppoll_time64), +#endif +#ifdef __NR_poll + SC_ALLOW(__NR_poll), +#endif +#ifdef __NR_pselect6 + SC_ALLOW(__NR_pselect6), +#endif +#ifdef __NR_pselect6_time64 + SC_ALLOW(__NR_pselect6_time64), +#endif +#ifdef __NR_read + SC_ALLOW(__NR_read), +#endif +#ifdef __NR_rt_sigprocmask + SC_ALLOW(__NR_rt_sigprocmask), +#endif +#ifdef __NR_select + SC_ALLOW(__NR_select), +#endif +#ifdef __NR_shutdown + SC_ALLOW(__NR_shutdown), +#endif +#ifdef __NR_sigprocmask + SC_ALLOW(__NR_sigprocmask), +#endif +#ifdef __NR_time + SC_ALLOW(__NR_time), +#endif +#ifdef __NR_write + SC_ALLOW(__NR_write), +#endif +#ifdef __NR_writev + SC_ALLOW(__NR_writev), +#endif +#ifdef __NR_socketcall + SC_ALLOW_ARG(__NR_socketcall, 0, SYS_SHUTDOWN), + SC_DENY(__NR_socketcall, EACCES), +#endif +#if defined(__NR_ioctl) && defined(__s390__) + /* Allow ioctls for ICA crypto card on s390 */ + SC_ALLOW_ARG(__NR_ioctl, 1, Z90STAT_STATUS_MASK), + SC_ALLOW_ARG(__NR_ioctl, 1, ICARSAMODEXPO), + SC_ALLOW_ARG(__NR_ioctl, 1, ICARSACRT), + SC_ALLOW_ARG(__NR_ioctl, 1, ZSECSENDCPRB), + /* Allow ioctls for EP11 crypto card on s390 */ + SC_ALLOW_ARG(__NR_ioctl, 1, ZSENDEP11CPRB), +#endif +#if defined(__x86_64__) && defined(__ILP32__) && defined(__X32_SYSCALL_BIT) + /* + * On Linux x32, the clock_gettime VDSO falls back to the + * x86-64 syscall under some circumstances, e.g. + * https://bugs.debian.org/849923 + */ + SC_ALLOW(__NR_clock_gettime & ~__X32_SYSCALL_BIT), #endif - /* Default: allow other syscalls (we only want to block file opens) */ - BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW), + /* Default deny */ + BPF_STMT(BPF_RET+BPF_K, SECCOMP_FILTER_FAIL), }; -static const struct sock_fprog naomi_program = { - .len = (unsigned short)(sizeof(naomi_insns) / sizeof(naomi_insns[0])), - .filter = (struct sock_filter *)naomi_insns, +static const struct sock_fprog preauth_program = { + .len = (unsigned short)(sizeof(preauth_insns) / sizeof(preauth_insns[0])), + .filter = (struct sock_filter *)preauth_insns, }; /* - * Apply seccomp sandbox restrictions mimicking OpenSSH behavior. + * Apply seccomp sandbox restrictions matching OpenSSH preauth behavior. * Returns 0 on success, -1 on failure. */ static int apply_seccomp_sandbox(void) @@ -202,8 +446,6 @@ static int apply_seccomp_sandbox(void) /* * Set resource limits like OpenSSH does. * RLIMIT_NOFILE = 1 allows existing fds but prevents new ones. - * Note: OpenSSH uses 1, not 0, because poll() fails with EINVAL - * if npfds > RLIMIT_NOFILE. */ struct rlimit rl_zero = {0, 0}; struct rlimit rl_one = {1, 1}; @@ -218,13 +460,12 @@ static int apply_seccomp_sandbox(void) return -1; } - /* Apply seccomp filter - must set NO_NEW_PRIVS first */ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) { PRINT_ERR_MSG("prctl(PR_SET_NO_NEW_PRIVS) failed: %s", strerror(errno)); return -1; } - if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &naomi_program) == -1) { + if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &preauth_program) == -1) { PRINT_ERR_MSG("prctl(PR_SET_SECCOMP) failed: %s", strerror(errno)); return -1; } @@ -232,8 +473,464 @@ static int apply_seccomp_sandbox(void) return 0; } +static int load_provider_into_ctx(OSSL_LIB_CTX *ctx, const char *providerDir, + const char *providerName, OSSL_PROVIDER **provider) +{ + int err = 0; + + if (OSSL_PROVIDER_set_default_search_path(ctx, providerDir) != 1) { + PRINT_ERR_MSG("OSSL_PROVIDER_set_default_search_path failed: %s", + providerDir); + err = 1; + } + + if (err == 0) { + *provider = OSSL_PROVIDER_load(ctx, providerName); + if (*provider == NULL) { + PRINT_ERR_MSG("Failed to load provider %s from %s", providerName, + providerDir); + err = 1; + } + } + + return err; +} + +/* Exit codes reported by seccomp helper child processes. */ +#define WP_SECCOMP_CHILD_FILTER_ERR 4 +#define WP_SECCOMP_CHILD_OPENSSL_RAND_ERR 20 +#define WP_SECCOMP_CHILD_WC_INIT_ERR 21 +#define WP_SECCOMP_CHILD_WC_RAND_ERR 22 + +static int child_exit_under_filter(void) +{ + if (apply_seccomp_sandbox() != 0) { + _exit(WP_SECCOMP_CHILD_FILTER_ERR); + } + + /* libc exit() is required here; _exit() skips glibc _IO_cleanup. */ + exit(0); +} + +static int run_exit_cleanup_case(const char *name) +{ + pid_t pid; + int status; + int err = 0; + + pid = fork(); + if (pid == -1) { + PRINT_ERR_MSG("%s: fork() failed: %s", name, strerror(errno)); + return 1; + } + + if (pid == 0) { + return child_exit_under_filter(); + } + + if (waitpid(pid, &status, 0) == -1) { + PRINT_ERR_MSG("%s: waitpid() failed: %s", name, strerror(errno)); + return 1; + } + + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { + PRINT_MSG("%s: child exited cleanly under OpenSSH preauth filter", + name); + } + else if (WIFSIGNALED(status) && WTERMSIG(status) == SIGSYS) { + PRINT_ERR_MSG("%s: child killed by SIGSYS during libc exit", name); + err = 1; + } + else if (WIFSIGNALED(status)) { + PRINT_ERR_MSG("%s: child killed by unexpected signal %d", name, + WTERMSIG(status)); + err = 1; + } + else if (WIFEXITED(status)) { + PRINT_ERR_MSG("%s: child exited with status %d", name, + WEXITSTATUS(status)); + err = 1; + } + else { + PRINT_ERR_MSG("%s: child exited abnormally", name); + err = 1; + } + + return err; +} + +static int seccomp_helper_single_stream(const char *providerDir, + const char *providerName) +{ + OSSL_LIB_CTX *ctx = NULL; + OSSL_PROVIDER *provider = NULL; + unsigned char buf[32]; + int err = 0; + + /* Entered before unit.c's OpenSSL setup. The customer's preauth child skips + * provider teardown; NO_ATEXIT reproduces that exit-time behavior here. */ + if (OPENSSL_init_crypto(OPENSSL_INIT_NO_ATEXIT, NULL) != 1) { + PRINT_ERR_MSG("OPENSSL_init_crypto(NO_ATEXIT) failed"); + err = 1; + } + + if (err == 0) { + ctx = OSSL_LIB_CTX_new(); + if (ctx == NULL) { + PRINT_ERR_MSG("OSSL_LIB_CTX_new failed"); + err = 1; + } + } + + if (err == 0) { + err = load_provider_into_ctx(ctx, providerDir, providerName, &provider); + } + + if (err == 0 && RAND_bytes_ex(ctx, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("single-context RAND_bytes_ex failed"); + err = 1; + } + + /* T1: one SEED-SRC read, no teardown, so the fd is still open at exit. */ + if (err == 0) { + err = run_exit_cleanup_case("T1 single-context SEED-SRC"); + } + + OSSL_PROVIDER_unload(provider); + OSSL_LIB_CTX_free(ctx); + + return err; +} + +static int seccomp_helper_leak_route(const char *providerDir, + const char *providerName) +{ + OSSL_LIB_CTX *ctx2 = NULL; + OSSL_PROVIDER *provider1 = NULL; + OSSL_PROVIDER *provider2 = NULL; + unsigned char buf[32]; + int err = 0; + + if (load_provider_into_ctx(NULL, providerDir, providerName, &provider1) + != 0) { + err = 1; + } + + if (err == 0 && RAND_bytes(buf, sizeof(buf)) != 1) { + PRINT_ERR_MSG("default-context RAND_bytes failed"); + err = 1; + } + + if (err == 0) { + ctx2 = OSSL_LIB_CTX_new(); + if (ctx2 == NULL) { + PRINT_ERR_MSG("second OSSL_LIB_CTX_new failed"); + err = 1; + } + } + + if (err == 0) { + err = load_provider_into_ctx(ctx2, providerDir, providerName, + &provider2); + } + + if (err == 0 && RAND_bytes_ex(ctx2, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("second-context RAND_bytes_ex failed"); + err = 1; + } + + /* T2: a second provider init shares the same fd via the refcount rather + * than orphaning a buffered stream for exit-time cleanup to rewind. */ + if (err == 0) { + err = run_exit_cleanup_case("T2 second-context SEED-SRC re-init"); + } + + OSSL_PROVIDER_unload(provider2); + OSSL_LIB_CTX_free(ctx2); + OSSL_PROVIDER_unload(provider1); + + return err; +} + +static int child_rand_under_filter(OSSL_LIB_CTX *ctx) +{ + EVP_RAND_CTX *rctx; + WC_RNG rng; + unsigned char buf[32]; + int rngInit = 0; + int err = 0; + + if (apply_seccomp_sandbox() != 0) { + return WP_SECCOMP_CHILD_FILTER_ERR; + } + + /* Force fresh entropy after the sandbox is installed; a cached public DRBG + * could otherwise hide a teardown that closed the inherited fd. */ + rctx = RAND_get0_public(ctx); + if (rctx == NULL) { + PRINT_ERR_MSG("multi-context RAND_get0_public failed under sandbox"); + err = WP_SECCOMP_CHILD_OPENSSL_RAND_ERR; + } + else if (EVP_RAND_reseed(rctx, 0, NULL, 0, NULL, 0) != 1) { + PRINT_ERR_MSG("multi-context EVP_RAND_reseed failed under sandbox"); + err = WP_SECCOMP_CHILD_OPENSSL_RAND_ERR; + } + + if (err == 0 && RAND_bytes_ex(ctx, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("multi-context RAND_bytes_ex failed under sandbox"); + err = WP_SECCOMP_CHILD_OPENSSL_RAND_ERR; + } + + /* Also exercise wolfSSL's global seed callback: the second provider init + * resets it before wp_urandom_init(), covering the stale-callback bug. */ + if (err == 0) { + if (wc_InitRng(&rng) != 0) { + PRINT_ERR_MSG("multi-context wc_InitRng failed under sandbox"); + err = WP_SECCOMP_CHILD_WC_INIT_ERR; + } + else { + rngInit = 1; + } + } + if (err == 0 && wc_RNG_GenerateBlock(&rng, buf, sizeof(buf)) != 0) { + PRINT_ERR_MSG( + "multi-context wc_RNG_GenerateBlock failed under sandbox"); + err = WP_SECCOMP_CHILD_WC_RAND_ERR; + } + if (rngInit) { + wc_FreeRng(&rng); + } + + return err; +} + +static int run_multi_context_survivor_child(OSSL_LIB_CTX *ctx, + const char *name) +{ + pid_t pid; + int status; + + pid = fork(); + if (pid == -1) { + PRINT_ERR_MSG("%s: fork() failed: %s", name, strerror(errno)); + return 1; + } + + if (pid == 0) { + _exit(child_rand_under_filter(ctx)); + } + + if (waitpid(pid, &status, 0) == -1) { + PRINT_ERR_MSG("%s: waitpid() failed: %s", name, strerror(errno)); + return 1; + } + + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { + PRINT_MSG("%s: child obtained entropy under OpenSSH preauth filter", + name); + return 0; + } + if (WIFEXITED(status)) { + int exitCode = WEXITSTATUS(status); + + if (exitCode == WP_SECCOMP_CHILD_OPENSSL_RAND_ERR) { + PRINT_ERR_MSG("%s: child failed OpenSSL RAND reseed under " + "sandbox (exit code %d)", name, exitCode); + } + else if (exitCode == WP_SECCOMP_CHILD_WC_INIT_ERR) { + PRINT_ERR_MSG("%s: child failed wolfCrypt RNG init under " + "sandbox (exit code %d)", name, exitCode); + } + else if (exitCode == WP_SECCOMP_CHILD_WC_RAND_ERR) { + PRINT_ERR_MSG("%s: child failed wolfCrypt RNG generate under " + "sandbox (exit code %d)", name, exitCode); + } + else { + PRINT_ERR_MSG("%s: child failed under sandbox (exit code %d)", + name, exitCode); + } + return 1; + } + if (WIFSIGNALED(status)) { + PRINT_ERR_MSG("%s: child killed by signal %d", name, + WTERMSIG(status)); + return 1; + } + + PRINT_ERR_MSG("%s: child exited abnormally", name); + return 1; +} + +static int seccomp_helper_multi_context_lifecycle(const char *providerDir, + const char *providerName) +{ + OSSL_LIB_CTX *ctx1 = NULL; + OSSL_LIB_CTX *ctx2 = NULL; + OSSL_PROVIDER *provider1 = NULL; + OSSL_PROVIDER *provider2 = NULL; + unsigned char buf[32]; + int err = 0; + + ctx1 = OSSL_LIB_CTX_new(); + if (ctx1 == NULL) { + PRINT_ERR_MSG("first OSSL_LIB_CTX_new failed"); + err = 1; + } + + if (err == 0) { + err = load_provider_into_ctx(ctx1, providerDir, providerName, + &provider1); + } + + if (err == 0 && RAND_bytes_ex(ctx1, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("first-context RAND_bytes_ex failed"); + err = 1; + } + + if (err == 0) { + ctx2 = OSSL_LIB_CTX_new(); + if (ctx2 == NULL) { + PRINT_ERR_MSG("second OSSL_LIB_CTX_new failed"); + err = 1; + } + } + + if (err == 0) { + err = load_provider_into_ctx(ctx2, providerDir, providerName, + &provider2); + } + + if (err == 0 && RAND_bytes_ex(ctx2, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("second-context RAND_bytes_ex failed"); + err = 1; + } + + /* + * Unload only the first provider/libctx. The second context is still live, + * so its child must be able to reseed under OpenSSH's filter using the + * inherited urandom fd and the wolfSSL seed callback. + */ + if (provider1 != NULL) { + OSSL_PROVIDER_unload(provider1); + provider1 = NULL; + } + OSSL_LIB_CTX_free(ctx1); + ctx1 = NULL; + + if (err == 0) { + err = run_multi_context_survivor_child(ctx2, + "T3 multi-context survivor after one unload"); + } + + OSSL_PROVIDER_unload(provider2); + OSSL_LIB_CTX_free(ctx2); + + return err; +} + /* - * Child process test function. + * Re-exec entry point for the seccomp helper child: dispatches to the + * single/leak/multi sub-case by mode. Returns 0 on success, non-zero on + * failure (2 on bad arguments or unknown mode). + */ +int test_seccomp_sandbox_helper(const char *mode, const char *providerDir, + const char *providerName) +{ + int err; + + if (mode == NULL || providerDir == NULL || providerName == NULL) { + PRINT_ERR_MSG("seccomp helper missing arguments"); + return 2; + } + + if (strcmp(mode, "single") == 0) { + err = seccomp_helper_single_stream(providerDir, providerName); + } + else if (strcmp(mode, "leak") == 0) { + err = seccomp_helper_leak_route(providerDir, providerName); + } + else if (strcmp(mode, "multi") == 0) { + err = seccomp_helper_multi_context_lifecycle(providerDir, + providerName); + } + else { + PRINT_ERR_MSG("unknown seccomp helper mode: %s", mode); + err = 2; + } + + return err; +} + +static int run_seccomp_helper_case(const char *mode, const char *name) +{ + char exePath[PATH_MAX]; + ssize_t exeLen; + pid_t pid; + int status; + int err = 0; + const char *providerDir = wpUnitProviderDir; + const char *providerName = wpUnitProviderName; + + if (providerDir == NULL) { + providerDir = ".libs"; + } + if (providerName == NULL) { + providerName = wolfprovider_id; + } + + exeLen = readlink("/proc/self/exe", exePath, sizeof(exePath) - 1); + if (exeLen < 0) { + PRINT_ERR_MSG("%s: readlink(/proc/self/exe) failed: %s", name, + strerror(errno)); + return 1; + } + if (exeLen >= (ssize_t)sizeof(exePath) - 1) { + PRINT_ERR_MSG("%s: /proc/self/exe path too long", name); + return 1; + } + exePath[exeLen] = '\0'; + + pid = fork(); + if (pid == -1) { + PRINT_ERR_MSG("%s: fork() failed: %s", name, strerror(errno)); + return 1; + } + + if (pid == 0) { + execl(exePath, exePath, "--seccomp-sandbox-helper", mode, providerDir, + providerName, (char *)NULL); + PRINT_ERR_MSG("%s: execl helper failed: %s", name, strerror(errno)); + _exit(127); + } + + if (waitpid(pid, &status, 0) == -1) { + PRINT_ERR_MSG("%s: waitpid(helper) failed: %s", name, strerror(errno)); + return 1; + } + + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { + PRINT_MSG("%s: PASSED", name); + } + else if (WIFEXITED(status)) { + PRINT_ERR_MSG("%s: FAILED, helper exited with status %d", name, + WEXITSTATUS(status)); + err = 1; + } + else if (WIFSIGNALED(status)) { + PRINT_ERR_MSG("%s: helper killed by signal %d", name, + WTERMSIG(status)); + err = 1; + } + else { + PRINT_ERR_MSG("%s: helper exited abnormally", name); + err = 1; + } + + return err; +} + +/* + * Child process regression test function. * Applies sandbox and attempts RAND_bytes operations. * Returns 0 on success, non-zero on failure. */ @@ -253,24 +950,18 @@ static int child_test_rand_under_sandbox(OSSL_LIB_CTX *libCtx) return 1; } - /* - * Now try to generate random bytes under sandbox. - * This is the critical test - the DRBG may need to reseed, - * but it cannot open /dev/urandom because openat() is blocked. - */ + /* Generate random bytes under the real preauth filter. The caller uses + * _exit(), so this stays a fork-safety guard without libc stdio cleanup. */ if (RAND_bytes(buf, sizeof(buf)) != 1) { PRINT_ERR_MSG("RAND_bytes failed under sandbox"); err = 1; } - /* Try a second call to potentially trigger reseed */ if (err == 0 && RAND_bytes(buf, sizeof(buf)) != 1) { PRINT_ERR_MSG("Second RAND_bytes failed under sandbox"); err = 1; } - /* Reseed under the sandbox with NULL entropy: drives the fresh-entropy - * path, which must use the cached SEED-SRC fd, not open() /dev/urandom. */ if (err == 0) { EVP_RAND_CTX *rctx = RAND_get0_public(libCtx); if (rctx == NULL) { @@ -283,7 +974,6 @@ static int child_test_rand_under_sandbox(OSSL_LIB_CTX *libCtx) } } - /* Confirm the DRBG is still usable after the sandboxed reseed. */ if (err == 0 && RAND_bytes(buf, sizeof(buf)) != 1) { PRINT_ERR_MSG("RAND_bytes after reseed failed under sandbox"); err = 1; @@ -294,7 +984,7 @@ static int child_test_rand_under_sandbox(OSSL_LIB_CTX *libCtx) } /* - * Run the fork+sandbox test for a given library context. + * Run the fork+sandbox regression test for a given library context. * Returns 0 on success, non-zero on failure. */ static int run_fork_sandbox_test(OSSL_LIB_CTX *libCtx, const char *provName) @@ -306,7 +996,6 @@ static int run_fork_sandbox_test(OSSL_LIB_CTX *libCtx, const char *provName) PRINT_MSG("Testing %s provider with fork+sandbox", provName); - /* Pre-fork: Initialize DRBG by generating some random bytes */ origCtx = OSSL_LIB_CTX_set0_default(libCtx); if (RAND_bytes(buf, sizeof(buf)) != 1) { PRINT_ERR_MSG("Pre-fork RAND_bytes failed for %s", provName); @@ -315,9 +1004,6 @@ static int run_fork_sandbox_test(OSSL_LIB_CTX *libCtx, const char *provName) } OSSL_LIB_CTX_set0_default(origCtx); - PRINT_MSG("Pre-fork RAND_bytes succeeded, forking child..."); - - /* Fork child process - mimics OpenSSH privsep_preauth() */ pid = fork(); if (pid == -1) { PRINT_ERR_MSG("fork() failed: %s", strerror(errno)); @@ -325,24 +1011,12 @@ static int run_fork_sandbox_test(OSSL_LIB_CTX *libCtx, const char *provName) } if (pid == 0) { - /* Child process */ int child_err; - /* - * Note: In OpenSSH, reseed_prngs() is called here BEFORE sandbox. - * We intentionally skip that step to test the failure case. - * Once wolfProvider has proper fork-safe DRBG, the reseed - * should happen automatically or the DRBG should work without - * needing to open /dev/urandom. - */ - child_err = child_test_rand_under_sandbox(libCtx); - - /* Exit with status indicating success (0) or failure (1) */ _exit(child_err); } - /* Parent process - wait for child */ if (waitpid(pid, &status, 0) == -1) { PRINT_ERR_MSG("waitpid() failed: %s", strerror(errno)); return 1; @@ -354,15 +1028,14 @@ static int run_fork_sandbox_test(OSSL_LIB_CTX *libCtx, const char *provName) PRINT_MSG("%s: Child succeeded under sandbox", provName); return 0; } - else { - PRINT_MSG("%s: Child failed under sandbox (exit code %d)", - provName, exit_code); - return 1; - } + + PRINT_ERR_MSG("%s: Child failed under sandbox (exit code %d)", + provName, exit_code); + return 1; } else if (WIFSIGNALED(status)) { PRINT_ERR_MSG("%s: Child killed by signal %d", provName, - WTERMSIG(status)); + WTERMSIG(status)); return 1; } @@ -370,67 +1043,57 @@ static int run_fork_sandbox_test(OSSL_LIB_CTX *libCtx, const char *provName) return 1; } -/* - * Main test function for seccomp sandbox DRBG behavior. - * - * This test verifies: - * 1. Default OpenSSL provider works under fork+sandbox (baseline) - * 2. wolfProvider behavior under fork+sandbox - * - * Currently wolfProvider is EXPECTED TO FAIL because the DRBG - * tries to open /dev/urandom after fork, which is blocked by seccomp. - */ int test_seccomp_sandbox(void *data) { int err = 0; - int wp_err; (void)data; - PRINT_MSG("=== Seccomp Sandbox DRBG Test ==="); - PRINT_MSG("This test mimics OpenSSH's fork+sandbox behavior"); + PRINT_MSG("=== OpenSSH seccomp sandbox SEED-SRC test ==="); - /* - * Test 1: Default OpenSSL provider (baseline - should pass) - * This verifies our test harness works correctly. - */ - PRINT_MSG(""); - PRINT_MSG("--- Test with OpenSSL default provider (baseline) ---"); - err = run_fork_sandbox_test(osslLibCtx, "OpenSSL default"); - if (err != 0) { - PRINT_ERR_MSG("BASELINE FAILED: OpenSSL default provider failed"); - PRINT_ERR_MSG("This indicates a problem with the test itself"); - return err; + if (run_seccomp_helper_case("single", + "T1 customer-faithful single SEED-SRC read") != 0) { + err = 1; } - PRINT_MSG("OpenSSL default provider: PASSED (baseline verified)"); - /* - * Test 2: wolfProvider - * Currently expected to fail because DRBG tries to open /dev/urandom - * after fork, which is blocked by seccomp. - */ - PRINT_MSG(""); - PRINT_MSG("--- Test with wolfProvider ---"); - wp_err = run_fork_sandbox_test(wpLibCtx, "wolfProvider"); + if (run_seccomp_helper_case("leak", + "T2 second-libctx SEED-SRC re-init") != 0) { + err = 1; + } - if (wp_err != 0) { - PRINT_MSG("wolfProvider: FAILED (expected - DRBG cannot reseed)"); - PRINT_MSG("This is expected until fork-safe DRBG is implemented"); - /* - * Return the error so the test is marked as failed. - * Once the fix is implemented, this test should pass. - */ - return wp_err; + if (run_seccomp_helper_case("multi", + "T3 multi-context survivor after one unload") != 0) { + err = 1; } - PRINT_MSG("wolfProvider: PASSED"); - PRINT_MSG("=== All seccomp sandbox tests passed ==="); + PRINT_MSG("--- Regression: child reads entropy under sandbox ---"); + if (run_fork_sandbox_test(osslLibCtx, "OpenSSL default") != 0) { + err = 1; + } + if (run_fork_sandbox_test(wpLibCtx, "wolfProvider") != 0) { + err = 1; + } - return 0; + if (err == 0) { + PRINT_MSG("=== All seccomp sandbox tests passed ==="); + } + + return err; } #else /* !WP_HAVE_SECCOMP */ +int test_seccomp_sandbox_helper(const char *mode, const char *providerDir, + const char *providerName) +{ + (void)mode; + (void)providerDir; + (void)providerName; + + PRINT_MSG("Seccomp sandbox helper skipped - seccomp not available"); + return 1; +} + int test_seccomp_sandbox(void *data) { (void)data; @@ -451,4 +1114,3 @@ int test_seccomp_sandbox(void *data) } #endif /* WP_TEST_SECCOMP_SANDBOX && WP_HAVE_SEED_SRC && WP_HAVE_RANDOM */ - diff --git a/test/unit.c b/test/unit.c index 95f22b5f..7188c8a5 100644 --- a/test/unit.c +++ b/test/unit.c @@ -30,6 +30,15 @@ OSSL_LIB_CTX* wpLibCtx = NULL; OSSL_LIB_CTX* osslLibCtx = NULL; int noKeyLimits = 0; +#if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) +/* + * Provider dir/name from the command line, so tests that load wolfProvider into + * their own library contexts (SEED-SRC refcount, seccomp helpers) pick up the + * same --dir/--provider location as the main suite. + */ +const char *wpUnitProviderDir = ".libs"; +const char *wpUnitProviderName = NULL; +#endif #ifdef WOLFPROV_DEBUG void print_buffer(const char *desc, const unsigned char *buffer, size_t len) @@ -303,6 +312,8 @@ TEST_CASE test_case[] = { #endif TEST_DECL(test_rand_seed, NULL), TEST_DECL(test_drbg_reseed, NULL), + TEST_DECL(test_seed_src_refcount, NULL), + TEST_DECL(test_seed_src_reload, NULL), TEST_DECL(test_seccomp_sandbox, NULL), #ifdef WP_HAVE_DH TEST_DECL(test_dh_pgen_pkey, NULL), @@ -557,6 +568,18 @@ static void usage(void) printf(" --valgrind Run wolfSSL only tests for Valgrind where OpenSSL " "has issues\n"); printf(" Run this test case, but not all\n"); +#if defined(WP_TEST_SECCOMP_SANDBOX) && defined(WP_HAVE_SEED_SRC) && \ + defined(WP_HAVE_RANDOM) + printf(" --seccomp-sandbox-helper [provider-dir] " + "[provider-name]\n"); + printf(" INTERNAL: seccomp re-exec protocol, not for " + "direct use.\n"); +#endif +#if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) + printf(" --seed-src-reload-helper [provider-dir] [provider-name]\n"); + printf(" INTERNAL: SEED-SRC reload re-exec protocol, not " + "for direct use.\n"); +#endif } #ifdef TEST_MULTITHREADED @@ -770,6 +793,37 @@ int main(int argc, char* argv[]) int runAll = 1; int runTests = 1; +#if defined(WP_TEST_SECCOMP_SANDBOX) && defined(WP_HAVE_SEED_SRC) && \ + defined(WP_HAVE_RANDOM) + /* + * Internal re-exec protocol: the seccomp test re-execs this binary as + * --seccomp-sandbox-helper [provider-dir] [name] + * to run a helper mode in a fresh process. See test_seccomp_sandbox.c. + */ + if (argc >= 2 && strcmp(argv[1], "--seccomp-sandbox-helper") == 0) { + const char *mode = (argc >= 3) ? argv[2] : NULL; + const char *helperDir = (argc >= 4) ? argv[3] : ".libs"; + const char *helperName = (argc >= 5) ? argv[4] : wolfprovider_id; + + return test_seccomp_sandbox_helper(mode, helperDir, helperName); + } +#endif + +#if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) + /* + * Internal re-exec protocol: the SEED-SRC reload test re-execs this binary + * as --seed-src-reload-helper [provider-dir] [name] to exercise the shared + * fd/callback teardown-to-zero and reload in a fresh process (refcount starts + * at zero). See test_rand_seed.c. + */ + if (argc >= 2 && strcmp(argv[1], "--seed-src-reload-helper") == 0) { + wpUnitProviderDir = (argc >= 3) ? argv[2] : ".libs"; + wpUnitProviderName = (argc >= 4) ? argv[3] : wolfprovider_id; + + return test_seed_src_reload_helper(); + } +#endif + for (--argc, ++argv; argc > 0; argc--, argv++) { if (strncmp(*argv, "--help", 6) == 0) { usage(); @@ -855,6 +909,11 @@ int main(int argc, char* argv[]) } } +#if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) + wpUnitProviderDir = dir; + wpUnitProviderName = name; +#endif + OpenSSL_add_all_ciphers(); OpenSSL_add_all_digests(); diff --git a/test/unit.h b/test/unit.h index e263dfd0..5eff4ff3 100644 --- a/test/unit.h +++ b/test/unit.h @@ -96,6 +96,26 @@ extern OSSL_LIB_CTX* wpLibCtx; extern OSSL_LIB_CTX* osslLibCtx; extern int noKeyLimits; +#if defined(WP_HAVE_SEED_SRC) && defined(WP_HAVE_RANDOM) +/* + * Provider location for the SEED-SRC tests, from --dir/--provider (and forwarded + * to re-exec'd helpers so a fresh helper loads the same provider). + */ +extern const char *wpUnitProviderDir; +extern const char *wpUnitProviderName; +/* + * Fresh-process worker for the SEED-SRC teardown -> reload test, re-exec'd by + * test_seed_src_reload() so the refcount starts from zero. + */ +int test_seed_src_reload_helper(void); +#endif + +#if defined(WP_TEST_SECCOMP_SANDBOX) && defined(WP_HAVE_SEED_SRC) && \ + defined(WP_HAVE_RANDOM) +int test_seccomp_sandbox_helper(const char *mode, const char *providerDir, + const char *providerName); +#endif + #ifdef WP_HAVE_DIGEST @@ -248,6 +268,8 @@ int test_random(void *data); /* DRBG SEED-SRC hierarchy tests */ int test_rand_seed(void *data); int test_drbg_reseed(void *data); +int test_seed_src_refcount(void *data); +int test_seed_src_reload(void *data); /* Seccomp sandbox test - mimics OpenSSH fork+sandbox behavior */ int test_seccomp_sandbox(void *data); From 6d6048cff6c518a766b9c988d8c1858eea6c826c Mon Sep 17 00:00:00 2001 From: Colton Willey Date: Mon, 13 Jul 2026 12:12:22 -0700 Subject: [PATCH 2/4] wp_seed_src: reopen /dev/urandom when its cached fd is reused SEED-SRC caches the /dev/urandom descriptor by number. Under OpenSSH's inetd-mode (sshd -i) privsep setup the parent dup2's its own descriptors over low fd numbers, reusing the number SEED-SRC cached. The forked, seccomp-sandboxed preauth child then inherits g_urandom_fd pointing at the wrong (non-blocking) descriptor; its reseed read returns EAGAIN, reseed_prngs fails, and the connection is dropped - the agent-pkcs11 regress failure. Record the device identity (dev/ino/mode/rdev) via fstat at open time and, like OpenSSL's check_random_device(), revalidate the cached fd before each read. If the number was reused, abandon it without closing (it belongs to the host now) and reopen. An fstat EACCES (a sandbox that denies fstat) is inconclusive, so the inherited fd is kept; EBADF means the fd was closed, so reopen. The cleanup close is likewise guarded so a reused descriptor is never closed. Add regression test T4 (fd-reuse) to test_seccomp_sandbox.c: it dup2's a non-blocking pipe over the cached fd, forces a parent reseed, and verifies a sandboxed child still reseeds from a valid fd. --- src/wp_seed_src.c | 89 ++++++++++++++++++- test/test_seccomp_sandbox.c | 167 +++++++++++++++++++++++++++++++++++- test/unit.c | 7 +- 3 files changed, 255 insertions(+), 8 deletions(-) diff --git a/src/wp_seed_src.c b/src/wp_seed_src.c index 491f103e..6dfacc7d 100644 --- a/src/wp_seed_src.c +++ b/src/wp_seed_src.c @@ -26,6 +26,8 @@ #include #include #include +#include +#include #include #include @@ -68,6 +70,16 @@ * seccomp sandbox that blocks open()/openat(). */ static int g_urandom_fd = XBADFD; +/* Identity of the device behind g_urandom_fd, captured by fstat at open time. + * A host application (e.g. OpenSSH's privsep setup) may dup2 its own descriptor + * onto our cached fd number; before reading we re-fstat and confirm the fd still + * refers to the same /dev/urandom, reopening if it was reused. */ +static int g_urandom_id_valid = 0; +static dev_t g_urandom_dev; +static ino_t g_urandom_ino; +static mode_t g_urandom_mode; +static dev_t g_urandom_rdev; + /* Live provider contexts referencing the shared fd/callback; mutex-guarded. */ static int g_urandom_ref_count = 0; @@ -76,8 +88,9 @@ static int g_seed_cb_registered = 0; #endif /** - * Open the cached /dev/urandom fd if not already open. Idempotent; caller must - * hold the urandom mutex. + * Open the cached /dev/urandom fd if not already open, capturing its device + * identity (via fstat) so later reads can detect fd-number reuse. Idempotent; + * caller must hold the urandom mutex. * * @return 0 on success. * @return -1 if /dev/urandom cannot be opened. @@ -94,6 +107,25 @@ static int wp_urandom_open_locked(void) "wp_urandom_open: failed to open " URANDOM_PATH); return -1; } + + /* Record the device identity so a later read can detect fd reuse. Uses + * a raw fstat(): wolfSSL exposes XSTAT (path-based) but no fd-based + * XFSTAT, and stat-ing the path cannot tell us our fd was reused. This + * matches the raw open() above (there is likewise no XOPEN). */ + { + struct stat st; + if (fstat(g_urandom_fd, &st) == 0) { + g_urandom_dev = st.st_dev; + g_urandom_ino = st.st_ino; + g_urandom_mode = st.st_mode; + g_urandom_rdev = st.st_rdev; + g_urandom_id_valid = 1; + } + else { + g_urandom_id_valid = 0; + } + } + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, "wp_urandom_open: opened " URANDOM_PATH); } @@ -101,6 +133,39 @@ static int wp_urandom_open_locked(void) return 0; } +/** + * Return 1 if the cached fd should be reopened - either its number was reused by + * another subsystem, or the fd was closed (fstat reports EBADF). Return 0 + * otherwise. Caller must hold the urandom mutex. + * + * Compares the current fstat against the identity captured at open. If fstat + * fails with anything other than EBADF - notably EACCES from a seccomp sandbox + * that denies fstat - we cannot tell, so we keep the fd rather than tearing down + * a possibly-good inherited descriptor. + */ +static int wp_urandom_fd_reused_locked(void) +{ + struct stat st; + + if (g_urandom_fd == XBADFD || !g_urandom_id_valid) { + return 0; + } + if (fstat(g_urandom_fd, &st) != 0) { + /* EBADF: our fd was closed - reopen. Any other error (e.g. a sandbox + * denying fstat with EACCES) is inconclusive, so keep the fd. */ + return (errno == EBADF); + } + /* Same identity check OpenSSL uses (check_random_device): ignore the low + * permission bits, require everything else to match. */ + if (st.st_dev == g_urandom_dev && st.st_ino == g_urandom_ino && + st.st_rdev == g_urandom_rdev && + ((st.st_mode ^ g_urandom_mode) & + ~(mode_t)(S_IRWXU | S_IRWXG | S_IRWXO)) == 0) { + return 0; + } + return 1; +} + /** * Read exactly len bytes from the cached /dev/urandom fd, retrying on EINTR. * Caller must hold the urandom mutex. @@ -120,6 +185,16 @@ static int wp_urandom_read_locked(unsigned char* buf, size_t len) return -1; } + /* If our cached fd number was reused by another subsystem, abandon it + * (do NOT close - the descriptor belongs to them now) and reopen a fresh + * /dev/urandom before reading. */ + if (wp_urandom_fd_reused_locked()) { + WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, + "wp_urandom_read: cached fd reused, reopening " URANDOM_PATH); + g_urandom_fd = XBADFD; + g_urandom_id_valid = 0; + } + if (wp_urandom_open_locked() != 0) { return -1; } @@ -260,10 +335,16 @@ void wp_urandom_cleanup(void) #endif if (g_urandom_fd != XBADFD) { - XCLOSE(g_urandom_fd); + /* Only close if the fd is still ours: if its number was reused by the + * host, closing would clobber their descriptor. Mirrors OpenSSL's + * close_random_device(). */ + if (!wp_urandom_fd_reused_locked()) { + XCLOSE(g_urandom_fd); + } g_urandom_fd = XBADFD; + g_urandom_id_valid = 0; WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, - "wp_urandom_cleanup: closed " URANDOM_PATH); + "wp_urandom_cleanup: released " URANDOM_PATH); } WP_URANDOM_UNLOCK(); diff --git a/test/test_seccomp_sandbox.c b/test/test_seccomp_sandbox.c index 50f05b56..d2c971a0 100644 --- a/test/test_seccomp_sandbox.c +++ b/test/test_seccomp_sandbox.c @@ -27,6 +27,13 @@ * rewinding fread read-ahead. The SEED-SRC path now uses a raw /dev/urandom fd * (open/read/close, no stdio buffering), so libc exit performs no lseek(). The * tests here would catch a reintroduction of the buffered-stream SIGSYS bug. + * + * T4 covers a second, distinct bug: SEED-SRC caches a /dev/urandom fd by number, + * and OpenSSH's inetd-mode privsep setup reuses low fd numbers (dup2's its own + * descriptors over them). If the cached fd number is reused, a later read hits + * the wrong descriptor. T4 reproduces this by dup2'ing a non-blocking pipe over + * the cached fd, then verifying SEED-SRC detects the reuse (via fstat) and + * reopens /dev/urandom, so a sandboxed child still reseeds from a valid fd. */ #include "unit.h" @@ -45,8 +52,12 @@ #include #include #include +#include +#include #include #include +#include +#include #include #include #include @@ -828,9 +839,155 @@ static int seccomp_helper_multi_context_lifecycle(const char *providerDir, return err; } +/* + * Simulate a host application (e.g. OpenSSH privsep) reusing the fd number that + * SEED-SRC cached for /dev/urandom: dup2 an empty non-blocking pipe over every + * /dev/urandom fd this process holds. A later read of the cached fd would then + * return EAGAIN unless SEED-SRC re-fstats it, detects the reuse, and reopens. + * + * Returns the number of fds clobbered, or -1 on setup failure. The pipe is left + * open for the lifetime of the (short-lived) helper process on purpose. + */ +static int clobber_urandom_fds(void) +{ + int pfd[2]; + DIR *d; + struct dirent *ent; + int dfd; + int clobbered = 0; + + if (pipe(pfd) != 0) { + PRINT_ERR_MSG("clobber_urandom_fds: pipe() failed: %s", + strerror(errno)); + return -1; + } + /* Non-blocking so a read of the clobbered fd fails with EAGAIN rather than + * blocking forever - matching OpenSSH's non-blocking socket/pipe fds. */ + if (fcntl(pfd[0], F_SETFL, O_NONBLOCK) == -1) { + PRINT_ERR_MSG("clobber_urandom_fds: fcntl(O_NONBLOCK) failed: %s", + strerror(errno)); + close(pfd[0]); + close(pfd[1]); + return -1; + } + + d = opendir("/proc/self/fd"); + if (d == NULL) { + PRINT_ERR_MSG("clobber_urandom_fds: opendir failed: %s", + strerror(errno)); + close(pfd[0]); + close(pfd[1]); + return -1; + } + dfd = dirfd(d); + + while ((ent = readdir(d)) != NULL) { + char path[64]; + char target[64]; + ssize_t n; + int fd; + + if (ent->d_name[0] < '0' || ent->d_name[0] > '9') { + continue; + } + fd = atoi(ent->d_name); + if (fd == pfd[0] || fd == pfd[1] || fd == dfd) { + continue; + } + n = snprintf(path, sizeof(path), "/proc/self/fd/%d", fd); + if (n < 0 || (size_t)n >= sizeof(path)) { + continue; + } + n = readlink(path, target, sizeof(target) - 1); + if (n <= 0) { + continue; + } + target[n] = '\0'; + if (strcmp(target, "/dev/urandom") == 0 && dup2(pfd[0], fd) == fd) { + clobbered++; + } + } + + closedir(d); + return clobbered; +} + +static int seccomp_helper_fd_reuse(const char *providerDir, + const char *providerName) +{ + OSSL_LIB_CTX *ctx = NULL; + OSSL_PROVIDER *provider = NULL; + unsigned char buf[32]; + int clobbered = 0; + int err = 0; + + ctx = OSSL_LIB_CTX_new(); + if (ctx == NULL) { + PRINT_ERR_MSG("fd-reuse OSSL_LIB_CTX_new failed"); + err = 1; + } + + if (err == 0) { + err = load_provider_into_ctx(ctx, providerDir, providerName, &provider); + } + + /* Draw entropy so SEED-SRC opens and caches its /dev/urandom fd. */ + if (err == 0 && RAND_bytes_ex(ctx, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("fd-reuse initial RAND_bytes_ex failed"); + err = 1; + } + + /* Reuse the cached fd number, as OpenSSH's privsep fd shuffle would. */ + if (err == 0) { + clobbered = clobber_urandom_fds(); + if (clobbered < 0) { + err = 1; + } + else if (clobbered == 0) { + PRINT_ERR_MSG("fd-reuse: no cached /dev/urandom fd found to clobber"); + err = 1; + } + else { + PRINT_MSG("fd-reuse: clobbered %d cached /dev/urandom fd(s)", + clobbered); + } + } + + /* Force a fresh seed draw in the parent, as OpenSSH's post-shuffle + * reseed_prngs does. This reads /dev/urandom, so SEED-SRC detects the reused + * fd (via fstat) and reopens; without the fix it reads the clobbered + * non-blocking pipe and fails with EAGAIN. Prediction resistance forces the + * DRBG down to the entropy source rather than reusing cached state. */ + if (err == 0) { + EVP_RAND_CTX *rctx = RAND_get0_public(ctx); + if (rctx == NULL || + EVP_RAND_reseed(rctx, 1, NULL, 0, NULL, 0) != 1) { + PRINT_ERR_MSG("fd-reuse parent reseed after clobber failed " + "(cached fd not revalidated/reopened)"); + err = 1; + } + } + if (err == 0 && RAND_bytes_ex(ctx, buf, sizeof(buf), 0) != 1) { + PRINT_ERR_MSG("fd-reuse RAND_bytes_ex after clobber failed"); + err = 1; + } + + /* Full flow: the reopened fd must be valid when inherited by a sandboxed + * child that reseeds under OpenSSH's preauth filter. */ + if (err == 0) { + err = run_multi_context_survivor_child(ctx, + "T4 SEED-SRC fd-reuse survivor after fd clobber"); + } + + OSSL_PROVIDER_unload(provider); + OSSL_LIB_CTX_free(ctx); + + return err; +} + /* * Re-exec entry point for the seccomp helper child: dispatches to the - * single/leak/multi sub-case by mode. Returns 0 on success, non-zero on + * single/leak/multi/reuse sub-case by mode. Returns 0 on success, non-zero on * failure (2 on bad arguments or unknown mode). */ int test_seccomp_sandbox_helper(const char *mode, const char *providerDir, @@ -853,6 +1010,9 @@ int test_seccomp_sandbox_helper(const char *mode, const char *providerDir, err = seccomp_helper_multi_context_lifecycle(providerDir, providerName); } + else if (strcmp(mode, "reuse") == 0) { + err = seccomp_helper_fd_reuse(providerDir, providerName); + } else { PRINT_ERR_MSG("unknown seccomp helper mode: %s", mode); err = 2; @@ -1066,6 +1226,11 @@ int test_seccomp_sandbox(void *data) err = 1; } + if (run_seccomp_helper_case("reuse", + "T4 SEED-SRC fd-reuse survivor after fd clobber") != 0) { + err = 1; + } + PRINT_MSG("--- Regression: child reads entropy under sandbox ---"); if (run_fork_sandbox_test(osslLibCtx, "OpenSSL default") != 0) { err = 1; diff --git a/test/unit.c b/test/unit.c index 7188c8a5..f739bfe3 100644 --- a/test/unit.c +++ b/test/unit.c @@ -570,8 +570,8 @@ static void usage(void) printf(" Run this test case, but not all\n"); #if defined(WP_TEST_SECCOMP_SANDBOX) && defined(WP_HAVE_SEED_SRC) && \ defined(WP_HAVE_RANDOM) - printf(" --seccomp-sandbox-helper [provider-dir] " - "[provider-name]\n"); + printf(" --seccomp-sandbox-helper " + "[provider-dir] [provider-name]\n"); printf(" INTERNAL: seccomp re-exec protocol, not for " "direct use.\n"); #endif @@ -797,7 +797,8 @@ int main(int argc, char* argv[]) defined(WP_HAVE_RANDOM) /* * Internal re-exec protocol: the seccomp test re-execs this binary as - * --seccomp-sandbox-helper [provider-dir] [name] + * --seccomp-sandbox-helper + * [provider-dir] [name] * to run a helper mode in a fresh process. See test_seccomp_sandbox.c. */ if (argc >= 2 && strcmp(argv[1], "--seccomp-sandbox-helper") == 0) { From 5088d9f7fff4a48145c2f1aa8339ffa9b29c19af Mon Sep 17 00:00:00 2001 From: Colton Willey Date: Tue, 14 Jul 2026 09:30:36 -0700 Subject: [PATCH 3/4] wp_seed_src/test: tighten comments to house style --- src/wp_seed_src.c | 34 ++++++++-------------------------- test/test_seccomp_sandbox.c | 29 +++++++++++------------------ 2 files changed, 19 insertions(+), 44 deletions(-) diff --git a/src/wp_seed_src.c b/src/wp_seed_src.c index 6dfacc7d..71c41ad0 100644 --- a/src/wp_seed_src.c +++ b/src/wp_seed_src.c @@ -70,10 +70,8 @@ * seccomp sandbox that blocks open()/openat(). */ static int g_urandom_fd = XBADFD; -/* Identity of the device behind g_urandom_fd, captured by fstat at open time. - * A host application (e.g. OpenSSH's privsep setup) may dup2 its own descriptor - * onto our cached fd number; before reading we re-fstat and confirm the fd still - * refers to the same /dev/urandom, reopening if it was reused. */ +/* Device identity of g_urandom_fd, captured by fstat at open, so a later read + * can detect when another subsystem has reused (dup2'd over) our fd number. */ static int g_urandom_id_valid = 0; static dev_t g_urandom_dev; static ino_t g_urandom_ino; @@ -108,10 +106,6 @@ static int wp_urandom_open_locked(void) return -1; } - /* Record the device identity so a later read can detect fd reuse. Uses - * a raw fstat(): wolfSSL exposes XSTAT (path-based) but no fd-based - * XFSTAT, and stat-ing the path cannot tell us our fd was reused. This - * matches the raw open() above (there is likewise no XOPEN). */ { struct stat st; if (fstat(g_urandom_fd, &st) == 0) { @@ -134,14 +128,10 @@ static int wp_urandom_open_locked(void) } /** - * Return 1 if the cached fd should be reopened - either its number was reused by - * another subsystem, or the fd was closed (fstat reports EBADF). Return 0 - * otherwise. Caller must hold the urandom mutex. - * - * Compares the current fstat against the identity captured at open. If fstat - * fails with anything other than EBADF - notably EACCES from a seccomp sandbox - * that denies fstat - we cannot tell, so we keep the fd rather than tearing down - * a possibly-good inherited descriptor. + * Return 1 if the cached fd must be reopened: its number was reused by another + * subsystem, or it was closed (fstat EBADF). Any other fstat failure (e.g. a + * sandbox denying fstat with EACCES) is inconclusive, so keep the fd. Identity + * match mirrors OpenSSL's check_random_device(). Caller holds the urandom mutex. */ static int wp_urandom_fd_reused_locked(void) { @@ -151,12 +141,8 @@ static int wp_urandom_fd_reused_locked(void) return 0; } if (fstat(g_urandom_fd, &st) != 0) { - /* EBADF: our fd was closed - reopen. Any other error (e.g. a sandbox - * denying fstat with EACCES) is inconclusive, so keep the fd. */ return (errno == EBADF); } - /* Same identity check OpenSSL uses (check_random_device): ignore the low - * permission bits, require everything else to match. */ if (st.st_dev == g_urandom_dev && st.st_ino == g_urandom_ino && st.st_rdev == g_urandom_rdev && ((st.st_mode ^ g_urandom_mode) & @@ -185,9 +171,7 @@ static int wp_urandom_read_locked(unsigned char* buf, size_t len) return -1; } - /* If our cached fd number was reused by another subsystem, abandon it - * (do NOT close - the descriptor belongs to them now) and reopen a fresh - * /dev/urandom before reading. */ + /* Reused fd number: drop it (do NOT close - it is theirs now) and reopen. */ if (wp_urandom_fd_reused_locked()) { WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, "wp_urandom_read: cached fd reused, reopening " URANDOM_PATH); @@ -335,9 +319,7 @@ void wp_urandom_cleanup(void) #endif if (g_urandom_fd != XBADFD) { - /* Only close if the fd is still ours: if its number was reused by the - * host, closing would clobber their descriptor. Mirrors OpenSSL's - * close_random_device(). */ + /* Close only if still ours - a reused fd number is the host's now. */ if (!wp_urandom_fd_reused_locked()) { XCLOSE(g_urandom_fd); } diff --git a/test/test_seccomp_sandbox.c b/test/test_seccomp_sandbox.c index d2c971a0..f9bee9f1 100644 --- a/test/test_seccomp_sandbox.c +++ b/test/test_seccomp_sandbox.c @@ -28,12 +28,10 @@ * (open/read/close, no stdio buffering), so libc exit performs no lseek(). The * tests here would catch a reintroduction of the buffered-stream SIGSYS bug. * - * T4 covers a second, distinct bug: SEED-SRC caches a /dev/urandom fd by number, - * and OpenSSH's inetd-mode privsep setup reuses low fd numbers (dup2's its own - * descriptors over them). If the cached fd number is reused, a later read hits - * the wrong descriptor. T4 reproduces this by dup2'ing a non-blocking pipe over - * the cached fd, then verifying SEED-SRC detects the reuse (via fstat) and - * reopens /dev/urandom, so a sandboxed child still reseeds from a valid fd. + * T4 covers a second bug: SEED-SRC caches the /dev/urandom fd by number, and + * OpenSSH's inetd-mode privsep reuses low fd numbers (dup2's over them). T4 + * dup2's a non-blocking pipe over the cached fd and checks SEED-SRC detects the + * reuse (via fstat) and reopens, so a sandboxed child still reseeds cleanly. */ #include "unit.h" @@ -840,13 +838,10 @@ static int seccomp_helper_multi_context_lifecycle(const char *providerDir, } /* - * Simulate a host application (e.g. OpenSSH privsep) reusing the fd number that - * SEED-SRC cached for /dev/urandom: dup2 an empty non-blocking pipe over every - * /dev/urandom fd this process holds. A later read of the cached fd would then - * return EAGAIN unless SEED-SRC re-fstats it, detects the reuse, and reopens. - * - * Returns the number of fds clobbered, or -1 on setup failure. The pipe is left - * open for the lifetime of the (short-lived) helper process on purpose. + * Simulate a host (e.g. OpenSSH privsep) reusing SEED-SRC's cached fd number: + * dup2 an empty non-blocking pipe over every /dev/urandom fd this process holds. + * Returns the count clobbered, or -1 on setup failure; the pipe is intentionally + * left open for the (short-lived) helper's lifetime. */ static int clobber_urandom_fds(void) { @@ -953,11 +948,9 @@ static int seccomp_helper_fd_reuse(const char *providerDir, } } - /* Force a fresh seed draw in the parent, as OpenSSH's post-shuffle - * reseed_prngs does. This reads /dev/urandom, so SEED-SRC detects the reused - * fd (via fstat) and reopens; without the fix it reads the clobbered - * non-blocking pipe and fails with EAGAIN. Prediction resistance forces the - * DRBG down to the entropy source rather than reusing cached state. */ + /* Force a source draw (prediction resistance), as OpenSSH's post-shuffle + * reseed does, so SEED-SRC re-fstats and reopens. Without the fix this reads + * the clobbered pipe and fails with EAGAIN. */ if (err == 0) { EVP_RAND_CTX *rctx = RAND_get0_public(ctx); if (rctx == NULL || From e03040e85a3b0c498c54b336f065b4c69a1b3992 Mon Sep 17 00:00:00 2001 From: Colton Willey Date: Tue, 14 Jul 2026 11:45:27 -0700 Subject: [PATCH 4/4] wp_seed_src: refuse entropy reads after teardown (refcount guard) --- src/wp_seed_src.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp_seed_src.c b/src/wp_seed_src.c index 71c41ad0..e4972b3e 100644 --- a/src/wp_seed_src.c +++ b/src/wp_seed_src.c @@ -165,6 +165,11 @@ static int wp_urandom_read_locked(unsigned char* buf, size_t len) { size_t total = 0; + /* After teardown (refcount 0) the fd is closed; reopening it would leak. */ + if (g_urandom_ref_count < 1) { + return -1; + } + if (len > (size_t)INT_MAX) { WOLFPROV_MSG_DEBUG(WP_LOG_COMP_RNG, "wp_urandom_read: requested length too large");