Articulo de referencia

Comportamiento indefinido

Un programa informático presenta un comportamiento indefinido ( UB ) cuando contiene o ejecuta código para el cual la especificación de su lenguaje de programación no impone req...

Un programa informático presenta un comportamiento indefinido ( UB ) cuando contiene o ejecuta código para el cual la especificación de su lenguaje de programación no impone requisitos específicos. [ 1 ] Esto difiere del comportamiento no especificado , para el cual la especificación del lenguaje no prescribe un resultado, y del comportamiento definido por la implementación, que se remite a la documentación de otro componente de la plataforma (como la ABI o la documentación del traductor ).

En la comunidad de programación en C , el comportamiento indefinido puede denominarse humorísticamente " demonios nasales ", en referencia a una publicación en comp.std.c que explicaba que el comportamiento indefinido permite al compilador hacer lo que quiera, incluso "hacer que salgan demonios volando de tu nariz". [ 2 ]

Descripción general

Algunos lenguajes de programación permiten que un programa funcione de manera diferente o incluso tenga un flujo de control distinto al del código fuente, siempre que presente los mismos efectos secundarios visibles para el usuario , siempre que no se produzca un comportamiento indefinido durante la ejecución del programa . El comportamiento indefinido es el nombre de una lista de condiciones que el programa no debe cumplir.

En las primeras versiones de C , la principal ventaja del comportamiento indefinido era la creación de compiladores de alto rendimiento para una amplia variedad de máquinas : una construcción específica podía asignarse a una característica propia de la máquina, y el compilador no tenía que generar código adicional para que el entorno de ejecución adaptara los efectos secundarios a la semántica impuesta por el lenguaje. El código fuente del programa se escribía con conocimiento previo del compilador específico y de las plataformas que admitiría.

Sin embargo, la estandarización progresiva de las plataformas ha reducido esta ventaja, especialmente en las versiones más recientes de C. Ahora, los casos de comportamiento indefinido suelen representar errores inequívocos en el código, como por ejemplo, indexar un array fuera de sus límites. Por definición, el entorno de ejecución puede asumir que el comportamiento indefinido nunca ocurre; por lo tanto, no es necesario comprobar ciertas condiciones inválidas. Para un compilador , esto también significa que diversas transformaciones del programa se vuelven válidas o que sus pruebas de corrección se simplifican; esto permite varios tipos de optimizaciones cuya corrección depende de la suposición de que el estado del programa nunca cumple ninguna de estas condiciones. El compilador también puede eliminar comprobaciones explícitas que pudieran haber estado en el código fuente, sin notificar al programador; por ejemplo, detectar el comportamiento indefinido comprobando si ocurrió no garantiza su funcionamiento, por definición. Esto dificulta o imposibilita la programación de una opción portátil a prueba de fallos (aunque existen soluciones no portátiles para algunas construcciones).

El desarrollo actual de compiladores suele evaluar y comparar su rendimiento con pruebas de referencia diseñadas en torno a microoptimizaciones, incluso en plataformas que se utilizan principalmente en el mercado de ordenadores de sobremesa y portátiles de uso general (como amd64). Por lo tanto, el comportamiento indefinido ofrece un amplio margen para mejorar el rendimiento del compilador, ya que el código fuente de una instrucción específica puede asignarse a cualquier cosa en tiempo de ejecución.

Para C y C++, el compilador puede proporcionar un diagnóstico en tiempo de compilación en estos casos, pero no está obligado a hacerlo: la implementación se considerará correcta independientemente de lo que haga en tales casos, de forma análoga a los términos de indiferencia en lógica digital. Es responsabilidad del programador escribir código que nunca invoque un comportamiento indefinido, aunque las implementaciones del compilador pueden emitir diagnósticos cuando esto sucede. Actualmente, los compiladores tienen indicadores que habilitan dichos diagnósticos, por ejemplo, -fsanitize=undefinedhabilita el "sanitizador de comportamiento indefinido" ( UBSan ) en gcc 4.9 [ 3 ] y en clang . Sin embargo, este indicador no es el predeterminado y su habilitación es una elección de quien compila el código.

En determinadas circunstancias, puede haber restricciones específicas sobre el comportamiento indefinido. Por ejemplo, las especificaciones del conjunto de instrucciones de una CPU podrían dejar sin definir el comportamiento de algunas formas de una instrucción, pero si la CPU admite protección de memoria , entonces la especificación probablemente incluirá una regla general que establezca que ninguna instrucción accesible al usuario puede causar una brecha en la seguridad del sistema operativo ; por lo tanto, una CPU real podría corromper los registros de usuario en respuesta a dicha instrucción, pero no se le permitiría, por ejemplo, cambiar al modo supervisor .

