From 9e8a3eecf06a4ba70e29bede20786ce2cd7397ae Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 22:18:04 -0400 Subject: [PATCH] security(cms): bound inverse-curve and CLUT interpolation work Two per-pixel/per-sample amplifiers driven by a crafted ICC profile: - table_inverse recomputed a sampled curve's flat-run edge indices by scanning the whole table on every call, and transform construction inverts each destination curve OUT_TABLE times. Compute the edges once per curve via a new Curve::inverse_table batch method. - eval_multilinear enumerated 2^inputs cube corners over every declared input, including single-grid-point (degenerate) dimensions that contribute nothing. Interpolate over the effective dimensions only, so a 15-channel CLUT of one-point dims no longer does 2^15 iterations per pixel. Findings: - acfbe3a3-ee58-4b7e-b72a-5a4877dd15c9 (CWE-407, repeated flat-run scans) - 9fcc72e7-46d6-4f75-9333-cb14a65eeafd (CWE-407, degenerate-dim corner blowup) --- crates/cms/src/clut.rs | 27 +++++++++++++++++--- crates/cms/src/curve.rs | 49 +++++++++++++++++++++++++++++++++++-- crates/cms/src/transform.rs | 11 ++++----- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs index 186cd3f9f1..38992a2ed4 100644 --- a/crates/cms/src/clut.rs +++ b/crates/cms/src/clut.rs @@ -172,11 +172,16 @@ impl Clut { } let o = self.outputs; out[..o].iter_mut().for_each(|v| *v = 0.0); - for corner in 0..(1usize << n) { + // Only dimensions with more than one grid point contribute a cube axis. Degenerate + // dimensions have step 0 and ratio 0, so they always multiply the corner weight by 1 + // and never change the offset; enumerating 2^n corners over all declared inputs would + // do 2^(degenerate) redundant iterations. Interpolate over the effective dims only. + let eff: Vec = (0..n).filter(|&d| s[d] != 0).collect(); + for corner in 0..(1usize << eff.len()) { let mut w = 1.0f32; let mut off = base; - for d in 0..n { - if corner & (1 << d) != 0 { + for (bit, &d) in eff.iter().enumerate() { + if corner & (1 << bit) != 0 { w *= r[d]; off += s[d]; } else { @@ -245,4 +250,20 @@ mod tests { c.eval(&[0.5, 0.25], &mut o); assert!((o[0] - 0.75).abs() < 1e-6); } + + #[test] + fn degenerate_dimensions_do_not_change_the_result() { + // Dimensions with a single grid point contribute nothing, but the old code still + // enumerated 2^inputs corners over them. Interpolating over effective dims only must + // give the same answer as a reference built without the degenerate axes. + let f = |p: &[f32], o: &mut [f32]| o[0] = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4]; + // Axes 1 and 3 have a single grid point (degenerate); axes 0, 2, 4 vary. + let c = Clut::sample(vec![3, 1, 4, 1, 5], 1, f); + let mut got = [0.0]; + for p in [[0.2f32, 0.0, 0.7, 0.0, 0.4], [1.0, 0.0, 0.0, 0.0, 1.0], [0.5, 0.0, 0.5, 0.0, 0.5]] { + c.eval_multilinear(&p, &mut got); + let want = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4]; + assert!((got[0] - want).abs() < 1e-5, "got {} want {want}", got[0]); + } + } } diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index fe23e6e059..8b619842d1 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -99,6 +99,20 @@ impl Curve { (0..n).map(|i| self.eval64(i as f64 / (n - 1) as f64) as f32).collect() } + /// Inverse of the curve at each `y` in `ys`. For sampled-table curves the flat-run edge + /// indices are computed once for the whole batch instead of rescanning the table on every + /// call, so inverting a long constant table many times stays linear. + pub fn inverse_table(&self, ys: impl Iterator) -> Vec { + match self { + Curve::Table(t) if t.len() >= 2 => { + let increasing = t[t.len() - 1] >= t[0]; + let (edge_start, edge_end) = (edge_index(t, true), edge_index(t, false)); + ys.map(|y| table_inverse_with(t, y as f32, increasing, edge_start, edge_end)).collect() + } + _ => ys.map(|y| self.eval_inverse64(y) as f32).collect(), + } + } + /// `true` when the curve never decreases over `[0, 1]`. pub fn is_monotonic(&self) -> bool { let s = self.sample(1024); @@ -244,13 +258,24 @@ fn table_inverse(t: &[f32], y: f32) -> f32 { return y; } let increasing = t[n - 1] >= t[0]; + table_inverse_with(t, y, increasing, edge_index(t, true), edge_index(t, false)) +} + +/// [`table_inverse`] with the flat-run edge indices supplied. Computing those edges scans the +/// whole table, and transform construction inverts the same table thousands of times, so the +/// edges are computed once and reused across a batch (see [`Curve::inverse_table`]). +fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge_end: f32) -> f32 { + let n = t.len(); + if n < 2 { + return y; + } let (lo_v, hi_v) = if increasing { (t[0], t[n - 1]) } else { (t[n - 1], t[0]) }; if y <= lo_v { // Flat start: choose the last index still at the lowest value. - return if increasing { edge_index(t, true) } else { edge_index(t, false) }; + return if increasing { edge_start } else { edge_end }; } if y >= hi_v { - return if increasing { edge_index(t, false) } else { edge_index(t, true) }; + return if increasing { edge_end } else { edge_start }; } let (mut lo, mut hi) = (0usize, n - 1); while hi - lo > 1 { @@ -336,4 +361,24 @@ mod tests { let g = Curve::Gamma(2.2); assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12); } + + #[test] + fn inverse_table_matches_per_call_including_flat_runs() { + // Batch inverse must equal calling eval_inverse64 per point. Include a table with flat + // runs at both ends (the case that used to rescan the table on every call). + let tables = [ + Curve::Table(vec![0.0, 0.0, 0.0, 0.25, 0.5, 1.0, 1.0, 1.0]), + Curve::Table(vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]), + Curve::Table(vec![1.0, 0.5, 0.0]), + Curve::Gamma(2.2), + ]; + let ys: Vec = (0..=40).map(|i| i as f64 / 40.0).collect(); + for c in &tables { + let batch = c.inverse_table(ys.iter().copied()); + for (i, &y) in ys.iter().enumerate() { + let one = c.eval_inverse64(y) as f32; + assert!((batch[i] - one).abs() < 1e-6, "y={y}: batch {} vs per-call {one}", batch[i]); + } + } + } } diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index 04d11247f4..f30c17c82e 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -133,12 +133,11 @@ impl Transform { let tables = cs .iter() .map(|c| { - (0..OUT_TABLE) - .map(|i| { - let u = i as f64 / (OUT_TABLE - 1) as f64; - c.eval_inverse64(u * u * u * u).clamp(0.0, 1.0) as f32 - }) - .collect() + let ys = (0..OUT_TABLE).map(|i| { + let u = i as f64 / (OUT_TABLE - 1) as f64; + u * u * u * u + }); + c.inverse_table(ys).into_iter().map(|v| v.clamp(0.0, 1.0)).collect() }) .collect(); post = Some(Post { tables });