Reports
ALTA[OAUTH Y SESIONES]#3475626

Fuga de cookies de sesión en la app de LinkedIn por una cabecera estática en WebViewerFragment

Un campo estático de cabeceras en WebViewerFragment de la app Android de LinkedIn conservaba las cookies entre cargas, de modo que las cookies de sesión de linkedin.com acababan enviándose a una web controlada por el atacante.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

La aplicación Android de LinkedIn incluye un componente llamado WebViewerFragment que abre páginas dentro de un WebView. Para autenticar esas cargas, adjunta las cookies del usuario en una cabecera Cookie. El problema es dónde las guarda: en un campo estático (CUSTOM_HEADERS) que nunca se limpia entre una carga y la siguiente.

Como ese mapa de cabeceras es compartido y persistente, las cookies calculadas al abrir una página de LinkedIn seguían presentes cuando después se abría otra dirección. Encadenando varios fallos, el investigador consiguió que esa segunda dirección fuese un servidor del atacante, al que la app terminaba enviando las cookies de sesión de LinkedIn.

Pasos de reproducción

El fallo de fondo está en el método loadUrl del fragmento. Al cargar una URL recupera las cookies asociadas y, si existen, las mete en el mapa estático antes de pasarlo al WebView:

public final void loadUrl(Uri uri0) {
    String s = uri0.toString();
    ...
    if(this.shouldUseCookies) {
        CookieManager cookieManager0 = CookieManager.getInstance();
        ...
        String s2 = cookieManager0.getCookie(s);
        ArrayMap arrayMap0 = WebViewerFragment.CUSTOM_HEADERS; // campo estático
        ...
        if(s2 != null) {
            arrayMap0.put("Cookie", s2);
        }
        this.webView.loadUrl(s, arrayMap0);
        return;
    }
    ...
}

Como CUSTOM_HEADERS es estático y no se vacía, si primero se abre un sitio con cookies (LinkedIn) y luego otro sin ellas, el segundo hereda la cabecera Cookie del primero.

Llegar a ese fragmento desde fuera, sin embargo, no es directo. El investigador describe una cadena de tres eslabones:

1. Validación de URL débil en la WebView de verificación. Se puede abrir con el deep-link https://www.linkedin.com/trust/verification, que toma la dirección a cargar del parámetro verificationUrl. El validador comprueba el host contra una lista blanca, pero no valida el esquema:

String host = uri1.getHost();
if(!CollectionsKt___CollectionsKt.contains(this.supportedUrls, host) || UriUtil.isSuspectedPathTraversalUri(uri1)) {
    uri1 = null;
}

Eso permite colar un esquema javascript: manteniendo un host permitido. La app, además, añade un parámetro renderContext al final de la URL, lo que rompía el JavaScript inyectado; el investigador lo sorteó cerrando una cadena con un fragmento (#) para que el parámetro añadido quedara dentro de la cadena y no afectara a la ejecución.

2. De la WebView de verificación al fragmento vulnerable. Esa WebView expone una interfaz JavaScript (addJavascriptInterface(..., "Android")) cuyo método sendWebMessage acaba invocando launchWebViewer con la URL que se le indique:

String s3 = VerificationWebViewFragment.getNonEmptyString("additionalWebViewUrl", ((JSONObject)object0));
if(s3 != null) {
    WebViewerBundle webViewerBundle0 = WebViewerBundle.create(s3, null, null);
    verificationWebViewFragment0.webRouterUtil.launchWebViewer(webViewerBundle0);
}

Desde el JavaScript ya ejecutado basta con llamar a Android.sendWebMessage(...) pasando un JSON con additionalWebViewUrl.

3. Forzar que se use el visor vulnerable. launchWebViewer decide el cliente (navegador, visor web, pestañas personalizadas) mediante interceptores. Uno de ellos fuerza el cliente web_viewer cuando la URL parece de LinkedIn, y usa un patrón sin ancla ^:

WebViewerUtils.FIRST_PARTY_ARTICLE_PATTERN = Pattern.compile("(http|https)://www.linkedin(-ei)?.com/pulse/+");

Al no estar anclado, el patrón casa en cualquier parte de la cadena: añadiendo algo como http://www.linkedin.com/pulse/1 dentro de la URL del atacante, la comprobación pasa y se abre el fragmento vulnerable.

Con los tres eslabones encadenados, el flujo es: abrir primero una dirección de LinkedIn (que deja las cookies en CUSTOM_HEADERS) y, tras una pequeña espera, abrir la dirección del atacante (que recibe esas cookies ya presentes en el mapa). El reporte incluye una página HTML de prueba de concepto con un enlace al deep-link trust/verification que automatiza estos dos sendWebMessage.

Impacto

El atacante recibe en su servidor la cabecera Cookie con los tokens de sesión de LinkedIn de la víctima. Con ellos puede suplantar la sesión, lo que el investigador describe como toma de control completa de la cuenta.

Remediación

El reporte no detalla el parche aplicado; consta que LinkedIn marcó el caso como Resolved, lo clasificó como Misconfiguration de severidad high y pagó recompensa (importe no publicado).

Qué aprender de este caso

  • En clientes Android, desconfía de cualquier estructura de cabeceras o cookies declarada static y reutilizada entre cargas de un WebView: busca mapas tipo CUSTOM_HEADERS que se rellenan en loadUrl pero no se limpian, porque arrastran credenciales de una URL a la siguiente. Construye las cabeceras por carga y vacíalas al terminar.
  • Al validar URLs contra listas blancas, revisa que se compruebe también el esquema, no solo el host: aceptar un host permitido con esquema javascript: abre la puerta a ejecución de JavaScript en la WebView.
  • Revisa los Pattern.compile usados para decidir si una URL es "de confianza": si al regex le falta el ancla ^ (y $), casará con una subcadena incrustada en cualquier parte, y un atacante solo tiene que insertar el fragmento esperado (/pulse/) dentro de su propia URL.
  • Trata cada addJavascriptInterface como superficie de ataque: si una WebView con validación débil expone un método que acaba abriendo otras URLs (additionalWebViewUrl → launchWebViewer), encadenar ambos convierte un XSS local en exfiltración de sesión.