Summary
Fuzzing of the encoder path (ConvertTTFToWOFF2) at commit 1f184d0 surfaced a resource-exhaustion bug in woff2::ReadGlyph. A modest (~100 KB) attacker-crafted sfnt can drive std::vector<Glyph::Point>::resize to request multi-GB allocations inside a single ConvertTTFToWOFF2 call.
- Severity: High (Denial of Service)
- Bug type: Logic / resource exhaustion, facilitated by
uint16_t subtraction wraparound. Not a memory-safety bug (no OOB/UAF/corruption).
- Affected file/function:
src/glyph.cc:98-99 — woff2::ReadGlyph.
- Reach:
ConvertTTFToWOFF2 (src/woff2_enc.cc:225) → NormalizeFontCollection → NormalizeFont → NormalizeWithoutFixingChecksums (src/normalize.cc:262) → NormalizeGlyphs (src/normalize.cc:150) → WriteNormalizedLoca (src/normalize.cc:58) → ReadGlyph.
- Not a browser/decoder-path bug. Browsers call
ConvertWOFF2ToTTF, not the encoder; this affects woff2_compress CLI, server-side font-conversion pipelines, CDN build steps, and any service that runs ConvertTTFToWOFF2 on untrusted sfnt input.
Root cause
In ReadGlyph (src/glyph.cc:98-99):
uint16_t num_points = point_index - last_point_index + (i == 0 ? 1 : 0);
glyph->contours[i].resize(num_points);
All three operands are uint16_t. When the input glyf record has a non-monotonic endPtsOfContours — a later contour's endpoint index that is less than the running last_point_index — the subtraction wraps modulo 2^16 and num_points lands near UINT16_MAX (~65535). No sanity check bounds num_points against the remaining buffer, and NormalizeGlyphs loops over every glyph in the font. With maxp.numGlyphs as high as int16_t allows (32767) and num_points saturated, a ~100 KB crafted font easily requests on the order of 65535 * 32767 * sizeof(Point) bytes of std::vector storage.
The existing kMaxPlausibleCompressionRatio / header guards in the decoder have no analogue on the encoder/normalize side — nothing bounds ReadGlyph's allocation against the physical input size. Commit 3831354 ("Check for overflow when decoding glyf") added bounds to the decoder subset path, not to ReadGlyph.
Reproduction
Four canonical crashing inputs (all produce byte-identical stacks):
oom-23ca635fe6112c3acbdd0d0defd834a7e9182bcd (152 KB)
oom-5bca983d7dcdd3dd683b211f19aac413860c708a (380 KB)
oom-661c3813ccc7764a45360b355082ec1dda26bad9 (108 KB)
oom-e83ab3619f65fc0d9eac6e2ce1996f594edc14f6 (168 KB)
Harness (libFuzzer, ASan + UBSan, -fsanitize=fuzzer,address,undefined -g -O1):
extern \"C\" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
std::string out;
woff2::WOFF2StringOut sink(&out);
sink.SetMaxSize(32 * 1024 * 1024);
woff2::ConvertTTFToWOFF2(data, size, &sink, /*brotli_quality=*/1);
return 0;
}
Run (default libFuzzer rss limit 2048 MB):
./convert_ttf2woff2_fuzzer oom-23ca635fe6112c3acbdd0d0defd834a7e9182bcd
libFuzzer aborts with out-of-memory (used: 2149 Mb; limit: 2048 Mb); the stack at abort is:
#5 std::vector<woff2::Glyph::Point>::resize (bits/stl_vector.h:940)
#6 woff2::ReadGlyph (src/glyph.cc:99)
#7 woff2::WriteNormalizedLoca (src/normalize.cc:58)
#8 woff2::NormalizeGlyphs (src/normalize.cc:150)
#9 woff2::NormalizeWithoutFixingChecksums (src/normalize.cc:262)
#10 woff2::NormalizeFont (src/normalize.cc:267)
#11 woff2::NormalizeFontCollection (src/normalize.cc:273)
#12 woff2::ConvertTTFToWOFF2 (src/woff2_enc.cc:225)
RSS at abort 2149–2391 MB; 5486–6535 live allocator chunks; 2.1–2.3 GB heap. Across 14 deduplicated OOM artifacts plus 2 slow-unit-* artifacts in the encoder harness, every one shares this stack.
Minimal hand-crafted reproducer: any sfnt whose maxp.numGlyphs is in the thousands and whose glyf records each contain at least two endPtsOfContours entries where the second is less than the first. The subtraction wraps on every such contour.
Suggested fix
Either (preferred) or both of:
(a) Reject the glyph when point_index < last_point_index in ReadGlyph — the sfnt spec requires monotonically non-decreasing endpoint indices, so the condition is always malformed input.
(b) Cap num_points against the remaining buffer length in ReadGlyph. Because each additional point consumes at least one flag byte, num_points <= len - buffer.offset() is a trivially sound upper bound.
(a) is simpler and strictly more correct; (b) also catches other variants of the same pattern. The same class of guard should be mirrored in ReconstructGlyf (src/woff2_dec.cc:406) and added alongside the existing kMaxPlausible* caps used by the decoder's decompression-ratio limiter, so the encoder/normalize path gains the same resource envelope as the decoder.
Notes
- Found via libFuzzer ASan + UBSan run against HEAD
1f184d0, ~1 h per harness, 4 workers.
- No upstream CVE located for this path;
git log src/glyph.cc shows no prior allocation-cap fix for ReadGlyph.
- Reproducer inputs can be attached or emailed on request; each is small enough to inline if preferred.
Summary
Fuzzing of the encoder path (
ConvertTTFToWOFF2) at commit1f184d0surfaced a resource-exhaustion bug inwoff2::ReadGlyph. A modest (~100 KB) attacker-crafted sfnt can drivestd::vector<Glyph::Point>::resizeto request multi-GB allocations inside a singleConvertTTFToWOFF2call.uint16_tsubtraction wraparound. Not a memory-safety bug (no OOB/UAF/corruption).src/glyph.cc:98-99—woff2::ReadGlyph.ConvertTTFToWOFF2(src/woff2_enc.cc:225) →NormalizeFontCollection→NormalizeFont→NormalizeWithoutFixingChecksums(src/normalize.cc:262) →NormalizeGlyphs(src/normalize.cc:150) →WriteNormalizedLoca(src/normalize.cc:58) →ReadGlyph.ConvertWOFF2ToTTF, not the encoder; this affectswoff2_compressCLI, server-side font-conversion pipelines, CDN build steps, and any service that runsConvertTTFToWOFF2on untrusted sfnt input.Root cause
In
ReadGlyph(src/glyph.cc:98-99):All three operands are
uint16_t. When the input glyf record has a non-monotonicendPtsOfContours— a later contour's endpoint index that is less than the runninglast_point_index— the subtraction wraps modulo 2^16 andnum_pointslands nearUINT16_MAX(~65535). No sanity check boundsnum_pointsagainst the remaining buffer, andNormalizeGlyphsloops over every glyph in the font. Withmaxp.numGlyphsas high asint16_tallows (32767) andnum_pointssaturated, a ~100 KB crafted font easily requests on the order of65535 * 32767 * sizeof(Point)bytes ofstd::vectorstorage.The existing
kMaxPlausibleCompressionRatio/ header guards in the decoder have no analogue on the encoder/normalize side — nothing boundsReadGlyph's allocation against the physical input size. Commit3831354("Check for overflow when decoding glyf") added bounds to the decoder subset path, not toReadGlyph.Reproduction
Four canonical crashing inputs (all produce byte-identical stacks):
oom-23ca635fe6112c3acbdd0d0defd834a7e9182bcd(152 KB)oom-5bca983d7dcdd3dd683b211f19aac413860c708a(380 KB)oom-661c3813ccc7764a45360b355082ec1dda26bad9(108 KB)oom-e83ab3619f65fc0d9eac6e2ce1996f594edc14f6(168 KB)Harness (libFuzzer, ASan + UBSan,
-fsanitize=fuzzer,address,undefined -g -O1):Run (default libFuzzer rss limit 2048 MB):
libFuzzer aborts with
out-of-memory (used: 2149 Mb; limit: 2048 Mb); the stack at abort is:RSS at abort 2149–2391 MB; 5486–6535 live allocator chunks; 2.1–2.3 GB heap. Across 14 deduplicated OOM artifacts plus 2
slow-unit-*artifacts in the encoder harness, every one shares this stack.Minimal hand-crafted reproducer: any sfnt whose
maxp.numGlyphsis in the thousands and whose glyf records each contain at least twoendPtsOfContoursentries where the second is less than the first. The subtraction wraps on every such contour.Suggested fix
Either (preferred) or both of:
(a) Reject the glyph when
point_index < last_point_indexinReadGlyph— the sfnt spec requires monotonically non-decreasing endpoint indices, so the condition is always malformed input.(b) Cap
num_pointsagainst the remaining buffer length inReadGlyph. Because each additional point consumes at least one flag byte,num_points <= len - buffer.offset()is a trivially sound upper bound.(a) is simpler and strictly more correct; (b) also catches other variants of the same pattern. The same class of guard should be mirrored in
ReconstructGlyf(src/woff2_dec.cc:406) and added alongside the existingkMaxPlausible*caps used by the decoder's decompression-ratio limiter, so the encoder/normalize path gains the same resource envelope as the decoder.Notes
1f184d0, ~1 h per harness, 4 workers.git log src/glyph.ccshows no prior allocation-cap fix forReadGlyph.