Bestiario de una memoria mal gestionada (I)

David García    14 abril, 2020
Bestiario de una memoria mal gestionada (I)

Si tuviéramos que elegir una vulnerabilidad especialmente dañina sería, con mucha probabilidad, la ejecución de código arbitrario, y más aún si puede ser explotada en remoto. Las consecuencias pueden ser fatales, como ya hemos visto en muchos episodios de este tipo (Conficker para los analistas de malware, MS08-067 y EternalBlue para los pentesters, WannaCry para todo el mundo, etc.).

La ejecución de código arbitrario ha sido y sigue siendo uno de los errores de programación que más pérdidas y reparaciones ha causado a lo largo y ancho de la historia del silicio. Por cierto, lo llamamos arbitrario porque, en realidad, la CPU ya está ejecutando código; la gracia de lo arbitrario es que se deja al arbitrio del atacante decidir qué código se ejecuta, puesto que es quien toma el control del proceso. De eso trata una explotación de este tipo: desviar la ejecución normal y determinada de un proceso a un agente extraño introducido en aquel de forma arbitraria por un atacante a través de un exploit.

¿Cómo sucede esto exactamente?

Existen muchísimas formas de ejecutar código (a partir de aquí entenderemos arbitrario). La definición, por cierto, no está circunscrita a los ejecutables nativos. Un cross-site scripting no deja de ser una inyección de código extraño que, de nuevo, desvía la ejecución de un script al fragmento de código inyectado.

Uno de los factores en la ejecución de código a nivel nativo es la derivada de los fallos en la gestión de memoria. Vamos a ver los tipos de errores más comunes, centrándonos en cómo ocurren y en cómo están evolucionando los sistemas operativos y lenguajes de programación para paliar el efecto que estos fallos suponen cuando son explotados maliciosamente.

Remontándonos atrás en el tiempo, no todos los lenguajes poseían una gestión manual del uso que hacían de la memoria. De hecho, John McCarthy, uno de los padres de la Inteligencia Artificial y creador de LISP, inventó el concepto de recolección automática de basura (memoria liberada a lo largo de la ejecución de un proceso) en la década de los sesenta.

No obstante, a pesar de que los recolectores de basura hacían más fácil la vida a los programadores (abstrayéndolos de la gestión manual), era una sobrecarga en el consumo de recursos que algunos sistemas no podían permitirse. Para hacernos una idea sería como si el seguimiento de los vuelos de una torre de control de un aeropuerto en tiempo real se detuviese unos segundos a eliminar la memoria liberada.

Es por ello por lo que lenguajes como C o C++ mantienen un peso enorme a la hora de programar aplicaciones de sistemas. Son lenguajes sin recolector de basura (aunque es posible hacer uso de ellos a través de librerías) en los que el peso de la gestión de memoria cae íntegramente en el programador. Y claro, cuando dejas el trabajo de una máquina en manos de un humano… Por el contrario, liberar los recursos que consume un recolector supone un incremento enorme en el rendimiento y respuesta del programa y eso se traduce en un menor coste en hardware.

¿Es tan difícil gestionar la memoria de forma manual?

Evidentemente, es una pregunta muy abierta y la respuesta dependerá de nuestro nivel de familiaridad con este tipo de programación y de las propias facilidades que el lenguaje nos dé, sumadas al empleo de herramientas externas y tecnología implementada en el compilador.

Vamos a poner un ejemplo: supongamos que deseamos asociar una cadena de texto a una variable. Una operación que es trivial en lenguajes con gestión automática de la memoria, por ejemplo, en Python (es código de ejemplo, no vamos a molestarnos en su corrección):

def asociar_cadena(cadena):
mi_cadena = input() 
# …
# procesamos mi_cadena
# …
return mi_cadena

Bien, pues esto en lenguaje C posee unos interesantes añadidos. En primer lugar, no sabemos la longitud de la cadena. Esa cantidad no viene “de serie” con la cadena, debemos encontrarla o añadirla como parámetro a la función. Segundo, dado que no disponemos de su longitud, tampoco sabemos que memoria vamos a necesitar para guardarla y, tercero: ¿quién se hace cargo de avisar cuando ya no necesitemos esa memoria?

Veamos un fragmento de código (existen múltiples formas de implementar esto, más seguras y mejores, pero esta nos servirá para ilustrar lo que queremos decir, por ejemplo, usando strdup, “%ms”, etc.):

Como vemos, ni tan siquiera hemos comenzado a manipular la cadena cuando ya hemos de escribir código para detectar el fin de una cadena, reservar memoria, vigilar los límites del array en la pila, etc.

Sin embargo, lo importante es fijarnos en la línea 28, esa función “free”, usada para indicarle al sistema que libere el trozo de memoria que habíamos reservado en la función “leer”. Aquí la situación es clara: ya no usamos esa memoria y la devolvemos.

En un ejemplo de código es fácil hacer uso de la memoria pero, ¿y si seguimos haciendo uso de esa memoria reservada 200 líneas de código después? ¿Y si tenemos que pasar ese puntero por varias funciones? ¿Cómo queda claro quien se hace cargo de la memoria, la función llamada o quien llama a esa función?

En las sucesivas entradas veremos ciertos escenarios que se transforman en vulnerabilidades por este tipo de descuidos: double free, uso de memoria no inicializada, memory leaks o fuga de memoria y dangling pointers o punteros descolgados (o colgantes).


No te pierdas la serie completa de este artículo:

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *