Skip to content

[Fuzzing] DoS: Unbounded ReadGlyph allocation via uint16_t wraparound in ConvertTTFToWOFF2 #191

@parasol-aser

Description

@parasol-aser

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-99woff2::ReadGlyph.
  • Reach: ConvertTTFToWOFF2 (src/woff2_enc.cc:225) → NormalizeFontCollectionNormalizeFontNormalizeWithoutFixingChecksums (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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions