Unbounded notification bitmap offload to /data/system — storage-exhaustion DoS on Android 14–16
Android ID 476317300 · Reported to the Android & Google Devices VRP, 16 January 2026
1. Summary
Android's notification bitmap offload path writes caller-supplied image data into the
privileged /data/system partition. It applies no server-side bound on bitmap dimensions
or byte size, no per-package quota and no total quota, and the offload happens before the
notification is subjected to permission and blocking checks.
The consequence is a storage-exhaustion denial of service: an unprivileged app
holding only POST_NOTIFICATIONS could drive system_server into writing hundreds of
megabytes per notification into /data, exhaust the partition, and — combined with a
Direct-Boot-aware boot receiver — prevent the device from recovering across reboots.
2. Affected surface
| Property | Detail |
|---|---|
| Component | NotificationManagerService, notification bitmap offload path |
| Reported versions | Android 14, 15, 16 (Baklava) |
| Distribution | AOSP and AOSP-derived OEM builds |
| Attacker position | Locally installed, unprivileged app |
| Permissions required | POST_NOTIFICATIONS only |
| User interaction | Install only; trigger can be fully backgrounded |
Authoritative source path referenced in the report:
platform/frameworks/base/+/main/
services/core/java/com/android/server/notification/NotificationManagerService.java
Devices used for reproduction
| Device | Build fingerprint |
|---|---|
| Redmi (garnet), Android 15 | Redmi/garnet_in/garnet:15/AQ3A.240912.001/OS2.0.207.0.VNRINXM:user/release-keys |
| OnePlus CPH2661IN, Android 16 | OnePlus/CPH2661IN/OP5E93L1:16/UKQ1.231108.001/U.R4T2.a1a83f_16e546a_16e5468:user/release-keys |
| Pixel 8 Pro (husky), Android 15 | google/husky/husky:15/BP4A.260105.004/12345678:user/release-keys |
| Samsung Galaxy S23 (dm1q), Android 15 | samsung/dm1q/dm1q:15/UP1A.250105.007/S911BXXU8DYD9:user/release-keys |
| AOSP Cuttlefish, Android 16 | google/aosp_cf_x86_64_only_phone/vsoc_x86_64_only:VanillaIceCream/MAIN/eng:userdebug/test-keys |
3. Root cause
Two defects, both amounting to the server trusting a bound that only the client enforces:
(a) No server-side image reduction before offload.
Notification.Builder reduces oversized images on the client, and that reduction is not
re-applied on the server side before the offload decision is made. A caller that
bypasses Notification.Builder — by writing bitmap payloads into notification extras after
build, or by calling INotificationManager over Binder directly — therefore delivers
unreduced image data to a privileged writer.
(b) No bound on what gets offloaded. The offload routine checks only whether free disk space exists: no maximum dimensions, no maximum byte count, no per-package quota, no total quota. It runs before the permission and blocking checks that would otherwise reject the notification.
The quantity that makes the primitive work needs no source access to state: an
8000 × 8000 ARGB_8888 bitmap is 8000 × 8000 × 4 B ≈ 256 MB. Repeated across
notifications, that is the whole attack.
4. Why this is a privilege-relevant write
The interesting property is not "an app can use disk". An app can always fill its own sandbox, and the platform accounts for that. The claim here is narrower and sharper:
- The write target is privileged.
/data/systemissystem-owned. The attacking app cannot write there directly; it inducessystem_serverto write on its behalf. This is a confused-deputy shape. - The write is not billed to the app. Storage consumed this way is not the app's sandbox usage, so app-level storage accounting and "clear app data" do not obviously reclaim it.
- The write happens before the notification is authorised. If offload precedes the permission and blocking checks, then the cost is incurred even when the notification is ultimately rejected — including when the user has blocked that app's notifications.
- The data outlives the app. Offloaded content survives both reboot and uninstall of the originating app.
- The partition is shared across users. Filling
/data/systemdegrades every user and profile on the device, not just the one that installed the app.
5. Attack chain
Remote trigger. The flood can be initiated by an FCM data message, so no user
interaction is needed after install. The proof of concept stands an adb broadcast in for
the FCM delivery as a test harness; an ordinary broadcast simulating the FCM-delivered
notification is equally sufficient.
6. Persistence and recovery-denial design
The proof of concept does not stop at filling storage; it denies the user the recovery window. Three phases were implemented. They are described here at the mechanism level — all three rely on documented public Android APIs, and this writeup deliberately does not reproduce weaponised code.
Phase 1 — hide before completion.
A LOCKED_BOOT_COMPLETED receiver with directBootAware=true runs before user unlock. It
checks free space via StatFs; while /data still has headroom, it calls
setComponentEnabledSetting(..., DISABLED) on its own launcher activity. The icon
disappears from the launcher while the background flood continues, so a user who reboots
mid-attack cannot find the app by the obvious route.
Phase 2 — deny the Settings path.
The app watches for the Settings app being opened, via UsageStatsManager or accessibility
events, and on detection escalates the bitmap flood hard enough to force a system_server
soft reboot within roughly 5–10 seconds — ejecting the user from Settings before they can
reach Apps and uninstall. This repeats on each attempt.
Phase 3 — terminal state.
Once /data reaches zero free bytes, boot cannot complete. LOCKED_BOOT_COMPLETED fires
on each attempt and the flood restarts ahead of any recovery action. The reported recovery
was factory reset, with full data loss.
Observed progression on the Redmi test device:
| Elapsed | Event | /data free |
|---|---|---|
| 0:00 | app installed | 8 GB |
| 0:01 | trigger received | 8 GB |
| 0:30 | user notices lag, reboots | 4 GB |
| 0:35 | app hides itself, flood resumes | 4 GB |
| 0:40 | Settings opened → soft reboot | 3 GB |
| 0:45 | Settings opened again → soft reboot | 2 GB |
| 1:45 | storage exhausted | 0 B |
| 2:00+ | persistent bootloop | 0 B |
Tombstones could not be collected: system_server was dying repeatedly, so the tombstone
writes themselves did not survive.
7. Disclosure timeline
| Date | Event |
|---|---|
| 2026-01-16 14:46 | Report filed via Bug Hunters; Android ID 476317300 opened |
| 2026-01-16 23:06 | Triaged; coordinated-disclosure request issued |
| 2026-01-16 23:07 | Google requests a 90-day grace period and advance sight of any publication |
| 2026-01-16 23:46 | Reporter agrees to the 90-day grace period |
| 2026-01-17 00:58 | Google acknowledges the agreement |
| 2026-01-22 01:41 | Google requests a minimal complete project and non-ADB reproduction steps |
| 2026-01-22 19:45 | Reporter responds with clarification, logs and a project archive |
| 2026-01-22 23:08 | Google acknowledges receipt |
| ~2026-04-16 | 90-day grace period expires |
| 2026-09-12 | Google confirms no objection to publication |
Disclosure status. Published. The agreed 90-day grace period elapsed in April 2026, the writeup was shared with Google for review ahead of publication, and Google confirmed it had no objection before this page was made public.
Reported by Ayush Kumar to the Android & Google Devices Vulnerability Reward Program. Published under coordinated disclosure.