CVE-2026-10941
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifrust/icc/FFI.cpp |
modified | |
forrust/icc/FFI.cpp |
modified | |
fortests/RustIccTest.cpp |
modified |
Files Changed
rust/icc/FFI.cpprust/icc/FFI.rssrc/codec/SkCodecColorProfileRust.cpptests/RustIccTest.cpp
Patch
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);
+ }
Original Bug Report
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.rsthird_party/skia/rust/icc/FFI.cppthird_party/skia/src/codec/SkCodecColorProfileRust.cppthird_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.
- The attacker creates an HTML page containing JavaScript to perform a precise heap spray in the renderer process.
- The heap spray lays out fake
skcms_Curvedata and fake vtables directly adjacent to whereColorProfileobjects are allocated. - The attacker embeds a PNG image with a crafted
iCCPchunk containing anmft2tag within_chan = 200andout_chan = 3. - When Chrome parses the image and invokes
skcms_Transform, the out-of-bounds heap read consumes the sprayed data. - The precisely controlled stack overflow overwrites the
contextspointer to point at the vtable of a neighboring C++ object. - The subsequent arbitrary heap write overwrites the vtable pointer with the address of the fake vtable.
- When a virtual method on the neighboring object is called, execution is hijacked to a ROP chain.
Suggested Fix
- moxcms: Fix the validation in
reader.rsto strictly check bothin_chanandout_chanlimits independently, avoiding theis_3_to_4logic flaw. - FFI Layer: In
FFI.cpp(ToSkcmsA2B), perform the range validation (< 1 || > 4) before assigninginput_channelstoout_skcms. - Skia: In
SkCodecColorProfileRust.cpp, explicitly check the return value ofrust_icc::ToSkcmsIccProfile. If it returnsfalse, abort the parsing and returnnullptr.
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.