Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Notifications
DescriptionUse after free in Notifications
ComponentNotifications
Sub-componentUI>Notifications
Bug ClassUAF
Tracker514079212
Fix commitdc54d38f62c0 (chromium/src) +265/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptor.java
modified

Files Changed

  • chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptor.java
  • chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptorTest.java
From dc54d38f62c00d2945dc27ad3c103c703e8c444d Mon Sep 17 00:00:00 2001
From: Min Qin <[email protected]>
Date: Tue, 15 Sep 2026 22:27:28 -0700
Subject: [PATCH] Set unique Intent identifier in NotificationIntentInterceptor

Notification wrapper PendingIntents are cached and deduplicated by the
Android framework based on component, request code, and
Intent#filterEquals. Because wrapper intents share actions and target
components, uniqueness previously relied entirely on a 32-bit hash code
used as the request code. In the event of a hash collision between
different notification tags, a new PendingIntent could unintentionally
overwrite the cached extras of an earlier notification.

This CL updates NotificationIntentInterceptor to set an explicit Intent
identifier containing the full notification tag and metadata. This
guarantees that Intent#filterEquals differentiates intents across
distinct notifications and prevents PendingIntent collisions.

TAG=agy

Bug: 514079212
Change-Id: I0943445feeea445c97ac696190482e9e33dd2ff2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8410667
Commit-Queue: Min Qin <[email protected]>
Reviewed-by: Yoshiki IGUCHI <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1698646}
---
diff --git a/chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptor.java b/chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptor.java
index 9bd9f063..65411c7 100644
--- a/chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptor.java
+++ b/chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptor.java
@@ -329,10 +329,12 @@
         // broadcast delivery.
         if (shouldUseBroadcast) intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
 
-        // Use request code to distinguish different PendingIntents on Android.
+        // Use request code and identifier to distinguish different PendingIntents on Android.
         int originalRequestCode =
                 pendingIntentProvider != null ? pendingIntentProvider.getRequestCode() : 0;
         int requestCode = computeHashCode(metadata, intentType, actionType, originalRequestCode);
+        intent.setIdentifier(
+                computeIntentIdentifier(metadata, intentType, actionType, originalRequestCode));
 
         return shouldUseBroadcast
                 ? PendingIntent.getBroadcast(applicationContext, requestCode, intent, flags)
@@ -400,6 +402,35 @@
     }
 
     /**
+     * Computes a unique identifier for the intercept {@link Intent} so that {@link
+     * Intent#filterEquals} distinguishes intents with different metadata.
+     *
+     * @param metadata Notification metadata including notification id, tag, etc.
+     * @param intentType The type of the {@link PendingIntent}.
+     * @param actionType Distinguishes actions for `ACTION_INTENT` {@link IntentType}.
+     * @param requestCode The request code of the wrapped {@link PendingIntent}.
+     * @return The unique identifier string for the intercept {@link Intent}.
+     */
+    private static String computeIntentIdentifier(
+            NotificationMetadata metadata,
+            @IntentType int intentType,
+            @NotificationUmaTracker.ActionType int actionType,
+            int requestCode) {
+        StringBuilder sb = new StringBuilder();
+        sb.append(metadata.type).append(':');
+        sb.append(intentType).append(':');
+        sb.append(actionType).append(':');
+        sb.append(metadata.id).append(':');
+        sb.append(requestCode).append(':');
+        if (metadata.tag == null) {
+            sb.append("null");
+        } else {
+            sb.append(metadata.tag.length()).append(':').append(metadata.tag);
+        }
+        return sb.toString();
+    }
+
+    /**
      * Computes an unique hash code to identify the intercept {@link PendingIntent} that wraps the
      * notification's {@link PendingIntent}.
      *
diff --git a/chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptorTest.java b/chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptorTest.java
index 8ccdd596..f528423a 100644
--- a/chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptorTest.java
+++ b/chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptorTest.java
@@ -272,4 +272,237 @@
                 interceptIntent0,
                 interceptIntent1);
     }
+
+    /**
+     * Verifies that notifications with different tags that have identical String hash codes produce
+     * distinct intercept PendingIntents and do not overwrite each other.
+     */
+    @Test
+    public void testTagsWithSameHashCodeProduceDistinctPendingIntents() throws Exception {
+        String tag1 = "p#http://localhost:8080/#1login-alert";
+        String tag2 = "p#http://localhost:8081/#1(~tzmkq";
+        Assert.assertEquals(tag1.hashCode(), tag2.hashCode());
+
+        NotificationMetadata metadata1 =
+                new NotificationMetadata(
+                        NotificationUmaTracker.SystemNotificationType.SITES,
+                        tag1,
+                        /* notificationId= */ -1);
+        NotificationMetadata metadata2 =
+                new NotificationMetadata(
+                        NotificationUmaTracker.SystemNotificationType.SITES,
+                        tag2,
+                        /* notificationId= */ -1);
+
+        Intent intent1 = new Intent("action_1");
+        PendingIntentProvider provider1 =
+                PendingIntentProvider.getActivity(
+                        RuntimeEnvironment.getApplication(),
+                        /* requestCode= */ 0,
+                        intent1,
+                        PendingIntent.FLAG_UPDATE_CURRENT,
+                        /* mutable= */ false);
+
+        Intent intent2 = new Intent("action_2");
+        PendingIntentProvider provider2 =
+                PendingIntentProvider.getActivity(
+                        RuntimeEnvironment.getApplication(),
+                        /* requestCode= */ 0,
+                        intent2,
+                        PendingIntent.FLAG_UPDATE_CURRENT,
+                        /* mutable= */ false);
+
+        PendingIntent interceptIntent1 =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.CONTENT_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadata1,
+                        provider1);
+
+        PendingIntent interceptIntent2 =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.CONTENT_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadata2,
+                        provider2);
+
+        Assert.assertNotEquals(
+                "PendingIntents for different notification tags must not be equal",
+                interceptIntent1,
+                interceptIntent2);
+
+        ShadowPendingIntent shadowIntent1 = Shadows.shadowOf(interceptIntent1);
+        ShadowPendingIntent shadowIntent2 = Shadows.shadowOf(interceptIntent2);
+        Intent savedIntent1 = shadowIntent1.getSavedIntent();
+        Intent savedIntent2 = shadowIntent2.getSavedIntent();
+        Assert.assertNotNull(savedIntent1);
+        Assert.assertNotNull(savedIntent2);
+
+        PendingIntent innerIntent1 =
+                NotificationIntentInterceptor.getPendingIntentForTesting(savedIntent1);
+        PendingIntent innerIntent2 =
+                NotificationIntentInterceptor.getPendingIntentForTesting(savedIntent2);
+        Assert.assertEquals(provider1.getPendingIntent(), innerIntent1);
+        Assert.assertEquals(provider2.getPendingIntent(), innerIntent2);
+        Assert.assertNotEquals(innerIntent1, innerIntent2);
+
+        // Verify broadcast delivery isolation for colliding tags as well.
+        Intent broadcastIntent1 = new Intent(TestReceiver.TEST_ACTION);
+        broadcastIntent1.setIdentifier("broadcast_inner_1");
+        broadcastIntent1.putExtra("source", "provider1");
+        PendingIntentProvider broadcastProvider1 =
+                PendingIntentProvider.getBroadcast(
+                        RuntimeEnvironment.getApplication(),
+                        /* requestCode= */ 0,
+                        broadcastIntent1,
+                        PendingIntent.FLAG_UPDATE_CURRENT,
+                        /* mutable= */ false);
+
+        Intent broadcastIntent2 = new Intent(TestReceiver.TEST_ACTION);
+        broadcastIntent2.setIdentifier("broadcast_inner_2");
+        broadcastIntent2.putExtra("source", "provider2");
+        PendingIntentProvider broadcastProvider2 =
+                PendingIntentProvider.getBroadcast(
+                        RuntimeEnvironment.getApplication(),
+                        /* requestCode= */ 0,
+                        broadcastIntent2,
+                        PendingIntent.FLAG_UPDATE_CURRENT,
+                        /* mutable= */ false);
+
+        PendingIntent broadcastIntercept1 =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.DELETE_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadata1,
+                        broadcastProvider1);
+
+        PendingIntent broadcastIntercept2 =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.DELETE_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadata2,
+                        broadcastProvider2);
+
+        Assert.assertNotEquals(broadcastIntercept1, broadcastIntercept2);
+
+        sendPendingIntent(broadcastIntercept1);
+        RobolectricUtil.runAllBackgroundAndUi();
+        Assert.assertNotNull(mReceiver.intentReceived());
+        Assert.assertEquals("provider1", mReceiver.intentReceived().getStringExtra("source"));
+
+        sendPendingIntent(broadcastIntercept2);
+        RobolectricUtil.runAllBackgroundAndUi();
+        Assert.assertNotNull(mReceiver.intentReceived());
+        Assert.assertEquals("provider2", mReceiver.intentReceived().getStringExtra("source"));
+    }
+
+    /**
+     * Verifies that null tags, empty tags, and literal "null" string tags produce distinct
+     * intercept PendingIntents.
+     */
+    @Test
+    public void testNullEmptyAndLiteralNullTagsProduceDistinctPendingIntents() {
+        NotificationMetadata metadataNull =
+                new NotificationMetadata(
+                        NotificationUmaTracker.SystemNotificationType.SITES,
+                        null,
+                        /* notificationId= */ 0);
+        NotificationMetadata metadataEmpty =
+                new NotificationMetadata(
+                        NotificationUmaTracker.SystemNotificationType.SITES,
+                        "",
+                        /* notificationId= */ 0);
+        NotificationMetadata metadataLiteralNull =
+                new NotificationMetadata(
+                        NotificationUmaTracker.SystemNotificationType.SITES,
+                        "null",
+                        /* notificationId= */ 0);
+
+        Intent sampleIntent = new Intent("sample_action");
+        PendingIntentProvider provider =
+                PendingIntentProvider.getActivity(
+                        RuntimeEnvironment.getApplication(),
+                        /* requestCode= */ 0,
+                        sampleIntent,
+                        PendingIntent.FLAG_UPDATE_CURRENT,
+                        /* mutable= */ false);
+
+        PendingIntent intentNull =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.CONTENT_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadataNull,
+                        provider);
+        PendingIntent intentEmpty =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.CONTENT_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadataEmpty,
+                        provider);
+        PendingIntent intentLiteralNull =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.CONTENT_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadataLiteralNull,
+                        provider);
+
+        Assert.assertNotEquals(intentNull, intentEmpty);
+        Assert.assertNotEquals(intentNull, intentLiteralNull);
+        Assert.assertNotEquals(intentEmpty, intentLiteralNull);
+    }
+
+    /**
+     * Verifies that updating a notification with the same metadata and intent type correctly
+     * updates the PendingIntent when FLAG_UPDATE_CURRENT is used.
+     */
+    @Test
+    public void testSameMetadataUpdatesPendingIntent() {
+        NotificationMetadata metadata =
+                new NotificationMetadata(
+                        NotificationUmaTracker.SystemNotificationType.SITES,
+                        "sample_notification_tag",
+                        /* notificationId= */ 100);
+
+        Intent intentInitial = new Intent("initial_action");
+        PendingIntentProvider providerInitial =
+                PendingIntentProvider.getBroadcast(
+                        RuntimeEnvironment.getApplication(),
+                        /* requestCode= */ 0,
+                        intentInitial,
+                        PendingIntent.FLAG_UPDATE_CURRENT,
+                        /* mutable= */ false);
+
+        PendingIntent interceptIntentInitial =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.CONTENT_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadata,
+                        providerInitial);
+
+        Intent intentUpdated = new Intent("updated_action");
+        PendingIntentProvider providerUpdated =
+                PendingIntentProvider.getBroadcast(
+                        RuntimeEnvironment.getApplication(),
+                        /* requestCode= */ 0,
+                        intentUpdated,
+                        PendingIntent.FLAG_UPDATE_CURRENT,
+                        /* mutable= */ false);
+
+        PendingIntent interceptIntentUpdated =
+                NotificationIntentInterceptor.createInterceptPendingIntent(
+                        NotificationIntentInterceptor.IntentType.CONTENT_INTENT,
+                        NotificationUmaTracker.ActionType.UNKNOWN,
+                        metadata,
+                        providerUpdated);
+
+        Assert.assertEquals(
+                "PendingIntents for the same notification metadata must be equal to allow updates",
+                interceptIntentInitial,
+                interceptIntentUpdated);
+
+        ShadowPendingIntent shadow = Shadows.shadowOf(interceptIntentInitial);
+        PendingIntent forwardedPendingIntent =
+                NotificationIntentInterceptor.getPendingIntentForTesting(shadow.getSavedIntent());
+        Assert.assertEquals(providerUpdated.getPendingIntent(), forwardedPendingIntent);
+    }
 }
