Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Start
DescriptionRace condition in Start
ComponentStart
Bug ClassRace
Tracker505967344
Fix commit753d325302fd (chromium/src) +438/-95
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
generate_jni
chrome/browser/ntp_customization/BUILD.gn
modified
if
chrome/browser/ntp_customization/BUILD.gn
modified
ImageLoadResult
chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java
modified
if
chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java
modified

Files Changed

  • chrome/browser/ntp_customization/BUILD.gn
  • chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java
From 753d325302fddfefab4b7003609d912fed5d8fe0 Mon Sep 17 00:00:00 2001
From: Xinyi Ji <xinyiji@google.com>
Date: Thu, 23 Jul 2026 19:16:14 -0700
Subject: [PATCH] [CustomizationV2] Fix Rule-of-2 violation and TOCTOU in image customization

This CL addresses two security vulnerabilities in the Android New Tab
Page (NTP) background image customization flow:

1. Rule-of-2 Violation:
   Previously, user-selected background images were decoded directly in
the privileged Browser process using BitmapFactory.decodeStream().
Parsing untrusted image data in native C++ decoders (Skia) within an un-
sandboxed process violates Chromium's Rule of 2.
   This CL refactors image decoding to use the sandboxed Utility process
via data_decoder::DecodeImageIsolated().

2. TOCTOU (Time-of-Check Time-of-Use) Vulnerability:
   Previously, the image URI input stream was opened twice: first to
inspect dimensions (inJustDecodeBounds = true) and second to perform the
actual decode. A malicious ContentProvider could swap the payload
between reads.
   This CL refactors getBitmapFromUriAsync() to read the input stream
exactly once on a background thread into an in-memory byte buffer capped
at 25 MiB (MAX_IMAGE_BYTES), preventing TOCTOU and memory exhaustion
(OOM) attacks.

Key Changes:
- Java (NtpCustomizationUtils.java): Reads URI stream once into a byte
array and passes raw bytes to C++ via JNI.
- C++ JNI Bridge (ntp_customization_utils.cc): Bridges Java byte array
to data_decoder::DecodeImageIsolated(). Once decoded in the sandbox,
DownsampleImageIfNeeded() scales down massive or extreme aspect-ratio
images (e.g. panoramic photos) by powers of 2 using Skia Lanczos3
resampling to prevent Android Canvas/GPU texture rendering crashes,
while retaining full sharpness for slightly oversized images.

Bug: 505967344
Change-Id: Ib03fabbe92a19f402526d703660cbd89d363063f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8111779
Reviewed-by: Xi Han <hanxi@chromium.org>
Commit-Queue: Xinyi Ji <xinyiji@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1667599}
---

diff --git a/chrome/browser/ntp_customization/BUILD.gn b/chrome/browser/ntp_customization/BUILD.gn
index 0bff1302..c5423fd 100644
--- a/chrome/browser/ntp_customization/BUILD.gn
+++ b/chrome/browser/ntp_customization/BUILD.gn
@@ -21,6 +21,7 @@
     "ntp_android_custom_background_service.cc",
     "ntp_android_custom_background_service_factory.cc",
     "ntp_android_theme_sync_bridge.cc",
+    "ntp_customization_utils.cc",
     "ntp_synced_theme_bridge.cc",
     "ntp_theme_collection_bridge.cc",
   ]
@@ -34,11 +35,15 @@
     "//build:android_buildflags",
     "//chrome/browser:browser_process",
     "//chrome/browser:global_features",
-    "//ui/webui:buildflags",
     "//chrome/common:constants",
     "//components/application_locale_storage",
     "//components/sync",
     "//content/public/browser",
+    "//services/data_decoder/public/cpp",
+    "//services/data_decoder/public/mojom",
+    "//skia",
+    "//ui/gfx",
+    "//ui/webui:buildflags",
     "//url",
   ]
 }
@@ -213,6 +218,7 @@
 
 generate_jni("jni_headers") {
   sources = [
+    "java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java",
     "java/src/org/chromium/chrome/browser/ntp_customization/theme/NtpSyncedThemeBridge.java",
     "java/src/org/chromium/chrome/browser/ntp_customization/theme/theme_collections/NtpThemeCollectionBridge.java",
     "java/src/org/chromium/chrome/browser/ntp_customization/theme_sync/CrossDeviceThemeTracker.java",
@@ -444,6 +450,7 @@
   testonly = true
   sources = [
     "ntp_android_custom_background_service_unittest.cc",
+    "ntp_customization_utils_unittest.cc",
   ]
   if (!is_desktop_android) {
     sources += [ "ntp_android_theme_sync_bridge_unittest.cc" ]
@@ -457,6 +464,7 @@
     "//components/application_locale_storage",
     "//components/prefs:test_support",
     "//components/sync:test_support",
+    "//skia",
     "//testing/gtest",
   ]
 }
diff --git a/chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java b/chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java
index c4975703..3f7a8ec 100644
--- a/chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java
+++ b/chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java
@@ -41,6 +41,7 @@
 import android.app.Activity;
 import android.content.Context;
 import android.content.Intent;
+import android.content.res.AssetFileDescriptor;
 import android.content.res.ColorStateList;
 import android.content.res.Configuration;
 import android.content.res.Resources;
@@ -70,6 +71,10 @@
 import com.google.android.material.color.DynamicColors;
 import com.google.android.material.color.DynamicColorsOptions;
 
+import org.jni_zero.JNINamespace;
+import org.jni_zero.JniType;
+import org.jni_zero.NativeMethods;
+
 import org.chromium.base.Callback;
 import org.chromium.base.ContextUtils;
 import org.chromium.base.IntentUtils;
@@ -109,6 +114,7 @@
 import org.chromium.ui.util.ColorUtils;
 import org.chromium.url.GURL;
 
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
@@ -119,6 +125,7 @@
 
 /** Utility class of the NTP customization. */
 @NullMarked
+@JNINamespace("ntp_customization")
 public class NtpCustomizationUtils {
 
     // LINT.IfChange(NtpBackgroundType)
@@ -161,15 +168,23 @@
     }
 
     private static class ImageLoadResult {
-        public final @Nullable Bitmap bitmap;
+        public final byte @Nullable [] data;
         public final String fileIdHash;
 
-        ImageLoadResult(@Nullable Bitmap bitmap, String fileIdHash) {
-            this.bitmap = bitmap;
+        ImageLoadResult(byte @Nullable [] data, String fileIdHash) {
+            this.data = data;
             this.fileIdHash = fileIdHash;
         }
     }
 
+    // Maximum allowed byte size for user-uploaded custom background images (25 MiB) to prevent
+    // memory exhaustion.
+    private static final int MAX_IMAGE_BYTES = 25 * 1024 * 1024;
+    // Initial in-memory buffer capacity when reading background image stream (1 MiB).
+    private static final int INITIAL_BUFFER_CAPACITY_BYTES = 1024 * 1024;
+    // Chunk size used to read the background image stream (8 KiB).
+    private static final int READ_BUFFER_SIZE_BYTES = 8192;
+
     public static final String NTP_UPLOAD_IMAGES_DIR = "upload_images";
     public static final String NTP_THEME_COLLECTION_IMAGES_DIR = "theme_collection_images";
 
@@ -179,7 +194,6 @@
     static final String NTP_BACKGROUND_IMAGE_FILE_FOR_DAILY_REFRESH =
             "ntp_background_image_for_daily_refresh";
 
-    private static final int MAX_IMAGE_SIZE = 2556;
     private static final int IMAGE_SIZE_FOR_EXTRACTING_COLOR = 100;
     private static final String TAG = "NtpCustomization";
     private static final String DELIMITER = "|";
@@ -1770,25 +1784,41 @@
             @Override
             protected ImageLoadResult doInBackground() {
                 String fileIdHash = NtpBackgroundDataUtils.getMetadataFingerprint(context, uri);
-                try {
-                    // 1. Decode with inJustDecodeBounds=true to check dimensions
-                    BitmapFactory.Options options = new BitmapFactory.Options();
-                    options.inJustDecodeBounds = true;
-                    try (var inputStream = context.getContentResolver().openInputStream(uri)) {
-                        BitmapFactory.decodeStream(inputStream, null, options);
-                    }
-
-                    // 2. Calculate inSampleSize
-                    options.inSampleSize =
-                            calculateInSampleSize(options, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE);
-
-                    // 3. Decode bitmap with inSampleSize set
-                    options.inJustDecodeBounds = false;
-                    try (var inputStream = context.getContentResolver().openInputStream(uri)) {
-                        return new ImageLoadResult(
-                                BitmapFactory.decodeStream(inputStream, null, options), fileIdHash);
+                try (AssetFileDescriptor afd =
+                        context.getContentResolver().openAssetFileDescriptor(uri, "r")) {
+                    if (afd != null) {
+                        long fileLength = afd.getLength();
+                        if (fileLength != AssetFileDescriptor.UNKNOWN_LENGTH
+                                && fileLength > MAX_IMAGE_BYTES) {
+                            Log.w(TAG, "Image exceeds maximum allowed size cap of 25 MiB.");
+                            return new ImageLoadResult(null, fileIdHash);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ntp_customization/ntp_customization_utils_unittest.cc b/chrome/browser/ntp_customization/ntp_customization_utils_unittest.cc
new file mode 100644
index 0000000..c76a48b
--- /dev/null
+++ b/chrome/browser/ntp_customization/ntp_customization_utils_unittest.cc
@@ -0,0 +1,59 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/skia/include/core/SkBitmap.h"
+#include "third_party/skia/include/core/SkColor.h"
+
+namespace ntp_customization {
+
+SkBitmap DownsampleImageIfNeeded(const SkBitmap& bitmap, int max_dimension);
+
+constexpr int kMaxDimension = 2556;
+
+static SkBitmap CreateTestBitmap(int width, int height) {
+  SkBitmap bitmap;
+  bitmap.allocN32Pixels(width, height);
+  bitmap.eraseColor(SK_ColorBLUE);
+  return bitmap;
+}
+
+TEST(NtpCustomizationUtilsTest, DownsampleImageIfNeeded_SmallImage) {
+  SkBitmap small_bitmap = CreateTestBitmap(500, 500);
+  SkBitmap result = DownsampleImageIfNeeded(small_bitmap, kMaxDimension);
+
+  EXPECT_EQ(result.width(), 500);
+  EXPECT_EQ(result.height(), 500);
+}
+
+TEST(NtpCustomizationUtilsTest,
+     DownsampleImageIfNeeded_SlightlyExceedsMaxDimension) {
+  // 3000x2000 exceeds 2556, but its halved dimension (1500) is < 2556.
+  // Downsampling is not triggered, preserving full resolution sharpness.
+  SkBitmap bitmap = CreateTestBitmap(3000, 2000);
+  SkBitmap result = DownsampleImageIfNeeded(bitmap, kMaxDimension);
+
+  EXPECT_EQ(result.width(), 3000);
+  EXPECT_EQ(result.height(), 2000);
+}
+
+TEST(NtpCustomizationUtilsTest, DownsampleImageIfNeeded_MassiveImage) {
+  // 6000x4000 has halved width 3000 >= 2556, so sample_size becomes 2.
+  SkBitmap large_bitmap = CreateTestBitmap(6000, 4000);
+  SkBitmap result = DownsampleImageIfNeeded(large_bitmap, kMaxDimension);
+
+  EXPECT_EQ(result.width(), 3000);
+  EXPECT_EQ(result.height(), 2000);
+}
+
+TEST(NtpCustomizationUtilsTest, DownsampleImageIfNeeded_PanoramicImage) {
+  // 8000x1000 has halved width 4000 >= 2556, so sample_size becomes 2.
+  SkBitmap panoramic_bitmap = CreateTestBitmap(8000, 1000);
+  SkBitmap result = DownsampleImageIfNeeded(panoramic_bitmap, kMaxDimension);
+
+  EXPECT_EQ(result.width(), 4000);
+  EXPECT_EQ(result.height(), 500);
+}
+
+}  // namespace ntp_customization
Loading diff…

Original Bug Report

reported by vm...@google.com

Android NTP: Potential Rule-of-2 violation and TOCTOU in image customization

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Android NTP customization feature decodes untrusted image URIs in the browser process using native C++ codecs, violating Chrome’s Rule of 2. Additionally, a Time-of-Check Time-of-Use (TOCTOU) vulnerability during decoding allows a malicious ContentProvider to bypass image size constraints.

Affected files:

  • chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/NtpCustomizationUtils.java
  • chrome/browser/ntp_customization/java/src/org/chromium/chrome/browser/ntp_customization/theme/NtpThemeMediator.java

Estimated timestamp from git blame: 2026-02-04

Description

The Android New Tab Page (NTP) customization feature (behind kNewTabPageCustomizationV2) contains two architectural security issues when handling user-uploaded background images:

1. Rule of 2 Violation (In-Process Untrusted Data Parsing) When a user selects “Upload an image”, NtpThemeMediator.java uses ActivityResultContracts.GetContent() to retrieve an image URI. This URI can be supplied by any app on the device. The URI is passed to NtpCustomizationUtils.getBitmapFromUriAsync(), which executes an AsyncTask on the THREAD_POOL_EXECUTOR. This task runs in the unsandboxed Chrome Browser process.

Within this task, Chrome calls BitmapFactory.decodeStream() on the untrusted URI. BitmapFactory is a Java wrapper around Android’s native C++ image codecs (Skia). Chrome’s security policy (Rule of 2) explicitly identifies BitmapFactory as unsafe and mandates that parsing untrusted data using unsafe languages must occur in a sandboxed utility process. Decoding untrusted images in the high-privilege Browser process exposes Chrome to full compromise if the underlying Android codec has a memory corruption vulnerability.

2. Double-Open TOCTOU (Size Check Bypass) NtpCustomizationUtils.getBitmapFromUriAsync() exhibits a Time-of-Check Time-of-Use (TOCTOU) vulnerability. It opens the untrusted URI twice:

  • TOC: openInputStream is called, and BitmapFactory.decodeStream is used with inJustDecodeBounds = true to check dimensions. calculateInSampleSize is then called to determine downsampling against a MAX_IMAGE_SIZE (2556).
  • TOU: openInputStream is called a second time, and the stream is decoded using the previously calculated inSampleSize.

A malicious ContentProvider can serve a benign 10x10 image on the first open (resulting in inSampleSize = 1) and a massive or malformed payload on the second open. This completely bypasses the intended MAX_IMAGE_SIZE protections, allowing the attacker to force the codec to parse arbitrarily large or complex malicious payloads without downsampling.

Potential Attack Scenario

(Note: These are suggested steps to illustrate the impact; our tooling does not execute code to provide a working PoC.)

  1. An attacker tricks a user into installing a malicious Android app. The app registers an intent filter for image/* and a custom ContentProvider.
  2. The user opens the Chrome NTP and selects “Upload an image” from the customization menu.
  3. The Android system picker opens. The user selects the attacker’s application.
  4. The attacker’s app returns a URI pointing to its custom ContentProvider.
  5. Chrome’s Browser process attempts to decode the image.
  6. The attacker’s ContentProvider intercepts the first openInputStream call and returns a valid 10x10 PNG header to pass size checks.
  7. The attacker’s ContentProvider intercepts the second openInputStream call and returns a crafted payload designed to trigger a known memory corruption vulnerability (e.g., heap overflow) in Android’s libpng or libjpeg.
  8. The native codec parses the malformed stream inside the Chrome Browser process. The resulting memory corruption allows the attacker to execute arbitrary code (RCE) and compromise the browser.

Suggested Fix

  1. Enforce Rule of 2: Do not use BitmapFactory directly on untrusted URIs in the Browser process. Image decoding should be delegated to the sandboxed data_decoder service. This is how the desktop implementation of this feature safely handles untrusted images.
  2. Eliminate TOCTOU: If local processing is strictly required before sending to the decoder, read the entire stream from the ContentResolver into a local, temporary file or memory buffer exactly once. Perform both the bounds check and the full decode on that static local copy.

Evaluated with Chrome root at commit: 3acbde3302da0cb19488c22c0eb007c791207b4b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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