Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds memory access in Skia
DescriptionOut of bounds memory access in Skia
ComponentSkia
Bug ClassOOB
Tracker503958940
Fix commitf4834c75d3ec (skia) +138/-24
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
rust/icc/FFI.cpp
modified
for
rust/icc/FFI.cpp
modified
for
tests/RustIccTest.cpp
modified

Files Changed

  • rust/icc/FFI.cpp
  • rust/icc/FFI.rs
  • src/codec/SkCodecColorProfileRust.cpp
  • tests/RustIccTest.cpp
From f4834c75d3ec17a87ab16a1340dc8e910f244072 Mon Sep 17 00:00:00 2001
From: Sergio Gonzalez Martin <sergiog@microsoft.com>
Date: Wed, 22 Apr 2026 13:12:25 +0000
Subject: [PATCH] [rust icc] Reject unsupported A2B/B2A channel counts and grid dimensions

Fixes:
- Check ToSkcmsIccProfile return; return nullptr on failure.
- Validate channel counts (1-4) in both FFI.rs and FFI.cpp.
- Validate grid_points[i] >= 2 for every active CLUT dimension.
- Fix Lut arm to populate grid_points for all active input channels.
- Move FFI.cpp writes to output struct after validation (don't write
  input_channels=64 before returning false).

Bug: 503958940
Bug: 504160794
Bug: 504103236
Change-Id: Ic034ec283807e665d50d6377a1757dee4451574b
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1215636
Reviewed-by: Florin Malita <fmalita@google.com>
Commit-Queue: Florin Malita <fmalita@google.com>
Reviewed-by: Kaylee Lubick <kjlubick@google.com>
---

diff --git a/rust/icc/FFI.cpp b/rust/icc/FFI.cpp
index 1b1de4d..935fdf2 100644
--- a/rust/icc/FFI.cpp
+++ b/rust/icc/FFI.cpp
@@ -65,11 +65,11 @@
     memset(out_skcms, 0, sizeof(skcms_A2B));
 
     // Input curves: If input_channels is non-zero, ensure we have enough curves
+    if (rust_a2b.input_channels > 4) {
+        return false;
+    }
     out_skcms->input_channels = rust_a2b.input_channels;
     if (rust_a2b.input_channels > 0) {
-        if (rust_a2b.input_channels < 1 || rust_a2b.input_channels > 4) {
-            return false;
-        }
         // Only validate curve count if input_channels is specified
         if (!rust_a2b.input_curves.empty() && rust_a2b.input_channels > rust_a2b.input_curves.size()) {
             return false;
@@ -89,6 +89,13 @@
     }
     memcpy(out_skcms->grid_points, rust_a2b.grid_points.data(), 4);
     if (!rust_a2b.grid_data.empty()) {
+        // Each active CLUT dimension must have >= 2 grid points, matching the
+        // constraint enforced by skcms_Parse (crbug.com/504103236).
+        for (uint32_t i = 0; i < out_skcms->input_channels; i++) {
+            if (out_skcms->grid_points[i] < 2) {
+                return false;
+            }
+        }
         if (rust_a2b.is_16bit_grid) {
             out_skcms->grid_16 = rust_a2b.grid_data.data();
         } else {
@@ -129,11 +136,11 @@
     }
 
     // Output curves: If output_channels is non-zero, ensure we have enough curves
+    if (rust_a2b.output_channels > 4) {
+        return false;
+    }
     out_skcms->output_channels = rust_a2b.output_channels;
     if (rust_a2b.output_channels > 0) {
-        if (rust_a2b.output_channels > 4) {
-            return false;
-        }
         // Only validate curve count if output_channels is specified
         if (!rust_a2b.output_curves.empty() && rust_a2b.output_channels > rust_a2b.output_curves.size()) {
             return false;
@@ -203,6 +210,12 @@
     }
     memcpy(out_skcms->grid_points, rust_b2a.grid_points.data(), 4);
     if (!rust_b2a.grid_data.empty()) {
+        // Each active CLUT dimension must have >= 2 grid points (crbug.com/504103236).
+        for (uint32_t i = 0; i < rust_b2a.output_channels; i++) {
+            if (out_skcms->grid_points[i] < 2) {
+                return false;
+            }
+        }
         if (rust_b2a.is_16bit_grid) {
             out_skcms->grid_16 = rust_b2a.grid_data.data();
         } else {
diff --git a/rust/icc/FFI.rs b/rust/icc/FFI.rs
index 0a1cc7a..46d4b14 100644
--- a/rust/icc/FFI.rs
+++ b/rust/icc/FFI.rs
@@ -535,6 +535,18 @@
 
     match lut {
         LutWarehouse::Multidimensional(mdt) => {
+            // ICC.1:2022 §7.2.6 defines colour spaces up to 15 channels
+            // (nCLR), but skcms_A2B/B2A structs use fixed-size arrays of
+            // 4 elements for input_curves[] and grid_points[], so we can
+            // only represent device spaces with 1-4 channels (up to CMYK).
+            // Reject anything outside that range (crbug.com/504160794).
+            if mdt.num_input_channels > 4
+                || mdt.num_output_channels == 0
+                || mdt.num_output_channels > 4
+            {
+                return None;
+            }
+
             let input_curves: Vec<ffi::Curve> =
                 mdt.a_curves.iter().filter_map(convert_to_curve).collect();
 
@@ -566,6 +578,17 @@
                 return None;
             }
 
+            // ICC.1:2022 §10.14/§10.15: each active CLUT dimension must
+            // have at least 2 grid points.  A zero would cause skcms clut()
+            // to underflow when computing grid_points[i] - 1 (crbug.com/504103236).
+            if !grid_data.is_empty() {
+                for i in 0..mdt.num_input_channels.min(4) as usize {
+                    if grid_points[i] < 2 {
+                        return None;
+                    }
+                }
+            }
+
             // If there is no CLUT, input and output channels must match
             // and we set input_channels to 0 to signal "skip this stage"
             let (final_input_channels, final_input_curves) = if grid_data.is_empty() {
@@ -598,6 +621,14 @@
             // Legacy Lut8Type/Lut16Type (mft1/mft2 tags)
             // Similar structure to Multidimensional, but uses uniform grid size
 
+            // Same channel-count constraint as Multidimensional above.
+            if ldt.num_input_channels > 4
+                || ldt.num_output_channels == 0
+                || ldt.num_output_channels > 4
+            {
+                return None;
+            }
+
             let input_curves: Vec<ffi::Curve> = {
                 let curve_data = lut_store_to_u16(&ldt.input_table);
                 split_table_to_curves(
@@ -610,11 +641,16 @@
             let (grid_data, is_16bit_grid) = convert_grid_data(&ldt.clut_table);
 
             let grid_size = ldt.num_clut_grid_points;
-            let grid_points: [u8; 4] = match ldt.num_input_channels {
-                3 => [grid_size, grid_size, grid_size, 0],
-                4 => [grid_size, grid_size, grid_size, grid_size],
-                _ => [grid_size, 0, 0, 0], // 1D or 2D case
-            };
+            let mut grid_points = [0u8; 4];
+            for i in 0..ldt.num_input_channels.min(4) as usize {
+                grid_points[i] = grid_size;
+            }
+
+            // Legacy lut8/lut16 types always have a CLUT.  Each active
+            // dimension must have >= 2 grid points (crbug.com/504103236).
+            if grid_size < 2 {
+                return None;
+            }
 
             let mut matrix = matrix3d_to_ffi(&ldt.matrix);
             // Legacy LUT matrix is typically applied post-CLUT, so bias is zero
diff --git a/src/codec/SkCodecColorProfileRust.cpp b/src/codec/SkCodecColorProfileRust.cpp
index 66707db..7398595 100644
--- a/src/codec/SkCodecColorProfileRust.cpp
+++ b/src/codec/SkCodecColorProfileRust.cpp
@@ -26,7 +26,9 @@
                         new rust_icc::IccProfile(std::move(rust_profile)));
 
                 skcms_ICCProfile profile;
-                rust_icc::ToSkcmsIccProfile(*retained, &profile);
+                if (!rust_icc::ToSkcmsIccProfile(*retained, &profile)) {
+                    return nullptr;
+                }
                 auto result = std::unique_ptr<ColorProfile>(
                         new ColorProfile(profile, std::move(data)));
                 result->fRetainedData = retained;
diff --git a/tests/RustIccTest.cpp b/tests/RustIccTest.cpp
index b68efe0..d74422d 100644
--- a/tests/RustIccTest.cpp
+++ b/tests/RustIccTest.cpp
@@ -13,6 +13,7 @@
 #include "tests/Test.h"
 #include "tools/Resources.h"
 
+#include <array>
 #include <cmath>
 #include <cstring>
 
@@ -440,13 +441,15 @@
     rust_profile.a2b.matrix.vals[2][1] = 0.1192f;
     rust_profile.a2b.matrix.vals[2][2] = 0.9505f;
 
-    // Set up minimal grid
+    // Set up minimal grid (2x2x2 = 8 points, 1 byte per output = 8 bytes)
     rust::Vec<uint8_t> grid_data;
-    grid_data.push_back(0x80);
+    for (int i = 0; i < 8; i++) {
+        grid_data.push_back(0x80);
+    }
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential Heap OOB Read and Stack Buffer Overflow in skcms via moxcms ICC parser

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: A chain of validation failures in the Rust-based moxcms ICC profile parser and the Skia C++ FFI layer allows malformed profiles to specify invalid color channel counts. This results in a heap out-of-bounds read and a subsequent stack buffer overflow within skcms_Transform, potentially leading to Remote Code Execution in the renderer. The vulnerability is active when the Rust-based parser is used (which is the default migration path).

Affected files:

  • third_party/rust/chromium_crates_io/vendor/moxcms-v0_8/src/reader.rs
  • third_party/skia/rust/icc/FFI.cpp
  • third_party/skia/src/codec/SkCodecColorProfileRust.cpp
  • third_party/skia/modules/skcms/skcms.cc

Estimated timestamp from git blame: 2026-02-24

Overview

A potential vulnerability exists in the handling of ICC profiles when parsed via the Rust-based moxcms library (the default path when kForceSkcmsICCParsing is disabled). A sequence of bugs across the Rust and C++ boundary allows a malicious ICC profile to define an invalid number of color channels (e.g., 200). When skcms processes this profile, it attempts to load curves for all 200 channels, leading to a massive heap out-of-bounds read and a stack buffer overflow. By carefully controlling the out-of-bounds heap memory, an attacker could selectively overwrite stack pointers without hitting the stack canary, constructing a highly reliable arbitrary heap write primitive to gain RCE.

Technical Details

The vulnerability is the result of three sequential failures:

1. Permissive Validation in moxcms: In third_party/rust/chromium_crates_io/vendor/moxcms-v0_8/src/reader.rs, when parsing mft2 tags, the channel counts are extracted and validated:

let in_chan = tag[8];
let out_chan = tag[9];
let is_3_to_4 = in_chan == 3 || out_chan == 4;
let is_4_to_3 = in_chan == 4 || out_chan == 3;
if !is_3_to_4 && !is_4_to_3 {
    return Err(CmsError::InvalidProfile);
}

If an attacker sets out_chan = 3 and in_chan = 200, is_4_to_3 evaluates to true, and the validation completely bypasses the invalid 200 value.

2. Early Assignment in FFI Layer: In third_party/skia/rust/icc/FFI.cpp, the ToSkcmsA2B function constructs the C++ skcms_A2B struct:

out_skcms->input_channels = rust_a2b.input_channels;
if (rust_a2b.input_channels > 0) {
    if (rust_a2b.input_channels < 1 || rust_a2b.input_channels > 4) {
        return false;
    }
    // ...
}

Although the function detects the invalid input_channels (200 > 4) and returns false, it writes the invalid value to out_skcms before the check, permanently polluting the structure.

3. Ignored Error Return: In third_party/skia/src/codec/SkCodecColorProfileRust.cpp, the boolean return value of ToSkcmsIccProfile is ignored:

skcms_ICCProfile profile;
rust_icc::ToSkcmsIccProfile(*retained, &profile); // Returns false, but is ignored
auto result = std::unique_ptr<ColorProfile>(
        new ColorProfile(profile, std::move(data)));

Memory Corruption in skcms: When drawing the image, skcms_Transform (third_party/skia/modules/skcms/skcms.cc) processes the profile. It calls add_curve_ops(srcProfile->A2B.input_curves, 200). Inside add_curve_ops:

auto add_curve_ops = [&](const skcms_Curve* curves, int numChannels) -> bool {
    OpAndArg oa[4];
    int numOps = select_curve_ops(curves, numChannels, oa);
// ...

select_curve_ops loops 200 times (for (int index = numChannels; index-- > 0; )). It accesses curves[index]. Because curves is only a 4-element array in the heap-allocated ColorProfile, this is a heap out-of-bounds read.

select_curve_ops writes to the 4-element stack array oa: ops[cursor] = select_curve_op(...). The cursor only increments if the returned arg is non-null. By using a heap spray to control the out-of-bounds memory, an attacker can ensure most reads result in nullptr arguments, precisely incrementing cursor to overflow the oa array just enough to overwrite local stack pointers (like contexts) without hitting the stack canary.

Later, add_op_ctx uses the corrupted contexts pointer (*contexts++ = c;), granting an arbitrary heap write primitive.

Potential Attacker Steps (Suggested)

Note: Our tooling agent cannot run code, so these are theoretical steps based on source code analysis.

  1. The attacker creates an HTML page containing JavaScript to perform a precise heap spray in the renderer process.
  2. The heap spray lays out fake skcms_Curve data and fake vtables directly adjacent to where ColorProfile objects are allocated.
  3. The attacker embeds a PNG image with a crafted iCCP chunk containing an mft2 tag with in_chan = 200 and out_chan = 3.
  4. When Chrome parses the image and invokes skcms_Transform, the out-of-bounds heap read consumes the sprayed data.
  5. The precisely controlled stack overflow overwrites the contexts pointer to point at the vtable of a neighboring C++ object.
  6. The subsequent arbitrary heap write overwrites the vtable pointer with the address of the fake vtable.
  7. When a virtual method on the neighboring object is called, execution is hijacked to a ROP chain.

Suggested Fix

  1. moxcms: Fix the validation in reader.rs to strictly check both in_chan and out_chan limits independently, avoiding the is_3_to_4 logic flaw.
  2. FFI Layer: In FFI.cpp (ToSkcmsA2B), perform the range validation (< 1 || > 4) before assigning input_channels to out_skcms.
  3. Skia: In SkCodecColorProfileRust.cpp, explicitly check the return value of rust_icc::ToSkcmsIccProfile. If it returns false, abort the parsing and return nullptr.

Evaluated with Chrome root at commit: 2b349e31cb87959d6a548625986c65e0a2d2e380


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