CWE-195: Signed to Unsigned Conversion Error - C
Overview
C converts a negative signed value to unsigned implicitly whenever it is assigned to an unsigned variable, passed to a parameter of unsigned type, or compared against an unsigned operand - there is no compiler error and, without -Wsign-conversion/-Wsign-compare, often no warning either. The result is the value's two's complement bit pattern reinterpreted as unsigned, which for any negative number is a huge positive one. The most common source is a function whose signed return type exists specifically to carry a negative error code (read(), recv(), snprintf(), ssize_t-returning POSIX calls). Such a return is then cast or compared as if it could only ever be a valid, non-negative size.
Common Vulnerable Patterns
Error Return Value Cast to an Unsigned Size
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
// VULNERABLE - no check for a negative (error) return before the unsigned cast
void read_file_data(int fd) {
char buffer[1024];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
// Attack: read() returns -1 on error
size_t count = (size_t)bytes_read; // -1 becomes SIZE_MAX
if (count > 0) { // this check is now useless
char *data = malloc(count); // huge allocation, likely fails or exhausts memory
memcpy(data, buffer, count); // buffer overread if malloc happened to succeed
}
}
Why this is vulnerable: read() returns -1 to signal an error. Casting that ssize_t straight to size_t before checking its sign turns the error indicator into SIZE_MAX, which then passes the count > 0 check that was meant to filter it out.
Signed Bounds Check Followed by an Unsigned Copy
#include <string.h>
// VULNERABLE - the bounds check is evaluated in int, the copy length in size_t
int copy_chunk(const char *src, int length, char *dest, size_t dest_size) {
// Attack: length = -1
if (length > (int)dest_size) { // -1 is not greater than a positive size: the check passes
return -1;
}
memcpy(dest, src, length); // memcpy takes size_t: -1 becomes SIZE_MAX
return 0;
}
Why this is vulnerable: the guard and the sink disagree about the type. length > (int)dest_size is evaluated entirely in int, where -1 is just a small number and passes any upper-bound test. memcpy's third parameter is size_t, so the same -1 converts on the call and asks for a copy of SIZE_MAX bytes. Nothing here ever compares length against 0, and the line that looks like a bounds check is what lets the negative value through to the copy.
The comparison written the other way round - if (length < dest_size), with length still int - has no such hole, but not because it validates the sign. Mixing the two operands converts length to size_t first, so -1 becomes SIZE_MAX, fails the test, and the branch is skipped. It rejects the value by accident rather than by design, and the same unchecked -1 still reaches any sink that sits outside that branch.
Negative Value Cast Directly to an Allocation Size
#include <stdlib.h>
// VULNERABLE - no negative check before the unsigned cast
void allocate_sized_buffer(int requested_size) {
// Attack: requested_size = -1
size_t alloc_size = (size_t)requested_size; // becomes SIZE_MAX
char *buffer = malloc(alloc_size); // huge allocation request
}
Why this is vulnerable: Nothing checks requested_size for a negative value before it is cast. A caller that passes -1 (whether from a bug or deliberately) produces a malloc request for the largest possible size_t.
Secure Patterns
Check the Return Value Before Converting
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
void read_file_data_safe(int fd) {
char buffer[1024];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
if (bytes_read < 0) { // catch the error while still signed
perror("read failed");
return;
}
size_t count = (size_t)bytes_read; // safe: known non-negative
if (count > 0 && count <= sizeof(buffer)) {
char *data = malloc(count);
if (data != NULL) {
memcpy(data, buffer, count);
free(data);
}
}
}
Why this works: Checking bytes_read < 0 while the value is still ssize_t catches the error case before any conversion happens. Only a confirmed non-negative value is ever cast to size_t.
Validate the Sign Before Converting or Comparing
#include <stdio.h>
#include <string.h>
int copy_chunk_safe(const char *src, int length, char *dest, size_t dest_size) {
if (length < 0) {
fprintf(stderr, "Negative length not allowed\n");
return -1;
}
size_t count = (size_t)length; // safe: validated non-negative
if (count > dest_size) {
fprintf(stderr, "Length exceeds destination buffer\n");
return -1;
}
memcpy(dest, src, count);
return 0;
}
Why this works: length < 0 is asked while the value is still int, which is the only place it can be answered - after the conversion, -1 is indistinguishable from a genuine SIZE_MAX. Converting once into a named size_t and bounding that against dest_size leaves the guard and the memcpy argument as the same value in the same type, so there is no second conversion at the call for a later reader to miss.
Centralize the Check in a Conversion Helper
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
bool safe_int_to_size_t(int value, size_t *result) {
if (value < 0) {
return false;
}
*result = (size_t)value;
return true;
}
void allocate_buffer_safe(int requested_size) {
size_t alloc_size;
if (!safe_int_to_size_t(requested_size, &alloc_size)) {
fprintf(stderr, "Invalid size: negative\n");
return;
}
if (alloc_size > 10000000) { // separate, practical upper bound
fprintf(stderr, "Size too large\n");
return;
}
char *buffer = malloc(alloc_size);
if (buffer != NULL) {
free(buffer);
}
}
Why this works: A single, reusable helper makes the non-negative check consistent everywhere a signed value needs to become a size_t, instead of relying on every call site to remember it. The bool return forces the caller to handle the failure case explicitly.
Testing
- Unit-test every conversion helper with
-1,INT_MIN,0, andINT_MAX, asserting negative inputs are rejected and non-negative ones round-trip correctly. - Feed functions under test a simulated error return (e.g., mock
read()to return-1) and confirm the caller rejects it instead of proceeding with a wrapped value. - Compile with
-Wsign-compare -Wsign-conversion -Wconversion -Werrorand treat any warning on a security-relevant path as a build failure. - Run Cppcheck (
--enable=warning), Clang Static Analyzer, or a commercial SAST tool (Coverity, Veracode) and confirm no signed/unsigned findings remain on the fixed paths. - On MSVC, the equivalent build setting is
/W4 /w44365with warnings treated as errors (/WX). C4365, the signed/unsigned conversion warning, is off by default at every warning level, so/W4on its own does not report it.
Common Pitfalls
- Checking
count > 0after the value is alreadysize_t: by that point a negative source value has already become a huge positive one, so the check no longer catches the error case it was meant to. - Fixing the cast and leaving the guard: adding a helper for casts to
size_twhile an upper-bound check elsewhere is still evaluated inint- the helper never runs on that path, because the negative value satisfied the guard and went straight to the sink. - Assuming
intparameters are always non-negative because "sizes can't be negative": many APIs use a signed parameter or return type precisely so a negative value can mean "error" - treating the type as if it were unsigned throws away that signal. - Relying on
-Wall -Wextraalone:-Wsign-compareis included in-Wextraon some compilers but-Wsign-conversiongenerally is not - it must be enabled explicitly, or these conversions compile silently.