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):