Linux Environment Variable Shim (carb.env.shim)#

Overview#

carb.env.shim is a small Linux-only preload library that interposes the libc environment variable accessor functions (getenv(), secure_getenv(), setenv(), putenv(), unsetenv(), clearenv()) with versions that serialize every call through a single mutex and return process-lifetime stable pointers from an in-process cache of observed values.

It exists to work around a long-standing thread-safety hole in glibc where a getenv() running on one thread can race against a setenv() (or unsetenv()) running on another, and observe a freed or torn pointer. The pattern is dangerous because it is pervasive: many GLIBC internals (locale, time-zone handling, ICU, OpenSSL, CUDA, the dynamic linker itself, etc.) call getenv() from inside otherwise unrelated APIs, so an application that “only sets environment variables during startup” can still trip the bug long after main() has returned if any of those library entry points race with a setter on a worker thread.

The race was finally addressed in glibc 2.41, but most production container base images still ship with older glibc (Ubuntu 22.04 carries 2.35, Ubuntu 24.04 carries 2.39, RHEL 9 carries 2.34, etc.), so the bug remains exploitable on systems users actually deploy on today. See the @warning block on carb::extras::EnvironmentVariable::setValue() for the in-tree commentary on the race.

Scope: The shim is Linux-only. It is not built on Windows; that platform has entirely different environment APIs and do not exhibit the glibc race.

When to Use It#

Preload carb.env.shim if any of the following apply to a Linux deployment:

  • The process is multi-threaded and any thread (your code, or any library you link against) may call setenv(), unsetenv(), putenv(), or clearenv() while another thread can call getenv() or secure_getenv().

  • The target environment runs glibc earlier than 2.41 (almost all production container base images today).

  • The host application reads back environment variable strings and keeps the returned pointer around for longer than the call that produced it. Vanilla getenv() does not guarantee that pointer remains valid after a subsequent modifying call; the shim does.

If a process is strictly single-threaded for the entire duration of its environment accesses, or if every thread is purely reading and nothing in the process (including third-party libraries) ever modifies the environment, the shim provides no benefit and should not be preloaded.

What It Does#

When loaded into a process, carb.env.shim:

  1. Runs its library constructor at load time (__attribute__((constructor))), which resolves the real libc implementations of each env function via dlsym(RTLD_NEXT, ...). Resolution also happens lazily on the first call to each exported function as a safety net for very early dynamic-linker activity that may reach into one of the shim’s functions before the constructor has had a chance to run.

  2. Exports its own versions of getenv(), secure_getenv(), setenv(), putenv(), unsetenv(), and clearenv(). Because the shim is loaded ahead of libc by the dynamic linker, these versions take precedence over libc’s symbols.

  3. Serializes every call through a single std::mutex (shimMutex()). This blocks any combination of readers and writers from running concurrently against libc’s environment block, eliminating the race.

  4. On reads, calls libc to get the current value and then interns the returned string into a process-lifetime hash set (std::unordered_set<std::string>). The pointer returned to the caller points into this cache, not into libc’s environment block. Once a value has been interned, the cached string lives for the rest of the process’ lifetime; the shim never erases from the cache.

  5. On writes, simply delegates to libc under the same mutex. The cache is not touched on the write path – the next read after the write will populate it.

How to Deploy#

Per-process via LD_PRELOAD#

Set the LD_PRELOAD environment variable to the absolute path of the shim before launching the host process:

LD_PRELOAD=/path/to/libcarb.env.shim.so /path/to/myapp [args...]

If LD_PRELOAD already has entries, append the shim with a : separator. Order matters when mixing with compiler sanitizer runtimes: ASan, TSan, UBSan, MSan, and LSan all require their own runtime to be loaded first. In a Carbonite test build, this is what carb::buildChildLdPreload() in source/tests/test.unit/common/TestHelpers.h exists for; for a manual deployment the order is:

LD_PRELOAD="/path/to/libasan.so:/path/to/libcarb.env.shim.so" /path/to/myapp

System-wide inside a container via /etc/ld.so.preload#

For container deployments where the host application cannot easily be wrapped or the launch command is controlled by an orchestration layer, the recommended option is to write the absolute path of the shim into the container’s /etc/ld.so.preload:

# In your Dockerfile, after the shim has been copied into the image:
RUN echo '/opt/myapp/lib/libcarb.env.shim.so' >> /etc/ld.so.preload

/etc/ld.so.preload is consumed by the dynamic linker for every dynamically-linked process started inside the container, so this makes the shim apply to everything in that container (including the shell, package managers, and child processes spawned by the host application) for the rest of the container’s lifetime.

Do not modify /etc/ld.so.preload outside of a container. Doing so on the host system would inject the shim into every process on the machine, including system-level daemons. Containerization is what makes the preload approach safe – it limits the blast radius to one image, one workload.

Guarantees and Tradeoffs#

Pointer stability#

The pointer returned by getenv() or secure_getenv() is stable for the remainder of the process’ lifetime, even across subsequent setenv(), unsetenv(), or clearenv() calls that change or remove the variable. Vanilla libc makes no such promise – in fact, the race the shim fixes is exactly the case where vanilla libc frees the previous backing buffer out from under a reader.

This is a strictly stronger guarantee than POSIX requires. Code that captures a const char* from getenv() and stores it longer than the strict POSIX-permitted lifetime (a very common pattern) is safe with the shim loaded, but still not advised. If the returned string needs to be retained, it should still be copied instead.

Cache growth#

The cache grows monotonically: it never erases entries, even when a variable is overwritten or unset. The size of the cache is bounded by the number of distinct value strings the host process ever produces – not by the number of getenv() calls. In practice this is a small, fixed working set; the cache only grows unboundedly if the host application actively churns through huge numbers of unique values (e.g. encoding a timestamp into an envvar value inside a tight loop), which is itself a use pattern the shim’s authors consider misuse.

Values are interned by string content, not by (name, value) pair. Two different envvars that happen to carry the same value (e.g. two paths that both resolve to the same directory) share a single cache entry.

Performance#

Each getenv() / setenv() call pays:

  • One std::mutex acquire and one release (a single uncontended atomic CAS on Linux).

  • For reads, one hash-set lookup against the cache. Cache hits are O(1) average and do not allocate; cache misses pay an additional std::string heap copy.

  • For writes, one delegation to libc.

Compared to vanilla libc the shim adds the mutex and the cache lookup. Compared to a std::shared_mutex-based interposer the shim is materially faster: glibc’s pthread_rwlock (which backs std::shared_mutex on Linux) has measurably higher per-call overhead than pthread_mutex even uncontended, and reading the cache to return a stable pointer would have required exclusive access on the cache anyway.

For workloads dominated by getenv() calls, a 1M-call multi-threaded stress test on the reference Linux x86_64 build completes in roughly 1.5 seconds with the shim loaded, vs. an unbounded crash without it.

POSIX semantics#

The shim is a drop-in functional replacement for the libc routines it interposes. The only deviations are:

  • getenv() / secure_getenv() return pointers into the shim’s cache instead of into libc’s environment block. Dereferencing them produces the same bytes that libc would have returned.

  • All write functions hold the shim mutex for the duration of the libc call, which means a setenv() cannot interleave with any getenv(), setenv(), etc. This is a strictly stronger ordering guarantee than POSIX requires.

  • If the underlying libc symbol cannot be resolved via dlsym(RTLD_NEXT, ...) (in practice this should never happen on a real system), the write functions set errno = ENOSYS and return -1, matching POSIX failure-reporting conventions for these calls.

The shim does not:

  • Modify what libc reports through environ[] or through getenv()’s own search. Read functions always consult libc first; the cache is only how the returned pointer stays stable.

  • Block or queue calls. Concurrent callers serialize through the mutex but no call is ever silently dropped or deferred.

  • Persist anything across process restarts; the cache lives only for the lifetime of the loaded library.

See Also#

  • include/carb/extras/EnvironmentVariable.h – the RAII wrapper around the libc env functions. Its docstrings include the original warning about the glibc race.

  • glibc bug tracker entries BZ#15607 and BZ#19086 for upstream discussion of the getenv() / setenv() race; the actual fix landed in glibc 2.41.