August 27, 2026
APK imposible de interceptar: SSLHandshakeException, ProviderInstaller y cómo recuperamos Burp en…
Una guía de debugging TLS con Frida, OkHttp, Conscrypt y GmsCore_OpenSSL para cuando el problema no es SSL Pinning.

By Grupo Oruss Security Research
7 min read
Una guía de debugging TLS con Frida, OkHttp, Conscrypt y GmsCore_OpenSSL para cuando el problema no es SSL Pinning.
— Por Equipo División81 | Grupo Oruss
Hay aplicaciones Android en las que configurar Burp Suite es cuestión de minutos: proxy, certificado CA, un poco de instrumentación si existe certificate pinning y a trabajar.
Y luego están las otras. Las intensas….
Durante una evaluación autorizada de seguridad móvil en la División81 Grupo Oruss, nos encontramos con una build Android que funcionaba perfectamente en un dispositivo físico, pero que en nuestro entorno de laboratorio mostraba un error durante el splash y quedaba inutilizable.
Inicialmente parecía uno de los escenarios habituales:
- detección de emulador;
- root detection;
- anti-Frida;
- certificate pinning;
- Network Security Config;
- incompatibilidad con el proxy;
- o algún mecanismo de protección del SDK biométrico integrado.
No era ninguno de ellos.
El problema terminó estando varias capas más abajo, en la interacción entre OkHttp, Google Play Services, Conscrypt y ProviderInstaller.
Esta es la historia técnica de cómo llegamos hasta allí.
Nota de privacidad:_ nombres, dominios, identificadores, endpoints y referencias específicas del cliente han sido reemplazados o generalizados. La aplicación se identifica únicamente como División81 Mobile._
El síntoma inicial
Nuestro laboratorio utilizaba:
- Android Emulator;
- Android 13 / API 33;
- arquitectura x86_64;
- Frida;
- JADX;
- ADB;
- Burp Suite Professional;
- una build de producción de la aplicación.
La aplicación estaba instalada correctamente, pero al arrancar mostraba un diálogo genérico durante el splash y posteriormente cerraba su interfaz.
En un teléfono Android físico, sin embargo, la misma APK funcionaba correctamente.
Ese detalle terminaría siendo fundamental.
El primer error habría sido asumir inmediatamente:
"La aplicación detecta que está siendo ejecutada en un emulador."
En pentesting móvil, una hipótesis razonable no es todavía una evidencia.
Así que decidimos seguir la ejecución.
1. ¿La aplicación realmente estaba haciendo crash?
La primera observación interesante fue que Logcat no mostraba el patrón esperado de una excepción fatal:
FATAL EXCEPTION
SIGSEGV
SIGABRT
Process has diedFATAL EXCEPTION
SIGSEGV
SIGABRT
Process has diedEl proceso incluso continuaba realizando determinadas operaciones después de desaparecer la interfaz.
Decidimos entonces interceptar mediante Frida las llamadas a:
Activity.finish()
Activity.finishAffinity()
Activity.finishAndRemoveTask()
System.exit()
Runtime.exit()
Process.killProcess()Activity.finish()
Activity.finishAffinity()
Activity.finishAndRemoveTask()
System.exit()
Runtime.exit()
Process.killProcess()El stack obtenido fue aproximadamente:
Activity.finish()
at Division81SplashActivity$Callback.onClick()
at CustomDialog$Listener.onClick()
at View.performClick()Activity.finish()
at Division81SplashActivity$Callback.onClick()
at CustomDialog$Listener.onClick()
at View.performClick()Primera conclusión:
finish() no era la causa.
Era simplemente la consecuencia de pulsar el botón del diálogo de error.
Había que retroceder un nivel.
2. Rastreando quién mostraba el diálogo
Interceptamos entonces Dialog.show().
La llamada reveló una cadena similar a:
CustomDialog.show()
↓
SplashActivity.showError()
↓
SplashPresenter
↓
UseCasePriorityLauncherCustomDialog.show()
↓
SplashActivity.showError()
↓
SplashPresenter
↓
UseCasePriorityLauncherEsto ya era mucho más interesante.
La lógica que disparaba el diálogo provenía del presenter del splash y no de una protección visual o de una terminación arbitraria.
Instrumentando tempranamente la aplicación mediante:
Application.attach(Context)Application.attach(Context)conseguimos colocar los hooks antes de que el flujo del splash comenzara.
Entonces apareció la primera evidencia realmente útil:
ApiErrorModel{
type='Error from app',
title='Error because server not respond',
status=555,
detail='Artificial Error because server not respond',
message='...'
}ApiErrorModel{
type='Error from app',
title='Error because server not respond',
status=555,
detail='Artificial Error because server not respond',
message='...'
}El código 555 no provenía del backend.
Era fabricado por la propia aplicación cuando una operación de red fallaba.
3. Retrofit estaba ocultando el error real
Seguimos el objeto hasta el callback que lo construía.
El stack quedó aproximadamente así:
ApiErrorModel()
↓
ApiErrorModel.create()
↓
RetrofitCallbacks.onFailure()
↓
DefaultCallAdapterFactoryApiErrorModel()
↓
ApiErrorModel.create()
↓
RetrofitCallbacks.onFailure()
↓
DefaultCallAdapterFactoryHabíamos encontrado el punto correcto.
El siguiente hook se colocó directamente sobre el callback de Retrofit que recibía:
ThrowableThrowableEsta vez apareció la excepción real:
javax.net.ssl.SSLHandshakeException:
Connection closed by peerjavax.net.ssl.SSLHandshakeException:
Connection closed by peerEl stack era aún más revelador:
com.google.android.gms.org.conscrypt.NativeCrypto.SSL_do_handshake
com.google.android.gms.org.conscrypt.NativeSsl.doHandshake
com.google.android.gms.org.conscrypt.ConscryptFileDescriptorSocket.startHandshake
okhttp3.internal.connection.RealConnection.connectTls
okhttp3.internal.connection.RealConnection.establishProtocol
okhttp3.internal.connection.RealConnection.connect
...
Retrofitcom.google.android.gms.org.conscrypt.NativeCrypto.SSL_do_handshake
com.google.android.gms.org.conscrypt.NativeSsl.doHandshake
com.google.android.gms.org.conscrypt.ConscryptFileDescriptorSocket.startHandshake
okhttp3.internal.connection.RealConnection.connectTls
okhttp3.internal.connection.RealConnection.establishProtocol
okhttp3.internal.connection.RealConnection.connect
...
RetrofitFinalmente teníamos una causa técnica.
No era:
Certificate pinning failureCertificate pinning failureNo era:
Trust anchor for certification path not foundTrust anchor for certification path not foundTampoco:
CertPathValidatorExceptionCertPathValidatorExceptionEra el peer cerrando la conexión durante el handshake TLS.
4. La comparación que eliminó media docena de hipótesis
Aquí hicimos una prueba extremadamente sencilla pero muy útil.
Desde Chrome, dentro del mismo AVD, accedimos directamente al backend de laboratorio:
https://api.division81.grupooruss.com/...https://api.division81.grupooruss.com/...El endpoint respondió correctamente.
Por tanto:
Mismo AVD
Misma red
Mismo destino
Chrome
→ TLS OK
Aplicación
→ SSLHandshakeExceptionMismo AVD
Misma red
Mismo destino
Chrome
→ TLS OK
Aplicación
→ SSLHandshakeExceptionEsto permitía descartar:
- DNS;
- routing;
- firewall general;
- indisponibilidad del backend;
- certificado público incorrecto;
- fallo global del AVD.
La diferencia estaba en la pila TLS utilizada por la aplicación.
5. Revisando el cliente OkHttp
JADX mostró un cliente Retrofit bastante convencional:
OkHttpClient.Builder builder =
new OkHttpClient.Builder();
builder
.connectTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS);OkHttpClient.Builder builder =
new OkHttpClient.Builder();
builder
.connectTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS);Había varios interceptores de aplicación:
AuthorizationInterceptor
RandomHeaderInterceptor
AuditInterceptor
EncryptInterceptorAuthorizationInterceptor
RandomHeaderInterceptor
AuditInterceptor
EncryptInterceptorPero no encontramos en ese punto:
sslSocketFactory(...)
certificatePinner(...)
hostnameVerifier(...)
connectionSpecs(...)
protocols(...)sslSocketFactory(...)
certificatePinner(...)
hostnameVerifier(...)
connectionSpecs(...)
protocols(...)También revisamos el interceptor de auditoría porque recibía directamente el:
OkHttpClient.BuilderOkHttpClient.BuilderSu constructor únicamente modificaba los timeouts y construía otro cliente Retrofit.
Nada relacionado con TLS.
La pregunta cambió entonces:
Si OkHttp no estaba seleccionando explícitamente ese proveedor TLS, ¿quién estaba introduciendo
com.google.android.gms.org.conscrypt?
6. ProviderInstaller
Una búsqueda estática reveló la presencia de:
ProviderInstallerProviderInstallerprocedente de Google Play Services.
La lógica de la clase era conceptualmente:
ProviderInstaller.installIfNeeded(context);ProviderInstaller.installIfNeeded(context);Internamente se cargaba dinámicamente:
com.google.android.gms.providerinstaller.dynamitecom.google.android.gms.providerinstaller.dynamitepara terminar invocando:
ProviderInstallerImpl.insertProvider(Context)ProviderInstallerImpl.insertProvider(Context)El APK estaba ofuscado, por lo que el nombre runtime del método no conservaba necesariamente installIfNeeded.
Esto es importante para quienes quieran reproducir la técnica:
no dependáis únicamente del nombre Java mostrado por la documentación.
JADX puede mostrar algo como:
JADX INFO: renamed from ...JADX INFO: renamed from ...y ese nombre runtime es el que posteriormente tendremos que utilizar desde Frida.
7. La prueba definitiva: inspeccionar Security.getProviders()
Creamos entonces un hook alrededor de ProviderInstaller.
Antes de ejecutarlo:
========== SECURITY PROVIDERS ==========
[0] AndroidNSSP
[1] AndroidOpenSSL
[2] CertPathProvider
[3] AndroidKeyStoreBCWorkaround
[4] BC
[5] HarmonyJSSE
[6] AndroidKeyStore========== SECURITY PROVIDERS ==========
[0] AndroidNSSP
[1] AndroidOpenSSL
[2] CertPathProvider
[3] AndroidKeyStoreBCWorkaround
[4] BC
[5] HarmonyJSSE
[6] AndroidKeyStoreInterceptamos la ejecución de ProviderInstaller.
Después:
========== SECURITY PROVIDERS ==========
[0] GmsCore_OpenSSL
[1] AndroidNSSP
[2] AndroidOpenSSL
[3] CertPathProvider
[4] AndroidKeyStoreBCWorkaround
[5] BC
[6] HarmonyJSSE
[7] AndroidKeyStore========== SECURITY PROVIDERS ==========
[0] GmsCore_OpenSSL
[1] AndroidNSSP
[2] AndroidOpenSSL
[3] CertPathProvider
[4] AndroidKeyStoreBCWorkaround
[5] BC
[6] HarmonyJSSE
[7] AndroidKeyStoreAhí estaba.
ProviderInstaller acababa de colocar:
GmsCore_OpenSSLGmsCore_OpenSSLcomo Security Provider número 0.
Y el error TLS que habíamos capturado anteriormente ocurría precisamente dentro de:
com.google.android.gms.org.conscrypt.NativeCryptocom.google.android.gms.org.conscrypt.NativeCryptoLa correlación era demasiado fuerte como para ignorarla.
8. La prueba A/B
Todavía faltaba demostrar causalidad.
No queríamos modificar permanentemente el APK.
Utilizamos Frida para convertir temporalmente la llamada a ProviderInstaller en un no-op.
Conceptualmente:
Java.perform(function () {
var ProviderInstaller =
Java.use("<runtime ProviderInstaller class>");
var install =
ProviderInstaller
.<runtime_install_method>
.overload("android.content.Context");
install.implementation = function (context) {
console.log(
"[+] ProviderInstaller bloqueado"
);
// No llamamos al método original.
return;
};
});Java.perform(function () {
var ProviderInstaller =
Java.use("<runtime ProviderInstaller class>");
var install =
ProviderInstaller
.<runtime_install_method>
.overload("android.content.Context");
install.implementation = function (context) {
console.log(
"[+] ProviderInstaller bloqueado"
);
// No llamamos al método original.
return;
};
});En nuestro caso concreto los nombres de clase y método estaban ofuscados, por lo que fueron obtenidos previamente mediante JADX.
Resultado:
TEST A
ProviderInstaller
↓
GmsCore_OpenSSL
↓
SSLHandshakeException
↓
aplicación inutilizableTEST A
ProviderInstaller
↓
GmsCore_OpenSSL
↓
SSLHandshakeException
↓
aplicación inutilizablefrente a:
TEST B
ProviderInstaller bloqueado
↓
AndroidOpenSSL permanece activo
↓
TLS OK
↓
LOGINTEST B
ProviderInstaller bloqueado
↓
AndroidOpenSSL permanece activo
↓
TLS OK
↓
LOGINLa pantalla de autenticación apareció inmediatamente.
Ese fue nuestro momento "ahí está".
9. Entonces sí: Burp
Resolver el handshake no significaba todavía que pudiéramos interceptar HTTPS.
Para disponer de un entorno suficientemente flexible creamos un AVD:
Android 13 / API 33
x86_64
Google APIsAndroid 13 / API 33
x86_64
Google APIsy evitamos una imagen Google Play, ya que necesitábamos:
adb rootadb rootLa imagen utilizada permitía:
uid=0(root)
gid=0(root)uid=0(root)
gid=0(root)Sistema escribible
Para modificar el almacén de certificados fue necesario arrancar el emulador como:
emulator @Pentest_AVD \
-writable-system \
-no-snapshotemulator @Pentest_AVD \
-writable-system \
-no-snapshotPosteriormente:
adb root
adb disable-verity
adb reboot
adb root
adb remountadb root
adb disable-verity
adb reboot
adb root
adb remountHasta conseguir:
remount succeededremount succeeded10. Instalando la CA de Burp como System CA
Exportamos la CA de Burp en DER y la convertimos:
openssl x509 \
-inform DER \
-in burp-ca.der \
-out burp-ca.pemopenssl x509 \
-inform DER \
-in burp-ca.der \
-out burp-ca.pemCalculamos el hash utilizado por Android:
openssl x509 \
-subject_hash_old \
-in burp-ca.pemopenssl x509 \
-subject_hash_old \
-in burp-ca.pemSupongamos:
9a5ba5759a5ba575Renombramos:
burp-ca.pem
→
9a5ba575.0burp-ca.pem
→
9a5ba575.0y copiamos:
adb push 9a5ba575.0 \
/system/etc/security/cacerts/9a5ba575.0adb push 9a5ba575.0 \
/system/etc/security/cacerts/9a5ba575.0Permisos:
adb shell chmod 644 \
/system/etc/security/cacerts/9a5ba575.0
adb shell chown root:root \
/system/etc/security/cacerts/9a5ba575.0adb shell chmod 644 \
/system/etc/security/cacerts/9a5ba575.0
adb shell chown root:root \
/system/etc/security/cacerts/9a5ba575.0Finalmente:
adb rebootadb reboot11. Proxy del Android Emulator
Cuando Burp se ejecuta en el host Windows, Android Emulator dispone de la dirección especial:
10.0.2.210.0.2.2que representa el host.
Por tanto:
adb shell settings put global \
http_proxy 10.0.2.2:8080adb shell settings put global \
http_proxy 10.0.2.2:8080Verificación:
adb shell settings get global http_proxyadb shell settings get global http_proxyResultado:
10.0.2.2:808010.0.2.2:8080Chrome comenzó a navegar correctamente a través de Burp mediante HTTPS.
Última prueba:
AVD
↓
CA Burp como System CA
↓
Proxy
↓
ProviderInstaller bypass
↓
AplicaciónAVD
↓
CA Burp como System CA
↓
Proxy
↓
ProviderInstaller bypass
↓
AplicaciónResultado:
tráfico HTTPS visible en Burp y aplicación completamente funcional.
Lo que aprendimos
La parte más interesante de este caso no fue escribir un script de Frida.
Fue no asumir demasiado pronto cuál era el problema.
Durante la investigación tuvimos razones razonables para sospechar de:
Emulator detection
Root detection
Anti-Frida
SSL Pinning
Network Security Config
SDK biométrico
WAF
DNS
TLSEmulator detection
Root detection
Anti-Frida
SSL Pinning
Network Security Config
SDK biométrico
WAF
DNS
TLSPero cada hipótesis fue sometida a una prueba.
La secuencia terminó siendo:
Splash error
↓
Activity.finish()
↓
CustomDialog
↓
SplashPresenter
↓
ApiErrorModel 555
↓
Retrofit onFailure
↓
SSLHandshakeException
↓
GMS Conscrypt
↓
ProviderInstaller
↓
GmsCore_OpenSSLSplash error
↓
Activity.finish()
↓
CustomDialog
↓
SplashPresenter
↓
ApiErrorModel 555
↓
Retrofit onFailure
↓
SSLHandshakeException
↓
GMS Conscrypt
↓
ProviderInstaller
↓
GmsCore_OpenSSLSolo entonces aplicamos el bypass.
El error 555 fue una pista, no la respuesta
Un detalle que merece mencionarse es el manejo de errores de la aplicación.
El error mostrado al usuario indicaba esencialmente:
Server not respondServer not respondpero la causa real era:
SSLHandshakeException:
Connection closed by peerSSLHandshakeException:
Connection closed by peerDurante pruebas dinámicas, los mensajes de aplicación suelen ser abstracciones de errores mucho más específicos.
Cuando aparezca un código propietario como:
555
999
-1
NETWORK_ERROR
GENERIC_ERROR555
999
-1
NETWORK_ERROR
GENERIC_ERRORno conviene detenerse ahí.
Hay que seguirlo hasta:
Throwable
IOException
SSLException
SocketExceptionThrowable
IOException
SSLException
SocketExceptionAhí suele comenzar la información interesante.
¿Esto es una vulnerabilidad?
No necesariamente.
Y esta distinción es importante.
Bloquear temporalmente ProviderInstaller fue una técnica de habilitación de nuestro entorno de análisis, no una vulnerabilidad que debiera reportarse automáticamente como tal.
El problema observado era una incompatibilidad concreta entre:
la build
+
el entorno AVD
+
el proveedor GMS Conscrypt disponible
+
el endpoint TLSla build
+
el entorno AVD
+
el proveedor GMS Conscrypt disponible
+
el endpoint TLSEl bypass nos permitió continuar con la evaluación.
Lo que sí puede convertirse posteriormente en un hallazgo independiente es, por ejemplo:
- ausencia de certificate pinning;
- confianza excesiva en determinados certificados;
- controles de autorización insuficientes;
- exposición de información sensible;
- problemas de sesión;
- BOLA/IDOR;
- vulnerabilidades de lógica de negocio.
Es importante no confundir la técnica utilizada para llegar hasta una superficie de ataque con la vulnerabilidad encontrada posteriormente en esa superficie.
Por qué merece la pena documentar estas técnicas
Una evaluación superficial podría haber terminado con:
"No fue posible ejecutar correctamente la aplicación en un entorno instrumentado."
Pero esa conclusión habría dejado fuera prácticamente toda la superficie dinámica.
El objetivo de un pentest no debería ser demostrar que nuestras herramientas funcionan.
El objetivo es conseguir suficiente visibilidad para evaluar el comportamiento real del objetivo.
A veces eso requiere llegar desde:
ActivityActivityhasta:
RetrofitRetrofitluego:
OkHttpOkHttpdespués:
Java Security ProviderJava Security Providery finalmente:
NativeCrypto.SSL_do_handshake()NativeCrypto.SSL_do_handshake()antes de poder enviar siquiera la primera petición a Burp.
Y esa, precisamente, es una de las partes más interesantes de este trabajo.
Checklist rápida para un caso similar
Si una aplicación funciona físicamente pero falla en un AVD durante una llamada HTTPS:
- No asumas inmediatamente certificate pinning.
- Comprueba que Chrome puede alcanzar el mismo endpoint desde el AVD.
- Captura el
Throwablereal de Retrofit/OkHttp. - Diferencia:
SSLPeerUnverifiedExceptionCertPathValidatorExceptionSSLHandshakeExceptionSocketTimeoutExceptionConnectException.
- Revisa:
Security.getProviders()Security.getProviders()- Comprueba si se ejecuta:
ProviderInstallerProviderInstaller-
Compara el orden de providers antes y después.
-
Si
GmsCore_OpenSSLpasa a prioridad 0, realiza una prueba A/B controlada. -
No modifiques permanentemente el APK si Frida permite validar la hipótesis dinámicamente.
-
Solo después configura Burp, CA de sistema y el resto del entorno de interceptación.
Conclusión
El problema parecía inicialmente una aplicación resistente a instrumentación.
Terminó siendo algo bastante diferente:
una incompatibilidad TLS introducida por el Security Provider instalado dinámicamente durante el arranque.
La evidencia crítica fue muy sencilla:
ProviderInstaller ON
→ GmsCore_OpenSSL
→ SSLHandshakeException
ProviderInstaller OFF
→ AndroidOpenSSL
→ TLS OK
→ aplicación funcionalProviderInstaller ON
→ GmsCore_OpenSSL
→ SSLHandshakeException
ProviderInstaller OFF
→ AndroidOpenSSL
→ TLS OK
→ aplicación funcionalNo es una técnica revolucionaria ni un nuevo ataque.
Pero es exactamente el tipo de problema que puede consumir horas durante una evaluación móvil si se persiguen primero las hipótesis equivocadas.
La parte reutilizable del caso no es el script.
Es el método:
observar → aislar → instrumentar → comparar → demostrar causalidad → modificar únicamente lo necesario.
Y después, finalmente:
abrir Burp y empezar el pentest de verdad!
Happy Hacking!
Código fuente y PoC con Frida
El proof-of-concept sanitizado en Frida utilizado durante esta investigación, junto con notas de troubleshooting y ejemplos de inspección de Security Providers, está disponible en GitHub:
GitHub → frida-android-providerinstaller-bypass
Research: Division81 Research Observar → aislar → instrumentar → comparar → demostrar causalidad.
— Equipo División81 | Grupo Oruss https://grupooruss.com