From cded0da7c27aa9e79c68d59c812083c3a8314638 Mon Sep 17 00:00:00 2001 From: Vladimir Saraikin Date: Fri, 10 Jul 2026 19:15:24 +0200 Subject: [PATCH 1/4] Don't pre-allocate the Subrs array from the declared count _parse_subrs allocated [None] * count from the count declared in the font before reading the body, so a malformed font declaring a huge count could force a large allocation before being rejected. Accumulate entries into a dict and build the list after the body parses, matching _parse_charstrings. --- lib/matplotlib/_type1font.py | 13 +++++++++++-- lib/matplotlib/tests/test_type1font.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py index c7b73f9c0c7e..614f51d67ca2 100644 --- a/lib/matplotlib/_type1font.py +++ b/lib/matplotlib/_type1font.py @@ -612,8 +612,13 @@ def _parse_subrs(self, tokens, _data): f"Token following /Subrs must be a number, was {count_token}" ) count = count_token.value() - array = [None] * count next(t for t in tokens if t.is_keyword('array')) + # Accumulate the parsed subrs into a dict and only allocate the result + # list once the body has been read. Allocating ``[None] * count`` up + # front lets a malformed font declare a huge count in a few bytes and + # force a large allocation before it is rejected; _parse_charstrings + # already avoids this by accumulating into a dict. + entries = {} for _ in range(count): next(t for t in tokens if t.is_keyword('dup')) index_token = next(tokens) @@ -635,7 +640,11 @@ def _parse_subrs(self, tokens, _data): f"was {token}" ) binary_token = tokens.send(1+nbytes_token.value()) - array[index_token.value()] = binary_token.value() + entries[index_token.value()] = binary_token.value() + + array = [None] * count + for index, value in entries.items(): + array[index] = value return array, next(tokens).endpos() diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py index b2f93ef28a26..7ba9ed53574d 100644 --- a/lib/matplotlib/tests/test_type1font.py +++ b/lib/matplotlib/tests/test_type1font.py @@ -158,3 +158,27 @@ def test_encrypt_decrypt_roundtrip(): decrypted = t1f.Type1Font._decrypt(encrypted, 'eexec') assert encrypted != decrypted assert data == decrypted + + +def test_Subrs_no_preallocation(tmp_path): + # Regression test for #31962: a font declaring a huge /Subrs count must not + # cause a large allocation sized from that (untrusted) count before the + # body is parsed. Here the body is empty, so parsing must fail without + # first allocating a count-sized array. + import tracemalloc + count = 5_000_000 + plaintext = (b'/FontName /X def\n/FontBBox [0 0 1 1] def\n' + + f'/Subrs {count} array\n'.encode()) + enc = t1f.Type1Font._encrypt(plaintext, 'eexec').hex().encode() + pfa = (b'%!PS-AdobeFont-1.0: X 001.000\neexec\n' + enc + b'\n' + + b'0' * 512 + b'\ncleartomark\n') + path = tmp_path / 'x.pfa' + path.write_bytes(pfa) + + tracemalloc.start() + with pytest.raises(StopIteration): + t1f.Type1Font(str(path)) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + # The pre-allocation regressed to ~40 MB ([None] * count) before failing. + assert peak < 5 * 1024 * 1024 From 17f2d775b3e5a7b179f5e765e32f9f7b0becdd33 Mon Sep 17 00:00:00 2001 From: Vladimir Saraikin Date: Tue, 28 Jul 2026 15:13:41 +0200 Subject: [PATCH 2/4] Raise a clear error on an incomplete /Subrs array --- lib/matplotlib/_type1font.py | 52 ++++++++++++++------------ lib/matplotlib/tests/test_type1font.py | 2 +- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py index 614f51d67ca2..db2bd8b5e23d 100644 --- a/lib/matplotlib/_type1font.py +++ b/lib/matplotlib/_type1font.py @@ -616,31 +616,35 @@ def _parse_subrs(self, tokens, _data): # Accumulate the parsed subrs into a dict and only allocate the result # list once the body has been read. Allocating ``[None] * count`` up # front lets a malformed font declare a huge count in a few bytes and - # force a large allocation before it is rejected; _parse_charstrings - # already avoids this by accumulating into a dict. + # force a large allocation before it is rejected. entries = {} - for _ in range(count): - next(t for t in tokens if t.is_keyword('dup')) - index_token = next(tokens) - if not index_token.is_number(): - raise RuntimeError( - "Token following dup in Subrs definition must be a " - f"number, was {index_token}" - ) - nbytes_token = next(tokens) - if not nbytes_token.is_number(): - raise RuntimeError( - "Second token following dup in Subrs definition must " - f"be a number, was {nbytes_token}" - ) - token = next(tokens) - if not token.is_keyword(self._abbr['RD']): - raise RuntimeError( - f"Token preceding subr must be {self._abbr['RD']}, " - f"was {token}" - ) - binary_token = tokens.send(1+nbytes_token.value()) - entries[index_token.value()] = binary_token.value() + try: + for _ in range(count): + next(t for t in tokens if t.is_keyword('dup')) + index_token = next(tokens) + if not index_token.is_number(): + raise RuntimeError( + "Token following dup in Subrs definition must be a " + f"number, was {index_token}" + ) + nbytes_token = next(tokens) + if not nbytes_token.is_number(): + raise RuntimeError( + "Second token following dup in Subrs definition must " + f"be a number, was {nbytes_token}" + ) + token = next(tokens) + if not token.is_keyword(self._abbr['RD']): + raise RuntimeError( + f"Token preceding subr must be {self._abbr['RD']}, " + f"was {token}" + ) + binary_token = tokens.send(1+nbytes_token.value()) + entries[index_token.value()] = binary_token.value() + except StopIteration: + raise RuntimeError( + "Malformed Type1 font file: Incomplete /Subrs" + ) from None array = [None] * count for index, value in entries.items(): diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py index 7ba9ed53574d..03fba550735f 100644 --- a/lib/matplotlib/tests/test_type1font.py +++ b/lib/matplotlib/tests/test_type1font.py @@ -176,7 +176,7 @@ def test_Subrs_no_preallocation(tmp_path): path.write_bytes(pfa) tracemalloc.start() - with pytest.raises(StopIteration): + with pytest.raises(RuntimeError, match='Incomplete /Subrs'): t1f.Type1Font(str(path)) _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() From d083a776131ceaebabcc6ee0255bd98e879b4c38 Mon Sep 17 00:00:00 2001 From: Vladimir Saraikin Date: Wed, 29 Jul 2026 20:38:09 +0300 Subject: [PATCH 3/4] Validate the /Subrs indices --- lib/matplotlib/_type1font.py | 11 ++++-- lib/matplotlib/tests/test_type1font.py | 46 +++++++++++++++++++++----- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py index db2bd8b5e23d..8f4c9413376c 100644 --- a/lib/matplotlib/_type1font.py +++ b/lib/matplotlib/_type1font.py @@ -646,9 +646,14 @@ def _parse_subrs(self, tokens, _data): "Malformed Type1 font file: Incomplete /Subrs" ) from None - array = [None] * count - for index, value in entries.items(): - array[index] = value + # The indices must cover 0 to count-1 exactly. Compare the length first + # so that a bogus count cannot force a large range() to be built here. + if len(entries) != count or sorted(entries) != list(range(count)): + raise RuntimeError( + "Malformed Type1 font file: /Subrs indices do not cover " + f"0 to {count - 1}" + ) + array = [entries[index] for index in range(count)] return array, next(tokens).endpos() diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py index 03fba550735f..95018da31e08 100644 --- a/lib/matplotlib/tests/test_type1font.py +++ b/lib/matplotlib/tests/test_type1font.py @@ -160,25 +160,53 @@ def test_encrypt_decrypt_roundtrip(): assert data == decrypted +def _write_pfa(path, private): + """Write a minimal font whose eexec-encrypted part is *private*.""" + plaintext = b'/FontName /X def\n/FontBBox [0 0 1 1] def\n' + private + enc = t1f.Type1Font._encrypt(plaintext, 'eexec').hex().encode() + path.write_bytes(b'%!PS-AdobeFont-1.0: X 001.000\neexec\n' + enc + b'\n' + + b'0' * 512 + b'\ncleartomark\n') + return str(path) + + def test_Subrs_no_preallocation(tmp_path): # Regression test for #31962: a font declaring a huge /Subrs count must not # cause a large allocation sized from that (untrusted) count before the # body is parsed. Here the body is empty, so parsing must fail without # first allocating a count-sized array. import tracemalloc - count = 5_000_000 - plaintext = (b'/FontName /X def\n/FontBBox [0 0 1 1] def\n' - + f'/Subrs {count} array\n'.encode()) - enc = t1f.Type1Font._encrypt(plaintext, 'eexec').hex().encode() - pfa = (b'%!PS-AdobeFont-1.0: X 001.000\neexec\n' + enc + b'\n' - + b'0' * 512 + b'\ncleartomark\n') - path = tmp_path / 'x.pfa' - path.write_bytes(pfa) + path = _write_pfa(tmp_path / 'x.pfa', b'/Subrs 5000000 array\n') tracemalloc.start() with pytest.raises(RuntimeError, match='Incomplete /Subrs'): - t1f.Type1Font(str(path)) + t1f.Type1Font(path) _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() # The pre-allocation regressed to ~40 MB ([None] * count) before failing. assert peak < 5 * 1024 * 1024 + + +def _write_subrs_pfa(path, indices): + """Write a font with a two-element /Subrs array declared at *indices*.""" + return _write_pfa(path, ( + b'/Subrs 2 array\n' + + b''.join(b'dup %d 5 RD \x00\x01\x02\x03\x04 NP\n' % index + for index in indices) + + b'ND\n' + b'/CharStrings 1 begin\n' + b'/.notdef 5 RD \x00\x01\x02\x03\x04 ND\n' + b'end\n' + )) + + +def test_Subrs_indices(tmp_path): + font = t1f.Type1Font(_write_subrs_pfa(tmp_path / 'x.pfa', (0, 1))) + assert len(font.prop['Subrs']) == 2 + + +@pytest.mark.parametrize('indices', [(0, 5), (0, 0)]) +def test_Subrs_bad_indices(tmp_path, indices): + # The declared indices must cover 0 to count-1 exactly, so neither an index + # past the end nor a duplicate may reach the returned array. + with pytest.raises(RuntimeError, match='indices do not cover'): + t1f.Type1Font(_write_subrs_pfa(tmp_path / 'x.pfa', indices)) From 37b5f3928bec17cacd3990f5f527ed6f2a8e2341 Mon Sep 17 00:00:00 2001 From: Vladimir Saraikin Date: Fri, 31 Jul 2026 12:30:38 +0300 Subject: [PATCH 4/4] Check the /Subrs index length and bounds --- lib/matplotlib/_type1font.py | 6 +++--- lib/matplotlib/tests/test_type1font.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py index 8f4c9413376c..0b5403a3720a 100644 --- a/lib/matplotlib/_type1font.py +++ b/lib/matplotlib/_type1font.py @@ -646,9 +646,9 @@ def _parse_subrs(self, tokens, _data): "Malformed Type1 font file: Incomplete /Subrs" ) from None - # The indices must cover 0 to count-1 exactly. Compare the length first - # so that a bogus count cannot force a large range() to be built here. - if len(entries) != count or sorted(entries) != list(range(count)): + # The indices must cover 0 to count-1 exactly. + if (len(entries) != count + or (count and (min(entries), max(entries)) != (0, count - 1))): raise RuntimeError( "Malformed Type1 font file: /Subrs indices do not cover " f"0 to {count - 1}" diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py index 95018da31e08..067e9640e409 100644 --- a/lib/matplotlib/tests/test_type1font.py +++ b/lib/matplotlib/tests/test_type1font.py @@ -204,7 +204,7 @@ def test_Subrs_indices(tmp_path): assert len(font.prop['Subrs']) == 2 -@pytest.mark.parametrize('indices', [(0, 5), (0, 0)]) +@pytest.mark.parametrize('indices', [(0, 5), (0, 0), (-1, 1)]) def test_Subrs_bad_indices(tmp_path, indices): # The declared indices must cover 0 to count-1 exactly, so neither an index # past the end nor a duplicate may reach the returned array.