Loading diff…

Original Bug Report

reported by [email protected]

Cross-origin Notification Intent Hijack via requestCode Collision

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential vulnerability in Chrome for Android allows an attacker to intercept notification click events from other origins by triggering a requestCode collision. This occurs because the system uses the weak String.hashCode() function on predictable notification tags to generate identifiers for wrapper PendingIntents.

Affected files:

  • chrome/browser/notifications/android/java/src/org/chromium/chrome/browser/notifications/NotificationIntentInterceptor.java

Estimated timestamp from git blame: 2019-01-16

Summary

A potential vulnerability exists in NotificationIntentInterceptor where a malicious origin can hijack notification click and action events intended for a victim origin. By crafting a notification tag that causes a collision in the requestCode of a wrapper PendingIntent, an attacker can leverage Android’s PendingIntent management to overwrite a victim’s intent extras with their own.

Vulnerability Details

When Chrome for Android displays a notification, it wraps the content, action, and delete PendingIntents in a wrapper PendingIntent for metrics collection (using TrampolineActivity or a BroadcastReceiver). These wrapper intents use a constant Action (INTENT_ACTION) and Component, meaning their uniqueness within the Android system relies entirely on the requestCode parameter.

In NotificationIntentInterceptor.java, the requestCode is generated by the computeHashCode method:

