Modern Web Exploitation Techniques  

SSRF Basic Filter Bypasses


Server-Side Request Forgery (SSRF) vulnerabilities occur when an attacker can coerce the server to fetch remote resources using HTTP requests; this might allow an attacker to identify and enumerate services running on the local network of the web server, which an external attacker would generally be unable to access due to a firewall blocking access. For more details on SSRF, check out the Server-side Attacks module.


Confirming SSRF

Let us consider the following vulnerable web application to illustrate how a developer might address SSRF vulnerabilities. Since this is just a quick recap of SSRF vulnerabilities, we will not go over the steps of Whitebox penetration testing in detail.

Code Review - Identifying the Vulnerability

Our sample web application allows us to take screenshots of websites we provide URLs for:

Let's look at the source code to determine how this is implemented. The web application contains two endpoints. The first one handles taking screenshots, while the second endpoint responds with a debug page and is only accessible from localhost:

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'GET':
        return render_template('index.html')
   
    try:
        screenshot = screenshot_url(request.form.get('url'))
    except Exception as e:
        return f'Error: {e}', 400

    # b64 encode image
    image = Image.open(screenshot)
    buffered = BytesIO()
    image.save(buffered, format="PNG")
    img_data = base64.b64encode(buffered.getvalue())

    return render_template('index.html', screenshot=img_data.decode('utf-8'))

@app.route('/debug')
def debug():
    if request.remote_addr != '127.0.0.1':
            return 'Unauthorized!', 401
    return render_template('debug.html')

Since our target is to obtain unauthorized access to the debug page, we need to bypass the check in the /debug endpoint. However, we cannot manipulate the request.remote_addr variable since this is the IP address the request originates from (i.e., our external IP address). We are thus unable to access the debug endpoint directly.

Let us have a look at how the web application implements taking screenshots in the screenshot_url function:

def take_screenshot(url, filename=f'./screen_{os.urandom(8).hex()}.png'):
    driver = webdriver.Chrome(options=chrome_options)
    driver.get(url)
    driver.save_screenshot(filename)
    driver.quit()

    return filename


def screenshot_url(url):
    scheme = urlparse(url).scheme
    domain = urlparse(url).hostname

    if not domain or not scheme:
        raise Exception('Malformed URL')
        
    if scheme not in ['http', 'https']:
        raise Exception('Invalid scheme')
    
    return take_screenshot(url)

The web application performs a few basic checks, including the scheme of the URL such that we are unable to provide the file scheme to read local files. Afterward, the provided URL is opened in a headless Chrome, and a screenshot of the website is taken and displayed to us.

Exploitation

Since the web application only restricts us to the http and https schemes but does not restrict the domain or IP address we can provide, we can simply provide a URL pointing to the /debug endpoint in the web application itself. The web application will then visit its own debug endpoint such that the request originates from 127.0.0.1. Therefore, access is granted, and the screenshot taken contains the debug page:


SSRF Basic Filter Bypasses

We will first discuss a few flawed SSRF filters that we can bypass using simple methods before doing so with DNS rebinding.

Obfuscation of localhost

The first and simplest SSRF filter is a one that explicitly blocks certain domains such as localhost or 127.0.0.1. Let us have a look at an implementation of such a filter. Assume the function screenshot_url was "improved" with the function check_domain, as follows:

def screenshot_url(url):
    scheme = urlparse(url).scheme
    domain = urlparse(url).hostname

    if not domain or not scheme:
        raise Exception('Malformed URL')
        
    if scheme not in ['http', 'https']:
        raise Exception('Invalid scheme')

    if not check_domain(domain):
        raise Exception('URL not allowed')
    
    return take_screenshot(url)


def check_domain(domain):
    if 'localhost' in domain:
        return False
    
    if domain == '127.0.0.1':
        return False

    return True

check_domain blocks all domains containing the word localhost and 127.0.0.1. However, many other ways exist to represent an IP address that points to the local machine. Here are a few examples:

  • Localhost Address Block: 127.0.0.0 - 127.255.255.255
  • Shortened IP Adress: 127.1
  • Prolonged IP Address: 127.000000000000000.1
  • All Zeroes: 0.0.0.0
  • Shortened All Zeroes: 0
  • Decimal Representation: 2130706433
  • Octal Representation: 0177.0000.0000.0001
  • Hex Representation: 0x7f000001
  • IPv6 loopback address: 0:0:0:0:0:0:0:1 (also ::1)
  • IPv4-mapped IPv6 loopback address: ::ffff:127.0.0.1

