Tecnologías utilizadas en el analizador de código PVS-Studio para buscar errores y vulnerabilidades potenciales

Tecnología y magia

Una breve descripción de las tecnologías utilizadas en la herramienta PVS-Studio que puede detectar efectivamente una gran cantidad de patrones de error y vulnerabilidades potenciales. El artículo describe la implementación del analizador para el código C y C ++, sin embargo, la información anterior también es válida para los módulos responsables de analizar el código C # y Java.

Introduccion


Hay ideas erróneas de que los analizadores de código estático son programas bastante simples basados ​​en la búsqueda de patrones de código utilizando expresiones regulares. Esto está lejos de la verdad. Además, identificar la gran mayoría de los errores utilizando expresiones regulares simplemente no es posible .

El error surgió sobre la base de la experiencia de los programadores al trabajar con algunas herramientas que existían hace 10-20 años. El trabajo de las herramientas a menudo se redujo a encontrar patrones peligrosos de código y funciones como strcpy , strcat , etc. Como representante de esta clase de herramientas puede llamarse RATS .

Tales herramientas, aunque podrían ser útiles, generalmente eran estúpidas e ineficaces. Es de aquellos tiempos en que muchos programadores todavía tienen recuerdos de que los analizadores estáticos son herramientas muy inútiles que interfieren más con el trabajo que con la ayuda.

Pasó el tiempo y los analizadores estáticos comenzaron a constituir soluciones complejas que realizan análisis de código en profundidad y encuentran errores que permanecen en el código incluso después de una revisión atenta del código. Desafortunadamente, debido a experiencias negativas pasadas, muchos programadores todavía consideran que la metodología de análisis estático es inútil y no tienen prisa por introducirla en el proceso de desarrollo.

En este artículo intentaré arreglar un poco la situación. Les pido a los lectores que tomen 15 minutos para familiarizarse con las tecnologías utilizadas en el analizador de código estático PVS-Studio para detectar errores. Quizás después de eso eche un vistazo a las herramientas de análisis estático y desee aplicarlas en su trabajo.

Análisis de flujo de datos


El análisis del flujo de datos le permite encontrar una variedad de errores. Entre ellos: salir de los límites de una matriz, pérdidas de memoria, condiciones siempre verdaderas / falsas, desreferenciar un puntero nulo, etc.

Además, el análisis de datos se puede usar para buscar situaciones en las que se usan datos no verificados que llegaron al programa desde afuera. Un atacante puede preparar un conjunto de datos de entrada para que el programa funcione de la manera que lo necesita. En otras palabras, puede usar el error de control de entrada insuficiente como una vulnerabilidad. Para buscar el uso de datos no verificados en PVS-Studio, se ha implementado el diagnóstico especializado V1010 y continúa mejorando.

El análisis del flujo de datos (Análisis de flujo de datos ) consiste en calcular los posibles valores de las variables en varios puntos de un programa de computadora. Por ejemplo, si el puntero está desreferenciado, y se sabe que en este momento puede ser cero, entonces esto es un error y el analizador estático lo informará.

Veamos un ejemplo práctico del uso del análisis de flujo de datos para buscar errores. Ante nosotros hay una función del proyecto Protocol Buffers (protobuf), diseñado para verificar la exactitud de la fecha.

static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; bool ValidateDateTime(const DateTime& time) { if (time.year < 1 || time.year > 9999 || time.month < 1 || time.month > 12 || time.day < 1 || time.day > 31 || time.hour < 0 || time.hour > 23 || time.minute < 0 || time.minute > 59 || time.second < 0 || time.second > 59) { return false; } if (time.month == 2 && IsLeapYear(time.year)) { return time.month <= kDaysInMonth[time.month] + 1; } else { return time.month <= kDaysInMonth[time.month]; } } 

El analizador PVS-Studio detectó dos errores lógicos en la función a la vez y muestra los siguientes mensajes:

  • V547 / CWE-571 La expresión 'time.month <= kDaysInMonth [time.month] + 1' siempre es verdadera. time.cc 83
  • V547 / CWE-571 La expresión 'time.month <= kDaysInMonth [time.month]' siempre es verdadera. time.cc 85

