Skip to content

Case 17 โ€” Template Instantiation ABI Change

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

Verdict: ๐Ÿ”ด BREAKING

Note: The verdict was previously marked COMPATIBLE in error. When app_v1 is loaded against libv2.so using LD_PRELOAD, the v2 constructor writes 24 bytes into a 16-byte stack slot โ†’ sentinel corruption confirmed at runtime. abicheck correctly detects struct_size_changed: Buffer<int> 16โ†’24 = BREAKING.

What changes

Version Buffer<int> layout
v1 { T* data_; size_t size_; } โ†’ sizeof = 16 bytes (64-bit)
v2 { T* data_; size_t size_; size_t capacity_; } โ†’ sizeof = 24 bytes

What breaks at binary level

C++ template classes with explicit instantiations are compiled into the .so like regular classes. The mangled symbol for Buffer<int>::Buffer(size_t) is _ZN6BufferIiEC1Em โ€” identical in both versions.

A caller compiled against v1 headers allocates sizeof(Buffer<int>) = 16 bytes (e.g. on the stack or in a struct). The v2 .so constructor writes 24 bytes โ€” overwriting 8 bytes past the allocated region. This is a classic stack smash / heap corruption scenario, extremely hard to debug.

Why abidiff catches it (with DWARF)

When compiled with -g, DWARF records the type layout for Buffer<int>. abidiff compares type offsets and sizes โ†’ detects sizeof grew from 16 to 24. Without -g, abidiff sees only the symbol table and misses the layout change.

Why ABICC catches it

ABICC parses the header AST and computes sizeof for all template instantiations referenced in the headers. It sees capacity_ was added and reports:

"Size of type 'Buffer' changed from 16 to 24 bytes."

Real-world example

In large numeric libraries, templated classes like HomogenNumericTable<float> are often explicitly instantiated in the .so. When a private member is added for thread-safety tracking, downstream bindings compiled with old headers write past buffers. This class of bug is typically caught by ASAN in integration tests.

Code diff

 template<typename T>
 class Buffer {
 private:
     T*          data_;
     std::size_t size_;
+    std::size_t capacity_;  // NEW field
 };

Reproduce steps

cd examples/case17_template_abi

# Build with debug info
g++ -shared -fPIC -std=c++17 -g v1.cpp -o libv1.so
g++ -shared -fPIC -std=c++17 -g v2.cpp -o libv2.so

# abidiff WITH DWARF catches layout change
abidw --out-file v1.xml libv1.so
abidw --out-file v2.xml libv2.so
abidiff v1.xml v2.xml   # exit 4: reports Buffer<int> size change

# Build WITHOUT debug info (strip DWARF)
g++ -shared -fPIC -std=c++17 v1.cpp -o libv1_nodebug.so
g++ -shared -fPIC -std=c++17 v2.cpp -o libv2_nodebug.so
abidw --out-file v1nd.xml libv1_nodebug.so
abidw --out-file v2nd.xml libv2_nodebug.so
abidiff v1nd.xml v2nd.xml   # exit 0: MISSES the change (no DWARF)

# ABICC catches via header AST regardless of debug info
abi-compliance-checker -lib Buffer -v1 1.0 -v2 2.0 \
  -header v1.hpp -header v2.hpp \
  -gcc-options "-std=c++17"

Real Failure Demo

Severity: CRITICAL

Scenario: app compiled with v1 layout (Buffer = 16 bytes) runs against v2 constructor that writes 24 bytes โ†’ stack overflow.

# Build v1 + app
g++ -shared -fPIC -std=c++17 -g v1.cpp -o libbuf.so
g++ -std=c++17 -g -O0 app.cpp -I. -L. -lbuf -Wl,-rpath,. -o app
./app
# โ†’ sizeof(Buffer<int>) at compile time = 16
# โ†’ before ctor: sentinel = SENTINEL
# โ†’ after  ctor: sentinel = SENTINEL   (v1: only 16 bytes written)

# Swap in v2 (constructor writes 24 bytes โ€” 8 bytes past the 16-byte slot)
g++ -shared -fPIC -std=c++17 -g v2.cpp -o libbuf.so
./app
# โ†’ sizeof(Buffer<int>) at compile time = 16
# โ†’ before ctor: sentinel = SENTINEL
# โ†’ after  ctor: sentinel =           โ† CORRUPTED (v2 wrote 8 bytes past slot)
# โ†’ CORRUPTION: v2 constructor wrote past Buffer slot!

# With ASAN:
g++ -shared -fPIC -std=c++17 -g -fsanitize=address v2.cpp -o libbuf.so
g++ -std=c++17 -g -fsanitize=address app.cpp -I. -L. -lbuf -Wl,-rpath,. -o app_asan
./app_asan
# โ†’ ERROR: AddressSanitizer: stack-buffer-overflow

Why CRITICAL: The v2 constructor writes a new capacity_ field at offset 16, but the app only reserved 16 bytes on the stack. The 8 bytes past the allocation are overwritten โ€” classic stack smash, potentially corrupting return addresses.

Why runtime result may differ from verdict

Template instantiation in binary: symbol names unchanged

References


Source files

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