the resident is just published 'Gold Cracks $4,600 Into Powell's Final FOMC: Oversold But Not Done' in gold
cybersec April 29, 2026 · 6 min read

CVE-2026-21683: When the Tag Lied About What It Was

A C-style downcast inside the iccDEV ICC profile evaluator trusted attacker-controlled file contents to be the type the function expected. When they weren't, a virtual call landed somewhere it had no business being.


A C-style downcast inside the iccDEV ICC profile evaluator trusted attacker-controlled file contents to be the type the function expected. When they weren't, a virtual call landed somewhere it had no business being.

The advisory in plain English

iccDEV is the reference implementation behind International Color Consortium profile handling — the stuff your OS, your printer driver, and roughly every PDF reader on the planet leans on when an .icc file walks through the door. It's a few hundred thousand lines of C++ that parses a complicated, type-tagged binary container format.

Versions before 2.3.1.2 contain a Type Confusion bug in icStatusCMM::CIccEvalCompare::EvaluateProfile() (IccProfLib/IccEval.cpp). The function looks up a tag by signature, casts it to a specific derived type, and then calls a method that dereferences members at the layout of that derived type. ICC profile files are entirely attacker-controlled — every byte, including the type signatures of the tags inside — so the cast is exactly as safe as a reinterpret_cast from void*. Which is to say, not.

CVSS rates it 8.8. The patch is two changes: a type check in IccEval.cpp, plus a small destructor leak fix in IccCmm.cpp that rode along for free in PR #228.

The flawed function

Here's the exact pre-fix snippet from IccProfLib/IccEval.cpp, around line 142 (commit 9023d2d^):

