diff --git a/timeinator.py b/timeinator.py index 5cbd1cf..61d6956 100644 --- a/timeinator.py +++ b/timeinator.py @@ -1,19 +1,23 @@ from re import sub from socket import gethostbyname -from threading import Thread -from time import time +from threading import Thread, Event +from time import time, sleep from javax.swing import (JTabbedPane, JPanel, JLabel, JTextField, JTextArea, JCheckBox, JMenuItem, JButton, JTable, - JScrollPane, JProgressBar) + JScrollPane, JProgressBar, SwingUtilities) +from javax.swing.event import ChangeListener from javax.swing.table import DefaultTableModel, DefaultTableCellRenderer -from java.awt import Color, GridBagLayout, GridBagConstraints, Insets +from java.awt import (Color, GridBagLayout, GridBagConstraints, Insets, Font, + FlowLayout) import java.lang from burp import ( IBurpExtender, ITab, IContextMenuFactory, IMessageEditorController) EXTENSION_NAME = "Timeinator" +PLUS_TITLE = "+" +ABOUT_TITLE = "About" COLUMNS = [ "Payload", "Number of Requests", "Status Code", "Length (B)", "Body (B)", "Minimum (ms)", "Maximum (ms)", "Mean (ms)", "Median (ms)", "StdDev (ms)"] @@ -33,13 +37,14 @@ def median(values): # Even number of values, so mean of middle two return mean([values[length/2], values[(length/2)-1]]) + def stdDev(values): ss = sum((x - mean(values))**2 for x in values) pvar = ss/len(values) return pvar**0.5 -class BurpExtender( - IBurpExtender, ITab, IContextMenuFactory, IMessageEditorController): + +class BurpExtender(IBurpExtender, ITab, IContextMenuFactory): # Implement IBurpExtender def registerExtenderCallbacks(self, callbacks): @@ -51,19 +56,26 @@ def registerExtenderCallbacks(self, callbacks): callbacks.setExtensionName(EXTENSION_NAME) - # Construct UI - insets = Insets(3, 3, 3, 3) - self._messageEditor = callbacks.createMessageEditor(self, True) - attackPanel = self._constructAttackPanel( - insets, self._messageEditor.getComponent()) - resultsPanel = self._constructResultsPanel(insets) - aboutPanel = self._constructAboutPanel(insets) + self._sessions = [] + self._sessionCounter = 0 + self._contextMenuData = None + # Guard against re-entrancy: inserting a tab changes the selected + # index, which fires the change listener again. Without this flag the + # '+' tab logic recurses infinitely. + self._addingSession = False + + # Top level tabbed pane: [session 1] [session 2] ... [+] [About] self._tabbedPane = JTabbedPane() - self._tabbedPane.addTab("Attack", attackPanel) - self._tabbedPane.addTab("Results", resultsPanel) - self._tabbedPane.addTab("About", aboutPanel) + self._tabbedPane.addTab(PLUS_TITLE, JPanel()) + self._tabbedPane.addTab( + ABOUT_TITLE, self._constructAboutPanel(Insets(3, 3, 3, 3))) + self._tabbedPane.addChangeListener(_PlusTabListener(self)) + callbacks.addSuiteTab(self) + # Start with a single session, like Intruder/Repeater do + self._addSession() + # Implement ITab def getTabCaption(self): return EXTENSION_NAME @@ -71,18 +83,6 @@ def getTabCaption(self): def getUiComponent(self): return self._tabbedPane - # Implement IMessageEditorController - def getHttpService(self): - self._updateClassFromUI() - return self._httpService - - def getRequest(self): - self._updateClassFromUI() - return self._request - - def getResponse(self): - return None - # Implement IContextMenuFactory def createMenuItems(self, contextMenuInvocation): messages = contextMenuInvocation.getSelectedMessages() @@ -98,22 +98,177 @@ def createMenuItems(self, contextMenuInvocation): def _contextMenuItemClicked(self, _): httpRequestResponse = self._contextMenuData[0] + httpService = httpRequestResponse.getHttpService() + request = httpRequestResponse.getRequest() + + # Every "Send to Timeinator" opens a brand new independent session, + # exactly like Burp Intruder does. + session = self._addSession() + session.loadRequest(httpService, request) + self._selectSession(session) + + def _addSession(self): + self._addingSession = True + try: + self._sessionCounter += 1 + session = TimeinatorSession( + self._callbacks, self._helpers, self._sessionCounter, + self._onSessionCloseRequested) + self._sessions.append(session) + + title = "Session {}".format(session.session_id) + insertIndex = self._tabbedPane.indexOfTab(PLUS_TITLE) + self._tabbedPane.insertTab( + title, None, session.getComponent(), None, insertIndex) + self._tabbedPane.setTabComponentAt( + insertIndex, self._buildTabHeader(title, session)) + self._tabbedPane.setSelectedIndex(insertIndex) + return session + finally: + self._addingSession = False + + def _selectSession(self, session): + index = self._tabbedPane.indexOfComponent(session.getComponent()) + if index != -1: + self._tabbedPane.setSelectedIndex(index) + + def _buildTabHeader(self, title, session): + panel = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)) + panel.setOpaque(False) + label = JLabel(title + " ") + closeButton = JButton("x") + closeButton.setMargin(Insets(0, 4, 0, 4)) + closeButton.setFont(Font("Arial", Font.PLAIN, 10)) + closeButton.setFocusPainted(False) + closeButton.addActionListener( + lambda event, s=session: self._onSessionCloseRequested(s)) + panel.add(label) + panel.add(closeButton) + return panel + + def _onSessionCloseRequested(self, session): + # Never close a session while its attack is still running, so + # in-flight parallel attacks can't be killed by accident. + if session.isRunning(): + return + + index = self._tabbedPane.indexOfComponent(session.getComponent()) + if index != -1: + self._tabbedPane.removeTabAt(index) + if session in self._sessions: + self._sessions.remove(session) + + # Always keep at least one session tab available + if not self._sessions: + self._addSession() + + def _handlePlusTabSelected(self): + self._addSession() + + def _constructAboutPanel(self, insets): + aboutPanel = JPanel(GridBagLayout()) + with open("about.html") as file: + aboutBody = file.read() + aboutLabel = JLabel( + aboutBody.format(extension_name=EXTENSION_NAME)) + aboutLabelConstraints = GridBagConstraints() + aboutLabelConstraints.weightx = 1 + aboutLabelConstraints.weighty = 1 + aboutLabelConstraints.insets = insets + aboutLabelConstraints.fill = GridBagConstraints.HORIZONTAL + aboutLabelConstraints.anchor = GridBagConstraints.PAGE_START + aboutPanel.add(aboutLabel, aboutLabelConstraints) - # Update instance variables with request data - self._httpService = httpRequestResponse.getHttpService() - self._request = httpRequestResponse.getRequest() + return aboutPanel + + +class _PlusTabListener(ChangeListener): + """Turns the '+' tab into an 'add session' button: as soon as it becomes + selected, a new session tab is created and selected instead.""" + + def __init__(self, extender): + self._extender = extender + + def stateChanged(self, event): + # Ignore selection changes that happen while we're inserting a tab. + if self._extender._addingSession: + return + pane = event.getSource() + index = pane.getSelectedIndex() + if index != -1 and pane.getTitleAt(index) == PLUS_TITLE: + self._extender._handlePlusTabSelected() + + +class TimeinatorSession(IMessageEditorController): + """One fully independent attack session: its own target, request, + payload list, message editor, results table, progress bar and worker + thread. Any number of sessions can run their attacks concurrently + because each one owns its own Thread and its own state - nothing is + shared between sessions.""" + + def __init__(self, callbacks, helpers, session_id, on_close_requested): + self._callbacks = callbacks + self._helpers = helpers + self.session_id = session_id + self._on_close_requested = on_close_requested + + self._stopEvent = Event() + self._running = False + + self._httpService = None + self._request = None + self._responses = {} - # Update fields in tab - self._hostTextField.setText(self._httpService.getHost()) - self._portTextField.setText(str(self._httpService.getPort())) + insets = Insets(3, 3, 3, 3) + self._messageEditor = callbacks.createMessageEditor(self, True) + attackPanel = self._constructAttackPanel( + insets, self._messageEditor.getComponent()) + resultsPanel = self._constructResultsPanel(insets) + + self._sessionTabbedPane = JTabbedPane() + self._sessionTabbedPane.addTab("Attack", attackPanel) + self._sessionTabbedPane.addTab("Results", resultsPanel) + + def getComponent(self): + return self._sessionTabbedPane + + def isRunning(self): + return self._running + + # Implement IMessageEditorController + def getHttpService(self): + self._updateClassFromUI() + return self._httpService + + def getRequest(self): + self._updateClassFromUI() + return self._request + + def getResponse(self): + return None + + def loadRequest(self, httpService, request): + """Called when a request is sent to this session from a context + menu elsewhere in Burp.""" + self._httpService = httpService + self._request = request + self._hostTextField.setText(httpService.getHost()) + self._portTextField.setText(str(httpService.getPort())) self._protocolCheckBox.setSelected( - True if self._httpService.getProtocol() == "https" else False) - self._messageEditor.setMessage(self._request, True) + httpService.getProtocol() == "https") + self._messageEditor.setMessage(request, True) def _startAttack(self, _): + if self._running: + return + + self._running = True + self._stopEvent.clear() + self._startAttackButton.setEnabled(False) + self._stopAttackButton.setEnabled(True) # Switch to results tab - self._tabbedPane.setSelectedIndex(1) + self._sessionTabbedPane.setSelectedIndex(1) # Clear results table self._resultsTableModel.setRowCount(0) @@ -121,11 +276,24 @@ def _startAttack(self, _): # Set progress bar to 0% self._progressBar.setValue(0) - Thread(target=self._makeHttpRequests).start() + Thread(target=self._runAttackThread).start() + + def _stopAttack(self, _): + self._stopEvent.set() + + def _runAttackThread(self): + try: + self._makeHttpRequests() + finally: + self._running = False + SwingUtilities.invokeLater( + lambda: self._startAttackButton.setEnabled(True)) + SwingUtilities.invokeLater( + lambda: self._stopAttackButton.setEnabled(False)) def _makeHttpRequests(self): - # Set class variables from values in UI + # Set instance variables from values in UI self._updateClassFromUI() self._responses = {} @@ -134,16 +302,28 @@ def _makeHttpRequests(self): self._progressBar.setMaximum(len(self._payloads) * self._numReq) for payload in self._payloads: + if self._stopEvent.is_set(): + break + self._responses[payload] = [] # Stick payload into request at specified position - # Use lambda function for replacement string to stop slashes being - # escaped + # Use lambda function for replacement string to stop slashes + # being escaped request = sub("\xa7[^\xa7]*\xa7", lambda x: payload, self._request) request = self._updateContentLength(request) - for _ in xrange(self._numReq): - # Make request and work out how long it took in ms. This method - # is crude, but it's as good as we can get with current Burp - # APIs. + for i in xrange(self._numReq): + if self._stopEvent.is_set(): + break + + # Apply delay between requests (except before the very + # first one) to avoid triggering rate-limiting + # (e.g. nginx 429). + if i > 0 and self._delayMs > 0: + sleep(self._delayMs / 1000.0) + + # Make request and work out how long it took in ms. This + # method is crude, but it's as good as we can get with + # current Burp APIs. # See https://support.portswigger.net/customer/portal/questions/16227838-request-response-timing # noqa: E501 startTime = time() response = self._callbacks.makeHttpRequest( @@ -155,13 +335,18 @@ def _makeHttpRequests(self): self._responses[payload].append(duration) - # If all responses for this payload have - # been added to array, add to results table. + # If all responses for this payload have been added to array + # (or the attack was stopped part-way through), add whatever + # was collected to the results table. results = self._responses[payload] - numReqs = self._numReq + if not results: + continue + + numReqs = len(results) statusCode = response.getStatusCode() analysis = self._helpers.analyzeResponse( response.getResponse()) + content_length = 0 for header in analysis.getHeaders(): if header.lower().startswith("content-length"): content_length = int(header.split(": ")[1]) @@ -174,18 +359,19 @@ def _makeHttpRequests(self): payload, numReqs, statusCode, len(response.getResponse()), content_length, minTime, maxTime, meanTime, medianTime, stdDevTime] - self._resultsTableModel.addRow(rowData) + SwingUtilities.invokeLater( + lambda r=rowData: self._resultsTableModel.addRow(r)) def _updateClassFromUI(self): host = self._hostTextField.text port = int(self._portTextField.text) protocol = "https" if self._protocolCheckBox.isSelected() else "http" - # In an effort to prevent DNS queries introducing a delay, an attempt - # was made to use the IP address of the destination web server instead - # of the hostname when building the HttpService. Unfortunately it - # caused issues with HTTPS requests, probably because of SNIs. As an - # alternative, the hostname is resolved in the next line and hopefully - # it will be cached at that point. + # In an effort to prevent DNS queries introducing a delay, an + # attempt was made to use the IP address of the destination web + # server instead of the hostname when building the HttpService. + # Unfortunately it caused issues with HTTPS requests, probably + # because of SNIs. As an alternative, the hostname is resolved in + # the next line and hopefully it will be cached at that point. gethostbyname(host) self._httpService = self._helpers.buildHttpService( @@ -194,22 +380,34 @@ def _updateClassFromUI(self): self._numReq = int(self._requestsNumTextField.text) self._payloads = set(self._payloadTextArea.text.split("\n")) + # Read delay, default to 0 if the field is empty or invalid + try: + self._delayMs = float(self._delayTextField.text) + if self._delayMs < 0: + self._delayMs = 0 + except (ValueError, AttributeError): + self._delayMs = 0 + def _addPayload(self, _): request = self._messageEditor.getMessage() selection = self._messageEditor.getSelectionBounds() + # Convert 0xa7 (167) to signed byte (-89) + marker = (0xa7 - 256) if selection[0] == selection[1]: # No text selected so in/out points are same - request.insert(selection[0], 0xa7) - request.insert(selection[1], 0xa7) + request.insert(selection[0], marker) + request.insert(selection[1], marker) else: - request.insert(selection[0], 0xa7) - request.insert(selection[1]+1, 0xa7) + request.insert(selection[0], marker) + request.insert(selection[1]+1, marker) self._messageEditor.setMessage(request, True) def _clearPayloads(self, _): request = self._messageEditor.getMessage() - request = self._helpers.bytesToString(request).replace("\xa7", "") - self._messageEditor.setMessage(request, True) + request_str = request.tostring().decode('latin-1') + request_str = request_str.replace(u'\xa7', '') + new_request = request_str.encode('latin-1') + self._messageEditor.setMessage(new_request, True) def _updateContentLength(self, request): messageSize = len(request) @@ -222,7 +420,8 @@ def _updateContentLength(self, request): def _constructAttackPanel(self, insets, messageEditorComponent): attackPanel = JPanel(GridBagLayout()) - targetHeadingLabel = JLabel("Target") + targetHeadingLabel = JLabel("Target") + targetHeadingLabel.setFont(Font("Arial", Font.BOLD, 14)) targetHeadingLabelConstraints = GridBagConstraints() targetHeadingLabelConstraints.gridx = 0 targetHeadingLabelConstraints.gridy = 0 @@ -231,13 +430,24 @@ def _constructAttackPanel(self, insets, messageEditorComponent): targetHeadingLabelConstraints.insets = insets attackPanel.add(targetHeadingLabel, targetHeadingLabelConstraints) - startAttackButton = JButton("Start Attack", - actionPerformed=self._startAttack) + self._startAttackButton = JButton( + "Start Attack", actionPerformed=self._startAttack) + self._startAttackButton.setFont(Font("Arial", Font.BOLD, 14)) startAttackButtonConstraints = GridBagConstraints() startAttackButtonConstraints.gridx = 4 startAttackButtonConstraints.gridy = 0 startAttackButtonConstraints.insets = insets - attackPanel.add(startAttackButton, startAttackButtonConstraints) + attackPanel.add(self._startAttackButton, startAttackButtonConstraints) + + self._stopAttackButton = JButton( + "Stop", actionPerformed=self._stopAttack) + self._stopAttackButton.setFont(Font("Arial", Font.BOLD, 14)) + self._stopAttackButton.setEnabled(False) + stopAttackButtonConstraints = GridBagConstraints() + stopAttackButtonConstraints.gridx = 5 + stopAttackButtonConstraints.gridy = 0 + stopAttackButtonConstraints.insets = insets + attackPanel.add(self._stopAttackButton, stopAttackButtonConstraints) hostLabel = JLabel("Host:") hostLabelConstraints = GridBagConstraints() @@ -287,7 +497,8 @@ def _constructAttackPanel(self, insets, messageEditorComponent): protocolCheckBoxConstraints.insets = insets attackPanel.add(self._protocolCheckBox, protocolCheckBoxConstraints) - requestHeadingLabel = JLabel("Request") + requestHeadingLabel = JLabel("Request") + requestHeadingLabel.setFont(Font("Arial", Font.BOLD, 14)) requestHeadingLabelConstraints = GridBagConstraints() requestHeadingLabelConstraints.gridx = 0 requestHeadingLabelConstraints.gridy = 4 @@ -301,7 +512,7 @@ def _constructAttackPanel(self, insets, messageEditorComponent): messageEditorComponentConstraints.gridy = 5 messageEditorComponentConstraints.weightx = 1 messageEditorComponentConstraints.weighty = .75 - messageEditorComponentConstraints.gridwidth = 4 + messageEditorComponentConstraints.gridwidth = 5 messageEditorComponentConstraints.gridheight = 2 messageEditorComponentConstraints.fill = GridBagConstraints.BOTH messageEditorComponentConstraints.insets = insets @@ -311,7 +522,7 @@ def _constructAttackPanel(self, insets, messageEditorComponent): addPayloadButton = JButton( "Add \xa7", actionPerformed=self._addPayload) addPayloadButtonConstraints = GridBagConstraints() - addPayloadButtonConstraints.gridx = 4 + addPayloadButtonConstraints.gridx = 5 addPayloadButtonConstraints.gridy = 5 addPayloadButtonConstraints.fill = GridBagConstraints.HORIZONTAL addPayloadButtonConstraints.insets = insets @@ -320,18 +531,19 @@ def _constructAttackPanel(self, insets, messageEditorComponent): clearPayloadButton = JButton( "Clear \xa7", actionPerformed=self._clearPayloads) clearPayloadButtonConstraints = GridBagConstraints() - clearPayloadButtonConstraints.gridx = 4 + clearPayloadButtonConstraints.gridx = 5 clearPayloadButtonConstraints.gridy = 6 clearPayloadButtonConstraints.anchor = GridBagConstraints.PAGE_START clearPayloadButtonConstraints.fill = GridBagConstraints.HORIZONTAL clearPayloadButtonConstraints.insets = insets attackPanel.add(clearPayloadButton, clearPayloadButtonConstraints) - payloadHeadingLabel = JLabel("Payloads") + payloadHeadingLabel = JLabel("Payloads") + payloadHeadingLabel.setFont(Font("Arial", Font.BOLD, 14)) payloadHeadingLabelConstraints = GridBagConstraints() payloadHeadingLabelConstraints.gridx = 0 payloadHeadingLabelConstraints.gridy = 7 - payloadHeadingLabelConstraints.gridwidth = 4 + payloadHeadingLabelConstraints.gridwidth = 5 payloadHeadingLabelConstraints.anchor = GridBagConstraints.LINE_START payloadHeadingLabelConstraints.insets = insets attackPanel.add(payloadHeadingLabel, payloadHeadingLabelConstraints) @@ -342,7 +554,7 @@ def _constructAttackPanel(self, insets, messageEditorComponent): payloadScrollPaneConstraints.gridx = 0 payloadScrollPaneConstraints.gridy = 8 payloadScrollPaneConstraints.weighty = .25 - payloadScrollPaneConstraints.gridwidth = 3 + payloadScrollPaneConstraints.gridwidth = 4 payloadScrollPaneConstraints.fill = GridBagConstraints.BOTH payloadScrollPaneConstraints.insets = insets attackPanel.add(payloadScrollPane, payloadScrollPaneConstraints) @@ -367,14 +579,35 @@ def _constructAttackPanel(self, insets, messageEditorComponent): attackPanel.add( self._requestsNumTextField, requestsNumTextFieldConstraints) + # --- Delay between requests --- + delayLabel = JLabel("Delay between requests (ms):") + delayLabelConstraints = GridBagConstraints() + delayLabelConstraints.gridx = 0 + delayLabelConstraints.gridy = 10 + delayLabelConstraints.gridwidth = 2 + delayLabelConstraints.anchor = GridBagConstraints.LINE_START + delayLabelConstraints.insets = insets + attackPanel.add(delayLabel, delayLabelConstraints) + + self._delayTextField = JTextField("0", 4) + self._delayTextField.setMinimumSize( + self._delayTextField.getPreferredSize()) + delayTextFieldConstraints = GridBagConstraints() + delayTextFieldConstraints.gridx = 2 + delayTextFieldConstraints.gridy = 10 + delayTextFieldConstraints.anchor = GridBagConstraints.LINE_START + delayTextFieldConstraints.insets = insets + attackPanel.add(self._delayTextField, delayTextFieldConstraints) + return attackPanel def _constructResultsPanel(self, insets): resultsPanel = JPanel(GridBagLayout()) - + progressColor = Color(0x008000) self._progressBar = JProgressBar() self._progressBar.setStringPainted(True) self._progressBar.setMinimum(0) + self._progressBar.setForeground(progressColor) progressBarContraints = GridBagConstraints() progressBarContraints.gridx = 0 progressBarContraints.gridy = 0 @@ -411,22 +644,6 @@ def _constructResultsPanel(self, insets): return resultsPanel - def _constructAboutPanel(self, insets): - aboutPanel = JPanel(GridBagLayout()) - with open("about.html") as file: - aboutBody = file.read() - aboutLabel = JLabel( - aboutBody.format(extension_name=EXTENSION_NAME)) - aboutLabelConstraints = GridBagConstraints() - aboutLabelConstraints.weightx = 1 - aboutLabelConstraints.weighty = 1 - aboutLabelConstraints.insets = insets - aboutLabelConstraints.fill = GridBagConstraints.HORIZONTAL - aboutLabelConstraints.anchor = GridBagConstraints.PAGE_START - aboutPanel.add(aboutLabel, aboutLabelConstraints) - - return aboutPanel - # Required for coloured cells class ColoredTableCellRenderer(DefaultTableCellRenderer):