La plataforma de ejecución también puede proporcionar restricciones o garantías sobre comportamientos indefinidos, si la cadena de herramientas o el entorno de ejecución documentan explícitamente que construcciones específicas del código fuente se asignan a mecanismos específicos bien definidos disponibles en tiempo de ejecución. Por ejemplo, un intérprete puede documentar un comportamiento particular para ciertas operaciones que no están definidas en la especificación del lenguaje, mientras que otros intérpretes o compiladores del mismo lenguaje podrían no hacerlo. Un compilador produce código ejecutable para una ABI específica , completando la brecha semántica de maneras que dependen de la versión del compilador: la documentación de esa versión y la especificación de la ABI pueden proporcionar restricciones sobre comportamientos indefinidos. Depender de estos detalles de implementación hace que el software no sea portable , pero la portabilidad puede no ser un problema si el software no está destinado a usarse fuera de un entorno de ejecución específico.

Un comportamiento indefinido puede provocar que el programa se bloquee o incluso que se produzcan fallos más difíciles de detectar, haciendo que el programa parezca funcionar con normalidad, como la pérdida silenciosa de datos y la obtención de resultados incorrectos.

Programa erróneo

En el diseño de lenguajes de programación , un programa erróneo es aquel cuya semántica no está bien definida, pero donde la implementación del lenguaje no está obligada a señalar un error ni en tiempo de compilación ni en tiempo de ejecución. Por ejemplo, en Ada :

Además de los errores acotados, las reglas del lenguaje definen ciertos tipos de errores que conducen a una ejecución errónea. Al igual que con los errores acotados, la implementación no necesita detectarlos ni antes ni durante la ejecución. A diferencia de los errores acotados, no existe un límite especificado por el lenguaje para el posible efecto de una ejecución errónea; en general, el efecto no es predecible. [ 4 ]

Definir una condición como "errónea" significa que la implementación del lenguaje no necesita realizar una comprobación potencialmente costosa ( por ejemplo , que una variable global haga referencia al mismo objeto que un parámetro de subrutina), pero puede, no obstante, depender de que una condición sea verdadera para definir la semántica del programa.

Beneficios

Documentar una operación como comportamiento indefinido permite a los compiladores asumir que dicha operación nunca ocurrirá en un programa que cumpla con las especificaciones. Esto proporciona al compilador más información sobre el código, lo que puede generar más oportunidades de optimización.

Un ejemplo para el lenguaje C:

