Los fallos de seguridad típicos de C++ y cómo evitarlos

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.

Cinco capas de defensa en C++: escribir con RAII, contenedores y cuidado con las vistas; compilar con Wall, Wextra y Wconversion; probar con AddressSanitizer, UBSan y ThreadSanitizer; analizar con clang-tidy y fuzzing; y entregar con fhardened y los parches al díaCinco capas: cada una atrapa lo que se escapó de la anteriorescribirRAII, contenedoresy vistas con cuidadocompilar-Wall -Wextra-WconversionprobarASan, UBSany TSananalizarclang-tidyy fuzzingentregar-fhardenedy parches al díaNinguna capa basta sola: el compilador no ve lo que pasa al ejecutar, y las pruebas no ven lo que no ejecutan.

Las defensas de esta página, ordenadas por el momento en que actúan. Esquema: decodigo.com.

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 valida

Có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 main

Y 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 main

El 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ínea

Có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 main

La 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ón

Có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 main

La 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 pase
salidaPedido 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ía

Có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álido

std::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-reference y -Wuse-after-freeSiempre
  • -Wconversion -Wsign-conversionConversiones que pierden datos o cambian el signoSiempre, en código nuevo
  • -Wformat=2Formatos de printf que no son constantesSiempre
  • -D_GLIBCXX_ASSERTIONSComprueba índices, contenedores vacíos y optional en 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

  1. 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.
  2. Activa _GLIBCXX_ASSERTIONS en todas las compilaciones. Es el cambio con mejor relación entre esfuerzo y seguridad.
  3. Ejecuta las pruebas con sanitizadores: address,undefined, y thread por separado si hay hilos.
  4. Pasa clang-tidy con las familias bugprone-*, clang-analyzer-*, cert-* y algunas de cppcoreguidelines-*, como en el proyecto.
  5. Busca new, delete, moldes de C y reinterpret_cast y sustitúyelos por contenedores, punteros inteligentes y static_cast/std::bit_cast.
  6. 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.
  7. Entrega con -fhardened y 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; for de rango, algoritmos o at()
  • ☐Nada de push_back, insert ni erase sobre el contenedor que se está recorriendo; std::erase_if para borrar
  • ☐Las funciones devuelven valores, no string_view, span ni referencias a temporales o locales
  • ☐Ningún new ni delete a mano: contenedores, make_unique, make_shared
  • ☐Ningún objeto se usa después de std::move sin asignarle antes un valor
  • ☐Todas las variables se inicializan al declararlas
  • ☐Ni moldes de C ni reinterpret_cast: static_cast o std::bit_cast
  • ☐Operaciones con datos de fuera comprobadas antes de desbordar; tamaños con std::ssize y std::cmp_less
  • ☐Formatos siempre constantes: std::print("{}", texto)
  • ☐Texto convertido con std::from_chars comprobando que no sobra nada, y el rango validado
  • ☐Nada de std::system con datos externos; posix_spawnp con un array de argumentos
  • ☐Variables compartidas entre hilos con std::atomic o un std::mutex
  • ☐Cada recurso dentro de un objeto RAII; ningún destructor lanza
  • ☐Secretos con getrandom o una biblioteca criptográfica, comparados en tiempo constante y borrados con explicit_bzero
  • ☐Pruebas con ASan, UBSan y TSan, clang-tidy y, para el código que lee datos, fuzzing
  • ☐Ejecutables con -fhardened o, 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.