├── LICENSE ├── README.md └── matchreplace.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Eric Labrador Sainz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Match & Replace (Repeater) — Burp Suite 2 | 3 | A Burp Suite extension (Jython) that adds a Match&Replace tab in Repeater (requests only). It allows replacing text only within the selection and ensures changes persist when switching back to Pretty/Raw/Hex view. 4 | 5 | ## TL;DR 6 | 7 | - Adds a Match&Replace tab in Repeater → Requests. 8 | - Two fields: Match and Replace, plus an Apply Replace button. 9 | - Replaces only inside the selection (does nothing if nothing is selected). 10 | - If nothing is selected, you can choose a part of the request to modify. 11 | 12 | ## Requirements 13 | 14 | - Burp Suite (Community or Pro). 15 | - Jython standalone (e.g., jython-standalone-2.7.3.jar) configured in Burp (Extender → Options → Python environment). 16 | 17 | ## Quick Usage 18 | 19 | 1. In Repeater, select a portion of the request (headers or body). 20 | 2. Open the Match&Replace tab. You will see the request in the native editor. 21 | 3. Enter the text to search in Match and the replacement in Replace. 22 | 4. Click Apply Replace. 23 | 5. If no selection exists, it will prompt you to select a region. 24 | 6. Only occurrences inside the selection are replaced. 25 | 26 | ## License 27 | 28 | MIT — free to use and modify, with attribution. 29 | -------------------------------------------------------------------------------- /matchreplace.py: -------------------------------------------------------------------------------- 1 | from burp import IBurpExtender, IMessageEditorTabFactory, IMessageEditorTab 2 | from javax.swing import (JPanel, JLabel, JTextField, JButton, BorderFactory, 3 | BoxLayout, Box, JCheckBox, JComboBox, JOptionPane) 4 | from java.awt import BorderLayout 5 | import re 6 | 7 | VERSION = "1.0" 8 | 9 | MAX_UNDO = 10 10 | 11 | class BurpExtender(IBurpExtender, IMessageEditorTabFactory): 12 | def registerExtenderCallbacks(self, callbacks): 13 | self.callbacks = callbacks 14 | self.helpers = callbacks.getHelpers() 15 | callbacks.setExtensionName("Match & Replace (Request) Enhanced") 16 | callbacks.registerMessageEditorTabFactory(self) 17 | 18 | print("MatchReplaceTab - Version %s" % VERSION) 19 | 20 | def createNewInstance(self, controller, editable): 21 | return MatchReplaceTab(self.callbacks, self.helpers, controller, editable) 22 | 23 | 24 | class MatchReplaceTab(IMessageEditorTab): 25 | def __init__(self, callbacks, helpers, controller, editable): 26 | self.callbacks = callbacks 27 | self.helpers = helpers 28 | self._controller = controller 29 | self._editable = editable 30 | 31 | # Burp native editor (Pretty/Raw/Hex support) 32 | self._txtInput = callbacks.createMessageEditor(None, editable) 33 | 34 | # Main panel 35 | self._panel = JPanel(BorderLayout()) 36 | self._panel.add(self._txtInput.getComponent(), BorderLayout.CENTER) 37 | 38 | # Top panel with 2 rows 39 | self._topPanel = JPanel() 40 | self._topPanel.setLayout(BoxLayout(self._topPanel, BoxLayout.Y_AXIS)) 41 | 42 | # --- First row: Match / Replace inputs + buttons --- 43 | firstRow = JPanel() 44 | firstRow.setLayout(BoxLayout(firstRow, BoxLayout.X_AXIS)) 45 | self._lblMatch = JLabel("Match: ") 46 | self._txtMatch = JTextField(20) 47 | self._lblReplace = JLabel("Replace: ") 48 | self._txtReplace = JTextField(20) 49 | self._btnDo = JButton("Apply Replace", actionPerformed=self.do_replace) 50 | self._btnUndo = JButton("Undo", actionPerformed=self.do_undo) 51 | self._btnClearHistory = JButton("Clear History", actionPerformed=self.clear_history) 52 | 53 | firstRow.add(self._lblMatch) 54 | firstRow.add(self._txtMatch) 55 | firstRow.add(Box.createHorizontalStrut(10)) 56 | firstRow.add(self._lblReplace) 57 | firstRow.add(self._txtReplace) 58 | firstRow.add(Box.createHorizontalStrut(10)) 59 | firstRow.add(self._btnDo) 60 | firstRow.add(Box.createHorizontalStrut(6)) 61 | firstRow.add(self._btnUndo) 62 | firstRow.add(Box.createHorizontalStrut(6)) 63 | firstRow.add(self._btnClearHistory) 64 | 65 | # --- Second row: checkboxes + scope --- 66 | secondRow = JPanel() 67 | secondRow.setLayout(BoxLayout(secondRow, BoxLayout.X_AXIS)) 68 | self._chkRegex = JCheckBox("Regex", False) # deselected 69 | self._chkIcase = JCheckBox("Ignore case", True) # selected by default 70 | self._scopeOptions = ["Selection", "Body", "Headers", "URL", "Whole request"] 71 | self._cmbScope = JComboBox(self._scopeOptions) 72 | self._cmbScope.setSelectedIndex(0) # default Selection 73 | 74 | secondRow.add(self._chkRegex) 75 | secondRow.add(Box.createHorizontalStrut(10)) 76 | secondRow.add(self._chkIcase) 77 | secondRow.add(Box.createHorizontalStrut(10)) 78 | secondRow.add(self._cmbScope) 79 | 80 | # Add rows to top panel 81 | self._topPanel.add(firstRow) 82 | self._topPanel.add(Box.createVerticalStrut(6)) 83 | self._topPanel.add(secondRow) 84 | 85 | # Add top panel to main panel 86 | self._panel.add(self._topPanel, BorderLayout.NORTH) 87 | 88 | # Store current message and state 89 | self._currentMessage = None 90 | self._isRequest = True 91 | self._modified = False 92 | 93 | # Undo/history stack: list of bytes 94 | self._undo_stack = [] 95 | 96 | # IMessageEditorTab methods 97 | def getTabCaption(self): 98 | return "Match&Replace" 99 | 100 | def getUiComponent(self): 101 | return self._panel 102 | 103 | def isEnabled(self, content, isRequest): 104 | return isRequest and content is not None 105 | 106 | def setMessage(self, content, isRequest): 107 | self._isRequest = isRequest 108 | if content is None: 109 | self._txtInput.setMessage(None, isRequest) 110 | self._currentMessage = None 111 | self._modified = False 112 | self._undo_stack = [] 113 | return 114 | self._currentMessage = content 115 | self._txtInput.setMessage(content, isRequest) 116 | self._modified = False 117 | self._undo_stack = [] 118 | 119 | def getMessage(self): 120 | # Ensure Burp always receives the latest bytes from our editor 121 | msg = self._txtInput.getMessage() 122 | if msg is not None: 123 | self._currentMessage = msg 124 | return self._currentMessage 125 | 126 | def isModified(self): 127 | return self._modified or self._txtInput.isMessageModified() 128 | 129 | def getSelectedData(self): 130 | return self._txtInput.getSelectedData() 131 | 132 | # Undo stack helpers 133 | def push_undo(self, bytes_msg): 134 | try: 135 | if bytes_msg is None: 136 | return 137 | # if stack top equals new, skip 138 | if len(self._undo_stack) > 0 and self._undo_stack[-1] == bytes_msg: 139 | return 140 | self._undo_stack.append(bytes_msg) 141 | if len(self._undo_stack) > MAX_UNDO: 142 | # drop oldest 143 | self._undo_stack.pop(0) 144 | except Exception: 145 | pass 146 | 147 | def do_undo(self, event): 148 | try: 149 | if not self._undo_stack: 150 | JOptionPane.showMessageDialog(None, "No history to undo.", "Info", JOptionPane.INFORMATION_MESSAGE) 151 | return 152 | # Pop current if equals editor state 153 | current = self.getMessage() 154 | if self._undo_stack and self._undo_stack[-1] == current: 155 | self._undo_stack.pop() # current state on top 156 | if not self._undo_stack: 157 | JOptionPane.showMessageDialog(None, "No earlier state to restore.", "Info", JOptionPane.INFORMATION_MESSAGE) 158 | return 159 | previous = self._undo_stack.pop() 160 | # apply previous 161 | self._txtInput.setMessage(previous, self._isRequest) 162 | self._currentMessage = previous 163 | self._modified = True 164 | except Exception as ex: 165 | self.callbacks.printError("Error in do_undo: %s" % str(ex)) 166 | 167 | def clear_history(self, event): 168 | self._undo_stack = [] 169 | JOptionPane.showMessageDialog(None, "History cleared.", "Info", JOptionPane.INFORMATION_MESSAGE) 170 | 171 | # Utility: split request into head (start-line + headers) and body string 172 | def split_request(self, request_str): 173 | # Return (head_str, body_str) where head_str includes start-line and headers 174 | sep = "\r\n\r\n" 175 | if sep in request_str: 176 | parts = request_str.split(sep, 1) 177 | return parts[0], parts[1] 178 | else: 179 | return request_str, "" 180 | 181 | # Replace helpers for each scope 182 | def replace_in_selection(self, orig_str, match_str, repl_str, use_regex, ignore_case, sel_start, sel_end): 183 | selected_part = orig_str[sel_start:sel_end] 184 | new_selected = self.perform_replace(selected_part, match_str, repl_str, use_regex, ignore_case) 185 | return orig_str[:sel_start] + new_selected + orig_str[sel_end:], sel_start, sel_start + len(new_selected) 186 | 187 | def replace_in_body(self, orig_str, match_str, repl_str, use_regex, ignore_case): 188 | head, body = self.split_request(orig_str) 189 | new_body = self.perform_replace(body, match_str, repl_str, use_regex, ignore_case) 190 | new_full = head + "\r\n\r\n" + new_body 191 | # body region start index: 192 | body_start = len(head) + 4 193 | return new_full, body_start, body_start + len(new_body) 194 | 195 | def replace_in_headers(self, orig_str, match_str, repl_str, use_regex, ignore_case): 196 | head, body = self.split_request(orig_str) 197 | lines = head.split("\r\n") 198 | if not lines: 199 | return orig_str, None, None 200 | start_line = lines[0] 201 | headers = lines[1:] 202 | headers_str = "\r\n".join(headers) 203 | new_headers = self.perform_replace(headers_str, match_str, repl_str, use_regex, ignore_case) 204 | new_head = start_line 205 | if new_headers != "": 206 | new_head = new_head + "\r\n" + new_headers 207 | new_full = new_head + "\r\n\r\n" + body 208 | # headers region start and end 209 | headers_start = len(start_line) + 2 # after start-line + CRLF 210 | headers_end = headers_start + len(new_headers) 211 | return new_full, headers_start, headers_end 212 | 213 | def replace_in_url(self, orig_bytes, match_str, repl_str, use_regex, ignore_case): 214 | # Use analyzeRequest to get the URL and reconstruct start-line 215 | try: 216 | analyzed = self.helpers.analyzeRequest(self._controller.getHttpService(), orig_bytes) 217 | url_obj = analyzed.getUrl() 218 | # get components 219 | protocol = url_obj.getProtocol() # "http" or "https" as string? In some Jython/Java versions it's string 220 | # We'll parse start-line by hand to ensure compatibility 221 | orig_str = self.helpers.bytesToString(orig_bytes) 222 | lines = orig_str.split("\r\n") 223 | if not lines: 224 | return orig_bytes, None, None 225 | start_line = lines[0] 226 | # start_line format: METHOD SP path SP HTTP/version 227 | parts = start_line.split(" ") 228 | if len(parts) < 3: 229 | return orig_bytes, None, None 230 | method = parts[0] 231 | path = parts[1] 232 | version = " ".join(parts[2:]) # HTTP/1.1 233 | # Replace inside path only 234 | new_path = path 235 | if use_regex: 236 | flags = re.IGNORECASE if ignore_case else 0 237 | new_path = re.sub(match_str, repl_str, path, flags=flags) 238 | else: 239 | if ignore_case: 240 | # naive case-insensitive replace: find occurrences ignoring case 241 | pattern = re.compile(re.escape(match_str), re.IGNORECASE) 242 | new_path = pattern.sub(repl_str, path) 243 | else: 244 | new_path = path.replace(match_str, repl_str) 245 | # rebuild start-line 246 | new_start_line = "%s %s %s" % (method, new_path, version) 247 | # rebuild whole request 248 | rest = "\r\n".join(lines[1:]) 249 | new_full = new_start_line + "\r\n" + rest 250 | # the URL region is within start-line between method+space and space+version 251 | url_region_start = len(method) + 1 252 | url_region_end = url_region_start + len(new_path) 253 | return self.helpers.stringToBytes(new_full), url_region_start, url_region_end 254 | except Exception: 255 | # fallback: do nothing 256 | return orig_bytes, None, None 257 | 258 | def perform_replace(self, input_text, match_str, repl_str, use_regex, ignore_case): 259 | if use_regex: 260 | try: 261 | flags = re.IGNORECASE if ignore_case else 0 262 | return re.sub(match_str, repl_str, input_text, flags=flags) 263 | except re.error: 264 | # invalid regex -> show message and do no change 265 | JOptionPane.showMessageDialog(None, "Invalid regular expression.", "Regex error", JOptionPane.ERROR_MESSAGE) 266 | return input_text 267 | else: 268 | if ignore_case: 269 | # case-insensitive simple replace 270 | pattern = re.compile(re.escape(match_str), re.IGNORECASE) 271 | return pattern.sub(repl_str, input_text) 272 | else: 273 | return input_text.replace(match_str, repl_str) 274 | 275 | # Custom logic 276 | def do_replace(self, event): 277 | try: 278 | match_str = self._txtMatch.getText() 279 | repl_str = self._txtReplace.getText() 280 | use_regex = self._chkRegex.isSelected() 281 | ignore_case = self._chkIcase.isSelected() 282 | scope = self._cmbScope.getSelectedItem() 283 | 284 | if match_str is None or match_str == "": 285 | JOptionPane.showMessageDialog(None, "Please enter a Match string.", "Info", JOptionPane.INFORMATION_MESSAGE) 286 | return 287 | 288 | # Get current message bytes (latest) 289 | orig_bytes = self.getMessage() 290 | if orig_bytes is None: 291 | return 292 | orig_str = self.helpers.bytesToString(orig_bytes) 293 | 294 | # push current state to undo stack 295 | self.push_undo(orig_bytes) 296 | 297 | # Based on scope, compute new_full and selection region 298 | new_bytes = orig_bytes 299 | sel_start = None 300 | sel_end = None 301 | 302 | if scope == "Selection": 303 | sel_bounds = self._txtInput.getSelectionBounds() 304 | if sel_bounds is None: 305 | JOptionPane.showMessageDialog(None, "No selection. Please select the region where you want replacements to occur.", "No selection", JOptionPane.INFORMATION_MESSAGE) 306 | # pop the pushed state since nothing changed 307 | if self._undo_stack and self._undo_stack[-1] == orig_bytes: 308 | self._undo_stack.pop() 309 | return 310 | start = sel_bounds[0] 311 | end = sel_bounds[1] 312 | if end <= start: 313 | JOptionPane.showMessageDialog(None, "Empty selection. Please select a non-empty region.", "Empty selection", JOptionPane.INFORMATION_MESSAGE) 314 | if self._undo_stack and self._undo_stack[-1] == orig_bytes: 315 | self._undo_stack.pop() 316 | return 317 | new_full, sel_start, sel_end = self.replace_in_selection(orig_str, match_str, repl_str, use_regex, ignore_case, start, end) 318 | new_bytes = self.helpers.stringToBytes(new_full) 319 | 320 | elif scope == "Body": 321 | new_full, sel_start, sel_end = self.replace_in_body(orig_str, match_str, repl_str, use_regex, ignore_case) 322 | new_bytes = self.helpers.stringToBytes(new_full) 323 | 324 | elif scope == "Headers": 325 | new_full, sel_start, sel_end = self.replace_in_headers(orig_str, match_str, repl_str, use_regex, ignore_case) 326 | new_bytes = self.helpers.stringToBytes(new_full) 327 | 328 | elif scope == "URL": 329 | new_bytes, sel_start, sel_end = self.replace_in_url(orig_bytes, match_str, repl_str, use_regex, ignore_case) 330 | # new_bytes already bytes 331 | 332 | elif scope == "Whole request": 333 | new_full = self.perform_replace(orig_str, match_str, repl_str, use_regex, ignore_case) 334 | new_bytes = self.helpers.stringToBytes(new_full) 335 | sel_start = 0 336 | sel_end = len(new_full) 337 | 338 | else: 339 | # fallback: do selection 340 | sel_bounds = self._txtInput.getSelectionBounds() 341 | if sel_bounds is None: 342 | JOptionPane.showMessageDialog(None, "No selection. Please select the region where you want replacements to occur.", "No selection", JOptionPane.INFORMATION_MESSAGE) 343 | if self._undo_stack and self._undo_stack[-1] == orig_bytes: 344 | self._undo_stack.pop() 345 | return 346 | start = sel_bounds[0] 347 | end = sel_bounds[1] 348 | new_full, sel_start, sel_end = self.replace_in_selection(orig_str, match_str, repl_str, use_regex, ignore_case, start, end) 349 | new_bytes = self.helpers.stringToBytes(new_full) 350 | 351 | # Update editor and internal state, mark modified so Burp syncs to Pretty 352 | self._txtInput.setMessage(new_bytes, self._isRequest) 353 | self._currentMessage = new_bytes 354 | self._modified = True 355 | 356 | # Try to restore selection if we have numeric region values 357 | try: 358 | if sel_start is not None and sel_end is not None: 359 | editor_comp = self._txtInput.getComponent() 360 | if hasattr(editor_comp, "setSelection"): 361 | editor_comp.setSelection(int(sel_start), int(sel_end)) 362 | except Exception: 363 | pass 364 | 365 | except Exception as ex: 366 | # On exception, try to rollback last pushed state to avoid orphaning history 367 | try: 368 | if self._undo_stack: 369 | last = self._undo_stack.pop() 370 | self._txtInput.setMessage(last, self._isRequest) 371 | self._currentMessage = last 372 | self._modified = True 373 | except Exception: 374 | pass 375 | self.callbacks.printError("Error in do_replace: %s" % str(ex)) 376 | --------------------------------------------------------------------------------