Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,63 @@ add_library(convert_woff2ttf_fuzzer STATIC src/convert_woff2ttf_fuzzer.cc)
target_link_libraries(convert_woff2ttf_fuzzer woff2dec)
add_library(convert_woff2ttf_fuzzer_new_entry STATIC src/convert_woff2ttf_fuzzer_new_entry.cc)
target_link_libraries(convert_woff2ttf_fuzzer_new_entry woff2dec)
add_library(convert_ttf2woff2_fuzzer STATIC src/convert_ttf2woff2_fuzzer.cc)
target_link_libraries(convert_ttf2woff2_fuzzer woff2enc)

# Tests (issue #191 regression coverage)
option(BUILD_TESTING "Build tests" ON)
if (BUILD_TESTING)
enable_testing()

# Unit test that drives ReadGlyph directly with crafted glyf byte payloads.
add_executable(test_read_glyph test/test_read_glyph.cc)
target_link_libraries(test_read_glyph woff2enc)
add_test(NAME test_read_glyph COMMAND test_read_glyph)

# End-to-end test: feeds crafted TTFs through ConvertTTFToWOFF2.
add_executable(test_convert_ttf2woff2 test/test_convert_ttf2woff2.cc)
target_link_libraries(test_convert_ttf2woff2 woff2enc)

# Generate fixtures at build time (requires Python + fontTools). CMake's
# Python3::Interpreter may resolve to a Python without fontTools; probe
# /usr/bin/python3 as well and pick whichever has fontTools importable.
find_package(Python3 COMPONENTS Interpreter)
set(FIXTURE_PYTHON "")
foreach(candidate "${Python3_EXECUTABLE}" "/usr/bin/python3" "python3")
if (candidate AND NOT FIXTURE_PYTHON)
execute_process(
COMMAND ${candidate} -c "import fontTools"
RESULT_VARIABLE _probe
OUTPUT_QUIET ERROR_QUIET)
if (_probe EQUAL 0)
set(FIXTURE_PYTHON "${candidate}")
endif()
endif()
endforeach()
if (FIXTURE_PYTHON)
message(STATUS "Using ${FIXTURE_PYTHON} for fixture generation (fontTools available)")
set(FIXTURE_DIR "${CMAKE_CURRENT_BINARY_DIR}/test_fixtures")
set(FIXTURE_STAMP "${FIXTURE_DIR}/.stamp")
add_custom_command(
OUTPUT ${FIXTURE_STAMP}
COMMAND ${CMAKE_COMMAND} -E make_directory ${FIXTURE_DIR}
COMMAND ${FIXTURE_PYTHON}
${CMAKE_CURRENT_SOURCE_DIR}/test/generate_fixtures.py
${FIXTURE_DIR}
COMMAND ${CMAKE_COMMAND} -E touch ${FIXTURE_STAMP}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/test/generate_fixtures.py
COMMENT "Generating TTF test fixtures for issue #191"
VERBATIM)
add_custom_target(test_fixtures DEPENDS ${FIXTURE_STAMP})
add_dependencies(test_convert_ttf2woff2 test_fixtures)
add_test(NAME test_convert_ttf2woff2
COMMAND test_convert_ttf2woff2 ${FIXTURE_DIR})
else()
message(WARNING "No Python with fontTools found — skipping "
"test_convert_ttf2woff2 (fixture generation requires "
"`pip install fonttools`).")
endif()
endif()

# PC files
include(CMakeParseArguments)
Expand Down
18 changes: 18 additions & 0 deletions src/convert_ttf2woff2_fuzzer.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#include <stddef.h>
#include <stdint.h>
#include <vector>

#include <woff2/encode.h>

