diff mbox series

[pseudo,22/23] test-bash-exec-env: Add bash env test case

Message ID 1783039578-31531-23-git-send-email-mark.hatle@kernel.crashing.org
State New
Headers show
Series Create new pseudo 1.99.0 version | expand

Commit Message

Mark Hatle July 3, 2026, 12:46 a.m. UTC
From: Mark Hatle <mark.hatle@amd.com>

Add a test reproducing the bash/pseudo environment conflict
(https://bugzilla.yoctoproject.org/show_bug.cgi?id=16078) where pseudo's
setenv workaround corrupted memory when pseudo ran under bash.

The helper binary exports its own getenv/setenv/unsetenv (as bash does),
maintaining a private variable table separate from environ. It strips the
derived PSEUDO_* variables and forks children, mirroring the opkg-build
pipeline that triggered the production crash. pseudo's fork wrapper runs
pseudo_setupenv() in each child, which re-adds those variables; the child
then checks whether they leaked back into the real environ:

  Unfixed pseudo calls glibc's setenv via dlsym(RTLD_NEXT), writing the
  variables straight into environ and desyncing it from bash's table.

  Fixed pseudo calls the process's own setenv, leaving environ untouched.

The check is deterministic, so the test fails when the fixes are reverted
and passes when they are present. MALLOC_CHECK_=3 is kept as a secondary
safety net.

AI-Generated: Test constructed with GitHub Copilot (Claude Opus 4.8)

Signed-off-by: Mark Hatle <mark.hatle@amd.com>
---
 test/test-bash-exec-env.c  | 667 +++++++++++++++++++++++++++++++++++++
 test/test-bash-exec-env.sh |  34 ++
 2 files changed, 701 insertions(+)
 create mode 100644 test/test-bash-exec-env.c
 create mode 100755 test/test-bash-exec-env.sh
diff mbox series

Patch

diff --git a/test/test-bash-exec-env.c b/test/test-bash-exec-env.c
new file mode 100644
index 0000000..68ed42a
--- /dev/null
+++ b/test/test-bash-exec-env.c
@@ -0,0 +1,667 @@ 
+/*
+ * SPDX-License-Identifier: LGPL-2.1-only
+ *
+ * test-bash-exec-env.c
+ *
+ * Reproducer for https://bugzilla.yoctoproject.org/show_bug.cgi?id=16078
+ *
+ * What this binary simulates
+ * --------------------------
+ * bash exports getenv(), setenv(), and unsetenv() as dynamic symbols.
+ * Because the main executable appears first in the dynamic-linker search
+ * order, every library loaded into the process (including libpseudo.so)
+ * resolves calls to those names to bash's own implementations.
+ *
+ * bash maintains a private variable hash table that is separate from the
+ * C-library 'environ' array.  bash's setenv/getenv operate on this table;
+ * 'environ' is only rebuilt lazily by maybe_make_export_env().
+ *
+ * The old pseudo workaround (commit 80c6334a) used
+ *   pseudo_real_setenv = dlsym(RTLD_NEXT, "setenv")
+ * to bypass bash's override and call glibc's setenv directly.
+ * dlsym(RTLD_NEXT, …) from within an LD_PRELOAD library searches the
+ * shared-library chain *after* the LD_PRELOAD library, so it finds
+ * glibc's implementation — it does NOT find the main executable's
+ * (bash's) version.
+ *
+ * glibc's setenv modifies 'environ' directly, allocating a new heap
+ * block (block A) for the updated "LD_PRELOAD=libpseudo.so:…" entry.
+ * bash's internal variable table is NOT updated.
+ *
+ * When bash later calls maybe_make_export_env() (before exec-ing each
+ * pipeline stage), it calls strvec_flush() which frees every entry in
+ * the current 'environ' array — including block A — and rebuilds from
+ * the internal table (which still has the original LD_PRELOAD without
+ * libpseudo.so).
+ *
+ * The freed block A therefore remains live in the allocator's free list
+ * while pseudo's exec wrapper (pseudo_setupenvp) iterates 'environ'.
+ * Whether the access to freed memory produces a crash depends on whether
+ * the allocator happens to reuse that block and overwrite its contents
+ * before pseudo reads from it.
+ *
+ * This binary faithfully reproduces the bash conditions:
+ *
+ *   1.  It exports its own setenv/getenv/unsetenv (like bash) that
+ *       maintain a private internal table, separate from 'environ'.
+ *       The regular setenv/getenv calls in pseudo therefore go to
+ *       *our* functions; only dlsym(RTLD_NEXT, …) from inside
+ *       libpseudo.so reaches glibc's implementations.
+ *
+ *   2.  It implements strvec_flush() and maybe_make_export_env() that
+ *       rebuild 'environ' from the internal table, freeing the old
+ *       array (which contains glibc-setenv-allocated blocks).
+ *
+ *   3.  It runs the pipeline pattern from opkg-build: fork N children
+ *       in parallel (simulating find|sort|tar|gzip|ar), each child
+ *       calling maybe_make_export_env() then execve().  This is the
+ *       exact sequence that triggered the production crash.
+ *
+ * Detection
+ * ---------
+ * The bug is detected deterministically rather than by relying on the
+ * probabilistic heap corruption it causes.  main() strips the derived
+ * PSEUDO_* variables (PSEUDO_BINDIR, PSEUDO_LIBDIR, …) from both the
+ * internal table and the real 'environ'.  pseudo's fork wrapper then
+ * runs pseudo_setupenv() in every child, which re-adds them:
+ *
+ *   Unfixed pseudo calls glibc's setenv (via dlsym(RTLD_NEXT, …)),
+ *   which writes the variables straight back into the real 'environ'
+ *   array — the very environ/allocator desync that corrupts bash's
+ *   heap in production.  The child observes the reappearing PSEUDO_*
+ *   entries and reports the bug.
+ *
+ *   Fixed pseudo calls the process's own setenv (ours/bash's), which
+ *   updates only the internal table, and the exec/system/popen
+ *   wrappers operate on a private pseudo_setupenvp() copy and restore
+ *   environ afterwards.  The real 'environ' is never mutated, so the
+ *   PSEUDO_* variables do not reappear and the child exits cleanly.
+ *
+ * See pseudo_mutated_environ() below for the check.  MALLOC_CHECK_=3 is
+ * still set by the shell wrapper as a secondary safety net so that any
+ * residual heap corruption also aborts the run.
+ */
+
+#define _GNU_SOURCE
+#include <errno.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+/* ------------------------------------------------------------------ */
+/* Internal variable table — analogous to bash's variable hash table.  */
+/* setenv/getenv/unsetenv operate on this table; 'environ' is rebuilt   */
+/* on demand by maybe_make_export_env().                                */
+/* ------------------------------------------------------------------ */
+
+static char **internal_table = NULL;  /* "KEY=VALUE" strings           */
+static int    internal_count  = 0;
+static int    internal_cap    = 0;
+
+static int table_find(const char *name, size_t nlen)
+{
+    for (int i = 0; i < internal_count; i++) {
+        if (strncmp(internal_table[i], name, nlen) == 0 &&
+            internal_table[i][nlen] == '=')
+            return i;
+    }
+    return -1;
+}
+
+/* ------------------------------------------------------------------ */
+/* Exported env functions — these override glibc's versions for code   */
+/* that resolves symbols via the normal dynamic-linker search order    */
+/* (i.e., callers inside bash/this binary itself).                     */
+/*                                                                      */
+/* dlsym(RTLD_NEXT, "setenv") from an LD_PRELOAD library searches      */
+/* *after* the LD_PRELOAD library, finding glibc's implementation —    */
+/* NOT these functions.  That is the exact asymmetry that pseudo's old  */
+/* workaround relied on and that caused the bug.                        */
+/*                                                                      */
+/* The defensive NULL checks below intentionally mirror bash's own      */
+/* env functions.  glibc declares setenv/getenv/unsetenv with the       */
+/* 'nonnull' attribute, so comparing the parameters to NULL trips       */
+/* -Wnonnull-compare; suppress it for this block since keeping the      */
+/* guards is deliberate.                                                */
+/* ------------------------------------------------------------------ */
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wnonnull-compare"
+
+int setenv(const char *name, const char *value, int overwrite)
+{
+    if (!name || !value || strchr(name, '=')) { errno = EINVAL; return -1; }
+    size_t nlen = strlen(name);
+    size_t vlen = strlen(value);
+
+    /* Before main() initialises internal_table, update environ directly. */
+    if (!internal_table) {
+        /* Find and replace existing entry, or append if not found. */
+        if (environ) {
+            for (int i = 0; environ[i]; i++) {
+                if (strncmp(environ[i], name, nlen) == 0 &&
+                    environ[i][nlen] == '=') {
+                    if (!overwrite) return 0;
+                    char *e = malloc(nlen + 1 + vlen + 1);
+                    if (!e) return -1;
+                    memcpy(e, name, nlen); e[nlen] = '=';
+                    memcpy(e + nlen + 1, value, vlen + 1);
+                    free(environ[i]); environ[i] = e;
+                    return 0;
+                }
+            }
+        }
+        /* Entry not found: can't resize kernel-provided environ safely;
+         * just ignore (the entry will be added when main() starts). */
+        return 0;
+    }
+
+    int idx = table_find(name, nlen);
+    if (idx >= 0) {
+        if (!overwrite) return 0;
+        free(internal_table[idx]);
+        internal_table[idx] = NULL;
+    } else {
+        /* Grow table if needed (keep a NULL sentinel). */
+        if (internal_count + 1 >= internal_cap) {
+            internal_cap = (internal_cap < 8) ? 16 : internal_cap * 2;
+            char **t = realloc(internal_table,
+                               (size_t)internal_cap * sizeof(char *));
+            if (!t) return -1;
+            internal_table = t;
+        }
+        idx = internal_count++;
+        internal_table[internal_count] = NULL;  /* sentinel */
+    }
+
+    char *entry = malloc(nlen + 1 + vlen + 1);
+    if (!entry) return -1;
+    memcpy(entry, name, nlen);
+    entry[nlen] = '=';
+    memcpy(entry + nlen + 1, value, vlen + 1);
+    internal_table[idx] = entry;
+    return 0;
+}
+
+char *getenv(const char *name)
+{
+    if (!name) return NULL;
+    size_t nlen = strlen(name);
+    /*
+     * Before main() initialises internal_table, fall back to searching
+     * 'environ' directly.  This is needed because pseudo's library
+     * constructor (which runs before main()) calls getenv() to read
+     * PSEUDO_PREFIX and similar variables.
+     */
+    if (!internal_table) {
+        for (int i = 0; environ && environ[i]; i++) {
+            if (strncmp(environ[i], name, nlen) == 0 &&
+                environ[i][nlen] == '=')
+                return environ[i] + nlen + 1;
+        }
+        return NULL;
+    }
+    int idx = table_find(name, nlen);
+    if (idx < 0) return NULL;
+    return internal_table[idx] + nlen + 1;
+}
+
+int unsetenv(const char *name)
+{
+    if (!name || !*name || strchr(name, '=')) { errno = EINVAL; return -1; }
+    size_t nlen = strlen(name);
+    /* Before internal_table is initialised, remove from environ directly. */
+    if (!internal_table) {
+        if (environ) {
+            for (int i = 0; environ[i]; i++) {
+                if (strncmp(environ[i], name, nlen) == 0 &&
+                    environ[i][nlen] == '=') {
+                    free(environ[i]);
+                    int j = i;
+                    while (environ[j]) { environ[j] = environ[j+1]; j++; }
+                    break;
+                }
+            }
+        }
+        return 0;
+    }
+    int idx = table_find(name, nlen);
+    if (idx < 0) return 0;
+    free(internal_table[idx]);
+    memmove(&internal_table[idx], &internal_table[idx + 1],
+            (size_t)(internal_count - idx) * sizeof(char *));
+    internal_count--;
+    return 0;
+}
+
+#pragma GCC diagnostic pop
+
+/* ------------------------------------------------------------------ */
+/* strip_asan_from_preload — remove any libasan*.so entry from an      */
+/* LD_PRELOAD value string (colon-separated list).                     */
+/*                                                                      */
+/* The ASAN library must be first in LD_PRELOAD so that ASAN properly  */
+/* intercepts malloc/free from all shared libraries (including         */
+/* libpseudo.so).  But the pseudo server-spawn grandchild must NOT     */
+/* inherit it: the server is not ASAN-compiled and loading the ASAN    */
+/* runtime into an uninstrumented binary causes SQLite lock failures.  */
+/*                                                                      */
+/* By stripping libasan from the rebuilt 'environ' (produced by        */
+/* maybe_make_export_env), the server inherits clean LD_PRELOAD while  */
+/* ASAN detection in the direct child (fork of our binary) remains    */
+/* active — ASAN is compiled into the binary and its hooks are already */
+/* live from process startup.                                           */
+/* ------------------------------------------------------------------ */
+static char *strip_asan_from_preload(const char *preload)
+{
+    if (!preload) return NULL;
+    char *buf = strdup(preload);
+    if (!buf) return NULL;
+
+    char *out = buf;
+    const char *in  = preload;
+    while (*in) {
+        const char *sep = strchr(in, ':');
+        size_t len = sep ? (size_t)(sep - in) : strlen(in);
+
+        /* skip any entry that contains "libasan" */
+        int skip = 0;
+        for (size_t k = 0; k + 6 <= len; k++) {
+            if (strncmp(in + k, "libasan", 7) == 0) { skip = 1; break; }
+        }
+
+        if (!skip) {
+            memcpy(out, in, len);
+            out += len;
+            if (sep) *out++ = ':';
+        }
+
+        in += len;
+        if (*in == ':') in++;
+    }
+    /* trim a trailing colon */
+    if (out > buf && *(out - 1) == ':') out--;
+    *out = '\0';
+    return buf;
+}
+
+/* ------------------------------------------------------------------ */
+/* strvec_flush — free every string in v[] then free v itself.         */
+/* Equivalent to bash's strvec_flush() in lib/sh/stringvec.c.         */
+/* ------------------------------------------------------------------ */
+static void strvec_flush(char **v)
+{
+    if (!v) return;
+    for (int i = 0; v[i]; i++)
+        free(v[i]);
+    free(v);
+}
+
+/* ------------------------------------------------------------------ */
+/* maybe_make_export_env — rebuild 'environ' from the internal table.  */
+/*                                                                      */
+/* This is the function that triggers the use-after-free:              */
+/*                                                                      */
+/*   1. pseudo's fork wrapper called glibc's setenv (via               */
+/*      dlsym(RTLD_NEXT)) which allocated block A and stored it in     */
+/*      environ[LD_PRELOAD_idx].                                        */
+/*                                                                      */
+/*   2. strvec_flush(old_environ) frees block A.                       */
+/*                                                                      */
+/*   3. environ is updated to point to the freshly built array         */
+/*      (built from internal_table, which has the original LD_PRELOAD  */
+/*      without libpseudo.so).                                         */
+/*                                                                      */
+/*   4. When the exec wrapper runs pseudo_setupenvp(environ), the      */
+/*      allocator may have already reused block A, causing the freed   */
+/*      memory to be read with stale or garbage content.               */
+/* ------------------------------------------------------------------ */
+static void maybe_make_export_env(void)
+{
+    char **old = environ;
+
+    /* Build fresh copy from internal_table. */
+    char **new_env = malloc((size_t)(internal_count + 1) * sizeof(char *));
+    if (!new_env) return;
+
+    for (int i = 0; i < internal_count; i++) {
+        new_env[i] = strdup(internal_table[i]);
+        if (!new_env[i]) {
+            for (int j = 0; j < i; j++) free(new_env[j]);
+            free(new_env);
+            return;
+        }
+    }
+    new_env[internal_count] = NULL;
+
+    /* Strip the ASAN runtime library from LD_PRELOAD in BOTH the rebuilt
+     * environ AND internal_table so that the pseudo server-spawn grandchild
+     * does not inherit it (via pseudo_setupenv reading internal_table).
+     *
+     * ASAN detection in the current process is unaffected: libasan.so is
+     * already mapped in the process address space (loaded at startup via the
+     * original LD_PRELOAD) and its malloc/free interception is active at the
+     * GOT/PLT level for all shared libraries including libpseudo.so.
+     * Removing it from environ/internal_table only affects exec'd children. */
+    const char *ldp = "LD_PRELOAD=";
+    size_t ldp_len = strlen(ldp);
+    for (int i = 0; i < internal_count; i++) {
+        if (strncmp(new_env[i], ldp, ldp_len) == 0) {
+            char *stripped = strip_asan_from_preload(new_env[i] + ldp_len);
+            if (stripped) {
+                char *new_entry = malloc(ldp_len + strlen(stripped) + 1);
+                if (new_entry) {
+                    memcpy(new_entry, ldp, ldp_len);
+                    strcpy(new_entry + ldp_len, stripped);
+                    free(new_env[i]);
+                    new_env[i] = new_entry;
+                }
+                free(stripped);
+            }
+            break;
+        }
+    }
+    /* Also strip from internal_table so pseudo_setupenv() doesn't
+     * re-inject libasan into environ via SETENV(). */
+    for (int i = 0; i < internal_count; i++) {
+        if (strncmp(internal_table[i], ldp, ldp_len) == 0) {
+            char *stripped = strip_asan_from_preload(internal_table[i] + ldp_len);
+            if (stripped) {
+                char *new_entry = malloc(ldp_len + strlen(stripped) + 1);
+                if (new_entry) {
+                    memcpy(new_entry, ldp, ldp_len);
+                    strcpy(new_entry + ldp_len, stripped);
+                    free(internal_table[i]);
+                    internal_table[i] = new_entry;
+                }
+                free(stripped);
+            }
+            break;
+        }
+    }
+
+    /*
+     * Free the old environ.  This releases every entry including any
+     * block that glibc's setenv (called by pseudo_setupenv inside the
+     * fork wrapper) had allocated.  After this, 'environ' briefly
+     * points to freed memory until the assignment below.
+     */
+    strvec_flush(old);
+
+    environ = new_env;
+}
+
+/* ------------------------------------------------------------------ */
+/* run_pipeline_iteration                                               */
+/*                                                                      */
+/* Simulate one opkg-build pipeline:                                   */
+/*   ( cd CTRL && find . | sort > list )                               */
+/*   ( cd PKG  && find . | sort > list )                               */
+/*   ( cd PKG  && tar  … | gzip  > data.tar.gz )                      */
+/*   ( cd CTRL && tar  … | gzip  > ctrl.tar.gz )                      */
+/*   ( cd TMP  && ar …               )                                 */
+/*                                                                      */
+/* Each child simulates one pipeline stage:                            */
+/*   fork → [pseudo fork wrapper runs pseudo_setupenv() in the child]  */
+/*   child: check whether pseudo mutated the real environ (bug), then  */
+/*          maybe_make_export_env() and execve("/bin/true")            */
+/* ------------------------------------------------------------------ */
+#define PIPELINE_STAGES 5   /* find, sort, tar, gzip, ar */
+
+/* Distinct exit code a child uses to report that pseudo mutated the
+ * real 'environ' array (i.e. the unfixed glibc-setenv code path ran). */
+#define BUG_ENV_MUTATED 42
+
+/* ------------------------------------------------------------------ */
+/* pseudo_mutated_environ — deterministic detector for the bug fixed   */
+/* by commits 9c6b4c1..5abd42c.                                        */
+/*                                                                      */
+/* pseudo's fork wrapper runs pseudo_setupenv() in every child before   */
+/* fork() returns.  pseudo_setupenv() re-adds the PSEUDO_* variables    */
+/* (PSEUDO_BINDIR, PSEUDO_LIBDIR, …) that main() stripped from the      */
+/* environment:                                                         */
+/*                                                                      */
+/*   Unfixed pseudo:  SETENV -> pseudo_real_setenv (dlsym RTLD_NEXT) -> */
+/*     glibc setenv, which writes directly into the real 'environ'      */
+/*     array.  The stripped PSEUDO_* vars therefore REAPPEAR in the     */
+/*     real environ — exactly the environ/allocator desync that         */
+/*     corrupts bash's heap in production.                              */
+/*                                                                      */
+/*   Fixed pseudo:  SETENV -> setenv -> our (bash's) setenv, which only */
+/*     updates internal_table; the exec/system/popen wrappers build a   */
+/*     private copy with pseudo_setupenvp() and restore environ.  The   */
+/*     real 'environ' is left untouched, so the stripped PSEUDO_* vars  */
+/*     do NOT reappear there.                                           */
+/*                                                                      */
+/* Returns 1 if a stripped PSEUDO_* var was found back in the real      */
+/* environ (buggy code path), 0 otherwise.  Must be called BEFORE       */
+/* maybe_make_export_env(), which would otherwise rebuild environ from  */
+/* internal_table and discard the injected entries.                     */
+/* ------------------------------------------------------------------ */
+static int pseudo_mutated_environ(void)
+{
+    for (int i = 0; environ && environ[i]; i++) {
+        if (strncmp(environ[i], "PSEUDO_BINDIR=", 14) == 0 ||
+            strncmp(environ[i], "PSEUDO_LIBDIR=", 14) == 0)
+            return 1;
+    }
+    return 0;
+}
+
+static int run_pipeline_iteration(void)
+{
+    pid_t pids[PIPELINE_STAGES];
+    char *const args[] = { "/bin/true", NULL };
+
+    /*
+     * Fork all pipeline stages before waiting for any of them, just
+     * as bash does for a pipeline.  pseudo's fork wrapper runs
+     * pseudo_setupenv() in each child before fork() returns.
+     */
+    for (int s = 0; s < PIPELINE_STAGES; s++) {
+        pid_t pid = fork();
+        if (pid < 0) {
+            perror("fork");
+            return -1;
+        }
+        if (pid == 0) {
+            /*
+             * Child: pseudo's fork wrapper has already run
+             * pseudo_setupenv() at this point.  Deterministically
+             * detect whether it mutated the real environ (the bug)
+             * before doing anything that would rebuild environ.
+             */
+            if (pseudo_mutated_environ())
+                _exit(BUG_ENV_MUTATED);
+            /*
+             * Otherwise behave like bash: rebuild environ from the
+             * internal table and exec the pipeline stage.
+             */
+            maybe_make_export_env();
+            execve("/bin/true", args, environ);
+            _exit(127);
+        }
+        pids[s] = pid;
+    }
+
+    /* Wait for all pipeline children. */
+    for (int s = 0; s < PIPELINE_STAGES; s++) {
+        int status;
+        if (waitpid(pids[s], &status, 0) < 0) {
+            perror("waitpid");
+            return -1;
+        }
+        if (WIFEXITED(status) && WEXITSTATUS(status) == BUG_ENV_MUTATED) {
+            fprintf(stderr,
+                "BUG: pseudo mutated the real environ in pipeline stage %d "
+                "(pseudo_setupenv used glibc setenv via dlsym instead of the "
+                "process's own setenv)\n", s);
+            return -1;
+        }
+        if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
+            fprintf(stderr, "pipeline stage %d failed: status=%d\n",
+                    s, status);
+            return -1;
+        }
+    }
+    return 0;
+}
+
+int main(void)
+{
+    /* Initialise internal_table from the process's current environ. */
+    int count = 0;
+    while (environ[count]) count++;
+
+    internal_cap = count + 32;
+    internal_table = malloc((size_t)internal_cap * sizeof(char *));
+    if (!internal_table) { perror("malloc"); return 1; }
+
+    for (int i = 0; i < count; i++) {
+        internal_table[i] = strdup(environ[i]);
+        if (!internal_table[i]) { perror("strdup"); return 1; }
+    }
+    internal_table[count] = NULL;
+    internal_count = count;
+
+    /*
+     * Replace environ with a heap-allocated copy before any call to
+     * maybe_make_export_env().  The initial environ supplied by the
+     * kernel exec is in static memory, not the heap; calling free()
+     * on those strings (as strvec_flush does) would crash immediately.
+     * bash avoids this by building its own heap-allocated export_env
+     * during shell initialisation; we replicate that here.
+     */
+    {
+        char **heap_env = malloc((size_t)(count + 1) * sizeof(char *));
+        if (!heap_env) { perror("malloc"); return 1; }
+        for (int i = 0; i < count; i++) {
+            heap_env[i] = strdup(internal_table[i]);
+            if (!heap_env[i]) { perror("strdup"); return 1; }
+        }
+        heap_env[count] = NULL;
+        environ = heap_env;   /* now environ is entirely heap-managed */
+    }
+
+    /*
+     * Run 20 pipeline iterations.  With the unfixed pseudo code and
+     * MALLOC_CHECK_=3, the heap corruption introduced by pseudo_setupenv
+     * (glibc setenv) followed by strvec_flush causes an abort within
+     * the first few iterations on affected platforms.
+     */
+
+    /*
+     * Strip the ASAN runtime library from LD_PRELOAD and strip derived
+     * PSEUDO_* vars (PSEUDO_BINDIR, PSEUDO_LIBDIR, etc.) from both
+     * internal_table and heap environ BEFORE any pseudo-intercepted call.
+     *
+     * Reason for stripping ASAN from LD_PRELOAD:
+     *   The pseudo server-spawn grandchild is not ASAN-compiled; loading the
+     *   ASAN runtime into an uninstrumented binary causes SQLite lock failures.
+     *
+     * Reason for stripping derived PSEUDO_* vars:
+     *   When the test runs inside the pseudo test framework, the outer pseudo's
+     *   exec wrapper pre-populates all PSEUDO_* vars (PSEUDO_BINDIR etc.) in
+     *   the binary's initial environ via pseudo_setupenvp().  If these are
+     *   already present, pseudo_setupenv() treats them as existing (overwrite=0)
+     *   and makes no change — no __add_to_environ() call, no heap-corruption
+     *   opportunity, MALLOC_CHECK_=3 detects nothing.
+     *   By stripping them here we force pseudo_setupenv() (in the fork wrapper
+     *   of every child) to ADD them as NEW environ entries.  The unfixed code
+     *   (glibc setenv via dlsym RTLD_NEXT) calls __add_to_environ() which
+     *   calls realloc(environ_array).  strvec_flush() later frees that
+     *   allocation, and MALLOC_CHECK_=3 aborts the server-spawn grandchild.
+     *   The fixed code calls our binary's setenv() which updates internal_table
+     *   only — no __add_to_environ(), no realloc, no corruption.
+     *
+     * We keep PSEUDO_PREFIX and PSEUDO_LOCALSTATEDIR so that pseudo can find
+     * the server binary and socket directory.
+     */
+    {
+        const char *ldp    = "LD_PRELOAD=";
+        size_t      ldp_len = strlen(ldp);
+
+        /* is_kept: return 1 if the env entry should NOT be stripped.
+         * Keep non-PSEUDO, non-LD_PRELOAD, PSEUDO_PREFIX, PSEUDO_LOCALSTATEDIR.
+         */
+        /* NOTE: GCC nested functions are used here for locality; the lambda
+         * captures ldp/ldp_len by enclosing scope (auto function). */
+        int is_kept(const char *entry) {
+            if (strncmp(entry, "PSEUDO_", 7) != 0 &&
+                strncmp(entry, ldp, ldp_len) != 0)
+                return 1;
+            if (strncmp(entry, "PSEUDO_PREFIX=",       14) == 0) return 1;
+            if (strncmp(entry, "PSEUDO_LOCALSTATEDIR=", 21) == 0) return 1;
+            return 0;
+        }
+
+        /* Process internal_table */
+        {
+            int w = 0;
+            for (int i = 0; i < internal_count; i++) {
+                if (strncmp(internal_table[i], ldp, ldp_len) == 0) {
+                    /* LD_PRELOAD: strip ASAN lib, keep pseudo lib */
+                    char *s = strip_asan_from_preload(
+                                  internal_table[i] + ldp_len);
+                    char *e = s ? malloc(ldp_len + strlen(s) + 1) : NULL;
+                    if (e) {
+                        memcpy(e, ldp, ldp_len);
+                        strcpy(e + ldp_len, s);
+                    }
+                    free(internal_table[i]);
+                    free(s);
+                    if (e) internal_table[w++] = e;
+                } else if (is_kept(internal_table[i])) {
+                    internal_table[w++] = internal_table[i];
+                } else {
+                    free(internal_table[i]); /* drop PSEUDO_BINDIR etc. */
+                }
+            }
+            internal_count = w;
+            internal_table[w] = NULL;
+        }
+
+        /* Process environ (the heap copy) */
+        {
+            int w = 0;
+            for (int i = 0; environ[i]; i++) {
+                if (strncmp(environ[i], ldp, ldp_len) == 0) {
+                    char *s = strip_asan_from_preload(environ[i] + ldp_len);
+                    char *e = s ? malloc(ldp_len + strlen(s) + 1) : NULL;
+                    if (e) {
+                        memcpy(e, ldp, ldp_len);
+                        strcpy(e + ldp_len, s);
+                    }
+                    free(environ[i]);
+                    free(s);
+                    if (e) environ[w++] = e;
+                } else if (is_kept(environ[i])) {
+                    environ[w++] = environ[i];
+                } else {
+                    free(environ[i]); /* drop PSEUDO_BINDIR etc. */
+                }
+            }
+            environ[w] = NULL;
+        }
+    }
+
+    /*
+     * Force pseudo server startup in the parent before any children are
+     * forked.  Without this, all PIPELINE_STAGES children may try to spawn
+     * the server simultaneously, causing a "lock_held" race condition on the
+     * first iteration.
+     */
+    access(".", F_OK);
+
+    int iterations = 20;
+    for (int i = 0; i < iterations; i++) {
+        if (run_pipeline_iteration() != 0) {
+            fprintf(stderr, "FAILED on pipeline iteration %d\n", i + 1);
+            return 1;
+        }
+    }
+
+    return 0;
+}
diff --git a/test/test-bash-exec-env.sh b/test/test-bash-exec-env.sh
new file mode 100755
index 0000000..d95a04d
--- /dev/null
+++ b/test/test-bash-exec-env.sh
@@ -0,0 +1,34 @@ 
+#!/bin/bash
+#
+# SPDX-License-Identifier: LGPL-2.1-only
+#
+# Test for the bash/pseudo environment conflict:
+#   https://bugzilla.yoctoproject.org/show_bug.cgi?id=16078
+#
+# The compiled helper binary (test-bash-exec-env.c) exports its own
+# getenv/setenv/unsetenv (simulating bash) and calls maybe_make_export_env()
+# before each execve(), exactly reproducing the opkg-build crash pattern.
+#
+# Detection mechanism:
+#   The binary strips all PSEUDO_* vars (except PSEUDO_PREFIX and
+#   PSEUDO_LOCALSTATEDIR) from its internal table and environ before
+#   starting.  This forces pseudo_setupenv() — called by pseudo's fork
+#   wrapper in each child — to ADD those vars back.
+#
+#   The check is deterministic: each child inspects the real environ after
+#   pseudo's fork wrapper has run.
+#
+#   Unfixed pseudo: SETENV() -> dlsym(RTLD_NEXT) -> glibc setenv writes the
+#     stripped PSEUDO_* vars straight back into the real environ array.  The
+#     child sees them reappear and reports the bug (exit 42); the test fails.
+#
+#   Fixed pseudo: SETENV() -> our binary's setenv() -> internal_table only;
+#     the real environ is left untouched, no PSEUDO_* vars reappear, the child
+#     execs cleanly and the test passes.
+#
+#   MALLOC_CHECK_=3 is kept as a secondary safety net so any residual heap
+#   corruption from the unfixed code also aborts the run.
+
+MALLOC_CHECK_=3 $(dirname "$0")/test-bash-exec-env || { echo "FAILED: test-bash-exec-env returned $?" ; exit 1; }
+
+exit 0