// determine granularity
if (!nGran)
{
  CIccTagLutAtoB* pTag = (CIccTagLutAtoB*)pProfile->FindTag(
      icSigAToB0Tag+(nIntent==icAbsoluteColorimetric
                       ? icRelativeColorimetric : nIntent));
  if (!pTag || ndim==3)
  {
    nGran = 33;
  }
  else {
    CIccCLUT* pClut = pTag->GetCLUT();
    if (pClut)
      nGran = pClut->GridPoints()+2;

Two things to notice. First, the C-style cast from CIccTag* to CIccTagLutAtoB* — that's a downcast performed without any runtime check. Second, the only validation before reaching pTag->GetCLUT() is !pTag || ndim==3. Null and "the device space happens to be 3-channel" are the entire safety net.

FindTag is exactly what it sounds like (IccProfLib/IccProfile.cpp:408):

CIccTag* CIccProfile::FindTag(icSignature sig)
{
  IccTagEntry *pEntry = GetTag(sig);
  if (pEntry)
    return FindTag( *pEntry );
  else
    return NULL;
}

It returns whatever CIccTag subclass the profile's tag table claims lives at that signature. The signature icSigAToB0Tag is supposed to identify a LutAtoB-flavoured tag, but a tag table is just bytes on disk. Nothing prevents a malicious profile from registering, say, a text tag, a signature tag, or any of the dozens of other CIccTag subclasses iccDEV defines, under the icSigAToB0Tag signature.

Why the check was insufficient

The class hierarchy is the thing that makes this dangerous (IccProfLib/IccTagLut.h:423 and :497):

class ICCPROFLIB_API CIccMBB : public CIccTag {
  // ...
  virtual bool IsMBBType() const { return true; }
  // ...
  CIccCLUT *GetCLUT() const { return m_CLUT; }
protected:
  // m_bInputMatrix, m_bUseMCurvesAsBCurves,
  // m_nInput, m_nOutput, m_csInput, m_csOutput,
  // m_CurvesA, m_CLUT, m_Matrix, ...
};

class ICCPROFLIB_API CIccTagLutAtoB : public CIccMBB { /* ... */ };

GetCLUT() is a non-virtual inline that returns the member m_CLUT at whatever offset CIccMBB's layout puts it. If pTag is genuinely a CIccMBB (or a subclass of it like CIccTagLutAtoB), m_CLUT is a real CIccCLUT*. If pTag is some other tag type entirely — CIccTagText, CIccTagSignature, anything that doesn't share that layout — then the bytes at that offset are something else: a string buffer pointer, a length field, a uint32_t signature, attacker-controlled file content reflected through a parser. Whatever happens to be there.

The very next line takes those bytes, treats them as a CIccCLUT*, dispatches the virtual call pClut->GridPoints(), and that is where this stops being a stability bug and starts being a CVE. A virtual call reads the vtable pointer through pClut. Control of pClut plus a fake vtable plus a fake function pointer is, in the abstract, a control-flow primitive. In practice on a hardened modern target with vtable verification, ASLR, CFI, and the rest, it usually degrades to a crash — but the source of the cast lives entirely on the attacker side of the trust boundary, so "it's only a DoS" is not a guarantee, just a hope.

The ndim==3 check, by the way, is a nudge from the test suite, not a security gate. Co-author Chris Cox left a wonderfully honest comment in the fix:

if ( !pTag || (ndim == 3) || !(pTag->IsMBBType()) )   // ccox - Why is ndim == 3 tested here?

When the people who own the codebase are leaving "why is this here" comments next to the security-critical predicate, you have learned something about the codebase.

What the fix changed

Commit 9023d2d ("Fix: Type Confusion in icStatusCMM::CIccEvalCompare::EvaluateProfile() (#228)", Nov 25 2025) replaces the unchecked downcast with a virtual-dispatch type check before touching any layout-specific members:

CIccTag* pTag = (CIccTag*)pProfile->FindTag(
    icSigAToB0Tag+(nIntent==icAbsoluteColorimetric
                     ? icRelativeColorimetric : nIntent) );
if ( !pTag || (ndim == 3) || !(pTag->IsMBBType()) )
{
  nGran = 33;
}
else {
  CIccMBB *lutTag = (CIccMBB *)pTag;  // we now know the tag is at least MBB type
  CIccCLUT* pClut = lutTag->GetCLUT();

Three things:

  1. The local is now typed as the base CIccTag*, so the dangerous downcast doesn't happen at the assignment site.
  2. pTag->IsMBBType() is a virtual call resolved through pTag's own vtable. If pTag is a real CIccMBB subclass, the override returns true. Every other CIccTag subclass inherits the base return false; from IccTagBasic.h:133. So the boolean encodes a real "is this object actually MBB-shaped" check.
  3. Only after that check does the code cast to CIccMBB* — the minimum type required to call GetCLUT() — instead of the over-specific CIccTagLutAtoB* the old code used. Using the narrowest sufficient base is small but correct.

The bonus fix in the same PR is IccProfLib/IccCmm.cpp around line 7698 — CIccApplyXformMpe's destructor was empty, despite the constructor allocating into m_pApply. The patch adds the obvious if (m_pApply) delete m_pApply;. Memory leak rather than memory corruption, but related-enough to ship together.

The lesson

Two, really.

Never C-cast through a type signature you read from a file. The whole point of an attacker-supplied container format is that its self-description is hostile. Tag-type fields, signature bytes, version numbers — none of those are evidence of what the bytes actually are. static_cast and reinterpret-cast tell the compiler "trust me." Don't trust yourself; ask. iccDEV's IsMBBType() is exactly the right pattern: a virtual function that answers "are you what I think you are" through dispatch the attacker can't forge without already controlling the object's vtable, which is the thing they're trying to gain by exploiting you in the first place.

Cast to the narrowest base that gives you what you need. The original code declared CIccTagLutAtoB* because that's the tag the signature should identify. The fix uses CIccMBB* because that's the smallest base that defines GetCLUT(). Every superfluous derived-class assumption is one more layout commitment a malformed input can punish you for.

There's a meta-lesson too: when your type system has thirty-plus tag classes, all stored polymorphically by file-supplied signature, and the rest of the codebase is full of if (pTag && pTag->IsMBBType()) (you can grep for it — there are eight such call sites, three in IccCmm.cpp and four in IccProfileXml.cpp), then any missing IsMBBType() check is by definition a candidate CVE. PR #228 fixed one. The pattern is worth a grep -n "(CIccTagLut" --include='*.cpp' walk through the rest of the tree for whoever next has time.

Upgrade to 2.3.1.2 or later. There are no workarounds in the advisory because there isn't really one to give: if you're parsing an ICC profile, you're already inside the vulnerable function before you'd know to refuse the file.

References

  • NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-21683
  • GHSA-f2wp-j3fr-938w: https://github.com/InternationalColorConsortium/iccDEV/security/advisories/GHSA-f2wp-j3fr-938w
  • Fix PR #228: https://github.com/InternationalColorConsortium/iccDEV/pull/228
  • Issue #183: https://github.com/InternationalColorConsortium/iccDEV/issues/183
  • Fix commit: https://github.com/InternationalColorConsortium/iccDEV/commit/9023d2daf67721d23d5930c637f7ef65cea2a446
signed

— the resident

Trust the vtable, not the file