Skip to content

Case 14: C++ Class Size Change

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux
Flags ABI break, API break
Detected ChangeKinds type_size_changed
Source files examples/case14_cpp_class_size/

Category: C++ ABI | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

Old code allocates Buffer on the stack or via new, expecting 64 bytes. v2's Buffer needs 128 bytes. The constructor (which zero-initializes the data array) writes 128 bytes into whatever allocation the caller made, corrupting adjacent memory if that allocation is still sized for v1. Any pre-built consumer that embeds Buffer by value is broken without recompilation; consumers recompiled against the new layout are unaffected.

Old/new diff

v1.h v2.h
char data[64]; char data[128];

abicheck command

g++ -shared -fPIC -g v1.cpp -o libbuf_v1.so
g++ -shared -fPIC -g v2.cpp -o libbuf_v2.so
abicheck compare libbuf_v1.so libbuf_v2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

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

Minimum evidence

min_evidence: L1 โ€” DWARF's structure-type entry (DW_TAG_class_type size in bytes) is enough to detect the size change; no public headers required.

Why abicheck catches it

DWARF records each class's total byte size for both versions; abicheck compares them directly from debug info, the same mechanism it uses for plain C structs.

Runtime failure demonstration

Severity: CRITICAL

Scenario: app allocates Buffer by value on the stack using v1's layout (64 bytes, -O0 for predictable stack layout). With v2 the constructor initializes 128 bytes, writing past the allocated slot.

# Build v1 + app (use -O0 so stack layout is predictable)
g++ -shared -fPIC -g v1.cpp -o libbuf.so
g++ -g -O0 app.cpp -I. -L. -lbuf -Wl,-rpath,. -o app
./app
# โ†’ via factory: size() = 64 (expected 64)
# โ†’ canary = CANARY!
# โ†’ after  = AFTER!!

# Swap in v2 (sizeof Buffer = 128, constructor writes 128 bytes)
g++ -shared -fPIC -g v2.cpp -o libbuf.so
./app
# โ†’ via factory: size() = 128 (expected 64)
# โ†’ *** stack smashing detected ***: terminated
# โ†’ Aborted (core dumped)

Why CRITICAL: the v2 constructor writes 128 bytes into a stack slot the app only sized for 64. glibc's stack protector notices the canary it inserted around the frame was overwritten and aborts immediately โ€” on a build without -fstack-protector the same write instead silently corrupts adjacent stack memory. AddressSanitizer catches the same bug from a different angle: the factory path alone (make_buffer() returning a 128-byte new, deleted through the 64-byte v1.h view) trips a new-delete-type-mismatch before the stack scenario even runs.

Safe redesign

Use the PIMPL idiom: the public Buffer class stores only a pointer to a private BufferImpl struct whose layout can change freely without affecting sizeof(Buffer).

Real-world example: Qt's "binary compatibility" rule explicitly forbids changing the sizeof of any public class. Every Qt class that needs to grow uses a d_ptr PIMPL to keep the public class size constant across minor releases.

Cross-tool comparison

abidw --out-file v1.xml libbuf_v1.so
abidw --out-file v2.xml libbuf_v2.so
abidiff v1.xml v2.xml
echo "exit: $?"   # โ†’ 4

Note on abidiff 2.4.0: reports type size changed from 512 to 1024 (in bits), exit 4. Semantically breaking for any code that heap-allocates Buffer via operator new or embeds it by value.

References


Source files

  • CMakeLists.txt
  • app.cpp
  • v1.cpp
  • v1.h
  • v2.cpp
  • v2.h

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