Security disclosures

Unbounded notification bitmap offload to /data/system — storage-exhaustion DoS on Android 14–16

Researcher
Ayush Kumar
Program
Android & Google Devices VRP
Android ID
476317300
Reported
2026-01-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:

  1. The write target is privileged. /data/system is system-owned. The attacking app cannot write there directly; it induces system_server to write on its behalf. This is a confused-deputy shape.
  2. 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.
  3. 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.
  4. The data outlives the app. Offloaded content survives both reboot and uninstall of the originating app.
  5. The partition is shared across users. Filling /data/system degrades every user and profile on the device, not just the one that installed the app.

5. Attack chain

How one notification bitmap fills /data/system and blocks bootA malicious app that holds only the POST_NOTIFICATIONS permission builds an 8000 by 8000 pixel ARGB_8888 bitmap, about 256 megabytes. Notification.Builder would normally shrink an oversized image to roughly 2 megabytes, but that shrink runs client-side inside the attacker’s own process, so the attacker skips it by attaching the bitmap to the notification’s extras after building it, or by calling INotificationManager over Binder directly. The unshrunk bitmap crosses the Binder trust boundary into system_server, a privileged process shared by every app on the device. NotificationManagerService does not re-apply the size reduction. Its bitmap offload path checks only whether the disk has free space: no maximum dimensions, no maximum byte count, no per-package quota, no total quota, and this weak check runs before the permission check and before the check for notifications the user has already blocked. system_server then writes the full 256 megabyte payload into /data/system, a protected partition shared by every user profile on the device; the app could never write there itself, so this is a confused deputy attack, and the storage is not billed against the app’s own sandbox quota. Repeating the notification adds another full write each time. Once free space reaches zero the device cannot complete boot, and because a Direct-Boot-aware receiver relaunches the flood on every boot attempt, the device stays stuck.repeats every boot attempt1Malicious appPOST_NOTIFICATIONS only8000×8000 ARGB_8888 bitmap≈ 256 MBBuilder auto-resize→ ~2 MB✕ skipped by attackerattach to extras after build(),or call INotificationManagerdirectly over Binder —stays 256 MB, unshrunkBINDER IPC — TRUST BOUNDARY2NotificationManagerServicefree disk space? — runsnot a bound —spending the space is the attackno max size, max bytes, per-app quota,or total quota — all absentpermission + blocked-notification checksrun — but only after the writewrites the full payload to /data/system3partition free space0 bytes free → boot incompleteDirect-Boot receiver relaunches the flood on every boot attemptsix schematic steps, not to scale — a real partition dwarfs six notifications
A malicious app holding only POST_NOTIFICATIONS permission builds an 8000×8000 ARGB_8888 bitmap (≈256 MB) and skips the client-side resize Notification.Builder would otherwise apply, attaching it to a notification’s extras or calling INotificationManager directly. Inside system_server, the bitmap offload path checks only whether the disk has free space — not a real bound, since consuming that space is the attack — before any permission or blocked-notification check runs. Each notification adds another unbounded write to /data/system, a partition shared by every user profile; once it fills, the device cannot complete boot, and a Direct-Boot-aware receiver relaunches the flood on the next attempt. The partition-fill meter above is a schematic six-step animation, not a literal scale — an actual /data/system partition is far larger than six notifications.

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.