From ff338b8e739fcf273f50a1becdb8aca71a0707bc Mon Sep 17 00:00:00 2001 From: kapytein <37546325+kapytein@users.noreply.github.com> Date: Sat, 24 Aug 2019 18:23:17 +0200 Subject: [PATCH] Initial commit, ready for public release --- .gitignore | 1 + README.md | 16 +++++ jsonp.py | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++ payloads.txt | 4 ++ test/test.txt | 9 +++ 5 files changed, 212 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 jsonp.py create mode 100644 payloads.txt create mode 100644 test/test.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3062be --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..b413bb6 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# jsonp + +jsonp is a Burp Extension which tries to discover JSONP functionality behind JSON endpoints. It does so by appending parameters and/or changing the extension of the requested URL. The payloads are taken from payloads.txt. + +The extension acts as a passive scanner (while it actually is not, since it creates requests based on the original request). For every request responding with `application/json`, the plugin will send `4` altered requests, using the payloads from `payloads.txt`. Only the request path and method will be altered. All requests made by the plugin are using the request method `GET`. + +JSONP functionalities (if not restricted) could be used to bypass content security policies. Besides that, in case there's authenticated data, you could attempt a cross-site script inclusion attack if no CSRF token or equivalent is used to migitate the exploitability. + +It's common that JSONP functionalities are hidden behind JSON endpoints, as learned on [Liberapay](https://hackerone.com/reports/361951). The template rendered using `jsonp_dump`, which would return valid JSON with content type `application/json` when no `callback` parameter is supplied. + +## Installation + +The extension is currently not in the BApp Store. You have to install it manually via "Extender > Add". + +## Common false-positivies for exploitability +The extension uses the cookies and (possibly additional) authentication headers from the original request. This means that the extension does not detect whether the JSONP functionality on the endpoint is exploitable or not. diff --git a/jsonp.py b/jsonp.py new file mode 100644 index 0000000..a7c7928 --- /dev/null +++ b/jsonp.py @@ -0,0 +1,182 @@ +from burp import IBurpExtender +from burp import IScannerCheck +from burp import IScanIssue +from burp import IExtensionHelpers +from java.net import URL +from array import array +from urlparse import urlparse +import sys +import os + +class BurpExtender(IBurpExtender, IScannerCheck): + + def registerExtenderCallbacks(self, callbacks): + # keep a reference to our callbacks object + self._callbacks = callbacks + + # obtain an extension helpers object + self._helpers = callbacks.getHelpers() + + # set our extension name + callbacks.setExtensionName("jsonp") + + # register ourselves as a custom scanner check + callbacks.registerScannerCheck(self) + + sys.stdout = callbacks.getStdout() + sys.stderr = callbacks.getStderr() + + def load_payloads(self): + lines = [] + with open('payloads.txt') as f: + lines = f.read().splitlines() + + return lines + + ''' + https://stackoverflow.com/questions/3675318/how-to-replace-the-some-characters-from-the-end-of-a-string + ''' + def replace_last(self, source_string, replace_what, replace_with): + head, _sep, tail = source_string.rpartition(replace_what) + return head + replace_with + tail + + def remove_parameters(self, url): + u = urlparse(url) + query = "?" + u.query + return url.replace(query, '') + + ''' + The function attempts to place the payload within the requested URL. A payload consists of an extension and query parameters only. + URL's end often (without query parameters / fragment) with a slash (/) or without a slash. Both scenarios are currently covered by this function. + ''' + def construct_url(self, url, payload): + has_slash = False + org_url = urlparse(url) + + url = self.remove_parameters(url) + + if url.endswith("/"): + has_slash = True + url = self.replace_last(url, '/', '') + + u = urlparse(url) + url_ext = os.path.splitext(u.path)[1] + payload_ext = urlparse(payload) + + # we have an ext in the payload + if payload_ext.path != "": + if url_ext != "": + url = self.replace_last(url, url_ext, payload_ext.path) + payload = payload.replace(payload_ext.path, '') + + elif has_slash == True and url_ext == "": + # place payload ext before the / + url = url + payload_ext.path + payload = payload.replace(payload_ext.path, '') + + if has_slash == True: + url = url + "/" + + if org_url.query != "": + if payload_ext.query != "": + payload = payload + "&" + org_url.query + else: + payload = payload + "?" + org_url.query + + return url + payload + + def replace_header(self, headers, value): + # the request method will always be the first value in the list + headers[0] = value + return headers + + def doPassiveScan(self, baseRequestResponse): + response = baseRequestResponse.getResponse() + + res_type = self._helpers.analyzeResponse(response).getStatedMimeType() + if res_type == "JSON": + payloads = self.load_payloads() + + for i in payloads: + request_url = self._helpers.analyzeRequest(baseRequestResponse).getUrl() + payload_url = urlparse(self.construct_url(str(request_url), i)) + + if payload_url.query != "": + payload_format = '{uri.path}?{uri.query}'.format(uri=payload_url) + else: + payload_format = '{uri.path}'.format(uri=payload_url) + + request_headers = self.replace_header(self._helpers.analyzeRequest(baseRequestResponse).getHeaders(), "GET " + payload_format + " HTTP/1.1") + + request = self._helpers.buildHttpMessage(request_headers, None) + print("Edited URL, and creating request to the following URL: " + payload_format) + + response = self._callbacks.makeHttpRequest(request_url.getHost(), request_url.getPort(), False if request_url.getProtocol() == "http" else True, request) + response_type = self._helpers.analyzeResponse(response).getStatedMimeType() + + if response_type == "script": + + return [CustomScanIssue( + baseRequestResponse.getHttpService(), + self._helpers.analyzeRequest(baseRequestResponse).getUrl(), + [baseRequestResponse], + "Hidden JSONP endpoint found", + # @TODO A class which implements IHttpRequestResponse needs to be created for a byte > ihttprequestresponse conversion. There's no helper for this + "Callback request path: " + payload_format + ". A JSON endpoint was found with a (possibly hidden) JSONP functionality. This allows you to retrieve the returned data cross-origin (in case there are no additional checks / CSRF tokens in place). This may also help to bypass content security policies.", + "Medium")] + + def consolidateDuplicateIssues(self, existingIssue, newIssue): + # This method is called when multiple issues are reported for the same URL + # path by the same extension-provided check. The value we return from this + # method determines how/whether Burp consolidates the multiple issues + # to prevent duplication + # + # Since the issue name is sufficient to identify our issues as different, + # if both issues have the same name, only report the existing issue + # otherwise report both issues + if existingIssue.getIssueName() == newIssue.getIssueName(): + return -1 + + return 0 + +class CustomScanIssue(IScanIssue): + def __init__(self, httpService, url, httpMessages, name, detail, severity): + self._httpService = httpService + self._url = url + self._httpMessages = httpMessages + self._name = name + self._detail = detail + self._severity = severity + + def getUrl(self): + return self._url + + def getIssueName(self): + return self._name + + def getIssueType(self): + return 0 + + def getSeverity(self): + return self._severity + + def getConfidence(self): + return "Certain" + + def getIssueBackground(self): + pass + + def getRemediationBackground(self): + pass + + def getIssueDetail(self): + return self._detail + + def getRemediationDetail(self): + pass + + def getHttpMessages(self): + return self._httpMessages + + def getHttpService(self): + return self._httpService \ No newline at end of file diff --git a/payloads.txt b/payloads.txt new file mode 100644 index 0000000..bf4d0b4 --- /dev/null +++ b/payloads.txt @@ -0,0 +1,4 @@ +.jsonp?callback=test +.jsonp +?callback=test +?jsonp=test \ No newline at end of file diff --git a/test/test.txt b/test/test.txt new file mode 100644 index 0000000..9cc9433 --- /dev/null +++ b/test/test.txt @@ -0,0 +1,9 @@ +http://example.com/path/?asd=test +http://example.com/path.json/?asd=test +http://example.com/path.json?asd=test&asd=test +http://example.com/path/ +http://example.com/path.json/ +http://example.com/path?asd=test&test=lol +http://example.com/path + +unit tests SOON ™ \ No newline at end of file