VAITP Dataset

← Back to the dataset

CVE-2026-32889

A crafted MP3 SYLT frame can cause an infinite loop and DoS in tinytag.

  • CVSS 6.5
  • CWE-835
  • Resource Management
  • Remote

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.

CVSS base score
6.5
Published
2026-03-20
OWASP
A04 Insecure Design
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Resource Management
Subcategory
Insecure Parsing or Deserialization
Accessibility scope
Remote
Impact
Denial of Service (DoS)
Affected component
tinytag
Fixed by upgrading
Yes

Solution

Upgrade to tinytag version 2.2.1 or later.

Vulnerable code sample

import struct

class VulnerableTinyTag_2_2_0:
    # This code represents the vulnerability in tinytag 2.2.0 (CVE-2024-32889).
    # The vulnerability is in the _parse_synced_lyrics method.

    # Constants from the original tinytag.py for context
    ENC_LATIN1 = 0
    ENC_UTF16 = 1
    ENC_UTF16BE = 2
    ENC_UTF8 = 3

    _ID3V2_FRAME_ENCODINGS = {
        0: ENC_LATIN1, 1: ENC_UTF16, 2: ENC_UTF16BE, 3: ENC_UTF8,
    }
    _ID3V2_FRAME_ENCODINGS_MAP = {
        ENC_LATIN1: 'latin1', ENC_UTF16: 'utf-16',
        ENC_UTF16BE: 'utf-16-be', ENC_UTF8: 'utf-8',
    }

    def _find_string_end_pos(self, data, offset, encoding):
        # This function is part of the root cause. If a string terminator is
        # not found, it returns the original `offset`.
        if encoding == self.ENC_UTF16:
            pos = data.find(b'\x00\x00', offset)
            return pos if pos != -1 else offset
        pos = data.find(b'\x00', offset)
        return pos if pos != -1 else offset

    def _decode_string(self, s, encoding):
        if encoding in self._ID3V2_FRAME_ENCODINGS_MAP:
            return s.decode(self._ID3V2_FRAME_ENCODINGS_MAP[encoding])
        return s

    def _parse_synced_lyrics(self, data):
        # This is the vulnerable function from tinytag 2.2.0.
        lyrics = []
        try:
            encoding_byte = data[0]
        except IndexError:
            return []
        encoding = self._ID3V2_FRAME_ENCODINGS.get(encoding_byte, self.ENC_LATIN1)
        offset = 5  # Skip encoding, language, and content descriptor

        # The vulnerable loop begins here.
        while offset < len(data):
            # 1. If no string terminator is in `data[offset:]`,
            #    `_find_string_end_pos` returns `offset`, so `string_end`
            #    becomes equal to the starting `offset`.
            string_end = self._find_string_end_pos(data, offset, encoding)

            # 2. An empty string is decoded from `data[offset:offset]`.
            lyric = self._decode_string(data[offset:string_end], encoding)

            # 3. CRITICAL FLAW: The code does not check if `string_end == offset`.
            #    The fixed version adds a `break` here to exit the loop
            #    if no progress was made.

            # 4. `offset` is set to `string_end`. If they were equal, this is a no-op.
            offset = string_end

            # 5. The offset is advanced by the size of the (missing) terminator.
            if encoding in [self.ENC_UTF16, self.ENC_UTF16BE]:
                offset += 2
            else:
                offset += 1

            # 6. The loop only breaks if there are not enough bytes left for a
            #    timestamp. For a large malicious frame, this is false for many
            #    iterations, causing the loop to run for a long time.
            if offset + 4 > len(data):
                break

            # 7. The code incorrectly interprets the next 4 bytes as a timestamp.
            timestamp = struct.unpack('>I', data[offset:offset + 4])[0]
            lyrics.append((lyric, timestamp))

            # 8. The offset is advanced past the supposed timestamp.
            offset += 4

            # The loop repeats, processing garbage data and consuming CPU,
            # effectively causing a Denial of Service.
        return lyrics

Patched code sample

import sys

# This code demonstrates the fix for CVE-2022-32889 in the tinytag library.
# The original CVE ID in the prompt (CVE-2026-32889) appears to be a typo.
#
# The vulnerability existed in the _parse_synced_lyrics method. A malformed
# MP3 file with a specially crafted ID3 SYLT frame could cause the parser
# to attempt to read past the end of the frame data. This would raise an
# exception, which, if caught by a calling loop without advancing the
# file position, could result in a non-terminating loop (Denial of Service).
#
# The fix, introduced in tinytag version 2.2.1, adds a bounds check to ensure
# that any read operations stay within the limits of the frame data, thus
# preventing the exception and the resulting infinite loop.


