package burp; import burp.Blake2b; import burp.Config; import com.cedarsoftware.util.io.JsonReader; import java.net.HttpURLConnection; import java.net.URL; import java.nio.ByteBuffer; import java.security.MessageDigest; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.concurrent.*; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.Set; import javax.swing.*; public class BurpExtender implements IBurpExtender, IHttpListener { // Dictionary mapping request body hashes to response bodies private Map<ByteBuffer, String> HttpReqMemoization; // Hashes of issues to avoid duplicates private Set<ByteBuffer> AlreadyFingerprinted; // Background thread that does the lookups private ExecutorService threader; final Blake2b blake2b = Blake2b.Digest.newInstance(16); private boolean showed429AlertWithApiKey = false; private boolean showed429Alert = false; final String htmlindent = " "; final String CRLF = "\r\n"; @Override public void registerExtenderCallbacks(final IBurpExtenderCallbacks callbacks) { GlobalVars.callbacks = callbacks; GlobalVars.callbacks.setExtensionName(GlobalVars.EXTENSION_NAME); GlobalVars.callbacks.registerHttpListener(this); this.AlreadyFingerprinted = new HashSet<ByteBuffer>(); this.HttpReqMemoization = new HashMap<ByteBuffer, String>(); this.threader = Executors.newSingleThreadExecutor(); GlobalVars.config = new Config(); GlobalVars.config.printSettings(); GlobalVars.callbacks.registerContextMenuFactory(new ContextMenuSettingsOptionAdder()); // Check if we already checked this URL IScanIssue[] issuelist = GlobalVars.callbacks.getScanIssues(""); if (issuelist == null) { JOptionPane.showMessageDialog(null, "Error loading scan issues. Unfortunately, this extension\nuses features only available in Burp Pro.", "Burp Extension " + GlobalVars.EXTENSION_NAME, JOptionPane.ERROR_MESSAGE ); return; } for (IScanIssue si : issuelist) { // Only add fingerprinting items if (si.getIssueName().equals(GlobalVars.config.getString("issuetitle"))) { AlreadyFingerprinted.add(hashScanIssue(si)); } } GlobalVars.debug("Found " + Integer.toString(AlreadyFingerprinted.size()) + " fingerprints in already-existing issues (to avoid creating duplicate issues)."); threader.submit(new Runnable() { public void run() { int timeSlept = 0; while (GlobalVars.config.getBurpFrame().getJMenuBar() == null) { if (timeSlept > GlobalVars.SLEEP_MAXTIME * 1000) { GlobalVars.debug("Sleeper timed out. Here be dragons."); break; } try { java.lang.Thread.sleep(GlobalVars.SLEEP_DURATION); } catch (java.lang.InterruptedException e) { break; } GlobalVars.debug("Sleeping " + GlobalVars.SLEEP_DURATION + "ms before adding " + GlobalVars.EXTENSION_NAME_SHORT + " menu button because Burp's menu bar UI does not yet exist..."); timeSlept += GlobalVars.SLEEP_DURATION; } SwingUtilities.invokeLater(new BeanstackMenu()); } }); } private String cvssToBurpSeverity(float cvss) { // Based on https://www.first.org/cvss/specification-document#5-Qualitative-Severity-Rating-Scale if (cvss < 4.0f) return "Information"; if (cvss < 7.0f) return "Low"; if (cvss < 9.0f) return "Medium"; return "High"; } private ByteBuffer hashScanIssue(IScanIssue si) { return ByteBuffer.wrap(blake2b.digest((si.getUrl().toString() + "\n" + si.getIssueDetail()).getBytes())); } private byte[] buildHttpRequest(String host, String URI, String method, String body) { String headers = ""; headers += "User-Agent: " + GlobalVars.USER_AGENT + "/" + GlobalVars.VERSION + CRLF; if (method.equals("POST")) { headers += "Content-Type: application/x-www-form-urlencoded\r\n"; headers += "Content-Length: " + body.length() + CRLF; } return (method + " " + URI + " HTTP/1.1\r\nHost: " + host + CRLF + headers + CRLF + body).getBytes(); } private SHR parseHttpResponse(byte[] response) { String[] headersbody = new String(response).split("\r\n\r\n", 2); String[] headers = headersbody[0].split(CRLF); Map<String,String> headermap = new HashMap<>(); for (String header : headers) { if (header == headers[0]) continue; // Skip first: that's the status line String[] nameval = header.split(":", 2); headermap.put(nameval[0].toLowerCase().trim(), nameval[1].trim()); } String[] methodcodestatus = headers[0].split(" ", 3); int status = Integer.parseInt(methodcodestatus[1]); return new SHR(status, headermap, headersbody[1]); } private String url2uri(URL url) { return (url.getPath() != null ? url.getPath() : "") + (url.getQuery() != null ? url.getQuery() : ""); } private boolean isBlacklisted(String stacktraceline) { String[] blacklisted_class_prefixes = GlobalVars.config.getString("classblacklist").split(","); for (String blacklisted_class_prefix : blacklisted_class_prefixes) { if (blacklisted_class_prefix.length() < 3) { continue; } if (stacktraceline.contains(blacklisted_class_prefix)) { return true; } } return false; } private String getHashedTrace(String stacktrace) { // This function assumes a sanitized stack trace String hashedTrace = ""; for (String line : stacktrace.split("\n")) { String[] match = line.trim().split("\\(|\\)|:"); String fullfunctionname = match[0]; String sourcename = match[1]; int lineno = Integer.parseInt(match[2]); String[] splitfunc = fullfunctionname.split("\\."); String[] tmp = Arrays.copyOfRange(splitfunc, 0, splitfunc.length - 1); String classname = String.join(".", tmp); String functionname = splitfunc[splitfunc.length - 1]; String functionname_2b = burp.Blake2b.Engine.LittleEndian.toHexStr(blake2b.digest(functionname.getBytes())); String classname_2b = burp.Blake2b.Engine.LittleEndian.toHexStr(blake2b.digest(classname.getBytes())); String fullfunctionname_2b = burp.Blake2b.Engine.LittleEndian.toHexStr(blake2b.digest(fullfunctionname.getBytes())); hashedTrace += String.format("%s:%s:%s:%d\n", fullfunctionname_2b, classname_2b, functionname_2b, lineno); } return hashedTrace; } private String checktrace(String stacktrace) { String retval = null; // Return value try { ByteBuffer tracedigest = ByteBuffer.wrap(blake2b.digest((GlobalVars.config.getString("apikey") + stacktrace).getBytes("UTF-8"))); if (HttpReqMemoization.containsKey(tracedigest)) { GlobalVars.debug("Trace found in memoization table, returning stored response."); return HttpReqMemoization.get(tracedigest); } boolean retry = true; while (retry) { retry = false; boolean isset_apikey = GlobalVars.config.getString("apikey").length() > 4; boolean submit_hashed_trace = isset_apikey && GlobalVars.config.getBoolean("hashtrace"); URL url = new URL(GlobalVars.config.getString("apiurl") + (submit_hashed_trace ? "hashTrace" : "")); boolean ishttps = url.getProtocol().toLowerCase().equals("https"); int port = url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); GlobalVars.debug(String.format("Submitting a trace to %s", url.toString())); String body = ""; if (isset_apikey) { body += "apikey="; body += GlobalVars.config.getString("apikey").trim(); body += "&"; } body += "trace="; body += java.net.URLEncoder.encode(submit_hashed_trace ? getHashedTrace(stacktrace) : stacktrace); byte[] httpreq = buildHttpRequest(url.getHost(), url2uri(url), "POST", body); SHR response = parseHttpResponse(GlobalVars.callbacks.makeHttpRequest(url.getHost(), port, ishttps, httpreq)); GlobalVars.debug("Response status " + response.status); if (response.status == 204) { retval = null; } else if (response.status == 301 && response.headers.containsKey("location") && response.headers.get("location").equals(GlobalVars.config.getString("apiurl").replace("http://", "https://"))) { // Oblige an HTTP -> HTTPS redirect (but nothing else) GlobalVars.debug(String.format("Got a 301, updating apiurl setting from <%s> to <%s>.", GlobalVars.config.getString("apiurl"), response.headers.get("location"))); GlobalVars.config.putAndSave("apiurl", response.headers.get("location")); retry = true; } else if (response.status == 429) { if (isset_apikey) { GlobalVars.debug("HTTP request failed: 429 (with API key)"); // An API key is set String msg = "Your API key ran out of requests. For bulk\nlookup of stack traces, please contact us."; if ( ! showed429AlertWithApiKey) { // Only alert once; nobody wants to be annoyed by this stuff showed429AlertWithApiKey = true; JOptionPane.showMessageDialog(null, msg, "Burp Extension " + GlobalVars.EXTENSION_NAME, JOptionPane.ERROR_MESSAGE); } GlobalVars.callbacks.issueAlert(msg); } else { GlobalVars.debug("HTTP request failed: 429 (no API key set)"); if ( ! showed429Alert) { // Only alert once; nobody wants to be annoyed by this stuff showed429Alert = true; // No API key set. Prompt for one and mention where they can get one. String result = JOptionPane.showInputDialog(GlobalVars.config.getBurpFrame(), "You have reached the request limit for " + GlobalVars.EXTENSION_NAME_SHORT + ". " + "Please register on " + GlobalVars.REGURL + "\nfor a free API key. If you already have an API key, please enter it here.", GlobalVars.EXTENSION_NAME + " API key", JOptionPane.PLAIN_MESSAGE ); if (result.length() > 0) { GlobalVars.config.putAndSave("apikey", result); GlobalVars.debug("apikey configured after prompt"); retry = true; } } else { GlobalVars.callbacks.issueAlert("Extension " + GlobalVars.EXTENSION_NAME_SHORT + ": You hit the request limit for the API. " + "To continue, please register for a free API key at " + GlobalVars.REGURL + ", or slow the rate of requests."); } } if (!retry) { return null; } } else if (response.status == 401 && isset_apikey) { GlobalVars.debug("HTTP request failed: invalid API key (401)"); // N.B. we thread this, but due to the thread pool of 1, further requests will just be queued, so we won't get dialogs on top of each other. // Further requests will also automatically use the API key if the user enters one here, even if they were already queued previously. String result = (String)JOptionPane.showInputDialog(GlobalVars.config.getBurpFrame(), "Your API key is invalid.\nIf you want to use a different API key, please enter it here.", GlobalVars.EXTENSION_NAME + " API key invalid", JOptionPane.PLAIN_MESSAGE, null, null, GlobalVars.config.getString("apikey") ); if (result != null && result.length() > 0) { GlobalVars.config.putAndSave("apikey", result); GlobalVars.debug("apikey reconfigured"); retry = true; } else { // If they cancelled the dialog or emptied it, override the string so they don't get more of those alerts. GlobalVars.config.putAndSave("apikey", "none"); } if (!retry) { return null; } } else if (response.status != 200) { GlobalVars.callbacks.issueAlert("Extension " + GlobalVars.EXTENSION_NAME + ": HTTP request to back-end failed with status " + Integer.toString(response.status)); GlobalVars.debug("HTTP request failed with status " + Integer.toString(response.status)); return null; } else { retval = response.body; } } // End of while(retry) loop // The code should only reach here if we want to memoize the result. Otherwise, early exit (return) above! GlobalVars.debug("Result: " + (retval == null ? "null" : retval.substring(0, Math.min(150, retval.length())))); HttpReqMemoization.put(tracedigest, retval); return retval; } catch (java.io.UnsupportedEncodingException e) { e.printStackTrace(new java.io.PrintStream(GlobalVars.debug)); } catch (java.io.IOException e) { e.printStackTrace(new java.io.PrintStream(GlobalVars.debug)); } return null; } @Override public void processHttpMessage(int toolFlag, boolean messageIsRequest, IHttpRequestResponse baseRequestResponse) { if (messageIsRequest) { // TODO maybe also the request instead of only the response? return; } if ( ! GlobalVars.config.getBoolean("enable")) { GlobalVars.debug("Note: " + GlobalVars.EXTENSION_NAME_SHORT + " plugin is disabled."); return; } threader.submit(new Runnable() { public void run() { String response = null; // Basically the pattern checks /\s[valid class path chars].[more valid class chars]([filename chars].java:1234)/ Pattern pattern = Pattern.compile("(\\s|/)([a-zA-Z0-9\\.\\$]{1,300}\\.[a-zA-Z0-9\\.\\$]{1,300})\\(([a-zA-Z0-9]{1,300})\\.java:\\d{1,6}\\)"); try { response = new String(baseRequestResponse.getResponse(), "UTF-8"); } catch (java.io.UnsupportedEncodingException e) { e.printStackTrace(new java.io.PrintStream(GlobalVars.debug)); } response = response.replace("\\$", "$").replace("\\/", "/").replace(" ", " "); response = java.net.URLDecoder.decode(response); // HTML is not decoded because stack traces do not contain any characters that have to be &escaped; Matcher matcher = pattern.matcher(response); // Reconstruct the trace (since who knows what might be in between the lines, e.g. "<br>" or "," or "\n") String stacktrace = ""; while (matcher.find()) { if ( ! matcher.group(2).contains(".")) { // Enforce a dot in the full class name (sanity check) continue; } if ( ! (matcher.group(2).indexOf(matcher.group(3) + "$") >= 2 || matcher.group(2).indexOf(matcher.group(3) + ".") >= 2)) { // TODO is this check too strict? // (It's strict because, if it's too loose, we might submit all sorts of private data to our API) // The filename should occur in the first part, either followed by a dollar or by a dot, // and it usually does not start with that (so match from position 2 onwards, because // there should be at least 1 character and a dot, like "a.test.run(test.java:42)"). continue; } String line = matcher.group(0).substring(1); if ( ! isBlacklisted(line)) { GlobalVars.debug(" " + line); stacktrace += " " + line + "\n"; } else { GlobalVars.debug(String.format("[filtered out blacklisted class: %s]", matcher.group(2))); } } if (stacktrace.length() == 0) { return; } Instant start = Instant.now(); // Check the trace with our back-end String result = checktrace(stacktrace); GlobalVars.debug("checktrace() returned in " + String.valueOf(Duration.between(start, Instant.now()).toMillis()) + "ms"); // Either some error (already handled) or no results if (result == null) { return; } Map args = new HashMap(); args.put(JsonReader.USE_MAPS, true); Map<String,Object> products = (Map<String,Object>)JsonReader.jsonToJava(result, args); String issuetext = ""; String comma = ""; boolean is_uncertain_cve; boolean any_uncertain_cves = false; boolean any_certain_cves = false; float maxcvss = 0; int i = 0; String outdated = ""; String notice = ""; issuetext += String.format("X41 BeanStack found the following versions based on the stack trace:<br>"); for (Map.Entry<String,Object> product : products.entrySet()) { if (product.getKey().equals("__BeanStack_demo")) { notice = "<br><br>Note: CVEs are shown for this stack trace as a demo. To view CVEs with other stack traces, please <a href='https://beanstack.io/signup.html'>request an API key</a>."; continue; } if (product.getKey().equals("__BeanStack_needs_upgrading")) { outdated = (String)product.getValue(); continue; } i += 1; issuetext += String.format("%d. %s<br>" + htmlindent, i, product.getKey()); Map<String,Object> productmap = (Map<String,Object>)product.getValue(); Object[] versions = (Object[])productmap.get("versions"); if (versions.length == 1) { issuetext += "version: " + versions[0].toString(); } else { issuetext += "matching versions: "; comma = ""; for (Object ver : versions) { issuetext += comma + ver.toString(); comma = ", "; } } if (productmap.containsKey("cves")) { Object[] cves = (Object[])productmap.get("cves"); if (cves.length > 0) { issuetext += "<br>" + htmlindent + "CVE(s): "; comma = ""; for (Object cveobj : cves) { Map<String,Object> cvemap = (Map<String,Object>)cveobj; Map<String,Map> nistobj = (Map<String,Map>)cvemap.get("data"); String cveid = (((Map<String,Map<String,String>>)nistobj.get("cve")).get("CVE_data_meta")).get("ID"); is_uncertain_cve = Integer.parseInt(cvemap.get("vermatch").toString()) != 0; any_uncertain_cves = is_uncertain_cve ? is_uncertain_cve : any_uncertain_cves; any_certain_cves = is_uncertain_cve ? any_certain_cves : ! is_uncertain_cve; issuetext += comma + "<a href='" + GlobalVars.CVEURL + cveid + "'>" + cveid + "</a>" + (is_uncertain_cve ? "*" : ""); comma = ", "; String score = "(not given)"; Map<String,Map> impactmap = nistobj.get("impact"); if (impactmap.size() > 0) { String cvssversion = impactmap.containsKey("baseMetricV3") ? "3" : "2"; Map<String,Object> scoremap = ((Map<String,Map<String,Object>>)impactmap.get("baseMetricV" + cvssversion)).get("cvssV" + cvssversion); score = scoremap.get("baseScore").toString(); issuetext += " (" + score + ")"; maxcvss = Math.max(Float.parseFloat(score), maxcvss); } if (GlobalVars.config.getBoolean("issuepercve") && ! is_uncertain_cve) { GlobalVars.debug(String.format("Logging separate issue for %s", cveid)); GlobalVars.callbacks.addScanIssue(new CustomScanIssue( baseRequestResponse.getHttpService(), GlobalVars.callbacks.getHelpers().analyzeRequest(baseRequestResponse).getUrl(), new IHttpRequestResponse[] { baseRequestResponse }, String.format("%s (%s)", GlobalVars.config.getString("issuetitle"), cveid), String.format("In the stack trace, %s with CVSS score %s was discovered. It is present in %s.", cveid, score, product.getKey()), cvssToBurpSeverity(Float.parseFloat(score)), "Firm" )); } } issuetext += "<br>"; } } else { if (GlobalVars.config.getString("apikey").length() > 4) { issuetext += " (no CVEs known)<br>"; } else { issuetext += "<br>"; } } } if (any_uncertain_cves) { issuetext += "<br>* This CVE applies to a range of versions. Many projects use non-semver versioning schemes and CVEs do not mention which versioning scheme " + "is used, so we can only do reliable version matching when an exact version is given instead of a range. Therefore, this CVE may not apply."; } if (notice.equals("") && GlobalVars.config.getString("apikey").length() <= 4) { notice = "<br><br>Note: to check for CVEs, please <a href='https://beanstack.io/signup.html'>request an API key</a> or <a href='https://beanstack.io/settings.html'>configure your key</a>."; } String certainty; if (any_uncertain_cves || any_certain_cves) { // If there are CVEs at all if ( ! any_uncertain_cves) { // Since the severity is determined by the highest CVSS score, and since that // CVSS score might belong to an uncertain CVE (one that might not apply to // the product we found, but we don't know because we can't do version // comparisons without knowing the versioning scheme), we can only be // "certain" if there are no uncertain CVEs. certainty = "Certain"; } else if (any_certain_cves) { certainty = "Firm"; } else { // Not a single one was an exact version match, so this is fairly uncertain certainty = "Tentative"; } } else { // We didn't find any CVEs, so return the standard certainty certainty = "Certain"; } IScanIssue issue = new CustomScanIssue( baseRequestResponse.getHttpService(), GlobalVars.callbacks.getHelpers().analyzeRequest(baseRequestResponse).getUrl(), new IHttpRequestResponse[] { baseRequestResponse }, GlobalVars.config.getString("issuetitle"), outdated + issuetext + notice, cvssToBurpSeverity(maxcvss), certainty ); ByteBuffer hash = hashScanIssue(issue); if ( ! AlreadyFingerprinted.add(hash)) { // We already created an issue for this, avoid creating a duplicate. if (GlobalVars.config.getBoolean("logdups")) { GlobalVars.debug("Issue already exists, but logging anyway because logdups config is set."); } else { GlobalVars.debug("Issue already exists! Avoiding duplicate."); return; } } GlobalVars.callbacks.addScanIssue(issue); GlobalVars.debug("Logged issue"); } }); } } class BeanstackMenu implements Runnable, java.awt.event.ActionListener, IExtensionStateListener { private JMenu topMenu; BeanstackMenu() { GlobalVars.callbacks.registerExtensionStateListener(this); } public void run() { topMenu = new JMenu(GlobalVars.EXTENSION_NAME_SHORT); JMenuItem settingsButton = new JMenuItem(GlobalVars.SETTINGS); settingsButton.addActionListener(this); JMenuItem menuHeader = new JMenuItem(GlobalVars.EXTENSION_NAME); menuHeader.setEnabled(false); topMenu.add(menuHeader); topMenu.add(settingsButton); GlobalVars.config.getBurpFrame().getJMenuBar().add(topMenu); GlobalVars.config.getBurpFrame().getJMenuBar().updateUI(); } public void actionPerformed(java.awt.event.ActionEvent e) { SwingUtilities.invokeLater(new Runnable() { public void run(){ GlobalVars.config.showSettings(); } }); } public void extensionUnloaded() { GlobalVars.config.getBurpFrame().getJMenuBar().remove(topMenu); GlobalVars.config.getBurpFrame().getJMenuBar().updateUI(); } } class SHR { public final int status; public final String body; public final Map<String,String> headers; public SHR(int status, Map<String,String> headers, String body) { this.status = status; this.headers = headers; this.body = body; } } // From the example project class CustomScanIssue implements IScanIssue { private IHttpService httpService; private URL url; private IHttpRequestResponse[] httpMessages; private String name; private String detail; private String severity; private String confidence; public CustomScanIssue( IHttpService httpService, URL url, IHttpRequestResponse[] httpMessages, String name, String detail, String severity, String confidence) { this.httpService = httpService; this.url = url; this.httpMessages = httpMessages; this.name = name; this.detail = detail; this.severity = severity; this.confidence = confidence; } @Override public URL getUrl() { return url; } @Override public String getIssueName() { return name; } @Override public int getIssueType() { return 0; } @Override public String getSeverity() { return severity; } @Override public String getConfidence() { return confidence; } @Override public String getIssueBackground() { return null; } @Override public String getRemediationBackground() { return null; } @Override public String getIssueDetail() { return detail; } @Override public String getRemediationDetail() { return null; } @Override public IHttpRequestResponse[] getHttpMessages() { return httpMessages; } @Override public IHttpService getHttpService() { return httpService; } }