Tenga en cuenta la subexpresión "time.month <1 || tiempo.mes> 12 ". Si el valor del mes está fuera del rango [1..12], entonces la función detiene su trabajo. El analizador toma esto en cuenta y sabe que si la segunda sentencia if comenzó a ejecutarse, el valor del mes se encuentra exactamente en el rango [1..12]. Del mismo modo, conoce el rango de otras variables (año, día, etc.), pero ahora no nos interesan.

Ahora echemos un vistazo a dos operadores idénticos para acceder a los elementos de la matriz: kDaysInMonth [time.month] .

La matriz se configura de forma estática y el analizador conoce los valores de todos sus elementos:

 static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; 

Como los meses están numerados de 1, el analizador no considera 0 al comienzo de la matriz. Resulta que un valor en el rango [28..31] se puede extraer de la matriz.

Dependiendo de si el año es bisiesto o no, se agrega 1 al número de días, pero esto tampoco es interesante para nosotros ahora. Las comparaciones en sí mismas son importantes:

 time.month <= kDaysInMonth[time.month] + 1; time.month <= kDaysInMonth[time.month]; 

El rango [1..12] (número de mes) se compara con el número de días del mes.

Teniendo en cuenta que en el primer caso el mes siempre es febrero ( time.month == 2 ), obtenemos que se comparan los siguientes rangos:

  • 2 <= 29
  • [1..12] <= [28..31]

Como puede ver, el resultado de la comparación siempre es cierto, que es lo que advierte el analizador PVS-Studio. De hecho, el código contiene dos errores tipográficos idénticos. El lado izquierdo de la expresión debe usar un miembro de la clase de día , no un mes en absoluto.

El código correcto debería ser así:

 if (time.month == 2 && IsLeapYear(time.year)) { return time.day <= kDaysInMonth[time.month] + 1; } else { return time.day <= kDaysInMonth[time.month]; } 

El error discutido aquí también se describió previamente en el artículo " 31 de febrero ".

Ejecución simbólica


En la sección anterior, consideramos un método en el que el analizador calcula los posibles valores de las variables. Sin embargo, para encontrar algunos errores, no es necesario conocer los valores de las variables. Ejecución simbólica significa resolver ecuaciones en forma simbólica.

No encontré una demostración adecuada en nuestra base de datos de errores , así que considere un ejemplo de código sintético.

 int Foo(int A, int B) { if (A == B) return 10 / (A - B); return 1; } 

El analizador PVS-Studio genera una advertencia V609 / CWE-369 Divide by zero. Denominador 'A - B' == 0. test.cpp 12

Los valores de las variables A y B son desconocidos para el analizador. Pero el analizador sabe que en el momento de calcular la expresión 10 / (A - B), las variables A y B son iguales. Por lo tanto, se producirá la división por 0.

Dije que los valores de A y B son desconocidos. Para el caso general, esto es cierto. Sin embargo, si el analizador ve una llamada a la función con valores específicos de los argumentos reales, lo tendrá en cuenta. Considere un ejemplo:

 int Div(int X) { return 10 / X; } void Foo() { for (int i = 0; i < 5; ++i) Div(i); } 

El analizador PVS-Studio advierte la división por cero: V609 CWE-628 Divide por cero. Denominador 'X' == 0. La función 'Div' procesa el valor '[0..4]'. Inspecciona el primer argumento. Verifique las líneas: 106, 110. consoleapplication2017.cpp 106

Una combinación de tecnologías ya funciona aquí: análisis de flujo de datos, ejecución simbólica y anotación automática de métodos (discutiremos esta tecnología en la siguiente sección). El analizador ve que la variable X se usa como divisor en la función Div . En base a esto, se crea automáticamente una anotación especial para la función Div . Además se tiene en cuenta que un rango de valores [0..4] se pasa a la función como un argumento X. El analizador concluye que la división por 0 debería ocurrir.