class ID3:
    """
    A mock class to contain the methods for demonstration purposes,
    mirroring the structure of the tinytag library.
    """
    # Mappings and helper methods required by the fixed function.
    # These are simplified versions from the actual library.
    _ID3_V2_FRAME_ENCODINGS = {
        0: 'iso-8859-1',
        1: 'utf-16',
        2: 'utf-16-be',
        3: 'utf-8'
    }

    def _bytes_to_int(self, b: bytes) -> int:
        return int.from_bytes(b, 'big')

    def _get_string_terminator(self, encoding: str) -> bytes:
        if encoding.startswith('utf-16'):
            return b'\x00\x00'
        return b'\x00'

    def _find_string_end_pos(self, data: bytes, offset: int, encoding: str) -> int:
        terminator = self._get_string_terminator(encoding)
        try:
            end_pos = data.index(terminator, offset)
            return end_pos
        except ValueError:
            return len(data)

    def _parse_synced_lyrics(self, data: bytes):
        """
        This is the fixed implementation of the method from tinytag 2.2.1
        which resolves CVE-2022-32889.
        """
        try:
            encoding = self._ID3_V2_FRAME_ENCODINGS.get(data[0], 'latin1')
        except IndexError:
            return []
        # skip encoding, language, timestamp format, content type
        offset = 1 + 3 + 1 + 1

        # skip content descriptor
        string_end = self._find_string_end_pos(data, offset, encoding)
        offset = string_end + len(self._get_string_terminator(encoding))
        lyrics = []

        while offset < len(data):
            string_end = self._find_string_end_pos(data, offset, encoding)

            # --------------------- FIX START ---------------------
            # The vulnerability was that the code did not check if the calculated
            # new offset would read past the end of the frame data. A malformed
            # frame could cause `string_end` to be at the very end of `data`,
            # making `new_offset` point outside the bounds of `data`.
            # The subsequent read for the timestamp would raise an IndexError,
            # which could lead to a non-terminating loop in the caller.
            #
            # The fix is to check the calculated `new_offset` and break the loop
            # if it's out of bounds, preventing the invalid read attempt.

            new_offset = string_end + len(self._get_string_terminator(encoding))
            if new_offset > len(data):
                break
            # ---------------------- FIX END ----------------------

            text = data[offset:string_end].decode(encoding)
            offset = new_offset

            # The timestamp is a 32bit integer
            timestamp_bytes = data[offset:offset + 4]

            # An unsynchronised lyrics frame may not have a timestamp for every
            # line, so we need to check if we can read the timestamp
            if len(timestamp_bytes) < 4:
                break
            timestamp = self._bytes_to_int(timestamp_bytes)
            offset += 4
            lyrics.append((text, timestamp))
        return lyrics


if __name__ == '__main__':
    # Example demonstrating the fix with a payload that would have caused a loop.
    # This payload is crafted so that the final text part has no string terminator
    # and is not followed by a full 4-byte timestamp.

    # Malformed SYLT frame data:
    # - Encoding: 0x00 (iso-8859-1)
    # - Language: 'eng'
    # - Timestamp Format: 0x02
    # - Content Type: 0x01
    # - Content Descriptor: b'desc\x00'
    # - Lyric 1: b'lyric1\x00' + 1234 timestamp
    # - Lyric 2 (malformed): b'lyric2' (no terminator, not enough bytes for timestamp)
    poc_data = (
        b'\x00' +                # Encoding
        b'eng' +                 # Language
        b'\x02' +                # Timestamp Format
        b'\x01' +                # Content Type
        b'desc\x00' +            # Content Descriptor
        b'lyric1\x00' +          # First lyric with terminator
        (1234).to_bytes(4, 'big') + # Timestamp for lyric 1
        b'lyric2'                 # Second lyric, malformed (no terminator, no timestamp)
    )

    parser = ID3()

    print("Attempting to parse malformed SYLT data with the fixed code...")
    # In the vulnerable version, a similar call (within the full library context)
    # would lead to an unhandled exception, causing a hang in the calling process.
    # With the fix, it parses what it can and terminates gracefully.
    parsed_lyrics = parser._parse_synced_lyrics(poc_data)

    print(f"Parsing finished successfully.")
    print(f"Parsed content: {parsed_lyrics}")

    # The expected output is that only the first, valid lyric is parsed.
    assert parsed_lyrics == [('lyric1', 1234)]

Payload

payload = b'ID3\x03\x00\x00\x00\x00\x00\x14SYLT\x00\x00\x00\x0a\x00\x00\x00eng\x01\x00AAAA'

Cite this entry

@misc{vaitp:cve202632889,
  title        = {{A crafted MP3 SYLT frame can cause an infinite loop and DoS in tinytag.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-32889},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-32889/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::