CWE-197: Numeric Truncation Error - C++
Overview
C++ inherits C's silent narrowing conversions, so the same bit-loss applies to int64_t-to-int32_t, double-to-int, and similar. C++11 added one real improvement: brace initialization (int x{value};) is a compile error if value would narrow, which makes a subset of these conversions impossible to write by accident - though it reports where the narrowing is written rather than whether the value fits. Everywhere else - an explicit static_cast, a function call, a regular = assignment - narrowing is exactly as silent as it is in C, so an explicit, validated conversion is still needed.
Common Vulnerable Patterns
Unchecked static_cast Narrowing
#include <cstdint>
#include <memory>
// VULNERABLE - static_cast narrows silently, no range check
void allocate_buffer(int64_t size) {
int32_t buffer_size = static_cast<int32_t>(size); // narrows silently
auto buffer = std::make_unique<char[]>(buffer_size);
}
Why this is vulnerable: static_cast documents that a conversion is intentional, but it performs no range check - a size whose value doesn't fit in int32_t is truncated exactly as a C-style cast would truncate it.
Narrowing That Brace Initialization Would Have Caught, Bypassed by Using =
#include <cstddef>
#include <cstdint>
// VULNERABLE - plain assignment narrows silently; brace-init would have caught this
void resize_from_length(std::size_t length) {
int32_t count = length; // ordinary assignment - narrows silently if length is large
// ... use count
}
Why this is vulnerable: int32_t count{length}; would be a compile error here if length's type could hold a value count can't represent. Writing = length instead of {length} opts back into C's silent narrowing behavior.
Secure Patterns
safe_narrow_cast Template
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <utility>
template <std::integral Target, std::integral Source>
Target safe_narrow_cast(Source value) {
if (!std::in_range<Target>(value)) {
throw std::overflow_error("Numeric truncation");
}
return static_cast<Target>(value);
}
void allocate_buffer(int64_t size) {
const auto buffer_size = safe_narrow_cast<int32_t>(size);
if (buffer_size <= 0) {
throw std::invalid_argument("Invalid buffer size");
}
auto buffer = std::make_unique<char[]>(static_cast<std::size_t>(buffer_size));
}
Why this works: std::in_range<Target>(value) (C++20, <utility>) asks
whether the value is representable in Target by comparing mathematical values,
which is the only form of the check that survives a change of signedness. The
obvious hand-written version does not:
// BROKEN - the guard performs the conversion it exists to prevent
if (value < std::numeric_limits<Target>::min() ||
value > std::numeric_limits<Target>::max()) { /* reject */ }
With an unsigned Source and a signed Target, std::numeric_limits<int32_t>::min()
converts to std::uint64_t before the comparison and becomes 18446744071562067968,
so safe_narrow_cast<int32_t>(std::uint64_t{5}) rejects a value that fits
comfortably - and the mirror arrangement accepts values that do not. It is also
precisely what -Wsign-compare reports, so the version above fails the
-Werror build this page recommends.
Constraining both parameters with std::integral is deliberate. A double
source needs a different check - NaN and infinity have to be rejected first,
and std::numeric_limits<std::int64_t>::max() is not exactly representable as a
double, so a value > max() test lets the boundary case through into an
undefined conversion. Keeping floating point out of this template forces that
case to be written separately rather than silently mishandled here. Before
C++20, the equivalent is a hand-written check that tests the sign first and only
then compares magnitudes through a common wide type.
Brace Initialization to Turn Narrowing Into a Compile Error
#include <cstddef>
#include <cstdint>
void process_small_count(int32_t small_count);
void from_size(std::size_t length) {
// int32_t count = length; // compiles, and narrows silently
// int32_t count{length}; // ill-formed: brace-init rejects the narrowing
const int32_t count = safe_narrow_cast<int32_t>(length); // what the error asks for
process_small_count(count);
}
Why this works: list-initialization ({}) makes a narrowing conversion
ill-formed, so the compiler names the file and line where a size_t is being
squeezed into an int32_t. That is its whole contribution, and it is worth
having - the alternative is a plain = that compiles and says nothing - but it
locates the conversion rather than validating it, so the error has to be answered
with a checked conversion.
The wrong way to answer it is int32_t count{static_cast<int32_t>(length)}. The
cast makes the types match, so there is no longer a narrowing conversion for
brace-init to reject, the diagnostic disappears, and the truncation it was
reporting happens exactly as before - now with a cast beside it implying somebody
checked. Common Pitfalls below names this; it is a common response to the
error, because the error message points at the braces rather than at the value.
Considerations
Decide whether the narrowing is provably safe for this input domain. A conversion is fine when the source is already bounded and that bound holds for every caller, not just the one in front of you. File sizes, lengths reported by a remote peer, and aggregated totals are the cases where it usually does not.
How the failure is reported is a codebase-wide decision, not a local one.
Throwing on an out-of-range conversion is the cleaner default, but plenty of
codebases build with -fno-exceptions - embedded targets, game engines, and
anything with a hard real-time budget. There, a checked helper returning bool
with an out-parameter is the equivalent. Pick whichever matches the surrounding
code and use it consistently; a throwing helper dropped into a no-exceptions
build fails at link time or terminates at runtime.
Testing
- Unit-test
safe_narrow_cast(or your project's equivalent) with the target type'smin(),max(), one past each boundary, and a value whose truncated low bits would look valid. - Confirm the exception-throwing path is actually exercised in tests, not just the success path.
- Compile with
-Wconversion -Wnarrowing -Werror(or/W4 /WX, which raises C4244 for a narrowing integer conversion and C4267 for asize_tnarrowed to a smaller type - both on by default at that level) and confirm the build is clean. - Run Clang-Tidy's
bugprone-narrowing-conversionscheck or an equivalent static analyzer across the codebase, not just new code.
Common Pitfalls
- Using
static_castand treating it as validation:static_caststates intent, it does not check the value - it truncates exactly as silently as a C-style cast. - Catching
std::overflow_errorand substituting a default value: this reintroduces the original problem under a different name - the truncated (now default) value still flows into whatever used the size or count. - Relying on brace-init narrowing errors as the only defense: they only fire when the compiler can prove narrowing at compile time from a constant or a type mismatch it tracks; a
static_castinside the braces defeats the check, and a value narrowed through an intermediate variable may not trigger it either. - Templating
safe_narrow_castbut forgetting the<= 0/ semantic check: a value can be perfectly representable in the target type and still be nonsensical for its use (a negative buffer size) - range-fitting and business-logic validity are two separate checks.
Additional Resources
- CWE-197: Numeric Truncation Error
- CERT INT31-C: Ensure that integer conversions do not result in lost or misinterpreted data
- cppreference: List-initialization (narrowing conversions)
- MSVC Compiler Warning (levels 3 and 4) C4244: conversion from 'type1' to 'type2', possible loss of data
- MSVC Compiler Warning (level 3) C4267: conversion from 'size_t' to 'type', possible loss of data