// Entry point for LibFuzzer.
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
size_t result_length = woff2::MaxWOFF2CompressedSize(data, size);
if (result_length == 0) {
return 0;
}
std::vector<uint8_t> result(result_length);
woff2::WOFF2Params params;
params.brotli_quality = 1;
woff2::ConvertTTFToWOFF2(data, size, result.data(), &result_length, params);
return 0;
}
6 changes: 6 additions & 0 deletions src/glyph.cc
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ bool ReadGlyph(const uint8_t* data, size_t len, Glyph* glyph) {
if (!buffer.ReadU16(&point_index)) {
return FONT_COMPRESSION_FAILURE();
}
// endPtsOfContours must be monotonically non-decreasing (sfnt spec).
// Reject point_index < last_point_index to prevent uint16_t subtraction
// wraparound that drives an unbounded resize (issue #191).
if (i > 0 && point_index < last_point_index) {
return FONT_COMPRESSION_FAILURE();
}
uint16_t num_points = point_index - last_point_index + (i == 0 ? 1 : 0);
glyph->contours[i].resize(num_points);
last_point_index = point_index;
Expand Down
179 changes: 179 additions & 0 deletions test/generate_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Generate TTF fixtures exercising issue #191 (unbounded ReadGlyph allocation).

Produces:
valid_baseline.ttf — a legitimate TTF with monotonic endPts.
non_monotonic_endpts.ttf — one glyph with endPtsOfContours == [5, 2].
non_monotonic_one_step.ttf — endPts == [1, 0] (smallest wrap-to-65535 case).
non_monotonic_large_drop.ttf — endPts == [0xFFFE, 0] (extreme downward jump).
dos_reproducer.ttf — many glyphs each crafted with wraparound.

Each "non-monotonic" / "dos" fixture is built by starting from a valid font
and surgically rewriting the glyf/loca tables so the arithmetic problem
described in PLAN.md section 1 appears exactly where ReadGlyph reads it.
"""

from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.ttLib import TTFont
import io
import os
import struct
import sys


def build_valid_font(num_glyphs=4):
"""Return a minimal but spec-complete TTF as bytes."""
fb = FontBuilder(1024, isTTF=True)
glyph_names = [".notdef"] + [f"g{i}" for i in range(1, num_glyphs)]
fb.setupGlyphOrder(glyph_names)
fb.setupCharacterMap({ord("A") + i - 1: glyph_names[i]
for i in range(1, num_glyphs)})

glyphs = {}
for name in glyph_names:
pen = TTGlyphPen(None)
# Draw a simple square so each glyph has at least one contour.
pen.moveTo((0, 0))
pen.lineTo((100, 0))
pen.lineTo((100, 100))
pen.lineTo((0, 100))
pen.closePath()
glyphs[name] = pen.glyph()
fb.setupGlyf(glyphs)

metrics = {name: (500, 0) for name in glyph_names}
fb.setupHorizontalMetrics(metrics)
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupOS2(sTypoAscender=800, sTypoDescender=-200, usWinAscent=800,
usWinDescent=200)
fb.setupNameTable({"familyName": "Test", "styleName": "Regular"})
fb.setupPost()

buf = io.BytesIO()
fb.save(buf)
return buf.getvalue()


def rewrite_glyph(font_bytes, glyph_index, new_glyph_bytes):
"""Replace the raw bytes of one glyph record in an sfnt.

Reads the compiled font's glyf + loca tables, splices the new bytes into
the glyf table, rewrites loca to reflect the new offsets, and rebuilds the
sfnt via SFNTWriter so the raw bytes survive serialisation unchanged.
"""
from fontTools.ttLib.sfnt import SFNTReader, SFNTWriter

src = io.BytesIO(font_bytes)
reader = SFNTReader(src)
num_glyphs = TTFont(io.BytesIO(font_bytes))["maxp"].numGlyphs
head_table = TTFont(io.BytesIO(font_bytes))["head"]
if head_table.indexToLocFormat == 0:
fmt_char = "H"
scale = 2
else:
fmt_char = "I"
scale = 1

glyf_data = reader["glyf"]
loca_data = reader["loca"]
loca_fmt = ">" + fmt_char * (num_glyphs + 1)
loca_values = list(struct.unpack(loca_fmt, loca_data))
start = loca_values[glyph_index] * scale
end = loca_values[glyph_index + 1] * scale

new_glyf = (glyf_data[:start] + new_glyph_bytes + glyf_data[end:])
# Keep glyf padded to 4 bytes (sfnt convention for table data).
pad = (-len(new_glyf)) & 3
new_glyf += b"\x00" * pad

delta_bytes = len(new_glyph_bytes) - (end - start)
delta_units = delta_bytes // scale # works for both short and long loca

new_loca_values = list(loca_values)
for i in range(glyph_index + 1, num_glyphs + 1):
new_loca_values[i] = loca_values[i] + delta_units
new_loca_bytes = struct.pack(loca_fmt, *new_loca_values)

out = io.BytesIO()
writer = SFNTWriter(out, numTables=len(reader.tables),
sfntVersion=reader.sfntVersion)
for tag in reader.tables:
data = reader[tag]
if tag == "glyf":
data = new_glyf
elif tag == "loca":
data = new_loca_bytes
writer[tag] = data
writer.close()
return out.getvalue()


def simple_glyph_bytes(endpts, num_points_override=None,
include_coords=True, trailing_padding=0):
"""Build the raw bytes of a simple glyph record.

endpts: list of uint16 endPtsOfContours values (may be non-monotonic).
num_points_override: if given, writes exactly that many flag/x/y bytes.
Otherwise derives from endpts[-1] + 1 when include_coords is True.
"""
buf = struct.pack(">hhhhh", len(endpts), 0, 0, 100, 100)
for ep in endpts:
buf += struct.pack(">H", ep & 0xFFFF)
buf += struct.pack(">H", 0) # instructionLength
if include_coords:
n = (num_points_override if num_points_override is not None
else (endpts[-1] + 1 if endpts else 0))
# flag 0x37: on-curve, x short (+), y short (+), no repeat
buf += bytes([0x37]) * n
buf += bytes([1]) * n # x coords
buf += bytes([1]) * n # y coords
buf += b"\x00" * trailing_padding
return buf


def main():
out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(__file__)
os.makedirs(out_dir, exist_ok=True)

def write(name, data):
path = os.path.join(out_dir, name)
with open(path, "wb") as f:
f.write(data)
print(f"wrote {path} ({len(data)} bytes)")

# 1. Baseline valid TTF — monotonic endpoints throughout.
baseline = build_valid_font()
write("valid_baseline.ttf", baseline)

# 2. Non-monotonic endpoints [5, 2] on glyph index 1.
mal1 = rewrite_glyph(baseline, 1, simple_glyph_bytes([5, 2],
include_coords=False,
trailing_padding=6))
write("non_monotonic_endpts.ttf", mal1)

# 3. Minimal wrap: endpts = [1, 0].
mal2 = rewrite_glyph(baseline, 1, simple_glyph_bytes([1, 0],
include_coords=False,
trailing_padding=6))
write("non_monotonic_one_step.ttf", mal2)

# 4. Extreme drop: endpts = [0xFFFE, 0].
mal3 = rewrite_glyph(baseline, 1, simple_glyph_bytes([0xFFFE, 0],
include_coords=False,
trailing_padding=6))
write("non_monotonic_large_drop.ttf", mal3)

# 5. DoS reproducer: every writable glyph has a wraparound pattern.
dos = baseline
font = TTFont(io.BytesIO(baseline))
num_glyphs = font["maxp"].numGlyphs
for i in range(1, num_glyphs):
dos = rewrite_glyph(dos, i, simple_glyph_bytes([100, 0],
include_coords=False,
trailing_padding=4))
write("dos_reproducer.ttf", dos)


if __name__ == "__main__":
main()
Loading