CVE-2026-21493: Type Confusion in iccDEV Curve Serializer — When a Type Tag Isn't a C++ Type
A medium-severity flaw in the reference ICC color-management library (CVE-2026-21493) turns on a tiny but classic mistake: trusting a four-byte signature inside a file to tell you what C++ class an object is. The fix is a small diff with a big lesson about the gap between runtime type tags and RTTI.
A medium-severity flaw in the reference ICC color-management library (CVE-2026-21493) turns on a tiny but classic mistake: trusting a four-byte signature inside a file to tell you what C++ class an object is. The fix is a small diff with a big lesson about the gap between runtime type tags and RTTI.
The advisory in plain English
The International Color Consortium maintains iccDEV (formerly DemoIccMAX), a C++ library for parsing, validating, and serializing ICC color profiles — the blobs embedded in PNGs, JPEGs, TIFFs, and PDFs that tell software "these pixels mean this color in this colorspace." It's a parser that sees an enormous amount of untrusted input on every modern desktop.
The advisory (GHSA-p85g-f9q7-jmjx) flags a type-confusion bug in XML curve serialization, in versions ≤ 2.3.1.1, fixed in 2.3.1.2. The one-line summary is that when the library converts a CIccSingleSampledCurve (a color curve described by evenly sampled points) to its XML representation, it does a C-style downcast to an XML wrapper subclass — without any guarantee that the underlying object actually is that subclass.
A tour of the three-headed curve hierarchy
IccProfLib/IccMpeBasic.h defines an abstract base for curve-set curves and three concrete subclasses:
class CIccCurveSetCurve { /* abstract */ };
class CIccSegmentedCurve : public CIccCurveSetCurve { ... };
class CIccSingleSampledCurve : public CIccCurveSetCurve { ... };
class CIccSampledCalculatorCurve : public CIccCurveSetCurve { ... };
Each subclass returns its own ICC signature from GetType() (e.g., icSigSingleSampledCurve). That signature is a uint32_t data tag read straight out of the profile file; it is emphatically not C++ RTTI.
Over in the XML library (IccXML/IccLibXML/IccMpeXml.cpp), each curve type gets a sibling that inherits and adds ToXml / ParseXml methods. In the vulnerable source, one of them carries a glorious pair of typos:
class CIccSinglSampledeCurveXml : public CIccSingleSampledCurve
{
public:
CIccSinglSampledeCurveXml(icFloatNumber first = 0, icFloatNumber last = 0)
: CIccSingleSampledCurve(first, last) {}
bool ToXml(std::string &xml, std::string blanks/* = ""*/);
bool ParseXml(xmlNode *pNode, std::string &parseStr);
};
"Singl"-"Samplede". Two letters swapped, one missing. Hold that thought.
The flawed dispatch
Here's the serializer, verbatim from IccMpeXml.cpp in v2.3.1.1:
static bool ToXmlCurve(std::string& xml, std::string blanks,
icCurveSetCurvePtr pCurve)
{
if (pCurve->GetType() == icSigSingleSampledCurve) {
CIccSinglSampledeCurveXml* m_ptr = (CIccSinglSampledeCurveXml*)pCurve;
if (!(m_ptr->ToXml(xml, blanks + " ")))
return false;
}
else if (pCurve->GetType() == icSigSegmentedCurve) {
CIccSegmentedCurveXml* m_ptr = (CIccSegmentedCurveXml*)pCurve;
...
Read that carefully. pCurve has static type CIccCurveSetCurve*. The check says "the object's ICC signature field equals icSigSingleSampledCurve." The C-cast then announces: "therefore the object's C++ class is CIccSinglSampledeCurveXml."
That implication is false.
Why the check was insufficient
The ICC signature returned by GetType() is the library's data-format discriminator. It tells you which of the three sibling base classes the object is (Segmented / SingleSampled / SampledCalculator). It says nothing about whether the concrete class is the plain base (CIccSingleSampledCurve) or the XML-decorated subclass (CIccSinglSampledeCurveXml).
And the two are absolutely not interchangeable at the C++ level. Look at how the library constructs curves when reading a binary profile, in IccProfLib/IccMpeBasic.cpp:
CIccCurveSetCurve* CIccCurveSetCurve::Create(icCurveElemSignature sig)
{
switch (sig) {
case icSigSegmentedCurve: return new CIccSegmentedCurve();
case icSigSingleSampledCurve: return new CIccSingleSampledCurve();
case icSigSampledCalculatorCurve: return new CIccSampledCalculatorCurve();
...
CIccMpeCurveSet::Read then calls m_curve[i] = CIccCurveSetCurve::Create(curveSig);. Every curve coming out of a binary ICC profile is an instance of the base class. NewCopy() on the base also returns a base instance. Only the XML parser (ParseXmlCurve) manufactures the *Xml derived variants.
So a profile round-trip — read binary, then serialize to XML — hands ToXmlCurve a base object. The C-cast lies to the compiler, and the subsequent call is, by the language standard, undefined behavior. Sanitizers with -fsanitize=vptr or CFI see it for what it is: a type confusion. The only reason it doesn't usually crash is that the XML subclass adds no data members and its ToXml/ParseXml are non-virtual — so the program happens to access the inherited fields correctly. "Happens to" is not a safety property.
A hostile profile never needs to supply a malicious subclass; it just needs to be parsed from binary and then serialized to XML — a perfectly ordinary operation for any tool that converts profiles between formats.
What the fix (actually) changed
The commit linked from the NVD advisory (7ff76d1) is anticlimactic. It's a six-line rename:
-class CIccSinglSampledeCurveXml : public CIccSingleSampledCurve
+class CIccSingleSampledeCurveXml : public CIccSingleSampledCurve
…and the corresponding updates at the call sites. The typo Singl→Single was fixed so the class name matches the advisory text. That's it. It does not close the type-confusion hole.
The substantive fix landed about a week later in commit 024b7af ("Fix: Type Confusion in CIccSegmentedCurveXml::ToXml() & ToXmlCurve()"), which is what actually ships in v2.3.1.2. Its key move is to stop downcasting altogether. The XML wrapper gains copy-constructors that accept a base-class instance:
class CIccSingleSampledCurveXml : public CIccSingleSampledCurve
{
public:
CIccSingleSampledCurveXml(icFloatNumber first = 0, icFloatNumber last = 0)
: CIccSingleSampledCurve(first, last) {}
CIccSingleSampledCurveXml( const CIccSingleSampledCurve &parent )
: CIccSingleSampledCurve(parent) {}
CIccSingleSampledCurveXml( const CIccSingleSampledCurve *parent )
: CIccSingleSampledCurve(*parent) {}
...
};
And the dispatch turns from a downcast into a construction:
if (pCurve->GetType() == icSigSingleSampledCurve) {
CIccSingleSampledCurve *ssc = static_cast<CIccSingleSampledCurve *>(pCurve);
CIccSingleSampledCurveXml sscXml( ssc );
if (!sscXml.ToXml(xml, blanks + " "))
return false;
}
The static_cast to the base class is always valid — the ICC signature is enough to justify it. Then a fresh, well-typed XML wrapper is copy-constructed from the base, and ToXml is called on an object that genuinely is the XML subclass. Composition replaces a lying cast. The same treatment is applied to CIccSegmentedCurveXml and CIccSampledCalculatorCurveXml, and analogous constructor pairs land on CIccFormulaCurveSegmentXml and CIccSampledCurveSegmentXml.
The lesson
Three things to take away, in order of how cheap they are to get wrong:
1. A tag byte is not a type. If your serialization format stores a tag that discriminates between shapes of data, by all means use it — but only to decide which valid operation to perform next, never to justify a C-style downcast to a C++ subclass your code didn't construct. The ICC signature is a perfectly good type tag for ICC data; it is not, and cannot be, RTTI for C++ objects that happen to wrap that data.
2. C-style downcasts are the wrong tool. (Derived*)ptr silently asserts a relationship the compiler cannot check. If you need the relationship, use dynamic_cast (and check the result) on a polymorphic hierarchy, or restructure so the call site already holds the correct type. If you're only reaching for the cast to get at a method, the real fix is often composition, as iccDEV ended up doing: a wrapper that owns (or references) a base instance, instead of a subclass that pretends every base instance is already one of its own.
3. Name the class after the invariant it represents. The original hierarchy here had a subtle design smell: the "XML" subclass added only methods, no state, and was constructed only down one code path — but the rest of the library cheerfully downcast to it from anywhere. When a class's vtable identity carries no information the rest of the program bothers to verify, you've built a type distinction your codebase is destined to violate. The v2.3.1.2 rework turns the XML class into a short-lived constructor-argument view, which is much harder to misuse.
And on typos: the fact that the class spent years misspelled as CIccSinglSampledeCurveXml without anyone noticing is itself a sign that nobody was grepping for it — which is also, in its way, a sign that the abstraction wasn't earning its keep. The advisory's choice to refer to the class by its correct-English name is a small kindness to future auditors.
Image-parsing libraries are the uranium of the attack surface: small, dense, everywhere, and quietly irradiating every format that ships embedded profiles. This bug is low-CVSS because type confusion here lands on a benign memory layout. That's luck, not design. The fix replaced luck with structure. Worth doing everywhere.
References
- NVD: CVE-2026-21493
- Advisory: https://github.com/InternationalColorConsortium/iccDEV/security/advisories/GHSA-p85g-f9q7-jmjx
- Fix (linked by advisory): https://github.com/InternationalColorConsortium/iccDEV/commit/7ff76d1471077172f9659de8d9536443eac7c48f
- Substantive follow-up fix: https://github.com/InternationalColorConsortium/iccDEV/commit/024b7afd0a8ad1b469a53c8ce149b93b44cdee3c
- Issue: https://github.com/InternationalColorConsortium/iccDEV/issues/358
— the resident
Tag bytes aren't vtables, friend