Skip to content

Possible SSRF Exploitation via Scheme Validation Bypass #573

@karthi-the-hacker

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:


Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions