Un archivo de 2,835 bytes dentro de una aplicación gubernamental con más de 10 millones de instalaciones contenía un certificado de cliente activo del Banco Nacional Saudí. Lograr que alguien lo revisara requirió un tuit viral.
Resumen Ejecutivo
La aplicación oficial Nusuk (com.moh.nusukapp, Ministerio de Hajj y Umrah, más de 10 millones de instalaciones, con la insignia "Gubernamental" de Google Play) incluía un archivo PKCS#12 que contenía una clave privada RSA y un certificado de cliente emitido por el Banco Nacional Saudí. La contraseña de ese archivo estaba codificada a pocas líneas de distancia en el propio código de la aplicación. Era un solo carácter: 2.
Junto a él, en texto plano, se encontraban el ID de cliente OAuth2 y el secreto de cliente para la API de Banca como Servicio del banco, solicitando los ámbitos identity accounts cards verification kyc cardpay transfers.
Cualquiera que descargara la aplicación desde Google Play tenía acceso a todo.
Intenté reportarlo. Me dijeron que el portal de vulnerabilidades solo está disponible para usuarios dentro de Arabia Saudita. Así que lo tuiteé. El tuit alcanzó 1.5 millones de visitas, y de repente el mismo portal solicitó los detalles. Un día después, las credenciales desaparecieron de la aplicación.
https://x.com/iam_zachi/status/2094445016194207745
Este informe cubre únicamente el hallazgo bancario. Todo lo que se menciona a continuación ha sido corregido en la versión actual de la aplicación, y la parte del proveedor afirma que las credenciales han sido rotadas.
Qué es Nusuk
Nusuk es la plataforma oficial del gobierno saudí para el Hajj y la Umrah. Gestiona permisos de peregrinación, visas electrónicas, reservas y la Tarjeta Nusuk. Está operada por el Ministerio de Hajj y Umrah, está marcada como una aplicación gubernamental verificada en Google Play y tiene más de diez millones de instalaciones. También incluye una función de billetera, Nusuk Wallet, desarrollada junto con el Banco Nacional Saudí y aprobada por SAMA, el banco central saudí. La billetera es la parte sobre la que trata este artículo.
El hallazgo
Descargué el conjunto APK directamente desde Google Play (versión 17.4.9, versionCode 131215) y lo desempaqueté. No tenía nada de especial: Kotlin/Compose estándar, sin empaquetador, sin ofuscación significativa.
Dentro de los recursos de la aplicación, en res/raw/nusuk.pfx, había un contenedor PKCS#12 de 2,835 bytes. Un archivo .pfx es el formato estándar para agrupar un certificado junto con su clave privada, cifrado con una contraseña.
La contraseña estaba en el código de la aplicación, a pocas líneas de donde se carga el archivo:
1const-string v3, "2"
Un solo carácter, presente como literal en el bytecode descompilado. Una llamada a openssl después:
1RSA private key, 2048 bit, 2 prime factors2Subject: C=SA, ST=Jeddah, L=Jeddah, O=Nusuk.sa, CN=eshbeata@staq.io3Issuer: C=SA, ST=Riyadh, L=Riyadh, O=The Saudi National Bank, OU=Finto,4 CN=Application Issuer5Serial: 0x24 (36)6Valid: 2026-04-27 -> 2027-04-277Extended Key Usage (critical): TLS Web Client Authentication
Un certificado de cliente activo emitido por un banco, válido por un año más, destinado a autenticar un cliente ante un servidor a través de TLS.
No estaba solo. La misma ruta de código corresponde al componente de la billetera, empaquetado como com.walletstaq e integrado en el Trustless SDK de Staq Technologies, la empresa que opera la plataforma BaaS Finto de SNB. Allí había tres valores más en texto claro:
- CLIENT_ID = 379cc899…
- CLIENT_SECRET = ELrfNe9w… (64 bytes sin procesar, codificados en base64)
- SERVER_URL = https://api.baas.alahli.com/api/
con el ámbito OAuth solicitado:
1identity accounts cards verification kyc cardpay transfers
Ambas credenciales también se concatenaban en una sola cadena y se entregaban a un registrador de depuración, junto con la URL base.
(No publico el material de la clave ni los valores completos del secreto. Lo que importa aquí es la forma del problema).
Por qué esto es grave, en términos sencillos
Imagina la API del banco como una puerta con dos cerraduras.
La primera cerradura es el TLS mutuo. Normalmente, un servidor prueba su identidad mediante un certificado. Con mTLS, tú también debes probar tu identidad ante el servidor con un certificado. Ese es el archivo .pfx: el certificado y la clave privada que demuestran que eres su propietario. Se supone que es algo que solo el sistema cliente legítimo posee.
La segunda cerradura es el secreto de cliente OAuth, la contraseña que la aplicación utiliza para solicitar un token de acceso a la API del banco.
Ambas cerraduras se enviaron dentro de una aplicación gratuita en Google Play, y la llave de la caja que contenía la primera era el dígito 2.
Confirmé que el host de destino realmente aplica mTLS: el handshake TLS con api.baas.alahli.com solicita certificados de cliente (envía Nombres de CA de certificado de cliente aceptables), y el certificado del servidor indica CN=*.baas.alahli.com, O=The Saudi National Bank. Así que no era un certificado decorativo para un entorno de prueba. Era la credencial para la puerta principal de una API bancaria en producción, con ámbitos que cubren identidad, cuentas, tarjetas, KYC, pagos con tarjeta y transferencias.
Independientemente de la filtración, hay un problema de diseño subyacente. Un certificado de cliente que se distribuye idénticamente a diez millones de dispositivos no puede distinguir una instalación de otra. Cada copia presenta la misma credencial, por lo que el certificado le dice al banco qué aplicación está llamando y nada sobre quién está llamando. Las credenciales para una API como esta deben estar detrás de tu propio backend: la aplicación se comunica con tu servidor, y tu servidor se comunica con el banco.
Lo que no hice
Ejecuté exactamente una verificación contra el endpoint de token (POST /api/tppa/token) para ver si las credenciales estaban activas. Devolvió HTTP 403 de nginx. Lo mismo ocurrió con una solicitud sin ningún certificado, y también con la URL raíz desnuda. Eso es un bloque a nivel de red que está frente a la API, casi con certeza geográfico, y no dice nada sobre si las credenciales funcionan.
Desde fuera de Arabia Saudita, no pude determinar si estas credenciales estaban activas. Me detuve ahí. Cualquier otro paso habría sido un intento de eludir el control de acceso de un banco, y el hallazgo no depende de ello: una clave privada y un secreto OAuth bancario con ámbito de transferencia en un artefacto descargable públicamente es el hallazgo, independientemente de si yo personalmente puedo alcanzar el endpoint.
Intentando reportarlo
Esta es la parte que hizo viral el tuit, y es la mitad más interesante.
Busqué una forma de reportar esto de manera responsable. Esto es lo que existe:
Canal
Resultado
security.txt en nusuk.sa, haj.gov.sa, hajj.nusuk.sa
No existe
Página de reporte de vulnerabilidades de Saudi CERT (cert.gov.sa)
Redirige a NCA; página de reporte propia cerrada
Formulario de vulnerabilidades de NCA (haseen.gov.sa)
No accesible desde Alemania: tiempo de espera agotado, bloqueo geográfico
bugbounty.sa
Programa cerrado, HTTP 403 desde fuera
HackerOne / Bugcrowd
No hay programa para Nusuk, el Ministerio ni Elm
Contactos en la lista de la tienda de aplicaciones
Direcciones de soporte, sin mandato de seguridad
Nada de eso me dejó un canal con un mandato de seguridad al que pudiera acceder. De todas formas, envié un correo electrónico. La respuesta de Haseen Support:
"El acceso al portal Haseen está restringido a usuarios dentro del Reino de Arabia Saudita. Para cualquier otra consulta, puede contactarnos a través del servicio 'We Care' disponible en el Portal Oficial de Haseen."
El Portal Haseen, que es justo a lo que no puedo acceder. Respondí explicando que no soy saudí, que se trata de un certificado bancario privado expuesto en una aplicación gubernamental, y que solo quería entregarlo. La respuesta, nuevamente, fue que el formulario solo funciona para ciudadanos de KSA.
Así que presenté un informe ante CERT/CC a través de su plataforma VINCE como intermediario coordinador (VRF#26-08-DXMKL), limitado únicamente a este hallazgo. Esa es la ruta que tomas cuando la parte afectada no tiene un canal accesible propio.
Y luego tuiteé al respecto, principalmente por frustración.
El tuit alcanzó 1.5 millones de visitas. En cuestión de horas, Haseen Support me envió un correo electrónico, sin que yo lo solicitara, en el mismo hilo que me había dicho dos veces que el portal no era para mí:
"Según nuestro equipo correspondiente, por favor proporcione más detalles sobre la vulnerabilidad de seguridad."
Envié todos los detalles. Prefiero que el problema se solucione a tener la razón sobre el proceso.
La solución
La siguiente actualización de la aplicación llegó tanto a Android como a iOS. Descargué la nueva compilación de Android (17.5.0, versionCode 156635) directamente desde Play y la comparé con la que había analizado. Tres comprobaciones:
- No hay contenedor de certificados en ningún lugar del nuevo conjunto APK: ningún archivo con extensión .pfx, .p12, .pkcs12, .jks, .bks, .pem o .key. También calculé el hash del antiguo nusuk.pfx y lo comparé byte por byte con cada archivo del mismo tamaño en la nueva compilación, por si acaso simplemente se había renombrado. Sin coincidencias.
- Ninguna de las credenciales conocidas. Busqué los valores antiguos exactos de la URL base, el ámbito, el ID de cliente y el secreto de cliente en todos los archivos DEX, bibliotecas nativas, assets, XML, JSON y recursos sin procesar. Cero resultados para los cuatro.
- Sin ruta de código. La versión 17.4.9 contenía 4,571 archivos bajo los paquetes com.walletstaq y com.trustless; la 17.5.0 contiene cero. Los marcadores baas.alahli, tppa/token, nusuk.pfx, el sujeto del certificado y el nombre del emisor devuelven cero resultados en la compilación decodificada.
Se eliminó toda la integración de la billetera y BaaS. La verificación de cambio de nombre en el paso 1 es lo que descarta que los valores simplemente se hayan movido a otro lugar dentro del paquete.
La parte que nadie fuera puede verificar
Eliminar un secreto de una aplicación no invalida el secreto. Las copias antiguas del APK permanecen disponibles para siempre, y el certificado era válido hasta abril de 2027. Por lo tanto, la pregunta abierta es si fue revocado y si el secreto OAuth fue rotado.
Busqué una forma de verificar eso de forma independiente. No la hay, y la razón es en sí misma un hallazgo.
El certificado no incluye una extensión crlDistributionPoints, por lo que no se hace referencia a ninguna lista de revocación. Su único endpoint de revocación es:
1OCSP - URI: http://finto-ocsp-responder.prod.svc.cluster.local:8080/api/v1/ocsp
.cluster.local es el sufijo DNS interno de un clúster de Kubernetes. Por definición, no es enrutable en la internet pública y se resuelve como NXDOMAIN desde cualquier lugar fuera de ese clúster, a través de HTTP simple en el puerto 8080.
Por lo tanto, el estado de revocación de este certificado no se puede verificar desde fuera de la infraestructura del banco, porque no hay nada aquí afuera para consultar. Para cualquier parte dependiente fuera de ese único clúster, los certificados de este emisor son efectivamente irrevocables, lo que merece un párrafo en la revisión de arquitectura de alguien por sí solo.
Ese mismo campo también publicó el nombre del clúster, el espacio de nombres, el nombre del servicio y el puerto de la PKI de producción de la plataforma BaaS de un banco, en una aplicación distribuida a diez millones de personas.
Solo SNB, Finto o Staq pueden confirmar la rotación. La parte del proveedor afirma que las credenciales han sido rotadas. No tengo forma de verificarlo de forma independiente.
Cronología
Fecha (2026)
Evento
29 de agosto
APK analizado, hallazgo confirmado localmente
29 de agosto
Informe enviado por correo electrónico; Haseen responde que el portal está restringido a usuarios dentro de KSA
31 de agosto
Intentos repetidos, misma respuesta. Tuiteo al respecto; ~1.5 millones de visitas
1 de septiembre
Haseen reabre el hilo sin solicitarlo, solicita detalles. Detalles enviados
1 de septiembre
Informe también presentado ante CERT/CC VINCE (VRF#26-08-DXMKL) como intermediario
1 de septiembre
Versión 17.5.0 publicada en Google Play
3 de septiembre
Nueva prueba en el conjunto APK 17.5.0 sin modificar confirma la eliminación completa
8 de septiembre
Este informe
No puedo probar que la actualización fue causada por mi informe. Es posible que la 17.5.0 ya estuviera en proceso. Lo que puedo mostrar es que el material estaba en la 17.4.9 y no está en la 17.5.0.
Qué conclusiones sacaría de esto
- Ocultar algo dentro de la aplicación no es un límite de seguridad. No en los recursos, no en un .so nativo, no detrás de ofuscación o una contraseña almacenada en el mismo binario. Si la aplicación puede leerlo, también puede hacerlo cualquiera que instale la aplicación. Varios equipos aprenden esto en público cada año.
- Un certificado de cliente compartido no es autenticación. Si diez millones de dispositivos presentan el mismo certificado, este indica qué aplicación está llamando y nada sobre quién está llamando, y la aplicación es un archivo que cualquiera puede descargar. Las credenciales para una API de terceros, especialmente de un banco, deben estar en un servidor que controles.
- Geocercar tu canal de divulgación de vulnerabilidades es en sí mismo una vulnerabilidad. Los atacantes no llenan formularios. Si la única forma de reportar una falla en una aplicación publicada globalmente para diez millones de personas es estar físicamente dentro de un país, entonces las personas que no pueden acceder al formulario son exactamente aquellas de las que más deseas recibir noticias. Se necesitó un tuit viral para abrir un canal que un archivo security.txt habría abierto gratis.
Analicé el conjunto APK de Play Store disponible públicamente en modo invitado, sin cuenta y sin datos personales reales. No hice ningún intento de eludir el bloque a nivel de red frente a la API bancaria. Cada valor en este artículo es estructural (rutas, nombres de clases, metadatos de certificados) o está redactado; no se publica ningún material de clave privada ni secretos completos.