private static int computeHashCode(
        NotificationMetadata metadata,
        @IntentType int intentType,
        @NotificationUmaTracker.ActionType int actionType,
        int requestCode) {
    // ... logic incorporating metadata.type, intentType, actionType
    hashcode = hashcode * 31 + (metadata.tag == null ? 0 : metadata.tag.hashCode());
    hashcode = hashcode * 31 + metadata.id;
    hashcode = hashcode * 31 + requestCode;
    return hashcode;
}

For persistent web notifications, metadata.tag is a string generated by the native NotificationIdGenerator, typically following the pattern p#<origin>#0 (or p#<origin>#1<developer_tag>).

The vulnerability is rooted in two factors:

  1. Predictable Hash Collisions: Java’s String.hashCode() uses a simple polynomial-31 rolling hash. An attacker can easily calculate a suffix string for their own notification tag that results in a hashCode() identical to a victim origin’s notification ID.
  2. Intent Overwriting: These wrapper PendingIntents are created using FLAG_UPDATE_CURRENT. In Android, if a new PendingIntent is requested with the same requestCode and matching Intent.filterEquals() criteria (which is true here as Action and Component are constant), the system replaces the extras of the existing PendingIntent with the new ones.

Impact

If an attacker successfully overwrites a victim’s PendingIntent extras, clicking the victim’s notification will invoke TrampolineActivity with the attacker’s EXTRA_PENDING_INTENT. This causes the notificationclick event to be dispatched to the attacker’s service worker instead of the victim’s.

This enables:

  • User Interaction Hijacking: The attacker receives the click event for a notification the user believed was from a trusted site.
  • Phishing/Spoofing: The attacker obtains a valid user activation, which they can use to open a malicious window or perform other actions while the user expects to be interacting with the victim origin.

Suggested Steps to Reproduce (Potential)

Note: These are suggested steps; our current tooling has not executed this exploit.

  1. Identify a victim origin currently displaying a notification (e.g., https://victim.com).
  2. Determine the victim’s notification ID (typically p#https://victim.com#0).
  3. Calculate a string S such that hashCode("p#https://attacker.evil#1" + S) == hashCode("p#https://victim.com#0").
  4. From https://attacker.evil, call self.registration.showNotification("Notification", { tag: S }).
  5. When the user clicks the notification from victim.com, observe that the click event is delivered to the service worker of attacker.evil.

Suggested Fix

Chrome should not rely on String.hashCode() for generating unique requestCode values. Instead, consider:

  • Using a more robust, cryptographically secure hashing function.
  • Incorporating unique data (such as the notification tag or a random UUID) directly into the Intent’s Data URI to ensure Intent.filterEquals() distinguishes between different notifications.
  • Disabling FLAG_UPDATE_CURRENT if multiple notifications are expected to use the same slot, though this may impact metrics accuracy.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker