Skip to content

Possible SSRF Exploitation via Scheme Validation Bypass #573

Description

@karthi-the-hacker

Vulnerability Name: Server-Side Request Forgery (SSRF) via Unvalidated URL Schemes

Severity: Critical

CWE: CWE-918 (Server-Side Request Forgery (SSRF))

OWASP Category: OWASP Top 10 - A04:2021 Insecure Deserialization

Description:
The isHttp() function returns true when a URI does not contain an explicit scheme, allowing scheme-less or protocol-relative URLs to bypass validation. When combined with the URI resolution logic in resolveUri(), this may enable requests to internal resources, private IP addresses, or cloud metadata endpoints, potentially leading to Server-Side Request Forgery (SSRF) if applications process untrusted user-supplied URLs.

Affected Files:

Vulnerable Code:

function isHttp(string $uri): bool
{
    $result = preg_match('/^(\w+):/', $uri, $matches);
    if ($result !== false && $result > 0) {
        return in_array(strtolower($matches[1]), ['http', 'https'], true);
    }

    return true;  // VULNERABLE: Returns true for empty/invalid schemes
}

// In Extractor.php:
public function resolveUri($uri): UriInterface
{
    if (is_string($uri)) {
        if (!isHttp($uri)) {  // This check can be bypassed
            throw new InvalidArgumentException(sprintf('Uri string must use http or https scheme (%s)', $uri));
        }

        $uri = $this->crawler->createUri($uri);
    }

    return resolveUri($this->uri, $uri);
}

Root Cause:

