Seguridad en C++: los dieciséis fallos que más se repiten en programas escritos en C++, agrupados en cuatro bloques: la memoria, los enteros, la entrada y el sistema, y los secretos. Para cada uno, el patrón de código que hay que evitar, cómo lo detectan el compilador, los sanitizadores, clang-tidy o la biblioteca estándar endurecida, y la forma segura comprobada con GCC 15.
C++ hereda de C el acceso directo a la memoria y añade herramientas que, bien usadas, eliminan la mayoría de sus fallos: contenedores que conocen su tamaño, cadenas que se gestionan solas, punteros inteligentes y RAII. El problema es que los atajos de C siguen ahí, y el C++ moderno trae sus propias trampas, como las vistas que apuntan a datos que ya no existen. Microsoft y el equipo de Chromium, que tienen millones de líneas de C++, han calculado por separado que alrededor del 70 % de sus fallos graves de seguridad son errores de memoria. Por eso CISA y el FBI piden desde 2024 a los fabricantes de software para infraestructuras críticas que publiquen un plan para reducirlos en sus productos escritos en C o C++, y por eso C++26 incorpora una biblioteca estándar «endurecida».
La buena noticia es que los fallos se repiten y casi todos se detectan con herramientas gratuitas que vienen con GCC y Clang. Esta página reúne los más habituales. Cada uno tiene la misma estructura: qué es, el patrón que hay que evitar, cómo se detecta (con la salida real del compilador, del sanitizador o de clang-tidy) y la forma segura.
Si estás aprendiendo C++, cada lección del curso tiene su recuadro «Ojo con la seguridad» con estos fallos en contexto, y el proyecto final pone a trabajar juntas todas las herramientas. Muchos fallos de C, como strcpy o sprintf sin límite, siguen siendo posibles en C++ si se escribe C con otra extensión: están en Seguridad en C.
Memoria
1. Acceder fuera de los límites
std::vector, std::array y std::string saben cuántos elementos tienen, pero su operator[] no lo comprueba: es tan rápido y tan peligroso como un array de C. Un índice que viene de fuera, o un bucle que se pasa en uno, lee o escribe memoria que no le pertenece.
El patrón que hay que evitar
std::vector<int> notas{7, 9, 6};
std::println("{}", notas[i]); // i viene de fuera y nadie lo validaCómo se detecta
Aquí está una de las ventajas de C++ frente a C: la biblioteca estándar de GCC, libstdc++, tiene un modo endurecido que comprueba los índices. Y desde GCC 15 viene encendido por omisión cuando se compila sin optimizar, que es como se compila mientras se desarrolla:
#include <print>
#include <vector>
int main(int argc, char*[])
{
std::vector<int> notas{7, 9, 6};
auto i = static_cast<std::size_t>(argc) + 2; // 3 si no hay argumentos
std::println("La nota {} es {}", i, notas[i]);
}g++ -std=c++23 -Wall -Wextra limites.cpp -o limites ./limites
salida con error/usr/local/include/c++/15.3.0/bits/stl_vector.h:1263: constexpr std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::reference std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::operator[](size_type) [with _Tp = int; _Alloc = std::allocator<int>; reference = int&; size_type = long unsigned int]: Assertion '__n < this->size()' failed.
El programa aborta (código de salida 134) en lugar de leer memoria ajena. Pero con -O2, como se compila para producción, esas comprobaciones se apagan, y el mismo programa lee lo que haya después del vector y sigue tan tranquilo:
g++ -std=c++23 -Wall -Wextra -O2 limites.cpp -o limites ./limites
salidaLa nota 3 es 0
Un valor inventado y ningún error: el peor resultado posible. Hay dos formas de verlo con optimización. En las pruebas, AddressSanitizer:
g++ -std=c++23 -O2 -fsanitize=address limites.cpp -o limites
salida con error==313==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x731e301e001c at pc 0x00000040d241 bp 0x7fffe7570cf0 sp 0x7fffe7570ce8
READ of size 4 at 0x731e301e001c thread T0
…
#7 0x000000402896 in main /curso/limites_asan.cpp:8
…
0x731e301e001c is located 0 bytes after 12-byte region [0x731e301e0010,0x731e301e001c)
allocated by thread T0 here:
…
#7 0x00000040275f in main /curso/limites_asan.cpp:6
…
SUMMARY: AddressSanitizer: heap-buffer-overflow /curso/limites_asan.cpp:8 in mainY en producción, encender el modo endurecido a mano con -D_GLIBCXX_ASSERTIONS (o con -fhardened, que lo incluye). Cuesta muy poco rendimiento:
g++ -std=c++23 -O2 -D_GLIBCXX_ASSERTIONS limites.cpp -o limites ./limites
salida con error/usr/local/include/c++/15.3.0/bits/stl_vector.h:1263: constexpr std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::reference std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::operator[](size_type) [with _Tp = int; _Alloc = std::allocator<int>; reference = int&; size_type = long unsigned int]: Assertion '__n < this->size()' failed.
Si alguna vez necesitas lo contrario, compilar sin optimizar pero sin las comprobaciones, GCC 15 tiene -D_GLIBCXX_NO_ASSERTIONS. Es exactamente lo que C++26 convierte en estándar con la «biblioteca endurecida»: operator[], front(), back(), std::span, std::string_view y el acceso a std::optional comprueban sus precondiciones si la implementación se compila en modo endurecido.
La forma segura
#include <print>
#include <stdexcept>
#include <vector>
int main()
{
std::vector<int> notas{7, 9, 6};
for (int nota : notas) { // sin índices, no hay nada que se pase
std::print("{} ", nota);
}
std::println("");
try {
std::println("{}", notas.at(3)); // at() comprueba y lanza
} catch (const std::out_of_range& e) {
std::println("Índice no válido: {}", e.what());
}
}salida7 9 6 Índice no válido: vector::_M_range_check: __n (which is 3) >= this->size() (which is 3)
Cómo encontrarlo en tu código: los [] con índices calculados o que vienen de fuera. Prefiere el for de rango y los algoritmos; usa at() cuando el índice no sea de fiar, y compila siempre con _GLIBCXX_ASSERTIONS.
2. Iteradores y referencias invalidados
Un iterador, una referencia o un puntero a un elemento de un contenedor dejan de ser válidos cuando el contenedor se reorganiza. Un std::vector que crece puede necesitar más espacio: reserva un bloque nuevo, mueve los elementos y libera el viejo. Todo lo que apuntaba al bloque viejo queda colgando. El caso típico es modificar un contenedor mientras se recorre:
El patrón que hay que evitar
for (const auto& t : tareas) {
if (t == "lavar") {
tareas.push_back("tender la ropa al sol"); // puede mover todos los elementos
}
}Cómo se detecta
#include <print>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> tareas{"lavar", "planchar", "guardar"};
for (const auto& t : tareas) {
std::println("{}", t);
if (t == "lavar") {
tareas.push_back("tender la ropa al sol");
}
}
}salida con error==247==ERROR: AddressSanitizer: heap-use-after-free on address 0x78ae9f1e0040 at pc 0x0000004106df bp 0x7fff5274a540 sp 0x7fff5274a538
READ of size 8 at 0x78ae9f1e0040 thread T0
…
#9 0x000000404a32 in main /curso/iteradores.cpp:9
…
0x78ae9f1e0040 is located 32 bytes inside of 96-byte region [0x78ae9f1e0020,0x78ae9f1e0080)
freed by thread T0 here:
…
#9 0x000000404ad3 in main /curso/iteradores.cpp:11
…
previously allocated by thread T0 here:
…
#7 0x00000040483e in main /curso/iteradores.cpp:7
…
SUMMARY: AddressSanitizer: heap-use-after-free /curso/iteradores.cpp:9 in mainEl push_back obligó al vector a crecer, y la siguiente vuelta del for leyó del bloque ya liberado. libstdc++ tiene además un modo de depuración, -D_GLIBCXX_DEBUG, que vigila cada iterador. Es más lento que _GLIBCXX_ASSERTIONS y cambia el tamaño de los contenedores, así que es solo para pruebas, pero explica el fallo en su idioma:
g++ -std=c++23 -D_GLIBCXX_DEBUG iteradores.cpp -o iteradores
salida con errorError: attempt to increment a singular iterator.
…
state = singular;La forma segura
#include <print>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> tareas{"lavar", "planchar", "guardar"};
// Recorrer por índice y con el tamaño de antes: los elementos nuevos no se visitan.
const std::size_t antes = tareas.size();
for (std::size_t i = 0; i < antes; ++i) {
if (tareas[i] == "lavar") {
tareas.push_back("tender la ropa al sol");
}
}
// Borrar mientras se recorre: mejor std::erase_if (C++20), que lo hace bien.
std::erase_if(tareas, [](const std::string& t) { return t == "planchar"; });
for (const auto& t : tareas) {
std::println("{}", t);
}
}salidalavar guardar tender la ropa al sol
Cómo encontrarlo en tu código: push_back, insert, erase o resize dentro de un bucle que recorre el mismo contenedor, y referencias a elementos (auto& x = v[0]) que se guardan mientras el contenedor cambia.
3. Referencias y vistas colgantes
Una referencia, un puntero, un std::string_view o un std::span no son dueños de lo que señalan. Si el objeto muere antes que ellos, siguen apuntando a su antigua dirección. En C++ moderno es el fallo de memoria más traicionero, porque el código parece impecable. Los tres casos clásicos: devolver una referencia a una variable local, guardar una vista de un temporal y guardar una referencia que devuelve una función a la que se pasó un temporal.
El patrón que hay que evitar
std::string_view saludo(const std::string& nombre)
{
return "Hola, " + nombre; // el std::string temporal muere aquí
}
const std::string& mayor = std::max(a, b + "!"); // b + "!" muere al final de la líneaCómo se detecta
GCC detecta algunos casos al compilar. El de std::max con un temporal lo avisa -Wdangling-reference, incluido en -Wall desde GCC 13:
#include <algorithm>
#include <print>
#include <string>
int main()
{
std::string a = "manzana";
std::string b = "pera";
const std::string& mayor = std::max(a, b + "!");
std::println("{}", mayor);
}g++: avisocolgante_max.cpp: In function 'int main()':
colgante_max.cpp:9:24: warning: possibly dangling reference to a temporary [-Wdangling-reference]
9 | const std::string& mayor = std::max(a, b + "!");
| ^~~~~
colgante_max.cpp:9:46: note: 'std::__cxx11::basic_string<char>' temporary created here
9 | const std::string& mayor = std::max(a, b + "!");
| ~~^~~~~Y el de la vista, no: compila sin un aviso. Lo encuentra AddressSanitizer al ejecutar:
#include <print>
#include <string>
#include <string_view>
std::string_view saludo(const std::string& nombre)
{
return "Hola, " + nombre;
}
int main()
{
std::string nombre = "María Fernanda López";
std::string_view s = saludo(nombre);
std::println("{}", s);
}salida con error==139==ERROR: AddressSanitizer: heap-use-after-free on address 0x6d4e9b7e0070 at pc 0x711e9cadca97 bp 0x7ffe8713d1a0 sp 0x7ffe8713c960
READ of size 28 at 0x6d4e9b7e0070 thread T0
…
#10 0x000000404910 in main /curso/colgante_vista.cpp:14
…
0x6d4e9b7e0070 is located 0 bytes inside of 31-byte region [0x6d4e9b7e0070,0x6d4e9b7e008f)
freed by thread T0 here:
…
#7 0x0000004046ea in saludo(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) /curso/colgante_vista.cpp:7
#8 0x000000404866 in main /curso/colgante_vista.cpp:13
…
previously allocated by thread T0 here:
…
#9 0x0000004046cb in saludo(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) /curso/colgante_vista.cpp:7
#10 0x000000404866 in main /curso/colgante_vista.cpp:13
…
SUMMARY: AddressSanitizer: heap-use-after-free /curso/colgante_vista.cpp:14 in mainLa forma segura
Las funciones devuelven valores (std::string), no vistas, salvo que la vista apunte a algo que el que llama ya tenía, como una parte de su propio parámetro. Las vistas son para parámetros, donde el objeto vive seguro durante toda la llamada. Y una variable que guarda el resultado de una función se declara por valor: auto mayor = std::max(a, b + "!");. En la lección 8 hay un caso real: un std::string_view a las claves de un mapa temporal que las pruebas normales no veían.
Cómo encontrarlo en tu código: funciones que devuelven std::string_view, std::span, const T& o punteros, y variables de esos tipos inicializadas con el resultado de una llamada o con una expresión que crea un temporal (+, std::string(...)).
4. Uso tras liberar y doble liberación
Con new y delete a mano vuelven todos los problemas de C: usar la memoria después de liberarla, liberarla dos veces u olvidarse de liberarla. El C++ moderno casi nunca necesita delete: para eso están los contenedores y los punteros inteligentes.
El patrón que hay que evitar
Pedido* p = new Pedido{...};
delete p;
std::println("{}", p->total); // uso tras liberar
delete p; // doble liberaciónCómo se detecta
#include <print>
struct Pedido {
int numero;
double total;
};
int main()
{
Pedido* p = new Pedido{1024, 59.90};
delete p;
std::println("Pedido {}: {}", p->numero, p->total);
}Compila sin un solo aviso, incluso con -Wall -Wextra: GCC tiene -Wuse-after-free, pero solo para free y realloc de C. ASan lo detecta en cuanto ese camino se ejecuta:
salida con error==482==ERROR: AddressSanitizer: heap-use-after-free on address 0x6c92ee7e0010 at pc 0x00000041c2ae bp 0x7ffee8e83ce0 sp 0x7ffee8e83cd8
READ of size 4 at 0x6c92ee7e0010 thread T0
…
#7 0x0000004047d0 in main /curso/uaf.cpp:12
…
0x6c92ee7e0010 is located 0 bytes inside of 16-byte region [0x6c92ee7e0010,0x6c92ee7e0020)
freed by thread T0 here:
…
#1 0x000000404730 in main /curso/uaf.cpp:11
…
previously allocated by thread T0 here:
…
#1 0x00000040469b in main /curso/uaf.cpp:10
…
SUMMARY: AddressSanitizer: heap-use-after-free /curso/uaf.cpp:12 in mainLa forma segura
#include <memory>
#include <print>
struct Pedido {
int numero;
double total;
};
int main()
{
auto p = std::make_unique<Pedido>(1024, 59.90);
std::println("Pedido {}: {}", p->numero, p->total);
} // aquí se libera, una sola vez, pase lo que pasesalidaPedido 1024: 59.9
std::unique_ptr tiene un solo dueño y libera al salir de su ámbito, incluso si se lanza una excepción. Si varios objetos comparten la propiedad, std::shared_ptr; si uno solo observa sin ser dueño, una referencia o std::weak_ptr. Todo esto, con detalle, en la lección 6.
Cómo encontrarlo en tu código: new y delete fuera de clases que gestionan memoria a bajo nivel, y punteros crudos que se guardan en miembros. La comprobación cppcoreguidelines-owning-memory de clang-tidy los señala.
5. Usar un objeto después de moverlo
std::move no mueve nada: solo permite que el objeto se «robe». Después, el objeto movido queda en un estado válido pero sin especificar; en la práctica, una cadena o un vector suelen quedar vacíos. Usarlo como si conservara su valor es un fallo lógico que el compilador acepta sin rechistar.
El patrón que hay que evitar
rutas.push_back(std::move(ruta));
if (!ruta.ends_with(".csv")) { ... } // ruta ya está vacíaCómo se detecta
Con clang-tidy y su comprobación bugprone-use-after-move:
#include <print>
#include <string>
#include <utility>
#include <vector>
int main()
{
std::vector<std::string> rutas;
std::string ruta = "gastos.txt";
rutas.push_back(std::move(ruta));
if (!ruta.ends_with(".csv")) {
std::println("aviso: «{}» no parece un CSV", ruta);
}
}salidaaviso: «» no parece un CSV
clang-tidy/src/movido.cpp:11:10: warning: 'ruta' used after it was moved [bugprone-use-after-move]
11 | if (!ruta.ends_with(".csv")) {
| ^
/src/movido.cpp:10:11: note: move occurred here
10 | rutas.push_back(std::move(ruta));
| ^El programa imprime el aviso con el nombre vacío: la comprobación se hizo sobre una cadena ya vaciada. Aquí solo es un mensaje equivocado; si lo que se comprobaba era un permiso o una ruta segura, es un fallo de seguridad.
La forma segura
Comprueba antes de mover, o no muevas. Y si de verdad hay que reutilizar la variable, asígnale un valor nuevo (ruta = otra;) o llama a ruta.clear(): después de eso vuelve a tener un estado conocido.
6. Variables sin inicializar
Una variable local de un tipo básico declarada sin valor (int total;) contiene lo que hubiera en esa memoria. Leerla es comportamiento indefinido en C++23 y puede filtrar datos que quedaron de antes, como restos de una contraseña. En C++26 pasa a ser «comportamiento erróneo»: sigue siendo un fallo, pero el compilador debe darle un valor fijo y puede avisar o abortar, en lugar de permitirse cualquier cosa.
Cómo se detecta
#include <print>
#include <vector>
int sumar(const std::vector<int>& v)
{
int total;
for (int x : v) {
total += x;
}
return total;
}
int main()
{
std::println("{}", sumar({1, 2, 3}));
}g++ -std=c++23 -Wall -Wextra -O2 sin_init.cpp
g++: avisosin_init.cpp: In function 'int sumar(const std::vector<int>&)':
sin_init.cpp:8:15: warning: 'total' may be used uninitialized [-Wmaybe-uninitialized]
8 | total += x;
| ~~~~~~^~~~
sin_init.cpp:6:9: note: 'total' was declared here
6 | int total;
| ^~~~~
In function 'int sumar(const std::vector<int>&)',
inlined from 'int main()' at sin_init.cpp:15:29:
sin_init.cpp:8:15: warning: 'total' is used uninitialized [-Wuninitialized]
8 | total += x;
| ~~~~~~^~~~
sin_init.cpp: In function 'int main()':
sin_init.cpp:6:9: note: 'total' was declared here
6 | int total;
| ^~~~~Con optimización, GCC sigue el flujo de datos y ve el problema. Sin -O2 muchos de estos avisos no salen: compila de vez en cuando optimizando aunque estés depurando.
La forma segura
Inicializa siempre al declarar: int total = 0; o int total{};. Para el código que ya existe, GCC y Clang tienen -ftrivial-auto-var-init=zero, que pone a cero todas las variables locales sin inicializar: no corrige la lógica, pero evita filtrar datos viejos. El núcleo de Linux, por ejemplo, la ofrece como opción de compilación.
7. Conversiones a ciegas: reinterpret_cast y los moldes de C
Un molde de estilo C, (int*) p, o un reinterpret_cast le dicen al compilador «confía en mí». Mirar los bytes de un float a través de un int* rompe además la regla de strict aliasing: el optimizador asume que dos punteros de tipos distintos no apuntan al mismo sitio y puede reordenar o eliminar lecturas.
El patrón que hay que evitar
float f = 1.5f; std::uint32_t bits = *reinterpret_cast<std::uint32_t*>(&f);
Cómo se detecta
#include <cstdint>
#include <print>
int main()
{
float f = 1.5f;
std::uint32_t bits = *reinterpret_cast<std::uint32_t*>(&f);
std::println("{:#010x}", bits);
}g++: avisoaliasing.cpp: In function 'int main()':
aliasing.cpp:7:27: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
7 | std::uint32_t bits = *reinterpret_cast<std::uint32_t*>(&f);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
aliasing.cpp:7:26: warning: 'f' is used uninitialized [-Wuninitialized]
7 | std::uint32_t bits = *reinterpret_cast<std::uint32_t*>(&f);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
aliasing.cpp:6:11: note: 'f' declared here
6 | float f = 1.5f;
| ^El segundo aviso es la prueba del problema: para el optimizador, leer los bytes de f a través de un std::uint32_t* no cuenta como leer f, así que cree que el valor 1.5 nunca se usa y que se lee memoria sin inicializar. Con la regla de aliasing rota, el programa puede imprimir cualquier cosa. clang-tidy, con las guías de C++ Core Guidelines, señala el reinterpret_cast directamente:
clang-tidy/src/aliasing.cpp:7:27: warning: do not use reinterpret_cast [cppcoreguidelines-pro-type-reinterpret-cast]
7 | std::uint32_t bits = *reinterpret_cast<std::uint32_t*>(&f);
| ^La forma segura
#include <bit>
#include <cstdint>
#include <print>
int main()
{
float f = 1.5f;
auto bits = std::bit_cast<std::uint32_t>(f); // C++20: copia los bytes, sin trampas
std::println("{:#010x}", bits);
}salida0x3fc00000
std::bit_cast (C++20) copia la representación de un tipo en otro del mismo tamaño y no compila si los tamaños no coinciden. Para el resto, static_cast, que solo permite conversiones con sentido. La opción -Wold-style-cast avisa de cada molde de estilo C.
Enteros
8. Desbordamiento de enteros con signo
Si un int se pasa de su máximo, el resultado no «da la vuelta»: es comportamiento indefinido, y el optimizador puede asumir que no ocurre y borrar las comprobaciones que lo vigilaban. Cuando el resultado se usa para reservar memoria o calcular un índice, un desbordamiento acaba en un acceso fuera de límites.
Cómo se detecta
#include <print>
int total_en_centavos(int unidades, int precio)
{
return unidades * precio;
}
int main()
{
std::println("{}", total_en_centavos(30'000, 99'990));
}salida con errordesborde.cpp:5:23: runtime error: signed integer overflow: 30000 * 99990 cannot be represented in type 'int' -1295267296
La forma segura
#include <limits>
#include <optional>
#include <print>
std::optional<long long> total_en_centavos(long long unidades, long long precio)
{
long long total;
if (__builtin_mul_overflow(unidades, precio, &total)) { // GCC y Clang
return std::nullopt;
}
return total;
}
int main()
{
std::println("{}", total_en_centavos(30'000, 99'990).value_or(-1));
std::println("{}", total_en_centavos(std::numeric_limits<long long>::max(), 2).value_or(-1));
}salida2999700000 -1
Usa un tipo lo bastante grande para el rango real de los datos y comprueba las operaciones con datos de fuera. __builtin_mul_overflow y sus hermanos (add, sub) existen en GCC y Clang; C++26 añade las funciones saturadas std::add_sat y std::mul_sat, que se quedan en el máximo en lugar de desbordar.
9. Con signo, sin signo y estrechamientos
Los tamaños en C++ son std::size_t, sin signo. Restarles algo cuando valen 0 no da -1, sino el número más grande posible, y mezclarlos con int en una comparación convierte el negativo en un positivo enorme.
El patrón que hay que evitar
for (std::size_t i = 0; i < v.size() - 1; ++i) // con v vacío, size() - 1 es enorme
comparar(v[i], v[i + 1]);#include <print>
#include <vector>
int main()
{
std::vector<int> v;
std::println("v.size() - 1 = {}", v.size() - 1);
int limite = -1;
if (limite < v.size()) {
std::println("-1 es menor que 0");
} else {
std::println("-1 NO es menor que 0");
}
}g++: avisosin_signo.cpp: In function 'int main()':
sin_signo.cpp:10:16: warning: comparison of integer expressions of different signedness: 'int' and 'std::vector<int>::size_type' {aka 'long unsigned int'} [-Wsign-compare]
10 | if (limite < v.size()) {
| ~~~~~~~^~~~~~~~~~salidav.size() - 1 = 18446744073709551615 -1 NO es menor que 0
La forma segura
#include <iterator>
#include <print>
#include <utility>
#include <vector>
int main()
{
std::vector<int> v;
for (std::ptrdiff_t i = 0; i < std::ssize(v) - 1; ++i) { // con signo: 0 - 1 es -1
std::println("{} {}", v[i], v[i + 1]);
}
int limite = -1;
std::println("{}", std::cmp_less(limite, v.size())); // compara bien con y sin signo
}salidatrue
std::ssize (C++20) devuelve el tamaño con signo, y std::cmp_less y su familia comparan enteros de cualquier tipo con el resultado matemático correcto. Con -Wconversion y -Wsign-conversion, GCC avisa de cada conversión dudosa. Y la inicialización con llaves prohíbe los estrechamientos, las conversiones que pueden perder datos, como de double a int:
int main()
{
double precio = 99.90;
int centavos{precio * 100};
return centavos;
}g++: avisoestrecho.cpp: In function 'int main()':
estrecho.cpp:4:25: warning: narrowing conversion of '(precio * (double)100)' from 'double' to 'int' [-Wnarrowing]
4 | int centavos{precio * 100};
| ~~~~~~~^~~~~clang++: errorestrecho.cpp:4:18: error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]
4 | int centavos{precio * 100};
| ^~~~~~~~~~~~
…
4 | int centavos{precio * 100};
| ^~~~~~~~~~~~
| static_cast<int>( )Para el estándar es un error, y Clang lo trata así. GCC, por compatibilidad con código antiguo, se queda en un aviso cuando el valor no es una constante: compila con -Werror=narrowing o -pedantic-errors para que también lo sea.
Entrada, salida y el sistema
10. Cadenas de formato
Pasar texto de fuera como formato de printf deja que ese texto lea, o con %n escriba, en la pila. En C++ el fallo sigue siendo posible si se usan las funciones de C.
El patrón que hay que evitar
std::printf(mensaje.c_str()); // mensaje viene de fuera
Cómo se detecta
#include <cstdio>
#include <string>
int main(int argc, char* argv[])
{
std::string mensaje = argc > 1 ? argv[1] : "hola";
std::printf(mensaje.c_str());
std::printf("\n");
}g++ -std=c++23 -Wall -Wextra -Wformat=2 formato.cpp
g++: avisoformato.cpp: In function 'int main(int, char**)':
formato.cpp:7:16: warning: format not a string literal and no format arguments [-Wformat-security]
7 | std::printf(mensaje.c_str());
| ~~~~~~~~~~~^~~~~~~~~~~~~~~~~-Wall por sí solo no lo avisa: hace falta -Wformat=2 (o -Wformat-security), que muchas distribuciones activan por omisión.
La forma segura
std::format y std::print son seguros por diseño: el formato tiene que ser conocido al compilar, así que un formato que viene de fuera ni siquiera compila.
#include <print>
#include <string>
int main(int argc, char* argv[])
{
std::string mensaje = argc > 1 ? argv[1] : "hola";
std::println(mensaje);
}g++: errorformato_print.cpp: In function 'int main(int, char**)':
formato_print.cpp:7:18: error: call to consteval function 'std::basic_format_string<char>(mensaje)' is not a constant expression
7 | std::println(mensaje);#include <print>
#include <string>
int main(int argc, char* argv[])
{
std::string mensaje = argc > 1 ? argv[1] : "hola";
std::println("{}", mensaje); // el texto de fuera es un dato, nunca el formato
}salida%s%s%n {} {:>50}Los %s, %n y llaves del texto se imprimen tal cual. Si de verdad necesitas un formato elegido en tiempo de ejecución, por ejemplo traducido, existe std::vformat, que valida el formato y lanza una excepción si no casa con los argumentos.
11. Números y texto sin validar
Las conversiones clásicas de texto a número aceptan más de lo que parece. std::stoi("12abc") devuelve 12 sin quejarse, y std::cin >> n con una entrada no numérica deja n a 0 y el flujo en estado de error, que si no se comprueba hace que las lecturas siguientes fallen en silencio.
#include <charconv>
#include <print>
#include <string>
#include <string_view>
bool a_entero(std::string_view texto, int& valor)
{
const char* fin = texto.data() + texto.size();
auto [donde, error] = std::from_chars(texto.data(), fin, valor);
return error == std::errc{} && donde == fin; // sin errores y sin restos
}
int main()
{
std::println("stoi(\"12abc\") = {}", std::stoi("12abc"));
for (std::string_view t : {"12", "12abc", "", "99999999999"}) {
int n = 0;
if (a_entero(t, n)) {
std::println("«{}» -> {}", t, n);
} else {
std::println("«{}» -> no es un entero válido", t);
}
}
}salidastoi("12abc") = 12
«12» -> 12
«12abc» -> no es un entero válido
«» -> no es un entero válido
«99999999999» -> no es un entero válidostd::from_chars (C++17) no lanza excepciones, no depende del idioma del sistema y dice exactamente dónde se paró. Después de convertir, valida el rango: una cantidad negativa o una edad de 500 años son números válidos y datos absurdos. Y con std::cin, comprueba siempre el resultado de la lectura (if (std::cin >> n)).
12. Inyección de órdenes
std::system pasa una cadena al intérprete de órdenes, que interpreta ;, |, $( ) y las comillas. Si parte de esa cadena viene de fuera, quien la escribe puede añadir órdenes propias.
El patrón que hay que evitar
std::string orden = "convert " + archivo + " miniatura.png"; std::system(orden.c_str()); // archivo viene de fuera
Cómo se detecta
#include <cstdlib>
#include <string>
int main(int argc, char* argv[])
{
std::string archivo = argc > 1 ? argv[1] : "foto.jpg";
std::string orden = "ls -l " + archivo;
return std::system(orden.c_str());
}clang-tidy/src/sistema.cpp:8:12: warning: calling 'system' uses a command processor [cert-env33-c]
8 | return std::system(orden.c_str());
| ^La forma segura
Ejecuta el programa directamente, con los argumentos en un array, sin pasar por el intérprete. En POSIX, posix_spawnp:
#include <spawn.h>
#include <sys/wait.h>
#include <print>
#include <string>
#include <vector>
extern char** environ;
int ejecutar(std::vector<std::string> args)
{
std::vector<char*> argv;
for (auto& a : args) {
argv.push_back(a.data());
}
argv.push_back(nullptr);
pid_t pid;
if (posix_spawnp(&pid, argv[0], nullptr, nullptr, argv.data(), environ) != 0) {
return -1;
}
int estado = 0;
waitpid(pid, &estado, 0);
return WIFEXITED(estado) ? WEXITSTATUS(estado) : -1;
}
int main()
{
std::string archivo = "foto.jpg; echo te colaste";
int rc = ejecutar({"printf", "argumento: [%s]\n", archivo});
std::println("código de salida: {}", rc);
}salidaargumento: [foto.jpg; echo te colaste] código de salida: 0
Todo el texto llega como un único argumento, con su punto y coma incluido: no hay intérprete que lo trocee. Además, valida los nombres de archivo contra lo que esperas (por ejemplo, solo letras, cifras, guiones y una extensión permitida) y pásalos detrás de -- si el programa lo admite, para que un nombre que empiece por - no se tome como opción.
13. Condiciones de carrera
Dos hilos que leen y escriben la misma variable sin sincronizarse provocan una carrera de datos, que en C++ es comportamiento indefinido. El resultado depende del momento exacto en que corre cada hilo: puede funcionar mil veces y fallar en producción.
Cómo se detecta
#include <print>
#include <thread>
int main()
{
int contador = 0;
{
std::jthread a([&] { for (int i = 0; i < 100'000; ++i) ++contador; });
std::jthread b([&] { for (int i = 0; i < 100'000; ++i) ++contador; });
}
std::println("contador = {}", contador);
}salida con errorWARNING: ThreadSanitizer: data race (pid=76)
Read of size 4 at 0x7ffd10e94c9c by thread T2:
#0 operator() /curso/carrera.cpp:9 (carrera+0x4025f8)
…
Previous write of size 4 at 0x7ffd10e94c9c by thread T1:
#0 operator() /curso/carrera.cpp:8 (carrera+0x40265b)
…
SUMMARY: ThreadSanitizer: data race /curso/carrera.cpp:9 in operator()La forma segura
#include <atomic>
#include <print>
#include <thread>
int main()
{
std::atomic<int> contador = 0;
{
std::jthread a([&] { for (int i = 0; i < 100'000; ++i) ++contador; });
std::jthread b([&] { for (int i = 0; i < 100'000; ++i) ++contador; });
} // los jthread esperan a su hilo al destruirse
std::println("contador = {}", contador.load());
}salidacontador = 200000
Para un contador, std::atomic. Para proteger varias variables que cambian juntas, un std::mutex con std::scoped_lock, que lo libera solo al salir del bloque. Ejecuta las pruebas de todo código con hilos con -fsanitize=thread.
14. Excepciones que dejan fugas o estados a medias
Una excepción sale de la función por cualquier línea. Si la función había reservado memoria con new, abierto un archivo con fopen o bloqueado un mutex a mano, el delete, el fclose o el unlock del final no llegan a ejecutarse. Con un mutex, el resultado es un programa bloqueado para siempre; con memoria, un servidor que se queda sin ella poco a poco.
#include <print>
#include <stdexcept>
void validar(int n)
{
if (n < 0) throw std::invalid_argument("cantidad negativa");
}
void procesar(int n)
{
char* bufer = new char[4096];
validar(n); // si lanza, el delete[] no se ejecuta
delete[] bufer;
}
int main()
{
try {
procesar(-1);
} catch (const std::exception& e) {
std::println("Error: {}", e.what());
}
}salida con error==228==ERROR: LeakSanitizer: detected memory leaks
…
Direct leak of 4096 byte(s) in 1 object(s) allocated from:
…
#1 0x000000404704 in procesar(int) /curso/fuga.cpp:11
#2 0x0000004047ad in main /curso/fuga.cpp:19
…
SUMMARY: AddressSanitizer: 4096 byte(s) leaked in 1 allocation(s).La forma segura
RAII: cada recurso dentro de un objeto que lo libera en su destructor. std::vector<char> bufer(4096); o std::make_unique<char[]>(4096) en lugar de new[]; std::ifstream en lugar de fopen; std::scoped_lock en lugar de lock() y unlock(). Los destructores se ejecutan siempre, salga la excepción por donde salga. Y un destructor nunca debe lanzar: si lo hace mientras se gestiona otra excepción, el programa termina con std::terminate.
Secretos
15. Números aleatorios predecibles
std::rand, std::mt19937 y el resto de motores de <random> son buenos para simulaciones y juegos, pero no son criptográficos: con unas cuantas salidas se puede reconstruir su estado y predecir las siguientes. Sembrarlos con la hora lo empeora, porque la semilla se puede adivinar. Tampoco std::random_device garantiza nada: el estándar permite que sea determinista.
#include <print>
#include <random>
int main()
{
std::mt19937 servidor(1'761'800'000); // sembrado con la hora: 30 de octubre de 2026
std::mt19937 curioso(1'761'800'000); // quien adivine la hora tiene el mismo generador
for (int i = 0; i < 3; ++i) {
std::println("token {:08x} predicho {:08x}", servidor(), curioso());
}
}salidatoken ceba2a18 predicho ceba2a18 token ace09f90 predicho ace09f90 token 3d9aedba predicho 3d9aedba
La forma segura
#include <sys/random.h>
#include <array>
#include <cstdint>
#include <format>
#include <print>
#include <stdexcept>
#include <string>
std::string token_hex()
{
std::array<std::uint8_t, 16> bytes{};
if (getrandom(bytes.data(), bytes.size(), 0) != static_cast<ssize_t>(bytes.size())) {
throw std::runtime_error("getrandom falló");
}
std::string hex;
for (auto b : bytes) {
hex += std::format("{:02x}", b);
}
return hex;
}
int main()
{
std::string t = token_hex();
std::println("token de {} caracteres hexadecimales", t.size());
}salidatoken de 32 caracteres hexadecimales
Para tokens, claves y contraseñas, el generador del sistema: getrandom en Linux, arc4random_buf en macOS y BSD (y en glibc desde la 2.36), BCryptGenRandom en Windows; o, mejor, una biblioteca criptográfica como libsodium u OpenSSL. Más sobre los generadores de <random> en los ejemplos de números aleatorios.
16. Secretos que se quedan en la memoria
Un std::string con una contraseña puede dejar copias por el camino: al crecer, al pasarlo por valor o al convertirlo. Y clear() solo pone el tamaño a 0, sin borrar los bytes. Además, comparar un secreto con == se detiene en el primer carácter distinto, y el tiempo que tarda revela cuántos acertaste.
#include <string.h> // explicit_bzero (glibc)
#include <array>
#include <cstddef>
#include <print>
// Recorre siempre los n bytes: tarda lo mismo acierte o no.
bool iguales_seguro(const unsigned char* a, const unsigned char* b, std::size_t n)
{
unsigned char diferencia = 0;
for (std::size_t i = 0; i < n; ++i) {
diferencia |= a[i] ^ b[i];
}
return diferencia == 0;
}
int main()
{
std::array<unsigned char, 8> esperado{'a', '3', 'f', '9', 'c', '2', '7', 'e'};
std::array<unsigned char, 8> recibido{'a', '3', 'f', '9', 'c', '2', '7', 'x'};
std::println("¿Iguales? {}", iguales_seguro(esperado.data(), recibido.data(), esperado.size()));
explicit_bzero(esperado.data(), esperado.size()); // el compilador no puede quitarlo
std::println("Primer byte tras borrar: {}", esperado[0]);
}salida¿Iguales? false Primer byte tras borrar: 0
Guarda los secretos en búferes de tamaño fijo que controles, bórralos con explicit_bzero (un memset normal lo puede eliminar el optimizador si ve que nadie vuelve a leer el búfer) y compáralos en tiempo constante. En producción, mejor las funciones de una biblioteca criptográfica: sodium_memcmp y sodium_memzero de libsodium, u OPENSSL_cleanse y CRYPTO_memcmp de OpenSSL.
Las protecciones que se activan al compilar
- OpciónQué haceCuándo
-Wall -WextraAvisos básicos, incluidos-Wdangling-referencey-Wuse-after-freeSiempre-Wconversion -Wsign-conversionConversiones que pierden datos o cambian el signoSiempre, en código nuevo-Wformat=2Formatos deprintfque no son constantesSiempre-D_GLIBCXX_ASSERTIONSComprueba índices, contenedores vacíos yoptionalen libstdc++Siempre, también en producción-D_GLIBCXX_DEBUGVigila cada iterador; más lento y cambia el ABISolo en pruebas-fsanitize=address,undefinedMemoria y comportamiento indefinido al ejecutarPruebas-fsanitize=threadCarreras de datosPruebas con hilos-ftrivial-auto-var-init=zeroPone a cero las variables locales sin inicializarCódigo heredado-fhardened(GCC 14+)_FORTIFY_SOURCE=3,_GLIBCXX_ASSERTIONS, PIE, RELRO, protector de pila, CET…Producción
Clang tiene el equivalente de _GLIBCXX_ASSERTIONS en su propia biblioteca, libc++: -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST. Y MSVC comprueba los índices de sus contenedores en las compilaciones de depuración. En el proyecto final del curso se ve qué añade exactamente -fhardened a un ejecutable.
Cómo revisar un proyecto que ya existe
- Compila con todos los avisos y con
-O2. En un proyecto antiguo saldrán cientos; empieza por-Wdangling-reference,-Wuse-after-free, las variables sin inicializar y las conversiones en tamaños. - Activa
_GLIBCXX_ASSERTIONSen todas las compilaciones. Es el cambio con mejor relación entre esfuerzo y seguridad. - Ejecuta las pruebas con sanitizadores:
address,undefined, ythreadpor separado si hay hilos. - Pasa clang-tidy con las familias
bugprone-*,clang-analyzer-*,cert-*y algunas decppcoreguidelines-*, como en el proyecto. - Busca
new,delete, moldes de C yreinterpret_casty sustitúyelos por contenedores, punteros inteligentes ystatic_cast/std::bit_cast. - Haz fuzzing del código que lee datos: libFuzzer (
clang++ -fsanitize=fuzzer,address) o AFL++. Muchos proyectos abiertos lo hacen de forma continua en OSS-Fuzz, de Google. - Entrega con
-fhardenedy mantén al día el compilador y las bibliotecas: Versiones y parches.
Lista de comprobación
- Comprobación
- ☐Ningún
[]con índices de fuera sin validar;forde rango, algoritmos oat() - ☐Nada de
push_back,insertnierasesobre el contenedor que se está recorriendo;std::erase_ifpara borrar - ☐Las funciones devuelven valores, no
string_view,spanni referencias a temporales o locales - ☐Ningún
newnideletea mano: contenedores,make_unique,make_shared - ☐Ningún objeto se usa después de
std::movesin asignarle antes un valor - ☐Todas las variables se inicializan al declararlas
- ☐Ni moldes de C ni
reinterpret_cast:static_castostd::bit_cast - ☐Operaciones con datos de fuera comprobadas antes de desbordar; tamaños con
std::ssizeystd::cmp_less - ☐Formatos siempre constantes:
std::print("{}", texto) - ☐Texto convertido con
std::from_charscomprobando que no sobra nada, y el rango validado - ☐Nada de
std::systemcon datos externos;posix_spawnpcon un array de argumentos - ☐Variables compartidas entre hilos con
std::atomico unstd::mutex - ☐Cada recurso dentro de un objeto RAII; ningún destructor lanza
- ☐Secretos con
getrandomo una biblioteca criptográfica, comparados en tiempo constante y borrados conexplicit_bzero - ☐Pruebas con ASan, UBSan y TSan, clang-tidy y, para el código que lee datos, fuzzing
- ☐Ejecutables con
-fhardenedo, como mínimo,_GLIBCXX_ASSERTIONS
Todo el código de esta página que tiene salida se compiló con GCC 15.3 (C++23) y se ejecutó en un contenedor limpio sin acceso a la red; las salidas, los avisos y los informes de los sanitizadores y de clang-tidy 19 están copiados de esa ejecución. Los fragmentos marcados como «el patrón que hay que evitar» se muestran para reconocerlos. Esta página explica los fallos para detectarlos y corregirlos, no cómo aprovecharlos.