Método de anotaciones


Nuestro equipo ha anotado miles de funciones y clases proporcionadas en:

  • Winapi
  • Biblioteca estándar C
  • biblioteca de plantillas estándar (STL),
  • glibc (biblioteca GNU C)
  • Qt
  • MFC
  • zlib
  • libpng
  • Openssl
  • y así sucesivamente

Todas las funciones se anotan manualmente, lo que le permite establecer muchas características que son importantes en términos de búsqueda de errores. Por ejemplo, se especifica que el tamaño del búfer pasado a la función fread no debe ser inferior al número de bytes que se planea leer del archivo. También se indica la relación entre los argumentos segundo, tercero y el valor que la función puede devolver. Todo se ve así:

PVS-Studio: marcado de funciones

Gracias a esta anotación, el siguiente código, que usa la función fread , revelará inmediatamente dos errores.

 void Foo(FILE *f) { char buf[100]; size_t i = fread(buf, sizeof(char), 1000, f); buf[i] = 1; .... } 

Advertencias de PVS-Studio:
  • V512 CWE-119 Una llamada de la función 'fread' provocará el desbordamiento del búfer 'buf'. test.cpp 116
  • V557 CWE-787 Arreglo de arrastre es posible. El valor del índice 'i' podría alcanzar 1000. test.cpp 117

Primero, el analizador multiplicó el segundo y el tercer argumento real y calculó que la función puede leer hasta 1000 bytes de datos. En este caso, el tamaño del búfer es de solo 100 bytes y puede desbordarse.

En segundo lugar, dado que la función puede leer hasta 1000 bytes, el rango de valores posibles de la variable i es [0..1000]. En consecuencia, el acceso a la matriz puede ocurrir en el índice incorrecto.

Veamos otro ejemplo simple de un error, cuya detección fue posible gracias al marcado de la función memset . Aquí hay un fragmento de código del proyecto CryEngine V.

 void EnableFloatExceptions(....) { .... CONTEXT ctx; memset(&ctx, sizeof(ctx), 0); .... } 

El analizador PVS-Studio encontró un error tipográfico: V575 La función 'memset' procesa elementos '0'. Inspeccione el tercer argumento. crythreadutil_win32.h 294

Confundió el segundo y tercer argumento de la función. Como resultado, la función procesa 0 bytes y no hace nada. El analizador nota esta anomalía y advierte a los programadores al respecto. Anteriormente, ya describimos este error en el artículo "La tan esperada verificación de CryEngine V ".

El analizador PVS-Studio no se limita a las anotaciones que configuramos manualmente. Además, intenta de forma independiente crear anotaciones estudiando los cuerpos de las funciones. Esto le permite encontrar errores de uso incorrecto de funciones. Por ejemplo, el analizador recuerda que una función puede devolver nullptr. Si el puntero devuelto por esta función se usa sin verificación preliminar, el analizador lo advertirá. Un ejemplo:

 int GlobalInt; int *Get() { return (rand() % 2) ? nullptr : &GlobalInt; } void Use() { *Get() = 1; } 

Advertencia: V522 CWE-690 Puede haber una desreferenciación de un puntero nulo potencial 'Get ()'. test.cpp 129

Nota Puede abordar la búsqueda del error que acaba de examinar de la manera opuesta. No recuerde nada, y cada vez que se encuentre una llamada a la función Get , analícela conociendo los argumentos reales. Tal algoritmo teóricamente te permite encontrar más errores, pero tiene una complejidad exponencial. El tiempo de análisis del programa crece cientos de miles de veces, y consideramos que este enfoque es un callejón sin salida desde un punto de vista práctico. En PVS-Studio, estamos desarrollando la dirección de anotación automática de funciones.

Coincidencia de patrones


La tecnología que coincide con un patrón, a primera vista, puede parecer una búsqueda con expresiones regulares. De hecho, esto no es así, y todo es mucho más complicado.

