jueves, 18 de octubre de 2012

No creas a todos los programas que calculan el hash MD5 de un fichero

Hace unos días, mi compañero Sergio de los Santos comentó en la oficina que eran ya unas cuantas personas las que, tras descargarse una de sus herramientas y comprobar su hash MD5, este era incorrecto una y otra vez. La herramienta utilizada por todas ellas era una gratuita, llamada filehasher (ntsecurity.nu/toolbox/filehasher/).  Me propuso investigarlo.

Tras ver que comprobando el hash con otras utilidades se obtenía el resultado correcto y que el fallo no residía en el fichero analizado, se tenía entonces al culpable: filehasher.

Se suponía que esta entrada debería contener líneas de ensamblador, capturas de pantalla del Olly o del IDA... pero, tras documentarme un poco antes de zambullirme entre registros y opcodes, creí ver dónde podía radicar el problema.

La primera suposición que nos hicimos en el laboratorio,era que debía existir algún fallo al tratar el buffer que sería 'digerido' por el algoritmo MD5. Evidentemente, supusimos que la situación más probable era que los programadores habrían usado una librería ya implementada y probada para el cálculo del hash y que ahí no podría estar el error. Implementar ellos mismo el cálculo sería una pérdida de tiempo. Esto supondría tener que hacer algo de reversing sobre el programa, ya que los ficheros fuentes no son públicos.

Antes de nada, me puse a comprobar que, ciertamente, el programa tenía un funcionamiento anómalo. Comparé el resultado de calcular el MD5 de un par de ficheros de prueba. Un fichero (1.txt) contenía un único byte "1" (0x31 h) y otro, llamado 'vacio.txt' que no contenía nada.

Los resultados fueron los siguientes

> filehasher 1.txt -md5
c4ca4238a0b923820dcc509a6f75849b  1.txt


> filehasher vacio.txt -md5
0123456789abcdeffedcba9876543210  vacio.txt





 
Curioso, llama la atención el valor devuelto para 'vacio.txt' que, además de parecer "poco aleatorio", no se parecía al valor del MD5 de un archivo vacío, que, más o menos, recordaba que empezaba por "d41...".

Para cerciorarme de que no era cosa mía, ejecuté el comando 'md5sum' de UNIX sobre los mismos archivos:

$ md5sum 1.txt
c4ca4238a0b923820dcc509a6f75849b  1.txt

$ md5sum vacio.txt
d41d8cd98f00b204e9800998ecf8427e  vacio.txt


Efectivamente, parece que el programita no funciona como debiese... pero solo en algunos casos. El hash de '1.txt' se calcula correctamente, pero, ¿a qué se debe que unas sumas se calculen bien y otras mal?

Para no meterme todavía a pelear con el ensamblador, decidí refrescar la memoria sobre el funcionamiento interno de MD5. Así, si tenía que enfrentarme a él dentro del código, podría localizarme dentro del algoritmo más fácilmente. Tocaba meterse ahora con su RFC, el RFC 1321 (http://www.ietf.org/rfc/rfc1321.txt) concretamente.

Yendo directamente a la sección relativa a la implementación del algoritmo, vemos que se diferencian 5 pasos a la hora de su cálculo. Sólo veremos los 3 primeros de una manera MUY resumida. El entendimiento de estos sencillos pasos nos será de utilidad más adelante.

Paso 1: Añadir un padding


  • Al buffer original se le añaden una serie de bits, de tal forma que crezca hasta una cifra cuyo tamaño, tras ser dividido por 512, nos dé un resto de 448. En caso de que el tamaño del buffer inicial cumpla ya esta condición, se añadirán 512 bits. 
  • Los bits añadidos estarán formados por un bit '1' seguido de tantos bits '0' como sean necesarios para que se de la condición matemática anterior.

Paso 2: Añadir la longitud del buffer

  • Además del 'padding' añadido en el paso 1, se concatenarán 64 bits adicionales que indican el tamaño del buffer original.
  • El valor estará formado por dos palabras de 32 bits representadas en little-endian.
  • Tras añadir estos últimos 64 bits, ya tenemos un buffer múltiplo de 512 bits (64 bytes), de tal manera que ya puede ser tratado por "EL ALGORITMO", todas esas engorrosas operaciones XOR, OR, AND,rotaciones, etc.

Paso 3: Inicialización del buffer MD

  • A grandes rasgos, el cálculo del hash MD5, se realiza modificando un "buffer base".
  • Las modificaciones vendrán dadas por los bytes que vayamos leyendo de nuestro buffer resultante de los dos primeros pasos.
  • El "buffer base" está formado por 4 palabras de 32 bits con la siguiente configuración:

word A: 01 23 45 67
word B: 89 ab cd ef
word C: fe dc ba 98
word D: 76 54 32 10

¿Os suenan esos valores? Pongámoslos en una sola línea:

01 23 45 67  89 ab cd ef  fe dc ba 98  76 54 32 10

Si recordamos el hash devuelto por 'filehasher' del archivo 'vacio.txt', éste era:

0123456789abcdeffedcba9876543210  vacio.txt

Si no me equivoco, parece que nuestros amigos de "ntsecurity.nu" omiten, en algunos casos una pequeña parte del algoritmo y fueron directamente a "lo feo". Concretamente me da que los pasos 1 y 2 se los ahorran para algunos ficheros, y empiezan directamente con el paso 3.

¿Cómo podía comprobar que mi suposición era cierta? Pues haciéndole el trabajo al programa. Calcular por mí mismo los pasos 1 y 2 a un fichero (vacío, en este caso, que sabemos que falla), y pasarle el resultado a filehasher. Si tras este tratamiento previo mío, el resultado es el válido, es que mi suposición es cierta: han implementado ellos mismos el algoritmo MD5 y han eludido el paso 1 y paso 2 (los que crean el padding).

Vamos a ver cuál debería ser el buffer de salida de un fichero vacío con un pequeño ejemplo en python:

>>> buff = "" # Primeramente nuestro buffer, vació obviamente
>>> buff += "\x80" + "\x00" * 55 # Le añadimos un '1' seguido de 447 '0's
>>> # 0x80 = 1000 0000
>>> # 1 '1' + 7 '0's + (8 '0's) * 55 ==> 1 + 7 + 440 = 448 bits
>>> buff += "\x00" * 8 # 8 bytes indicando el tamaño del buffer original
>>> fd = open('hashme.bin', 'w')
>>> fd.write(buff)
>>> fd.close()


Ahora el nuevo archivo 'hashme.bin' contiene los bits que resultan de aplicar los pasos 1 y 2 del algoritmo MD5 sobre un fichero vacío. Vamos a ver qué nos dice ahora 'filehasher' sobre el hash de nuestro nuevo fichero:

> filehasher hashme.bin -md5
d41d8cd98f00b204e9800998ecf842
7e  hashme.bin


Y comprobamos si es el mismo resultado que nos daría el algoritmo original.

$ md5sum hashme.bin
d100adc7d963ac0b837f7ac2
9dc701d7  hashme.bin


Ahora sí es el MD5 (real) del archivo vacío. Parece que no andábamos muy descaminados en nuestras suposiciones.

En las primeras pruebas que realicé al calcular el MD5 con 'filehasher', al comprobar el hash de '1.txt', cometí un pequeño fallo porque abrí el fichero con un editor de texto, el cual le añadió un carácter de fin de línea al final. Al añadir un carácter de más, obtuve un hash diferente al devuelto por el 'md5sum' del byte '0x31 h', cuando en verdad 'filehasher sí lo calculaba bien. Creía entonces, que el cálculo fallaba para todos los archivos. En ese momento, me propuse generar el buffer resultante de aplicar los pasos 1 y 2 sobre el byte '0x31 h', creando la versión 2 de '1.txt':

>>> buff = "1" # contenido del fichero 1.txt 
>>> buff += "\x80" # primer '1' y siete '0' 
>>> buff += "\x00" * 54 # '0's adicionales hasta completar los 448 bits 
>>> len(buff) * 8 # 'len' devuelve el conteo en bytes 
448 
>>> buff += "\x08" + "\x00" * 7 # 64 bits con el tamaño original del fichero (8 bits) en little-endian 
>>> len(buff) * 8  
512 
>>> fd = open('1_2.txt', 'w') 
>>> fd.write(buff) 
>>> fd.close()



Cuál es mi sorpresa cuando estoy volviendo a comprobar todos los pasos y los ficheros, cuando veo que los hashes de 1.txt y 1_2.txt devueltos por 'filehasher' son exactamente los mismos:

> filehasher 1.txt -md5
c4ca4238a0b923820dcc509a6f75849b  1.txt

> filehasher 1_2.txt -md5
c4ca4238a0b923820dcc509a6f75849b  1_2.txt


Para asegurarme de que no está ocurriendo algo raro, compruebo las sumas con el comando que sé que lo calcula bien:

$ md5sum 1_2.txt
72937eb55e83c95f11f12b96715b9d8f  1_2.txt

$ md5sum 1.txt
c4ca4238a0b923820dcc509a6f75849b  1.txt


¿Qué está pasando entonces? El tamaño de 1.txt es de 1 byte, mientras que el de 1_2.txt es 64 bytes... sí, el mismo tamaño que ha de tener el buffer antes de comenzar el paso 3. Me pongo a probar diferentes archivos de distintos tamaños y efectivamente, el MD5 devuelto por 'filehasher' falla para todos aquellos ficheros cuyo tamaño en bytes es múltiplo de 64.

Todo parece indicar que a la hora de la comprobación del tamaño del buffer para obtener la cantidad de padding a añadir, se cae en un fallo en la lógica del programa en la que, si el tamaño del fichero es múltiplo de 512 bits (64 bytes), no se añade padding ni tamaño del fichero alguno.