Skip to content

fix(#1932): reject a spectral PCS header whose channel count and range disagree - #1942

Merged
xsscx merged 1 commit into
masterfrom
fix/1932-spectral-pcs-range-consistency
Aug 1, 2026
Merged

fix(#1932): reject a spectral PCS header whose channel count and range disagree#1942
xsscx merged 1 commit into
masterfrom
fix/1932-spectral-pcs-range-consistency

Conversation

@colourbill-ctrl

Copy link
Copy Markdown
Contributor

Fixes #1932.

@xsscx — reproduced your PoC exactly, then found the defect is not where the stack trace
points. Evidence first, because the sparse matrix is innocent.

The sparse matrix is well formed

Instrumenting CIccSparseMatrix::MultiplyVector at the moment of the over-read:

rows=3 cols=184 maxEntries=176 rawSize=1080
RowStart: 0 58 116 174
ColIdx:   0 2 3 9 10 15 ... 179 180 183

Monotonic row starts, all within m_nMaxEntries, every column index ascending and
< 184. IsValid() returns true on it. Nothing about the matrix is wrong, and
hardening MultiplyVector would be hardening the wrong object — it is handed a bare
const icFloatNumber * and cannot know how long it is.

ASan names the buffer that actually overflowed:

0x...410 is located 0 bytes after 144-byte region [0x...380,0x...410)
allocated by thread T0 here:
    #1 CIccApplyCmm::InitPixel() IccProfLib/IccCmm.cpp:8331

144 bytes = 36 floats. That is the CMM pixel buffer, and a 3x184 matrix is being run
over it.

Root cause: the header states its sample count twice, and the two disagree

Your reproducer is a TIFF whose embedded ICC carries an ICC5/ICCp tag, and that
v5 sub-profile is the payload (-embedded 10003 selects it, +10000 = "use V5
sub-profile if present"). Its header says:

field value
spectralPCS 0x72730024"rs" + 36 channels
spectralRange start 398nm, end 538nm, 184 steps

Both numbers describe the same quantity, and the two halves of the CMM read different ones:

  • the channel count in the signature is what GetNumSrcSamples()/GetNumDstSamples()
    report, and what CIccApplyCmm::InitPixel() sizes m_Pixel/m_Pixel2 from → 36;
  • CIccPcsXform::Connect() sizes every spectral step it pushes from spectralRange, so
    pushRef2Xyz()pushRad2Cie()pushMatrix(3, 184, observer) builds a 3x184
    observer matrix, which CIccPcsStepMatrix::reduce() converts to the sparse matrix above.

Dumping the chain makes the contradiction explicit — note the xform declares 36 source
samples while its first step consumes 184:

[PCSXFORM] srcSamples=36 dstSamples=3 MaxChannels=184 steps: [type=14 src=184 dst=3] [type=4 src=3 dst=3]
[INITPIXEL] nSamples=36

MultiplyVector then reads pVector[36] on the 37th column. CWE-125.

The check already exists — the apply path just never asks

CIccProfile::Validate() reports exactly this as a critical error, in the
spectralPCS switch of CheckHeader():

Number of channels defined for spectral PCS do not match spectral range definition.

Neither iccApplyProfiles nor CIccCmm calls Validate(), so nothing enforced it. The
fix puts the same rule on the apply path.

Fix

One guard in CIccPcsXform::Connect(), placed right after m_nSrcSamples/m_nDstSamples
are taken from the neighbouring xforms and before the dispatch switch — the single
choke point every spectral route passes through. Returns icCmmStatInvalidProfile before
any step is pushed.

Rejection is the only correct outcome: there is no way to tell which of the two numbers
the producer meant, and honouring either silently reinterprets the pixel data.

$ iccApplyProfiles hbo-...icc out.tif 1 1 0 1 1 -embedded 10003 \
    -pcc hybrid/ICC/Spec400_10_700-F11_2deg-Abs.icc sRGB_v4_ICC_preference.icc 1
Error - Begin() failed (status 3: Invalid profile); profile chain is incompatible

The scoping deliberately mirrors the validator's. The sparse-matrix PCS (sm) is
excluded, because there the channel count is the length of the encoded matrix blob the
pixel carries rather than a sample count, so it is independent of the ranges by design —
CIccPcsStepSrcSparseMatrix is handed the two separately. iccdev.spectral-pcs-range-consistency
pins that exclusion so a later tidy-up cannot quietly widen it.

ConnectFirst()/ConnectLast() need no equivalent: both only push colorimetric steps and
never read spectralRange.

Considered and rejected

I drafted a second, more general guard — "a step chain's first step may not read more
channels than the xform declares as its source". It is wrong: pushXYZNormalize()
legitimately applies a scratch CIccPcsXform with m_nSrcSamples == 0 and an 81-channel
step, so the invariant would have broken a working path. Recording it here so it does not
get re-proposed.

Test

iccdev.spectral-pcs-range-consistency (.github/ci/regression/) drives Connect()
through stub xforms, so every assertion is on returned status and needs no sanitizer. No
fixture and no I/O, so a corpus change cannot silently disable it. It covers:

  • the reproducer's shape for all three plain spectral types (rs/ts/es), both directions
    of the mismatch;
  • bi-spectral, where the channel count must equal the steps x steps product;
  • the sparse-matrix exclusion;
  • conformant headers that must still connect (36/36, 81/81, a consistent 31x41 bi-spectral);
  • an XYZ-to-XYZ connection carrying leftover spectral range bytes, which must be unaffected.

Red-green: with the guard removed, 8 assertions fail and every control still passes
— so the failures are attributable to the guard, not to a broken harness.

Verification

check result
Reproducer under ASan, before fix heap-buffer-overflow at IccSparseMatrix.cpp:312, matching the issue
Reproducer under ASan, after fix rejected, status 3: Invalid profile, no report
Every .icc in the tree: 255 in Testing/, 24 under .github/ci/ 77 carry a spectral PCS in the check's scope, 0 rejected
CI's own container (iccdev-ci-regression), g++ 15.2.0, Release + LTO, -Wall -Wextra -Wpedantic -Werror 0 warnings, including the new test target
Full ctest, clang ASan+UBSan Debug green
New test under ASAN_OPTIONS=detect_leaks=1 clean (CTest env sets detect_leaks=0, so a leak would otherwise pass silently)

Not caused by this change: iccdev.hybrid-pipeline fails under ctest -j4 and passes
standalone (358 s), and its container failure ("Unable to save profile as
ICC/MultSpectralRGB.icc") reproduces identically on pristine master 05b5d54f in the
same image
— a mounted-volume write issue, not a regression. Same family as the known
tool-coverage parallel flake.

On the corpus files and CB tooling you posted in #1931 — I have not harvested those yet;
they look directly useful for the IccMpeXml.cpp atoi family and I will pick them up there.

…e disagree

CIccPcsXform::Connect() sizes the PCS steps it pushes from the profile header's
spectralRange, while the pixel buffers those steps run in are sized from the channel
count carried in the spectralPCS signature. Nothing checked that the two agreed.

The reproducer's embedded v5 sub-profile declares "rs" + 36 channels alongside
spectralRange.steps == 184. CIccApplyCmm::InitPixel() therefore allocates 144 bytes,
while pushRef2Xyz() -> pushRad2Cie() -> pushMatrix(3, 184, observer) builds a 3x184
observer matrix. CIccSparseMatrix::MultiplyVector() walks that matrix -- well formed,
every column index in range for 184 columns, IsValid() passes -- against the 36-float
pixel, and reads past its end on the 37th column: an ASan heap-buffer-overflow, 4-byte
read 0 bytes after a 144-byte region (CWE-125). Nothing in the sparse matrix is wrong;
the caller handed it a vector shorter than the matrix it was built for.

CIccProfile::Validate() already reports the same disagreement as a critical error, but
neither iccApplyProfiles nor CIccCmm calls Validate(), so Connect() now makes the check
itself and returns icCmmStatInvalidProfile before pushing any step. There is no way to
tell which of the two numbers the producer meant, so rejection is the only correct
outcome.

The scoping mirrors the validator's exactly: the sparse-matrix PCS is excluded, because
there the channel count is the length of the encoded matrix blob the pixel carries
rather than a sample count, and is independent of the ranges by design.

Also considered and rejected: a general invariant that a step chain's first step may not
read more channels than the xform declares as its source. pushXYZNormalize() legitimately
applies a scratch CIccPcsXform with m_nSrcSamples == 0 and an 81-channel step, so that
guard would have broken a working path.

New CTest iccdev.spectral-pcs-range-consistency drives Connect() through stub xforms, so
its assertions are on returned status and need no sanitizer. It pins the rejections, the
deliberate sparse-matrix exclusion, the conformant shapes, and a non-spectral connection.
Red-green: removing the guard fails 8 of its assertions while every control still passes.
@github-actions github-actions Bot added Testing CTest, regression, or test coverage Source C or C++ source code changes Configuration Repository, CMake, YAML, JSON, or tool configuration Build Build system, CMake, compiler, or packaging pending CI checks still running passed All CI checks passed and removed pending CI checks still running labels Aug 1, 2026

@xsscx xsscx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2026-08-01 10:19:24 UTC

  • Fuzzed Overnight

@xsscx
xsscx merged commit 8b66b43 into master Aug 1, 2026
55 of 57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Build Build system, CMake, compiler, or packaging Configuration Repository, CMake, YAML, JSON, or tool configuration passed All CI checks passed Source C or C++ source code changes Testing CTest, regression, or test coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bisect: 1f0a9dd CIccSparseMatrix::MultiplyVector() hbo-17r

2 participants