Skip to content

Case 26: Union Field Added (Size Grows)

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux, macOS, Windows
Flags ABI break
Detected ChangeKinds union_field_added, type_size_changed
Source files examples/case26_union_field_added/

Category: Type Layout | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

union Value gains a double d member. double (8 bytes) is larger than either existing member (int i/float f, both 4 bytes), so sizeof(union Value) grows from 4 to 8 bytes. Every existing field stays at offset 0, but any caller that stack- or heap-allocated the old 4-byte size and passes it to the v2 library's fill() โ€” which now writes a full 8-byte double โ€” writes past the end of its own allocation.

Old/new diff

old/lib.h new/lib.h
union Value { int i; float f; }; union Value { int i; float f; double d; };
fill() writes v->i = 42; fill() writes v->d = 3.1415926535;

abicheck command

gcc -shared -fPIC -g old/lib.c -Iold -o libval_v1.so
gcc -shared -fPIC -g new/lib.c -Inew -o libval_v2.so
abicheck compare libval_v1.so libval_v2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- type_size_changed: Size changed: Value (32 -> 64 bits)
  > Old code allocates or copies the type with the old size; heap/stack
    corruption, out-of-bounds access.
  Affected symbols: fill

Additions:
- union_field_added: Union field added: Value::d (double)
  > Union size may grow; old code allocating with old sizeof gets
    truncated data.
  Affected symbols: fill

Minimum evidence

min_evidence: L1 โ€” DWARF's DW_TAG_union_type carries the union's total byte size directly (the max of its members' sizes), so -g alone is enough to see the 32โ†’64 bit size change; no public headers required.

Why abicheck catches it

DWARF records each union's total byte size for both versions; abicheck compares the sizes directly from debug info and reports a mismatch as type_size_changed, alongside the new member itself as union_field_added.

Runtime failure demonstration

Severity: CRITICAL

Scenario: app stack-allocates a 4-byte union Value (the v1 layout) and calls fill(). Against v2, fill() writes a full 8-byte double into that 4-byte allocation.

# Build old library + app
gcc -shared -fPIC -g old/lib.c -Iold -o libval.so
gcc -g app.c -Iold -L. -lval -Wl,-rpath,. -o app
./app
# โ†’ after fill: v.i = 42
# โ†’ unexpected old-compatible value
# (exit 0)

# Swap in new library (no recompile)
gcc -shared -fPIC -g new/lib.c -Inew -o libval.so
./app
# โ†’ *** stack smashing detected ***: terminated
# (exit 134 โ€” aborted by __stack_chk_fail)

Why CRITICAL: sizeof(union Value) grows from 4 to 8 bytes because of the new double d member, but the app still only reserves 4 bytes on the stack for it. v2's fill() writes 8 bytes into that 4-byte slot, overwriting the stack canary; glibc's stack protector detects the corruption and aborts the process.

Safe redesign

Never add a union member whose size exceeds the current largest member in a binary-compatible release. If a wider variant is genuinely needed, introduce a new type (ValueV2) or a tagged union with an explicit discriminator so callers opt in to the larger size deliberately, rather than being silently under-allocated by an old header.

Real-world example: kernel UAPI unions (e.g. union bpf_attr) are deliberately padded to a fixed maximum size up front, precisely so that new members can be added later without ever changing sizeof().

Cross-tool comparison

abidw --out-file v1.xml libval_v1.so
abidw --out-file v2.xml libval_v2.so
abidiff v1.xml v2.xml

References


Source files

  • CMakeLists.txt
  • app.c

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