The isHttp() function has a logic flaw: it returns true by default for URIs without a scheme. This combined with URI resolution logic allows bypass of scheme validation:

  1. URL without scheme (e.g., //internal.local/admin) passes validation
  2. Protocol-relative URLs are resolved against the base URL scheme
  3. File:// URLs can be accessed if the base URL is file://
  4. Attacker can craft URLs targeting:
    • Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
    • Localhost (127.0.0.1, localhost)
    • Cloud metadata endpoints (169.254.169.254)
    • Private DNS records

Impact:

  • Access Internal Services: Attackers can scan/access internal APIs, databases, admin panels
  • Cloud Metadata Access: On AWS/GCP/Azure, can steal temporary credentials from metadata endpoints
  • Credential Theft: Access to internal service authentication tokens
  • Information Disclosure: Enumeration of internal network topology
  • Denial of Service: Attacks on internal services through the library

Exploitation Steps:

  1. Attacker identifies application uses Embed library
  2. Attacker submits URLs targeting:
    • Private metadata: http://169.254.169.254/latest/meta-data/iam/security-credentials/
    • Internal services: http://internal-api.local/admin
    • Localhost: http://localhost:8080/admin
  3. Library validates URI (passes due to default true return)
  4. Curl makes request to internal resource
  5. Response data is parsed and may be disclosed to attacker
  6. Posible to perfome XSPA (Cross Site Port Attack)

POC Video : https://youtu.be/S8IoZHeaGa0

Proof of Concept:

$embed = new Embed\Embed();

// Attack 1: Access AWS metadata
$info = $embed->get('http://169.254.169.254/latest/meta-data/iam/security-credentials/');

// Attack 2: Protocol-relative URL to internal service
$info = $embed->get('//internal-database.local:5432/');

// Attack 3: Private IP access
$info = $embed->get('http://10.0.0.1/admin');

// Attack 4: XSPA
$info = $embed->get('http://127.0.0.1:8080');//posible to scan internal or lan port with local IP

<!-- Failed to upload "Embed-ssrf-poc.mp4" -->

<!-- Failed to upload "Embed-ssrf-poc.mp4" -->

Remediation:

Implement strict URL validation with whitelist approach:

  1. Validate URL scheme is http/https
  2. Reject private/internal IP ranges
  3. Reject cloud metadata endpoints
  4. Implement request filtering
function isHttp(string $uri): bool
{
    $result = preg_match('/^(\w+):/', $uri, $matches);
    if ($result === 1) {
        $scheme = strtolower($matches[1]);
        return in_array($scheme, ['http', 'https'], true);
    }
    
    // SECURE: Reject URIs without explicit http/https scheme
    return false;
}

function isBlockedUrl(UriInterface $uri): bool
{
    $host = $uri->getHost();
    
    // Reject localhost variants
    if (in_array($host, ['localhost', '127.0.0.1', '::1', '0.0.0.0'], true)) {
        return true;
    }
    
    // Reject private IP ranges
    $ip = @ip2long($host);
    if ($ip !== false) {
        // 10.0.0.0/8
        if (($ip >= 167772160 && $ip <= 184549375)) return true;
        // 172.16.0.0/12
        if (($ip >= 2886729728 && $ip <= 2887778303)) return true;
        // 192.168.0.0/16
        if (($ip >= 3232235520 && $ip <= 3232301055)) return true;
        // 127.0.0.0/8
        if (($ip >= 2130706432 && $ip <= 2147483647)) return true;
    }
    
    // Reject cloud metadata endpoints
    if (in_array($host, ['169.254.169.254', 'metadata.google.internal'], true)) {
        return true;
    }
    
    return false;
}

Fixed Code Example:

// src/functions.php
function isHttp(string $uri): bool
{
    $result = preg_match('/^(\w+):/', $uri, $matches);
    if ($result === 1) {
        return in_array(strtolower($matches[1]), ['http', 'https'], true);
    }
    return false;  // SECURE: Default to false for schemeless URIs
}

// src/Extractor.php
public function resolveUri($uri): UriInterface
{
    if (is_string($uri)) {
        if (!isHttp($uri)) {
            throw new InvalidArgumentException(sprintf('Uri string must use http or https scheme (%s)', $uri));
        }
        $uri = $this->crawler->createUri($uri);
    }

    $resolved = resolveUri($this->uri, $uri);
    
    // SECURE: Validate resolved URI is safe
    if (isBlockedUrl($resolved)) {
        throw new InvalidArgumentException(sprintf('Access to this URL is blocked for security reasons (%s)', $resolved));
    }
    
    return $resolved;
}

function isBlockedUrl(\Psr\Http\Message\UriInterface $uri): bool
{
    $host = $uri->getHost();
    if ($host === null || $host === '') {
        return true;
    }
    
    // Reject localhost variants
    if (in_array($host, ['localhost', '127.0.0.1', '::1', '0.0.0.0'], true)) {
        return true;
    }
    
    // Reject private IP ranges
    $ip = @ip2long($host);
    if ($ip !== false) {
        // 10.0.0.0/8
        if (($ip >= 167772160 && $ip <= 184549375)) return true;
        // 172.16.0.0/12
        if (($ip >= 2886729728 && $ip <= 2887778303)) return true;
        // 192.168.0.0/16
        if (($ip >= 3232235520 && $ip <= 3232301055)) return true;
        // 127.0.0.0/8
        if (($ip >= 2130706432 && $ip <= 2147483647)) return true;
    }
    
    // Reject cloud metadata endpoints
    if (in_array($host, ['169.254.169.254', 'metadata.google.internal'], true)) {
        return true;
    }
    
    return false;
}

References:


Activity

  1. markfullmer commented on Oct 1, 2026

    @markfullmer

    I can confirm that this is technically an SSRF vulnerability. I have provided a resolution in #578 for review. Thanks!

  2. Vitorinox commented on Oct 2, 2026

    @Vitorinox
    Collaborator

    Thanks for working on this! I tested the branch locally against master (c45a900) and found a few issues:

    1. The main PoC from Possible SSRF Exploitation via Scheme Validation Bypass #573 is still exploitable. isValidUrl() is only called from Extractor::resolveUri(), but Embed::get()/getMulti() send the request directly via the Crawler. $embed->get('http://127.0.0.1:PORT/secret') (and 169.254.169.254, 10.x, localhost) still fetch and return the internal page. HTTP redirects (curl FOLLOWLOCATION) to internal hosts are also not checked.
    2. Regression with relative URLs. Making isHttp() return false for scheme-less strings means resolveUri('/img.png'), '../x', '//cdn.example.com/x' now throw. Relative og:image, icons, feeds and oEmbed links are silently dropped, and $info->languages throws an uncaught InvalidArgumentException on pages with relative hreflang links. The suite goes from 1 failure on master to 18 (17 new failures in PagesTest).
    3. Range gaps: FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE still allows 100.100.100.200 (Alibaba metadata), 100.64.0.0/10, 198.18.0.0/15 and 64:ff9b::/96. Consider FILTER_FLAG_GLOBAL_RANGE (PHP >= 8.2) or an explicit blocklist. FILTER_VALIDATE_URL also rejects valid hosts with underscores and non-punycode IDNs.
    4. The new tests depend on live DNS (example.com, foo.com), which makes them flaky offline/in CI.

    Suggestion: keep isHttp() permissive for relative refs and validate the resolved absolute URI after resolveUri(). Add the same check in Embed::get()/getMulti() and on every redirect hop (or disable FOLLOWLOCATION and follow redirects manually). Ideally pin the validated IP with CURLOPT_RESOLVE to avoid DNS rebinding, and restrict CURLOPT_PROTOCOLS/CURLOPT_REDIR_PROTOCOLS to HTTP(S).

  3. markfullmer commented on Oct 5, 2026

    @markfullmer

    Thanks for the feedback, @Vitorinox . I've added a series of commits that address the remaining issues you called out. Full disclosure: some of the reworking of cURL behavior was beyond my area of expertise, so I used an LLM to assist with staging that code. I then performed a code review to confirm that it looked technically accurate and can confirm that automated tests are continuing to behave the same way as they were before. I also added new test coverage to demonstrate that direct calls to Embed::get() and Embed::getMulti() have URL validation. Summary of changes:

    Regression with relative URLs. Making isHttp() return false for scheme-less strings means resolveUri('/img.png'), '../x', '//cdn.example.com/x' now throw ... Suggestion: keep isHttp() permissive for relative refs

    This is done in 31aa303

    Range gaps: FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE still allows 100.100.100.200 (Alibaba metadata), 100.64.0.0/10, 198.18.0.0/15 and 64:ff9b::/96. Consider FILTER_FLAG_GLOBAL_RANGE (PHP >= 8.2) or an explicit blocklist.

    These range gaps are covered using FILTER_FLAG_GLOBAL_RANGE, with test coverage added, in 3faf69e . I chose to leave this fix for PHP >= 8.2 rather than creating an explicit blocklist; if you think it's important to provide coverage for PHP < 8.2, let me know.

    The main PoC from #573 is still exploitable. isValidUrl() is only called from Extractor::resolveUri(), but Embed::get()/getMulti() send the request directly via the Crawler.

    Done in 1bbcb91 , with test coverage demonstrating the fix.

    I also added checking after the RedirectUri is obtained in 24661be

    Ideally pin the validated IP with CURLOPT_RESOLVE to avoid DNS rebinding

    This is the most complicated code change, replacing default cURL parameters with custom ones to avoid SSRF rebinding in scenarios where a DNS has an extremely short or nonexistent TTL: 35c8560 . This also adds a getValidUrlIps() wrapper to return an array of valid IPs, relevant for URLs with redirects.

    restrict CURLOPT_PROTOCOLS/CURLOPT_REDIR_PROTOCOLS to HTTP(S).

    This and other hardening is performed in
    d6f6915
    3550e83
    0810bef

  4. karthi-the-hacker commented on Oct 8, 2026

    @karthi-the-hacker
    Author

    Thanks for addressing the SSRF issue and the additional bypasses.

    Since the vulnerability has now been patched and regression tests have been added, I'd like to request that this be tracked as a GitHub Security Advisory and assigned a CVE.

    Could you please create a draft security advisory for the issue and request a CVE ID through GitHub?

    I'm happy to provide any additional information needed for the advisory, including affected versions, the original PoC, impact, and the timeline.

    Please also credit karthi-the-hacker as the security researcher in the advisory.

  5. added a commit that references this issue on Oct 9, 2026
  6. added a commit that references this issue on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions