CVE-2026-32889
Description
tinytag is a Python library for reading audio file metadata. Version 2.2.0 allows an attacker who can supply MP3 files for parsing to trigger a non-terminating loop while the library parses an ID3v2 SYLT (synchronized lyrics) frame. In server-side deployments that automatically parse attacker-supplied files, a single 498-byte MP3 can cause the parsing operation to stop making progress and remain busy until the worker or process is terminated. The root cause is that _parse_synced_lyrics assumes _find_string_end_pos always returns a position greater than the current offset. That assumption is false when no string terminator is present in the remaining frame content. This issue has been fixed in version 2.2.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
tinytagPyPI | < 2.2.1 | 2.2.1 |
Affected products
1- tinytag/tinytagv5Range: >= 2.2.0, < 2.2.1
Patches
344e496310f7cID3: Bail out if SYLT offset exceeds content length
4 files changed · +29 −0
tinytag/tests/samples/synced_lyrics_no_terminator_latin1.mp3+0 −0 addedtinytag/tests/samples/synced_lyrics_no_terminator_utf16.mp3+0 −0 addedtinytag/tests/test_all.py+27 −0 modified@@ -667,6 +667,33 @@ 'duration': 0.1306122448979592, 'samplerate': 44100, }), + ('synced_lyrics_no_terminator_latin1.mp3', { + 'other': OtherFields({ + 'lyrics': ['[18246:35.58]\n[18246:35.58]\n[18246:35.58]\n' + '[18246:35.58]\n[18246:35.58]\n[18246:35.58]\n' + '[18246:35.58]\n[18246:35.58]\n[18246:35.58]\n' + '[18246:35.58]\n[18246:35.58]\n[18246:35.58]'], + }), + 'filesize': 498, + 'bitrate': 128.0, + 'channels': 2, + 'duration': 0.026122448979591838, + 'samplerate': 44100, + }), + ('synced_lyrics_no_terminator_utf16.mp3', { + 'other': OtherFields({ + 'lyrics': ['[71580:52.86]\n[18175:35.68]\n[18175:35.68]\n' + '[18175:35.68]\n[18175:35.68]\n[18175:35.68]\n' + '[18175:35.68]\n[18175:35.68]\n[18175:35.68]\n' + '[18175:35.68]\n[18175:35.68]\n[18175:35.68]\n' + '[18175:35.68]'], + }), + 'filesize': 507, + 'bitrate': 128.0, + 'channels': 2, + 'duration': 0.026122448979591838, + 'samplerate': 44100, + }), ('empty.ogg', { 'other': OtherFields(), 'duration': 3.684716553287982,
tinytag/tinytag.py+2 −0 modified@@ -1233,6 +1233,8 @@ def _parse_synced_lyrics(self, content: bytes) -> str: value = self._decode_string( encoding + content[offset:end_pos]).lstrip('\n') offset = end_pos + if offset + 4 > content_length: + break time = unpack('>I', content[offset:offset + 4])[0] offset += 4 if found_line:
5cd321521ff0ID3: Ensure string end position is never smaller than start position
1 file changed · +4 −3
tinytag/tinytag.py+4 −3 modified@@ -1343,13 +1343,14 @@ def _find_string_end_pos(content: bytes, start_pos: int = 0) -> int: # latin1 and utf-8 are 1 byte if encoding in {b'\x00', b'\x03'}: - return content.find(b'\x00', start_pos) + 1 - end_pos = 0 + end_pos = content.find(b'\x00', start_pos) + return start_pos if end_pos < 0 else end_pos + 1 + end_pos = -1 for i in range(start_pos, len(content), 2): if content[i:i + 2] == b'\x00\x00': end_pos = i + 2 break - return end_pos + return start_pos if end_pos < 0 else end_pos def _decode_string(self, value: bytes, language: bool = False) -> str: default_encoding = 'ISO-8859-1'
4d649b9c314aID3: Make synced lyrics available in 'other.lyrics' (LRC format) (#270)
4 files changed · +63 −1
tinytag/tests/samples/synced_lyrics_invalid.mp3+0 −0 addedtinytag/tests/samples/synced_lyrics_milliseconds.mp3+0 −0 addedtinytag/tests/test_all.py+21 −0 modified@@ -646,6 +646,27 @@ 'album': 'some album', 'artist': 'some artist', }), + ('synced_lyrics_milliseconds.mp3', { + 'other': OtherFields({ + 'lyrics': ['[00:00.00]\n[00:01.00]first line\n[00:01.55]second ' + 'line\n[00:05.99]third line'], + }), + 'filesize': 2007, + 'bitrate': 57.39124999999999, + 'channels': 1, + 'duration': 0.1306122448979592, + 'samplerate': 44100, + }), + ('synced_lyrics_invalid.mp3', { + 'other': OtherFields({ + 'lyrics': ['\nfirst line\nsecond line\nthird line'], + }), + 'filesize': 2007, + 'bitrate': 57.39124999999999, + 'channels': 1, + 'duration': 0.1306122448979592, + 'samplerate': 44100, + }), ('empty.ogg', { 'other': OtherFields(), 'duration': 3.684716553287982,
tinytag/tinytag.py+42 −1 modified@@ -798,6 +798,7 @@ class _ID3(TinyTag): _EMPTY_FRAME_IDS = {b'\x00\x00\x00\x00', b'\x00\x00\x00'} _IMAGE_FRAME_IDS = {b'APIC', b'PIC'} _CUSTOM_FRAME_IDS = {b'TXXX', b'TXX'} + _SYNCED_LYRICS_FRAME_IDS = {b'SYLT', b'SLT'} _IGNORED_FRAME_IDS = { b'AENC', b'CRA', b'APIC', b'PIC', @@ -826,7 +827,6 @@ class _ID3(TinyTag): b'SEEK', b'SIGN', b'SYTC', b'STC', - b'SYLT', b'SLT', } _ID3V1_TAG_SIZE = 128 _MAX_ESTIMATION_SEC = 30.0 @@ -1209,6 +1209,44 @@ def _parse_image(self, return self._create_tag_image( content[desc_end_pos:], pic_type, mime_type, desc) + @staticmethod + def _lrc_timestamp(seconds: float) -> str: + cs = int(seconds * 100) + minutes, cs = divmod(cs, 6000) + seconds, cs = divmod(cs, 100) + return f"{minutes:02d}:{seconds:02d}.{cs:02d}" + + def _parse_synced_lyrics(self, content: bytes) -> str: + # Convert ID3 synced lyrics to LRC format + content_length = len(content) + encoding = content[:1] + # skip language (3) + timestamp_format = content[4:5] + # skip content type (1) + start_pos = 6 + end_pos = self._find_string_end_pos(content, encoding, start_pos) + lyrics = "" + offset = end_pos + found_line = False + while offset < content_length: + end_pos = self._find_string_end_pos(content, encoding, offset) + value = self._decode_string( + encoding + content[offset:end_pos]).lstrip('\n') + offset = end_pos + time = unpack('>I', content[offset:offset + 4])[0] + offset += 4 + if found_line: + lyrics += '\n' + found_line = True + if timestamp_format == b'\x02': + # time in milliseconds + timestamp = self._lrc_timestamp(time / 1000) + else: + lyrics += value + continue + lyrics += f'[{timestamp}]{value}' + return lyrics + def _parse_frame(self, fh: BinaryIO, total_size: int, @@ -1275,6 +1313,9 @@ def _parse_frame(self, should_set_field = False if should_set_field: self._set_field(fieldname, value) + elif self._parse_tags and frame_id in self._SYNCED_LYRICS_FRAME_IDS: + lyrics = self._parse_synced_lyrics(fh.read(frame_size)) + self._set_field('other.lyrics', lyrics) elif self._parse_tags and frame_id in self._CUSTOM_FRAME_IDS: # custom fields value = self._decode_string(fh.read(frame_size))
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/tinytag/tinytag/commit/44e496310f7ced8077e9087e3774acbaa324b18anvdPatchWEB
- github.com/tinytag/tinytag/commit/4d649b9c314ada8ff8a74e0469e9aadb3acb252anvdPatchWEB
- github.com/tinytag/tinytag/commit/5cd321521ff097e41724b601d7e3d7adc7e53402nvdPatchWEB
- github.com/tinytag/tinytag/security/advisories/GHSA-f4rq-2259-hv29nvdExploitPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-f4rq-2259-hv29ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32889ghsaADVISORY
News mentions
0No linked articles in our index yet.