Any of these enable us to bypass the filter successfully:

Bypass via DNS Resolution

As a second example, let us have a look at the following improved check_domain function:

def check_domain(domain):
    if 'localhost' in domain:
        return False

    try:
        # parse IP
        ip = ipaddress.ip_address(domain)

        # check internal IP address space
        if ip in ipaddress.ip_network('127.0.0.0/8'):
            return False
        if ip in ipaddress.ip_network('10.0.0.0/8'):
            return False
        if ip in ipaddress.ip_network('172.16.0.0/12'):
            return False
        if ip in ipaddress.ip_network('192.168.0.0/16'):
            return False
        if ip in ipaddress.ip_network('0.0.0.0/8'):
            return False
    except:
        pass

    return True

This time, the filter parses any IP address we provide and blocks it if it is within any private address range. However, any domain name we pass is fine if it does not contain the blacklisted word localhost, enabling us to pass any domain that resolves to an internal IP address.

We can register a domain and point it to any internal IP address; however, we can abuse some already existing ones, such as localtest.me, which resolves to 127.0.0.1:

[!bash!]$ nslookup localtest.me

Server:		1.1.1.1
Address:	1.1.1.1#53

Non-authoritative answer:
Name:	localtest.me
Address: 127.0.0.1
Name:	localtest.me
Address: ::1

Passing this domain allows us to bypass the filter:

Bypass via HTTP Redirect

The web application can resolve domain names provided by the user and check whether they are private IPs to fix the bypass via DNS resolution. Let us look at the following improved check_domain function:

def check_domain(domain):
    try:
        # resolve domain
        ip = socket.gethostbyname(domain)

        # parse IP
        ip = ipaddress.ip_address(ip)

        # check internal IP address space
        if ip in ipaddress.ip_network('127.0.0.0/8'):
            return False
        if ip in ipaddress.ip_network('10.0.0.0/8'):
            return False
        if ip in ipaddress.ip_network('172.16.0.0/12'):
            return False
        if ip in ipaddress.ip_network('192.168.0.0/16'):
            return False
        if ip in ipaddress.ip_network('0.0.0.0/8'):
            return False

        return True
    except:
        pass

    return False

In addition to resolving the domain name, the improved filter returns False by default and only returns True if no exception was raised. However, the filter does not account for HTTP redirects which the headless Chrome browser will follow. Thus, we can bypass the filter by providing a URL pointing to a web server under our control, redirecting the web application to the local debug endpoint. To do so, we can host the following PHP code on our web server:

<?php header('Location: http://127.0.0.1/debug'); ?>

We can host the file using the built-in PHP web server:

[!bash!]$ php -S 0.0.0.0:80

[Sun Aug 13 10:55:35 2023] PHP 7.4.33 Development Server (http://0.0.0.0:80) started

We can then bypass the filter by providing a URL pointing to the PHP code hosted on our web server:

Preventing this is not a simple task. In the debug endpoint, it is impossible to distinguish a redirected request from a direct request. Blocking redirects completely might impact the user experience since benign web applications also use redirects. Furthermore, it is insufficient to block all HTTP redirects, as there are other ways to force a redirect, such as JavaScript and using meta tags. These cases need to be handled separately, for instance, by disabling JavaScript in the headless Chrome browser, downloading the HTML response first, and stripping meta tags that cause redirects before rendering the downloaded HTML file in the headless Chrome browser.

This demonstrates well why we should never implement security controls on our own. Due to the increased complexity and many edge cases, removing SSRF vulnerabilities entirely is challenging. Even if we successfully manage to prevent all forms of redirects, the filter can still be bypassed using DNS rebinding, as we will discuss in the upcoming section.

The simplest and safest way to prevent the SSRF vulnerability is via firewall rules. The system running Webshot (the sample web application) should be separated from the internal web application hosting the debug endpoint. Then, we can implement firewall rules to prevent incoming connections from the Webshot system to the internal web application to prevent SSRF vulnerabilities.

/ 1 spawns left

Waiting to start...

Questions

Answer the question(s) below to complete this Section and earn cubes!

Click here to spawn the target system!

Target: Click here to spawn the target system!

+10 Streak pts

Previous

+10 Streak pts

Next