int foo ( unsigned char x ) { int value = 2147483600 ; // suponiendo un entero de 32 bits y un carácter de 8 bits value += x ; if ( value < 2147483600 ) { bar (); } return value ; }

El valor de xno puede ser negativo y, dado que el desbordamiento de enteros con signo es un comportamiento indefinido en C, el compilador puede asumir que value < 2147483600siempre será falso. Por lo tanto, la ifinstrucción, incluida la llamada a la función bar, puede ser ignorada por el compilador ya que la expresión de prueba en ifno tiene efectos secundarios y su condición nunca se cumplirá. Por consiguiente, el código es semánticamente equivalente a:

int foo ( unsigned char x ) { int value = 2147483600 ; value += x ; return value ; }

Si el compilador se hubiera visto obligado a asumir que el desbordamiento de enteros con signo tiene un comportamiento de envoltura , entonces la transformación anterior no habría sido válida.

Estas optimizaciones se vuelven difíciles de detectar para los humanos cuando el código es más complejo y se producen otras optimizaciones, como la inserción de código en línea . Por ejemplo, otra función puede llamar a la función anterior:

void run_tasks ( unsigned char * ptrx ) { int z ; z = foo ( * ptrx ); while ( * ptrx > 60 ) { run_one_task ( ptrx , z ); } }

El compilador puede optimizar eliminando el whilebucle aquí aplicando un análisis de rango de valores : al inspeccionar foo(), sabe que el valor inicial al que apunta ptrxno puede exceder 47 (ya que cualquier valor mayor provocaría un comportamiento indefinido en foo()); por lo tanto, la comprobación inicial de *ptrx > 60siempre será falsa en un programa conforme. Yendo más allá, dado que el resultado zahora nunca se usa y foo()no tiene efectos secundarios, el compilador puede optimizar run_tasks()para que sea una función vacía que regresa inmediatamente. La desaparición del whilebucle puede ser especialmente sorprendente si foo()está definido en un archivo objeto compilado por separado .

Otra ventaja de permitir que el desbordamiento de enteros con signo no esté definido es que permite almacenar y manipular el valor de una variable en un registro del procesador de mayor tamaño que el de la variable en el código fuente. Por ejemplo, si el tipo de una variable, tal como se especifica en el código fuente, es más estrecho que el ancho del registro nativo (como inten una máquina de 64 bits , un escenario común), el compilador puede usar de forma segura un entero con signo de 64 bits para la variable en el código máquina que produce, sin modificar el comportamiento definido del código. Si un programa dependiera del comportamiento de un desbordamiento de enteros de 32 bits, el compilador tendría que insertar lógica adicional al compilar para una máquina de 64 bits, ya que el comportamiento de desbordamiento de la mayoría de las instrucciones máquina depende del ancho del registro. [ 5 ]

El comportamiento indefinido también permite realizar más comprobaciones en tiempo de compilación, tanto por parte de los compiladores como del análisis estático del programa .

Riesgos

Los estándares de C y C++ presentan diversas formas de comportamiento indefinido, lo que ofrece mayor libertad en las implementaciones del compilador y en las comprobaciones en tiempo de compilación, a costa de un comportamiento indefinido en tiempo de ejecución, si este se presenta. En particular, el estándar ISO para C incluye un apéndice que enumera las fuentes comunes de comportamiento indefinido. [ 6 ] Además, los compiladores no están obligados a diagnosticar el código que depende de comportamiento indefinido. Por lo tanto, es común que los programadores, incluso los experimentados, recurran a comportamiento indefinido, ya sea por error o simplemente por desconocimiento de las reglas del lenguaje, que pueden abarcar cientos de páginas. Esto puede dar lugar a errores que se manifiestan al utilizar un compilador o configuraciones diferentes. Las pruebas o el fuzzing con comprobaciones dinámicas de comportamiento indefinido habilitadas, por ejemplo, los sanitizadores de Clang , pueden ayudar a detectar comportamientos indefinidos no diagnosticados por el compilador o los analizadores estáticos. [ 7 ]

El comportamiento indefinido puede provocar vulnerabilidades de seguridad en el software. Por ejemplo, los desbordamientos de búfer y otras vulnerabilidades de seguridad en los principales navegadores web se deben al comportamiento indefinido. Cuando los desarrolladores de GCC modificaron su compilador en 2008, omitiendo ciertas comprobaciones de desbordamiento que dependían del comportamiento indefinido, CERT emitió una advertencia sobre las versiones más recientes del compilador. [ 8 ] Linux Weekly News señaló que se observó el mismo comportamiento en PathScale C , Microsoft Visual C++ 2005 y otros compiladores; [ 9 ] la advertencia se modificó posteriormente para advertir sobre varios compiladores. [ 10 ]

Ejemplos

C y C++

Las principales formas de comportamiento indefinido en C se pueden clasificar ampliamente como: [ 11 ] violaciones de seguridad de memoria espacial, violaciones de seguridad de memoria temporal, desbordamiento de enteros , violaciones de alias estricto, violaciones de alineación, modificaciones no secuenciadas, carreras de datos y bucles que ni realizan E/S ni terminan.

En C, el uso de cualquier variable automática antes de su inicialización produce un comportamiento indefinido, al igual que la división entera por cero , el desbordamiento de enteros con signo, la indexación de un array fuera de sus límites definidos (véase desbordamiento de búfer ) o la desreferenciación de punteros nulos . En general, cualquier instancia de comportamiento indefinido deja a la máquina de ejecución abstracta en un estado desconocido y provoca que el comportamiento de todo el programa sea indefinido.

Debido a que los literales de cadena generalmente se almacenan en memoria de solo lectura, intentar modificar uno provoca un comportamiento indefinido: [ 12 ]

char * p = "wikipedia" ; // válido en C, obsoleto en C++98/C++03, mal formado a partir de C++11 p [ 0 ] = 'W' ; // comportamiento indefinido

La división entera por cero produce un comportamiento indefinido: [ 13 ]

int x = 1 ; return x / 0 ; // comportamiento indefinido

Ciertas operaciones con punteros pueden dar lugar a un comportamiento indefinido: [ 14 ]

int arr [ 4 ] = { 0 , 1 , 2 , 3 }; int * p = arr + 5 ; // comportamiento indefinido para indexar fuera de los límites p = nullptr ; int a = * p ; // comportamiento indefinido para desreferenciar un puntero nulo

En C y C++, la comparación relacional de punteros a objetos (para comparación menor que o mayor que) solo está estrictamente definida si los punteros apuntan a miembros del mismo objeto o a elementos del mismo array . [ 15 ] Ejemplo:

int main ( void ) { int a = 0 ; int b = 0 ; return & a < & b ; // comportamiento indefinido en C, comportamiento no especificado en C++ }

Llegar al final de una función que devuelve un valor (que no sea main()) sin una instrucción de retorno da como resultado un comportamiento indefinido si el valor de la llamada a la función es utilizado por quien la llama: [ 16 ]

int f () {}int x = f (); // comportamiento indefinido

Modificar un objeto entre dos puntos de secuencia más de una vez produce un comportamiento indefinido. [ 17 ] Hay cambios considerables en lo que causa comportamiento indefinido en relación con los puntos de secuencia a partir de C++11. [ 18 ] Los compiladores modernos pueden emitir advertencias cuando encuentran múltiples modificaciones no secuenciadas en el mismo objeto. [ 19 ] [ 20 ] El siguiente ejemplo causará comportamiento indefinido tanto en C como en C++.

int f ( int i ) { // comportamiento indefinido: dos modificaciones no secuenciadas a i return i ++ + i ++ ; }

Al modificar un objeto entre dos puntos de secuencia, leer el valor del objeto para cualquier otro propósito que no sea determinar el valor que se almacenará también es un comportamiento indefinido. [ 21 ]

a [ i ] = i ++ ; // comportamiento indefinido printf ( "%d %d \n " , ++ n , pow ( 2 , n )); // también comportamiento indefinido

En C/C++, el desplazamiento bit a bit de un valor en una cantidad de bits que sea negativa o mayor o igual que la cantidad total de bits de dicho valor produce un comportamiento indefinido. La forma más segura (independientemente del proveedor del compilador) es mantener siempre la cantidad de bits a desplazar (el operando derecho de los operadores bit a bit<< AND ) dentro del rango: [ 0 , sizeof value * CHAR_BIT - 1 ] (donde es el operando izquierdo).>>value

int num = -1 ; unsigned int val = 1 << num ; // Desplazamiento por un número negativo: comportamiento indefinidonum = 32 ; // o cualquier número mayor que 31 val = 1 << num ; // el literal '1' se escribe como un entero de 32 bits; en este caso, un desplazamiento mayor a 31 bits es un comportamiento indefinidonum = 64 ; // o cualquier número mayor que 63 unsigned long long val2 = 1ULL << num ; // el literal '1ULL' se tipifica como un entero de 64 bits; en este caso, un desplazamiento mayor a 63 bits es un comportamiento indefinido

DO#

En C# , el comportamiento indefinido puede invocarse en unsafecontexto.

usando el sistema ;unsafe { int * p = ( int * ) 0x12345678 ; Console . WriteLine ( * p ); // leyendo una dirección de memoria arbitraria }

El uso de memoria de pila después de su liberación también desencadena un comportamiento indefinido.

usando el sistema ;int * GetPointer ( ) int x = 100 ; return & x ; }int * p = GetPointer (); Console . WriteLine ( * p ); // obtiene un puntero a una memoria de pila que ya no es válida

Java

En Java , la interoperabilidad nativa y las condiciones de carrera son los casos más notables en los que se produce un comportamiento indefinido.

La siguiente condición de carrera de datos puede provocar un comportamiento indefinido al violar el modelo de memoria de Java.

int x = 0 ; boolean ready = false ;Hilo t1 = nuevo Hilo (() -> { x = 33 ; listo = verdadero ; });Thread t2 = new Thread (() -> { if ( ready ) { System . out . println ( x ); // puede imprimir 33 o 0 } });t1.start ( ) ; t2.start ( ) ;

La memoria indefinida puede surgir en las llamadas a la interfaz nativa de Java , lo que puede provocar un comportamiento indefinido. Desde C:

#include <jni.h>JNIEXPORT jint JNICALL Java_Crash_boom ( JNIEnv * env , jclass cls ) { int * p = NULL ; return * p ; // desreferenciando un puntero nulo }

Sobre Java:

paquete org.wikipedia.examples ;public class Crash { static { System . loadLibrary ( "crash" ); }privado estático nativo int boom ();public static void main ( String [] args ) { System . out . println ( boom ()); } }

Óxido

Si bien generalmente se puede esperar que el comportamiento indefinido no ocurra en Rust seguro , el código inapropiado e inseguro aún puede exponer el comportamiento indefinido al código seguro en lo que se conoce como agujeros de solidez . [ 22 ]

Por ejemplo, muchos tipos de datos en Rust utilizan invariantes que permiten optimizaciones útiles. Las referencias son un ejemplo, ya que , si bien fundamentalmente tienen la misma representación que los punteros sin formato , nunca pueden ser nulas , no estar alineadas o apuntar a destinos no válidos. Por lo tanto, romper cualquiera de estas invariantes es indefinido, independientemente de cómo se utilice la referencia resultante.

usar std :: mem ;/// Construye una referencia nula. pub const fn null_ref < T : ? Sized > () -> & T { unsafe { mem :: zeroed () } }

La llamada null_refsiempre está mal formada debido a las invariantes impuestas por todos los tipos de referencia, aunque la función en sí no sea un unsafe fnelemento y se pueda llamar desde código seguro.

Además, la desreferenciación de cualquier puntero nulo no está definida, aunque muchos sistemas anfitriones todavía están diseñados para manejar tales casos en un fallo de segmentación :

usar std :: ptr ;fn main () { let p : * const i32 = ptr :: null ();// SEGURIDAD: `p` es nulo y no se puede desreferenciar. unsafe { * p }; }

Referencias

  1. "Lo que todo programador de C debería saber sobre el comportamiento indefinido #1/3" . 13 de mayo de 2011. Consultado el 23 de febrero de 2025 .
  2. "demonios nasales" . Archivo de jerga . Consultado el 12 de junio de 2014 .
  3. Sanitizador de comportamiento indefinido de GCC – ubsan
  4. "1.1.5 Clasificación de errores" . Manual de referencia de Ada . ISO/IEC 8652:1995(E).
  5. "Un poco de información sobre compiladores que explotan el desbordamiento con signo" .
  6. ISO/IEC 9899:2011 §J.2.
  7. John Regehr (19 de octubre de 2017). "Comportamiento indefinido en 2017, cppcon 2017" . YouTube .
  8. "Nota de vulnerabilidad VU#162289: gcc descarta silenciosamente algunas comprobaciones de desbordamiento" . Base de datos de notas de vulnerabilidad . CERT. 4 de abril de 2008. Archivado del original el 9 de abril de 2008.
  9. Jonathan Corbet (16 de abril de 2008). "GCC y desbordamientos de punteros" . Linux Weekly News .
  10. "Nota de vulnerabilidad VU#162289: los compiladores de C pueden descartar silenciosamente algunas comprobaciones de desbordamiento" . Base de datos de notas de vulnerabilidad . CERT. 8 de octubre de 2008 [4 de abril de 2008].
  11. Pascal Cuoq y John Regehr (4 de julio de 2017). "Comportamiento indefinido en 2017, Blog Embedded in Academia" .
  12. ISO / IEC (2003). ISO/IEC 14882:2003(E): Lenguajes de programación – C++ §2.13.4 Literales de cadena [lex.string] párr. 2
  13. ISO / IEC (2003). ISO/IEC 14882:2003(E): Lenguajes de programación – C++ §5.6 Operadores multiplicativos [expr.mul] párr. 4
  14. ISO / IEC (2003). ISO/IEC 14882:2003(E): Lenguajes de programación - C++ §5.7 Operadores aditivos [expr.add] párr. 5
  15. ISO / IEC (2003). ISO/IEC 14882:2003(E): Lenguajes de programación – C++ §5.9 Operadores relacionales [expr.rel] párr. 2
  16. ISO / IEC (2007). ISO/IEC 9899:2007(E): Lenguajes de programación – C §6.9 Definiciones externas párr. 1
  17. Lenguaje de programación C según ANSI X3.159-1989, nota al pie 26
  18. "Orden de evaluación - cppreference.com" . en.cppreference.com . Consultado el 9 de agosto de 2016 .
  19. "Opciones de advertencia (Uso de la Colección de Compiladores GNU (GCC))" . GCC, la Colección de Compiladores GNU - Proyecto GNU - Fundación del Software Libre (FSF) . Consultado el 9 de julio de 2021 .
  20. "Indicadores de diagnóstico en Clang" . Documentación de Clang 13. Consultado el 9 de julio de 2021 .
  21. ISO / IEC (1999). ISO/IEC 9899:1999(E): Lenguajes de programación – C §6.5 Expresiones párr. 2
  22. "Comportamiento considerado indefinido" . The Rust Reference . Consultado el 28 de noviembre de 2022 .

Lecturas adicionales

  • Peter van der Linden , experto en programación en C. ISBN 0-13-177429-8
  • UB Canaries (abril de 2015), John Regehr (Universidad de Utah, EE. UU.)
  • Comportamiento indefinido en 2017 (julio de 2017) Pascal Cuoq (TrustInSoft, Francia) y John Regehr (Universidad de Utah, EE. UU.)
  • Versión corregida del estándar C99 . Véase la sección 6.10.6 para #pragma