En primer lugar, como ya dije , las expresiones regulares generalmente no tienen valor. En segundo lugar, los analizadores no funcionan con líneas de texto, sino con árboles de sintaxis, lo que permite reconocer patrones de error más complejos y de alto nivel.

Considere dos ejemplos, uno más simple y otro más complejo. El primer error que encontré al verificar el código fuente de Android.

 void TagMonitor::parseTagsToMonitor(String8 tagNames) { std::lock_guard<std::mutex> lock(mMonitorMutex); if (ssize_t idx = tagNames.find("3a") != -1) { ssize_t end = tagNames.find(",", idx); char* start = tagNames.lockBuffer(tagNames.size()); start[idx] = '\0'; .... } .... } 

El analizador PVS-Studio reconoce el patrón de error clásico asociado con la concepción errónea de un programador sobre la prioridad de las operaciones en C ++: V593 / CWE-783 Considere revisar la expresión del tipo 'A = B! = C'. La expresión se calcula de la siguiente manera: 'A = (B! = C)'. TagMonitor.cpp 50

Eche un vistazo de cerca a esta línea:

 if (ssize_t idx = tagNames.find("3a") != -1) { 

El programador supone que se realiza una asignación al principio, y solo entonces una comparación con -1 . De hecho, la comparación es lo primero. Clásico Este error se describe con más detalle en el artículo dedicado a la verificación de Android (consulte el capítulo "Otros errores").

Ahora considere una opción de coincidencia de patrón de nivel superior.

 static inline void sha1ProcessChunk(....) { .... quint8 chunkBuffer[64]; .... #ifdef SHA1_WIPE_VARIABLES .... memset(chunkBuffer, 0, 64); #endif } 

Advertencia de PVS-Studio: V597 CWE-14 El compilador podría eliminar la llamada a la función 'memset', que se utiliza para vaciar el búfer 'chunkBuffer'. La función RtlSecureZeroMemory () debe usarse para borrar los datos privados. sha1.cpp 189

La esencia del problema es que después de llenar un búfer con ceros usando la función memset , este búfer no se usa en ninguna parte. Al compilar código con indicadores de optimización, el compilador decidirá que esta llamada de función es redundante y la eliminará. Tiene derecho a esto, ya que desde el punto de vista del lenguaje C ++, llamar a una función no tiene ningún comportamiento observable en el programa. Inmediatamente después de llenar el búfer chunkBuffer , la función sha1ProcessChunk finaliza. Como el búfer se crea en la pila, después de salir de la función, no estará disponible para su uso. Por lo tanto, desde el punto de vista del compilador, no tiene sentido llenarlo con ceros.

Como resultado, en algún lugar de la pila permanecerán datos privados, lo que puede generar problemas. Este tema se trata con más detalle en el artículo " Limpieza segura de datos privados ".

Este es un ejemplo de coincidencia de patrones de alto nivel. Primero, el analizador debe ser consciente de la existencia de esta falla de seguridad, clasificada según la Enumeración de Debilidad Común como CWE-14: Eliminación del Código del Compilador para Borrar Buffers .

En segundo lugar, debe encontrar en el código todos los lugares donde se crea el búfer en la pila, se limpia con la función memset y no se usa en ningún otro lugar.

Conclusión


Como puede ver, el análisis estático es una metodología muy interesante y útil. Le permite eliminar una gran cantidad de errores y vulnerabilidades potenciales en las primeras etapas (ver SAST ). Si aún no está completamente imbuido de análisis estático, lo invito a leer nuestro blog , donde analizamos regularmente los errores encontrados utilizando PVS-Studio en varios proyectos. Simplemente no puedes permanecer indiferente.

Estaremos encantados de ver a su empresa entre nuestros clientes y ayudar a que sus aplicaciones sean mejores, más confiables y más seguras.



Si desea compartir este artículo con una audiencia de habla inglesa, utilice el enlace a la traducción: Andrey Karpov. Tecnologías utilizadas en el analizador de código PVS-Studio para encontrar errores y vulnerabilidades potenciales .

Source: https://habr.com/ru/post/es430604/


All Articles