VYPR
Critical severityNVD Advisory· Published Aug 20, 2021· Updated Aug 4, 2024

CVE-2020-36474

CVE-2020-36474

Description

SafeCurl before 0.9.2 has a DNS rebinding vulnerability.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

SafeCurl before 0.9.2 is vulnerable to DNS rebinding, allowing an attacker to bypass SSRF protections and access internal services.

Vulnerability

SafeCurl, a PHP library for SSRF protection in cURL requests, before version 0.9.2 is vulnerable to DNS rebinding attacks [1][2]. The library resolves a hostname to an IP address and validates it against a blocklist, but does not pin the resolved IP after validation. An attacker can control a DNS server that initially returns a safe IP, then after validation, returns a malicious internal IP, causing the cURL request to be sent to the attacker's target [2][3].

Exploitation

An attacker must control a domain name that they can make the application request via SafeCurl. The attacker's DNS server initially responds with a safe IP (e.g., a public IP not in the blocklist). After SafeCurl validates the IP, the attacker changes the DNS response to point to an internal IP (e.g., 169.254.169.254 for cloud metadata). The subsequent cURL request then goes to the internal service [2][3]. No authentication is required beyond the ability to trigger a request to the attacker-controlled domain.

Impact

Successful exploitation allows an attacker to bypass SSRF protections and make requests to internal network services, such as cloud metadata endpoints or other internal systems, potentially leading to information disclosure or further compromise [3]. The attacker gains the ability to interact with internal services that should be inaccessible from the internet.

Mitigation

The vulnerability is fixed in SafeCurl version 0.9.2, released on 2021-08-20 [1]. The fix adds DNS pinning after host validation, ensuring that the resolved IP used for the actual request matches the validated IP [2]. Users should upgrade to version 0.9.2 or later. No workaround is documented; upgrading is the recommended action.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
vanilla/safecurlPackagist
< 0.9.20.9.2

Affected products

3

Patches

1
4552574b5908

Merge pull request #2 from vanilla/fix/ssrf-dns-rebinding

