Skip to content

Case 67: TLS Variable Size Changed

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux, macOS
Flags ABI break, API break
Detected ChangeKinds tls_var_size_changed
Source files browse on GitHub

Category: Variable ABI | Verdict: BREAKING

What breaks

The thread-local ErrorCtx struct grows from 68 bytes (v1) to 72 bytes (v2) because a new severity field is inserted between code and message. This shifts message from offset 4 to offset 8.

A consumer compiled against v1 accesses tls_error.message at offset 4, but v2 wrote the severity integer there. The app reads the integer bytes as a string โ€” getting garbage or an empty string instead of the error message.

Why this matters

Thread-local variables (__thread / thread_local) are commonly used for per-thread error state, logging context, and locale data. When exported as part of a library's public ABI, the struct layout of TLS variables becomes a binary contract:

  • Consumers that access struct fields directly (not through accessor functions) embed the field offsets at compile time
  • Changing the struct layout changes the ELF symbol size (st_size in .dynsym) which abicheck tracks
  • The corruption is per-thread and hard to reproduce in testing because each thread gets its own TLS copy

This break is particularly dangerous because: - TLS variables are often accessed on hot paths (error checking, logging) - The struct may be large (message buffers, context data) - Inserting a field is a natural "improvement" that seems harmless

Code diff

// v1: message at offset 4
typedef struct ErrorCtx {
    int   code;          /* offset 0 */
    char  message[64];   /* offset 4 */
} ErrorCtx;  /* sizeof = 68 */

// v2: severity inserted, message shifts to offset 8
typedef struct ErrorCtx {
    int   code;          /* offset 0 (unchanged) */
    int   severity;      /* offset 4 (NEW!) */
    char  message[64];   /* offset 8 (was 4 โ€” shifted!) */
} ErrorCtx;  /* sizeof = 72 */

Real Failure Demo

Severity: CRITICAL

Scenario: compile app against v1, swap in v2 .so without recompile.

# Build old library + app
gcc -shared -fPIC -g v1.c -o liblogger.so
gcc -g app.c -L. -llogger -Wl,-rpath,. -o app
./app
# โ†’ error code = 404 (expected 404)
# โ†’ message = "not found" (expected "not found")

# Swap in new library (no recompile)
gcc -shared -fPIC -g v2.c -o liblogger.so
./app
# โ†’ error code = 404 (expected 404)
# โ†’ message = "\x03" (expected "not found")   โ† reads severity=3 as a char!
# โ†’ CORRUPTION: TLS struct layout changed

Why CRITICAL: The app reads tls_error.message at v1's offset 4, but v2 placed the severity integer (value 3) there. On little-endian x86, the app interprets bytes 0x03 0x00 0x00 0x00 as a 1-character string "\x03" (a non-printable control character). The actual message "not found" is at offset 8, which the app never reads. No crash occurs โ€” just silently wrong error messages, making debugging extremely difficult.

How to fix

  1. Use accessor functions: don't export TLS variables directly; provide logger_get_message() / logger_set_message() instead
  2. Append-only layout: only add new fields at the end of the struct, never insert between existing fields
  3. Use opaque pointers: extern __thread void *tls_error_ctx; with accessor functions that cast internally
  4. Freeze exported struct layout: treat the sizeof and field offsets of any exported TLS variable as part of the ABI contract
  5. Add reserved space: include padding fields for future expansion

Real-world example

glibc's errno is a TLS variable (__thread int errno). Its size (4 bytes) has been frozen since glibc 2.0 โ€” changing it would break every C program in existence. Similarly, __thread locale_t __locale in glibc has a fixed layout that cannot change without breaking the ABI.

OpenSSL's per-thread error queue used to be a public TLS struct; OpenSSL 3.0 moved to an opaque handle specifically to avoid this class of break when the error context needed to grow.

abicheck detection

abicheck detects this as tls_var_size_changed (BREAKING) by comparing the st_size field in the .dynsym entry for TLS symbols (those with STT_TLS type) between the two library versions.

References


Source files

See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.