Skip to content

CWE-195: Signed to Unsigned Conversion Error - C++

Overview

C++ inherits C's conversion rules, so a negative int assigned to a size_t becomes a very large positive value with no diagnostic. What C++ adds is a standard library that expresses almost every length and index as std::size_t: .size(), .length(), .capacity(), operator[], resize(), reserve() and std::span::subspan all take or return an unsigned type. Any signed arithmetic that produces a length or an offset therefore has to cross into unsigned territory before it can be used, and that crossing is where the defect lives.

The C page covers the same conversion at the syscall boundary - error returns from read() and friends. This page covers the container and iterator idioms that are specific to C++, and the tools the language provides for them.

Common Vulnerable Patterns

Signed index or length passed to a container

#include <vector>

// VULNERABLE - a negative count becomes an enormous allocation request
void reserve_slots(std::vector<int>& slots, int requested) {
    slots.resize(requested);   // int -> size_type conversion, no check
}

// VULNERABLE - operator[] does not bounds-check, and the index is unsigned
int read_slot(const std::vector<int>& slots, int index) {
    return slots[index];       // index = -1 reads far outside the vector
}

Why this is vulnerable: resize() and operator[] take std::vector<int>::size_type, an unsigned type. requested = -1 arrives as SIZE_MAX, so resize() either throws std::length_error or attempts an absurd allocation, and slots[-1] indexes at SIZE_MAX elements - undefined behaviour, not a caught error. In practice the address arithmetic wraps too, so the read usually lands a few bytes before the buffer rather than somewhere unmapped. It does not crash, it returns whatever precedes the allocation, and that is what makes it exploitable rather than merely fatal. operator[] performs no bounds check by design.

A length computed by subtraction

#include <cstring>
#include <vector>

// VULNERABLE - the subtraction can go negative before the conversion
void copy_range(std::vector<char>& dst, const char* src, int begin, int end) {
    std::memcpy(dst.data(), src + begin, end - begin);   // end < begin -> huge size_t
}

Why this is vulnerable: end - begin is signed arithmetic. If the caller passes a reversed or attacker-influenced range, the result is negative, and memcpy's third parameter is std::size_t - so the negative length converts to a value larger than any real buffer. The conversion is implicit and produces no warning under -Wall -Wextra, because -Wsign-conversion is not in either set.

Reverse iteration with an unsigned counter

#include <cstddef>
#include <vector>

// VULNERABLE - the loop condition can never be false
void process_backwards(const std::vector<int>& data) {
    for (std::size_t i = data.size() - 1; i >= 0; --i) {
        use(data[i]);
    }
}

Why this is vulnerable: Two separate defects meet here. On an empty container, data.size() - 1 is unsigned arithmetic that wraps to SIZE_MAX, so the first iteration indexes far out of bounds. And i >= 0 is always true for an unsigned type, so the loop never terminates normally - it runs until the out-of-bounds access crashes. Compilers warn about the tautological comparison under -Wtype-limits, which is also not in -Wall.

Secure Patterns

Convert through a checked helper

#include <cstddef>
#include <limits>
#include <optional>
#include <stdexcept>
#include <utility>
#include <vector>

inline constexpr std::size_t MAX_SLOTS = 4096;

// SECURE - the conversion cannot lose the sign or the magnitude
template <typename Unsigned, typename Signed>
constexpr std::optional<Unsigned> to_unsigned(Signed value) noexcept {
    if (std::cmp_less(value, 0)) {
        return std::nullopt;                       // negative: reject
    }
    if (std::cmp_greater(value, std::numeric_limits<Unsigned>::max())) {
        return std::nullopt;                       // too large for the target
    }
    return static_cast<Unsigned>(value);
}

void reserve_slots(std::vector<int>& slots, int requested) {
    auto count = to_unsigned<std::vector<int>::size_type>(requested);
    if (!count || *count > MAX_SLOTS) {
        throw std::invalid_argument("Invalid slot count");
    }
    slots.resize(*count);
}

Why this works: std::cmp_less and std::cmp_greater (C++20) compare by mathematical value rather than converting one operand to the other's type, so the guard itself cannot fall to the bug it is checking for - a plain value > std::numeric_limits<Unsigned>::max() would convert value to unsigned first and let -1 through. Returning std::optional rather than throwing from the helper keeps the policy at the call site, where the caller knows whether an out-of-range value is a client error or a bug. Before C++20, write the same check as value < 0 || static_cast<std::uintmax_t>(value) > MAX, in that order: the sign test must come first.

gsl::narrow from the Microsoft GSL does the same job and throws gsl::narrowing_error; use it if the project already depends on GSL rather than adding a dependency for one conversion.

Keep sizes signed with std::ssize

#include <cstddef>
#include <iterator>
#include <ranges>
#include <vector>

// SECURE - the counter stays signed, so the loop arithmetic behaves
void process_backwards(const std::vector<int>& data) {
    for (std::ptrdiff_t i = std::ssize(data) - 1; i >= 0; --i) {
        use(data[static_cast<std::size_t>(i)]);   // i is known non-negative here
    }
}

// SECURE - or avoid the index entirely
void process_backwards_ranges(const std::vector<int>& data) {
    for (int value : data | std::views::reverse) {
        use(value);
    }
}

Why this works: std::ssize (C++20) returns a signed size, so std::ssize(data) - 1 on an empty container is -1 rather than SIZE_MAX, and i >= 0 is a meaningful test rather than a tautology. The cast at the point of use is safe because the loop condition has already established the sign. The range-based version is better still: iterating removes the index arithmetic, and with it every conversion this CWE is about.

Validate the range before it becomes a length

#include <algorithm>
#include <cstddef>
#include <span>
#include <stdexcept>

// SECURE - the ordering check happens while the values are still signed
void copy_range(std::span<char> dst, std::span<const char> src, int begin, int end) {
    if (begin < 0 || end < begin) {
        throw std::invalid_argument("Invalid range");
    }

    const auto first = static_cast<std::size_t>(begin);
    const auto count = static_cast<std::size_t>(end - begin);
    if (first > src.size() || count > src.size() - first || count > dst.size()) {
        throw std::out_of_range("Range exceeds buffer");
    }

    std::copy_n(src.begin() + begin, count, dst.begin());
}

Why this works: end < begin is tested in signed arithmetic, where a reversed range is simply a smaller number - once the conversion has happened, that comparison is no longer possible to make. Passing std::span rather than a raw pointer and a length means both buffers carry their own size, so the bound check has something real to check against instead of a length the caller asserted.

The source check is deliberately in two parts. A range can be the right length and still start past the end of src: begin = 4090, end = 4100 is a ten-byte range, so a count <= src.size() test passes it against a 4096-byte source and the copy then reads four bytes off the end. count > src.size() - first asks the question that matters - does the range fit from where it starts - and the preceding first > src.size() test is what keeps that subtraction from wrapping, since it is unsigned arithmetic and an underflow there would produce an enormous bound that admits everything. Prefer that subtraction form in general: first + count > src.size() expresses the same intent, but the addition can wrap, which is the class of bug this page is about.

Considerations

  • Which type family owns a quantity, per module. Mixing int counters with .size() is what generates these conversions, and there are two coherent answers: keep everything unsigned and never subtract without checking, or keep everything signed with std::ssize and convert only at the container boundary. Choose one for a translation unit. Alternating between them produces conversions at every call and a lot of casts that look like validation.
  • operator[] versus .at(). operator[] is unchecked; .at() throws std::out_of_range. For an index derived from untrusted input, the exception is usually worth its cost, and it converts a silent memory-safety bug into a handled error. For a hot loop over a range the code already bounded, the unchecked form is defensible.
  • -Wsign-conversion on an existing codebase. It is not in -Wall -Wextra and typically produces hundreds of hits on first use. Enabling it per-file as code is touched, rather than repo-wide with a wave of silencing casts, is what keeps the signal - a static_cast added to quiet the warning documents the conversion without checking it, which is the bug the warning was reporting.
  • Standard version. std::ssize and std::cmp_* require C++20. On C++17 the equivalents are static_cast<std::ptrdiff_t>(c.size()) and a hand-written sign-then-magnitude check, in that order.

Testing

Sign-conversion defects survive a re-scan easily: a static_cast added to silence a warning changes what the tool sees without changing what the code does.

  • Call every converting entry point with -1, INT_MIN, 0, and a value above the target type's maximum. Assert an exception or a rejected result, and assert no allocation was attempted - a std::length_error from resize() is the container catching your bug, not your validation working.
  • Run the reverse-iteration paths over an empty container and assert the loop body never executes. This is the case the wrapped size() - 1 gets wrong, and a test with a populated container passes against the broken version.
  • Build the test suite with Clang's -fsanitize=undefined,implicit-conversion. implicit-integer-sign-change reports exactly this weakness at runtime, and it catches conversions in code paths no static rule flagged.
  • Compile with -Wsign-conversion -Wsign-compare -Wtype-limits -Werror (or /W4 /w44365 /WX, since C4365 is off by default and /W4 alone does not report the conversion) and confirm the build is clean without new casts having been added to achieve it.
  • Assert the legitimate range still works end to end: a valid begin/end pair, a full-length copy, and a zero-length copy. Zero-length is where an over-tight count > 0 guard breaks real callers.

Additional Resources