CVE-2025-48583 AI-generated analysis
platform/frameworks/base, core/java/android/os (Bundle / Parcel lazy-value handling)
Once a bundle's lazy values have all been consumed, or the bundle is cleared, BaseBundle no longer puts its backing Parcel back into the Parcel pool with recycle(). Instead it calls Parcel.destroy(), which is now exposed as a public @hide method, so these Parcel instances are never reused.
Root cause
In core/java/android/os/BaseBundle.java, if a bundle owned its lazy values (mOwnsLazyValues), BaseBundle called recycleParcel(), which calls Parcel.recycle(), in two places: when the lazy-value refcount mLazyValues reached zero in unwrapLazyValueFromMapLocked, and in clear(). recycle() puts the Parcel object back into the static sOwnedPool/sHolderPool, so a later obtain() can hand the same instance out again. The bundle only held the parcel through a WeakReference (mWeakParcelledData). According to the commit message, recycling the parcel once all lazy values are consumed may lead to several UAF issues. The material does not show which remaining references see the reused instance. On Android 14, the code also asserted with Preconditions.checkState that the weak reference was still non-null when the refcount hit zero ("Parcel recycled earlier than expected").
How the fix works
- core/java/android/os/Parcel.java: destroy() goes from private to public (@hide) so that BaseBundle can call it. The visible part of destroy() calls resetSqaushingState() and then handles the native pointer depending on mOwnsNativeParcelObject. According to the commit message, BaseBundle now destroys the dangling parcel and never calls recycle on it, so these instances cannot be reused.
- core/java/android/os/Parcel.java: the line 'mClassCookies = null' moves out of recycle() into the buffer-freeing helper that calls nativeFreeBuffer and resets mReadWriteHelper. That helper is presumably freeBuffer(), which recycle() still calls.
- core/java/android/os/BaseBundle.java:unwrapLazyValueFromMapLocked: when mOwnsLazyValues is set and mLazyValues reaches 0, the code reads 'Parcel parcel = mWeakParcelledData.get()'. Only if parcel is non-null does it call parcel.destroy() and set mWeakParcelledData to null. On Android 14 this change also removes the Preconditions.checkState(mWeakParcelledData.get() != null, ...) assertion. On 15 and 16 the null-check form is already present as context.
- core/java/android/os/BaseBundle.java:initializeFromParcelLocked: when the bundle owns the parcel and numLazyValues[0] == 0, it now calls parcelledData.recycle() directly, with the comment 'No lazy value, we can directly recycle this parcel'. The empty-parcel case already returns early at the top of the method.
- core/java/android/os/BaseBundle.java:clear(): if mOwnsLazyValues is set and mWeakParcelledData is non-null, the parcel is destroy()ed (if still reachable) instead of recycled.
- core/java/android/os/BaseBundle.java: the private static helper recycleParcel(Parcel) is deleted.
- core/tests/mockingcoretests/src/android/os/BundleRecyclingTest.java: the lazy-value tests now count destroy() calls instead of recycle() calls. The no-lazy test bundleClear_whenUnparcelledWithoutLazy_recyclesParcelOnce still checks recycle().
Completeness
- The fix covers both BaseBundle paths that used to recycle a parcel after lazy values existed: the refcount reaching zero in unwrapLazyValueFromMapLocked, and clear(). After the fix, those paths destroy the parcel instead of putting it back in the pool.
- The no-lazy-values path in initializeFromParcelLocked still recycles the parcel. The commit treats this as safe ('No lazy value, we can directly recycle this parcel').
- Shared or copied bundles (mOwnsLazyValues false) still neither destroy nor recycle the parcel, as the *_doesNotDestroyParcel tests check.
- The material does not show whether code outside BaseBundle recycles Parcels that lazy values still refer to.
Behavior changes
- Parcels that backed bundles with lazy values are no longer returned to the Parcel pool. They are destroyed when the last lazy value is consumed or the bundle is cleared.
- Parcel.destroy() becomes a public @hide method.
- mClassCookies is now cleared in the buffer-freeing helper instead of directly in recycle(). recycle() still reaches it through freeBuffer().
- Android 14 only: the Preconditions.checkState assertion ("Parcel recycled earlier than expected") at refcount zero is gone. A weak reference that has already been cleared is now skipped.
Per major
- Android 14: Adapted fix: commit 401cc81d7d94 in android-security-14.0.0_r24. Besides the shared changes, it replaces the Preconditions.checkState null assertion in unwrapLazyValueFromMapLocked with a plain null check, a form already present on 15 and 16. The per-major facts report 4/8 patched-region lines present at android-security-14.0.0_r29.
- Android 15: Same fix: commit 26f25bbc2ae8 in android-security-15.0.0_r12, with the same BaseBundle, Parcel and test changes. The null-check form was already present. 6/8 lines present at android-security-15.0.0_r17.
- Android 16: Same fix: commit 02751bc65824 in android-security-16.0.0_r3, with the same changes. On this branch BaseBundle implements Parcel.ClassLoaderProvider, which only shows up as diff context. 6/8 lines present at android-security-16.0.0_r8.
- Android 17: Not affected: the bulletin does not list Android 17. The per-major facts show 4/8 patched-region lines present at android-security-17.0.0_r1.
Unknowns
- The material does not show the concrete UAF scenario(s) behind bugs 377704076 and 381885240, that is, which other holder of the pooled Parcel saw it reused.
- The material does not show the attack surface, for example whether the triggering bundle content can come from another app over IPC.
- The full body of Parcel.destroy() is not shown, including whether it calls freeBuffer() and so also clears mClassCookies.
- The material does not show whether any code outside BaseBundle recycles Parcels while lazy-value closures still refer to them.
- The material does not explain why Android 17 is not affected or what the code looks like there, beyond the 4/8 line-match figure.