https://github.com/vanilla/safecurlTodd BurryOct 30, 2020via osv
4 files changed · +236 23
  • .gitignore+5 4 modified
    @@ -1,5 +1,6 @@
    -.idea
    -.php_cs.cache
    -build
    -composer.lock
     vendor
    +composer.lock
    +build
    +.php_cs.cache
    +.idea/*
    +.phpunit.result.cache
    
  • src/CurlHandler.php+94 0 added
    @@ -0,0 +1,94 @@
    +<?php
    +/**
    + * @copyright 2009-2019 Vanilla Forums Inc.
    + * @license MIT
    + */
    +
    +namespace Garden\SafeCurl;
    +
    +/**
    + * Class CurlHandler
    + *
    + * A wrapper for cURL methods.
    + * Allows for a more object-oriented style
    + * Allows for better unit testing (stub out this class and prevent actual network calls)
    + *
    + * @package Garden\SafeCurl
    + */
    +class CurlHandler
    +{
    +    /**
    +     * @var resource
    +     */
    +    private $curlResource;
    +
    +    /**
    +     * CurlHandler constructor.
    +     * @param $curlResource
    +     */
    +    public function __construct($curlResource) {
    +        $this->curlResource = $curlResource;
    +    }
    +
    +    /**
    +     * Runs curl_exec with the local resource
    +     *
    +     * @see curl_exec()
    +     * @return bool|string
    +     */
    +    public function execute() {
    +        return curl_exec($this->curlResource);
    +    }
    +
    +    /**
    +     * Runs get_error with the local resource
    +     *
    +     * @see curl_error()
    +     * @return string
    +     */
    +    public function getError(): string {
    +        return curl_error($this->curlResource);
    +    }
    +
    +    /**
    +     * Runs curl_errno with the local resource
    +     *
    +     * @see curl_errno()
    +     * @return int
    +     */
    +    public function getErrorNumber(): int {
    +        return curl_errno($this->curlResource);
    +    }
    +
    +    /**
    +     * Runs curl_getinfo with the local resource
    +     *
    +     * @see curl_getinfo()
    +     * @param int|null $option
    +     * @return mixed
    +     */
    +    public function getInfo(int $option = null) {
    +        return curl_getinfo($this->curlResource, $option);
    +    }
    +
    +    /**
    +     * Runs curl_version with the local resource
    +     *
    +     * @see curl_version()
    +     * @return array
    +     */
    +    public function getVersion(): array {
    +        return curl_version();
    +    }
    +
    +    /**
    +     * Runs curl_setopt with the local resource
    +     *
    +     * @see curl_setopt()
    +     * @param $option
    +     * @param $value
    +     */
    +    public function setOption($option, $value): void {
    +        curl_setopt($this->curlResource, $option, $value);
    +    }
    +}
    
  • src/SafeCurl.php+52 16 modified
    @@ -7,6 +7,8 @@
     namespace Garden\SafeCurl;
     
     use Garden\SafeCurl\Exception\CurlException;
    +use Garden\SafeCurl\Exception\InvalidURLException;
    +use InvalidArgumentException;
     
     /**
      * A wrapper to curl_exec for safely executing requests.
    @@ -15,7 +17,7 @@ class SafeCurl {
     
         private const CURL_RESOURCE_TYPE = "curl";
     
    -    /** @var resource */
    +    /** @var CurlHandler */
         private $curlHandle;
     
         /** @var boolean */
    @@ -33,7 +35,7 @@ class SafeCurl {
         /**
          * Setup the instance.
          *
    -     * @param resource $curlHandle
    +     * @param resource|CurlHandler $curlHandle
          * @param UrlValidator $urlValidator
          */
         public function __construct($curlHandle, ?UrlValidator $urlValidator = null) {
    @@ -56,18 +58,18 @@ public function execute($url) {
             do {
                 $url = $this->urlValidator->validateUrl($url);
     
    -            curl_setopt($this->curlHandle, CURLOPT_URL, $url["url"]);
    +            $this->curlHandle->setOption(CURLOPT_URL, $url["url"]);
     
    -            $response = curl_exec($this->curlHandle);
    +            $response = $this->curlHandle->execute();
     
    -            if (curl_errno($this->curlHandle)) {
    -                $error = curl_error($this->curlHandle);
    +            if ($this->curlHandle->getErrorNumber()) {
    +                $error = $this->curlHandle->getError();
                     throw new CurlException($error);
                 }
     
                 //Check for an HTTP redirect.
                 if ($this->shouldFollowLocation()) {
    -                $statusCode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE);
    +                $statusCode = $this->curlHandle->getInfo(CURLINFO_HTTP_CODE);
                     switch ($statusCode) {
                         case 301:
                         case 302:
    @@ -76,7 +78,7 @@ public function execute($url) {
                         case 308:
                             //Redirect received, so rinse and repeat.
                             if (0 === $redirectLimit || ++$redirectCount < $redirectLimit) {
    -                            $url = curl_getinfo($this->curlHandle, CURLINFO_REDIRECT_URL);
    +                            $url = $this->curlHandle->getInfo(CURLINFO_REDIRECT_URL);
                                 $redirected = true;
                             } else {
                                 throw new Exception("Redirect limit exceeded.");
    @@ -114,22 +116,22 @@ public function getFollowLocationLimit(): int {
          */
         protected function init(): void {
             //To start with, disable FOLLOWLOCATION since we'll handle it.
    -        curl_setopt($this->curlHandle, CURLOPT_FOLLOWLOCATION, false);
    +        $this->curlHandle->setOption(CURLOPT_FOLLOWLOCATION, false);
     
    -        curl_setopt($this->curlHandle, CURLOPT_RETURNTRANSFER, true);
    +        $this->curlHandle->setOption(CURLOPT_RETURNTRANSFER, true);
     
             //Force IPv4, since this class isn't yet comptible with IPv6.
    -        $curlVersion = curl_version();
    +        $curlVersion = $this->curlHandle->getVersion();
             if ($curlVersion["features"] & CURLOPT_IPRESOLVE) {
    -            curl_setopt($this->curlHandle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
    +            $this->curlHandle->setOption(CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
             }
         }
     
         /**
          * Remove headers from an output string, based
          *
          * @param string $output
    -     * @param resource $curlHandle
    +     * @param int $headersLength
          * @return string
          */
         private function removeHeadersFromOutput(string $output, int $headersLength): string {
    @@ -146,11 +148,17 @@ private function removeHeadersFromOutput(string $output, int $headersLength): st
         /**
          * Sets cURL handle.
          *
    -     * @param resource $curlHandle
    +     * @param resource|CurlHandler $curlHandle
          */
         public function setCurlHandle($curlHandle): void {
    -        $this->validateCurlHandle($curlHandle);
    -        $this->curlHandle = $curlHandle;
    +        if (is_resource($curlHandle)){
    +            $this->validateCurlHandle($curlHandle);
    +            $this->curlHandle = new CurlHandler($curlHandle);
    +        } elseif ($curlHandle instanceof CurlHandler) {
    +            $this->curlHandle = $curlHandle;
    +        } else {
    +            throw new InvalidArgumentException('curlHandle must be a resource or instance of Garden\SafeCurl\CurlHandler');
    +        }
         }
     
         /**
    @@ -171,6 +179,34 @@ public function setFollowLocationLimit(int $limit): void {
             $this->followLocationLimit = $limit;
         }
     
    +    /**
    +     * After a host's IPs have been resolved, we set them as a cURL option.
    +     * This prevents the use of DNS rebinding as an SSRF attack
    +     *
    +     * @param array $url
    +     */
    +    private function setHostIPs(array $url): void {
    +        $port = parse_url($url['url'], PHP_URL_PORT);
    +        if (is_null($port) ){
    +            $scheme = parse_url($url['url'], PHP_URL_SCHEME);
    +            switch($scheme){
    +                case 'https':
    +                    $port = 443;
    +                    break;
    +                case 'http':
    +                default:
    +                    $port = 80;
    +                    break;
    +            };
    +        }
    +
    +        $resolves = [];
    +        foreach ($url['ips'] as $url_ip) {
    +            $resolves[] = "{$url['host']}:$port:$url_ip";
    +        }
    +        $this->curlHandle->setOption(CURLOPT_RESOLVE, $resolves);
    +    }
    +
         /**
          * Set whether or not headers should be included in the output of the request.
          *
    
  • tests/SafeCurlTest.php+85 3 modified
    @@ -6,6 +6,7 @@
     
     namespace Garden\SafeCurl\Tests;
     
    +use Garden\SafeCurl\CurlHandler;
     use Garden\SafeCurl\Exception;
     use Garden\SafeCurl\Exception\CurlException;
     use Garden\SafeCurl\SafeCurl;
    @@ -20,6 +21,35 @@
      */
     class SafeCurlTest extends TestCase {
     
    +    private $curlHandler;
    +
    +    /**
    +     * Invoke an other protected method on an object
    +     *
    +     * @param $object
    +     * @param $methodName
    +     * @param array $parameters
    +     * @return mixed
    +     * @throws \ReflectionException
    +     */
    +    public function invokeMethod(&$object, $methodName, array $parameters = array())
    +    {
    +        $reflection = new \ReflectionClass(get_class($object));
    +        $method = $reflection->getMethod($methodName);
    +        $method->setAccessible(true);
    +
    +        return $method->invokeArgs($object, $parameters);
    +    }
    +
    +    protected function setUp(): void {
    +        parent::setUp();
    +        $this->curlHandler = $this->createStub(CurlHandler::class);
    +        $this->curlHandler
    +            ->expects($this->any())
    +            ->method('getVersion')
    +            ->willReturn(curl_version());
    +    }
    +
         /**
          * Verify the ability to retrieve a normal URL using the default configuration.
          */
    @@ -34,10 +64,11 @@ public function testFunctionnalGet() {
     
         /**
          * Verify a valid cURL handle is required to use the class.
    +     *
          */
         public function testBadCurlHandler() {
             $this->expectException(InvalidArgumentException::class);
    -        $this->expectExceptionMessage("Invalid cURL handle provided.");
    +        $this->expectExceptionMessage('curlHandle must be a resource or instance of Garden\SafeCurl\CurlHandler');
             new SafeCurl(null);
         }
     
    @@ -170,16 +201,67 @@ public function testWithFollowLocation() {
             $this->assertNotEmpty($response);
         }
     
    +    public function testSettingHostIPs() {
    +        $url = [
    +            'url' => 'http://example.com',
    +            'host' => 'example.com',
    +            'ips' => [
    +                '1.2.3.4'
    +            ]
    +        ];
    +
    +        $option = CURLOPT_RESOLVE;
    +        $value = [
    +            'example.com:80:1.2.3.4'
    +        ];
    +
    +
    +        $setOptionValue = null;
    +        $this->curlHandler
    +            ->expects($this->any())
    +            ->method('setOption')
    +            ->will(
    +                $this->returnCallback(function($op, $val) use($option, &$setOptionValue){
    +                    if($op === $option){
    +                        $setOptionValue = $val;
    +                    }
    +                })
    +            )
    +        ;
    +
    +        $safeCurl = new SafeCurl($this->curlHandler);
    +        $this->invokeMethod($safeCurl, 'setHostIPs', [$url]);
    +        $this->assertSame($value, $setOptionValue);
    +    }
    +
         /**
          * Verify blocking a URL that redirects to a blacklisted IP address.
          */
         public function testWithFollowLocationLeadingToABlockedUrl() {
             $this->expectException(InvalidURLException::class);
             $this->expectExceptionMessage("Port is not whitelisted.");
     
    -        $safeCurl = new SafeCurl(curl_init());
    +        $httpCode = 301;
    +        $redirectUrl = 'http://0.0.0.0:123';
    +
    +        $this->curlHandler
    +            ->expects($this->any())
    +            ->method('getInfo')
    +            ->will(
    +                $this->returnCallback(function($option) use($httpCode, $redirectUrl) {
    +                    if (CURLINFO_HTTP_CODE === $option) {
    +                        return $httpCode;
    +                    }
    +                    if (CURLINFO_REDIRECT_URL === $option) {
    +                        return $redirectUrl;
    +                    }
    +                })
    +            )
    +        ;
    +
    +        $safeCurl = new SafeCurl($this->curlHandler);
             $safeCurl->setFollowLocation(true);
    -        $safeCurl->execute("http://httpbin.org/redirect-to?url=http://0.0.0.0:123");
    +        $safeCurl->execute("http://httpbin.org/redirect-to?url=$redirectUrl");
         }
     
         /**
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.