CVE-2026-28671 AI-generated analysis
platform/packages/providers/MediaProvider, MediaProvider.java update path (pending-file publish / file movement)
MediaProvider.updateInternal's file-movement path now checks, before Os.rename, whether a database row already claims the destination _data path. If no file backs that row it deletes the row; if a file exists it enforces write permission on the row. The SQLiteConstraintException handler in updateAllowingReplace now deletes a conflicting row at the active rename destination and retries the update in the same transaction.
Root cause
In MediaProvider.java updateInternal, an update can touch placement columns (e.g. clearing IS_PENDING, changing DISPLAY_NAME or RELATIVE_PATH) with movement allowed. In that case the code computes afterPath and calls Os.rename(beforePath, afterPath) on disk. Only after that does it run the database update through updateAllowingReplace. Nothing checked whether a different Files row with _data == afterPath already existed. Per the commit message, a malicious app could insert such a row ahead of time. In the tests the squatter row has the same DISPLAY_NAME and RELATIVE_PATH, IS_PENDING=0, and no backing file. The qb.update then hit the UNIQUE constraint on _data. For callers with targetSdk >= R the exception was simply rethrown. For legacy callers the replace logic ran only if a file existed at the path. The bulletin rates this High / ID.
How the fix works
- MediaProvider.java:updateInternal (movement branch): once afterPath is final and before Os.rename, it calls the new checkIfPathAlreadyExists(helper, beforeVolume, afterPath). It also records renameAfterPath = afterPath and passes it to updateAllowingReplace.
- MediaProvider.java:checkIfPathAlreadyExists: with the local calling identity cleared, it uses queryForSingleItem to look up _ID in Files.getContentUri(volume) where DATA == afterPath. If there is no row (FileNotFoundException or null cursor), it returns. If a row exists but new File(afterPath).exists() is false, it deletes that row with deleteEntryForPath and returns. If both the row and the file exist, it restores the calling identity and calls enforceCallingPermission(Files.getContentUri(volume, id), Bundle.EMPTY, /*forWrite*/ true).
- MediaProvider.java:updateAllowingReplace: takes a new @Nullable String renameAfterPath parameter. In the SQLiteConstraintException handler, before the targetSdk >= R check, it reads path = values.getAsString(FileColumns.DATA) and calls handleNonExistingEntryFile. If that returns anything other than -1, the value is returned directly. The old 'path == null || !new File(path).exists() -> throw e' guard is removed: a null path is now handled in handleNonExistingEntryFile, and the file-exists check for the legacy replace path is gone.
- MediaProvider.java:handleNonExistingEntryFile: throws e if path is null. If Objects.equals(path, renameAfterPath), it calls deleteEntryForPath(helper, path). If a row was deleted, it retries qb.update(helper, values, userWhere, userWhereArgs) inside the enclosing helper.runWithTransaction. Otherwise it returns -1. It does not check whether a file exists at path, and it does not check who owns the row.
- MediaProvider.java:deleteEntryForPath: clears the local calling identity and runs db.delete(Files.TABLE, "_data=?", {path}) inside helper.runWithTransaction. On any exception it logs and returns 0.
- MediaProviderTest.java: adds testPendingUpdate_SquatterRowDeleted, where a squatter row with no file is removed when the pending row is published. It also adds testPendingUpdate_SquatterRowFileExists, where a squatter row backed by a real file is kept and the published row gets a unique name.
Completeness
- The fix covers only the movement branch of updateInternal. handleNonExistingEntryFile acts only when renameAfterPath is non-null and equals the conflicting DATA value. Updates that do not go through movement include those that supply DATA directly, thumbnails, and calls where allowMovement is false. For a UNIQUE conflict in those updates, callers with targetSdk >= R still get the exception. Legacy callers go to the replace branch, which no longer has its file-exists guard.
- checkIfPathAlreadyExists runs before Os.rename. A row inserted between that check and the database update is handled only by the constraint-exception path. That path deletes the conflicting row with no owner check and no file-existence check.
- The stale-row deletion in checkIfPathAlreadyExists depends on new File(afterPath).exists(). If a file exists at the path, the row is not deleted. Instead, the caller doing the move must hold write permission on that row (enforceCallingPermission forWrite).
- deleteEntryForPath catches all exceptions and returns 0. In that case handleNonExistingEntryFile returns -1 and the rest of the handler runs: a rethrow for targetSdk >= R, or the legacy replace branch.
- The legacy (targetSdk < R) replace branch lost its 'file must exist on disk' guard. It now goes on to query and replace the conflicting row whenever path is non-null.
- The insert side is not changed: rows with no backing file can still be inserted. The fix only reclaims the name when a row is published or moved.
Behavior changes
- Moving or publishing a row to a path that has a Files row but no file: that other row, whoever owns it, is now deleted with a direct db.delete on Files.TABLE, and a verbose 'Deleted stale entry' line is logged.
- Moving or publishing to a path where both a row and a file exist, when the caller lacks write access to that row: enforceCallingPermission(..., forWrite=true) now fails the update before the rename.
- A UNIQUE(_data) conflict on the rename destination is now resolved by deleting the conflicting row and retrying the update, for all targetSdk levels. Before the fix, callers with targetSdk >= R got the SQLiteConstraintException.
- updateAllowingReplace signature changed (extra @Nullable String renameAfterPath parameter).
Per major
- Android 14: same fix: RESTRICT AUTOMERGE cherry-pick e27ea39d7184 in android-security-14.0.0_r29 (Change-Id Ica7cec90bb2d1870ce8902fa3d3e9899d59a743b); 8/8 patched lines present.
- Android 15: same fix: 9e42f05c7c32 in android-security-15.0.0_r17, identical hunks at shifted line numbers; 8/8 patched lines present.
- Android 16: same fix: 94374dc41151 in android-security-16.0.0_r8, identical hunks; the test methods are inserted before testGetType; 8/8 patched lines present.
- Android 17: same logic with context adaptation: 87beb3978b6a in android-security-17.0.0_r1. The surrounding code uses Logging.logIfLoggable and markPathAsDeletedAndInvalidateFuseDentry instead of Log.d and invalidateFuseDentry. The checkIfPathAlreadyExists call is placed before that logging line. 8/8 patched lines present.
Unknowns
- The bulletin classifies this as ID (information disclosure). The material does not say what information is exposed beyond the namespace squatting described in the commit message.
- What happened before the fix to the file already renamed on disk, and to the rows, after the UNIQUE conflict was rethrown (for example, whether the rename was reverted).
- Where the unique-name fallback used in testPendingUpdate_SquatterRowFileExists is implemented (it is not in the shown hunks).
- Whether queryForSingleItem with the identity cleared sees pending or trashed rows at the destination path.
- Whether the direct db.delete in deleteEntryForPath triggers the same change notifications and cleanup as the normal delete path.