-
Notifications
You must be signed in to change notification settings - Fork 0
/
fuzz.py
343 lines (258 loc) · 14.4 KB
/
fuzz.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
from burp import IScannerInsertionPoint, IParameter
from implementations import ScannerInsertionPoint, ContextMenuInvocation
from java.util.concurrent import Executors, ExecutionException
from tables import NoResponseException
from threading import Lock
from utility import log, ShutdownException, resend_request_model, PythonFunctionRunnable, sendMessageToSlack, resend_session_check
import java.lang.Exception
import java.lang.NullPointerException
import time
import utility
import logging
class SessionCheckNotReproducibleException(Exception):
pass
class FuzzRunner(object):
"""
Main fuzz runner class. In charge of interacting with the extensions
"""
def __init__(self, state, callbacks, extensions):
self.state = state
self.callbacks = callbacks
self.extensions = extensions
self.lock = Lock()
def run(self):
"""
Main run method. Blocks the calling thread until threads are finished running.
"""
log("Starting scan.")
result = self.fuzzEndpoints()
log("Scan finished normally.")
return result
def fuzzEndpoints(self):
"""
Fuzzes endpoints as present in the state based on the user preferences.
We attempt to fuzz only one request per endpoint, using our own criteria to differentiate between endpoints as defined in `EndpontTableModel.generateEndpointHash`. For each endpoint, we iterate through requests until we can find a single request whose status code is the same between both the original and the repeated request, we only fuzz once. Requests are ordered based on the number of parameters they have, giving preference to those with the higher number of parameters in order to attempt to maximise attack surface.
Returns:
tuple: (int, int) number of items scanned and number of items for which the scan could not be completed due to an exception.
"""
endpoints = self.state.endpointTableModel.endpoints
futures = []
endpointsNotReproducibleCount = 0
nbFuzzedTotal = 0
nbExceptions = 0
for key in endpoints:
endpoint = endpoints[key]
nbExceptions += self.checkMaxConcurrentRequests(futures, 10) # Only work on some futures at a time. This prevents a memory leak.
if endpoint.fuzzed:
continue
sortedRequests = sorted(endpoint.requests, key=lambda x: len(x.analyzedRequest.parameters), reverse=True)
fuzzed = False
for request in sortedRequests:
utility.sleep(self.state, 0.2)
try:
resend_request_model(self.state, self.callbacks, request)
except NoResponseException:
continue
if request.wasReproducible():
endpointsNotReproducibleCount = 0
nbFuzzedTotal += 1
frm_futures = self.fuzzRequestModel(request)
futures.append((endpoint, request, frm_futures))
fuzzed = True
break
if not fuzzed:
endpointsNotReproducibleCount += 1
log("Did not fuzz '%s' because no reproducible requests are possible with the current replacement rules" % endpoint.url)
nbExceptions += self.checkMaxConcurrentRequests(futures, 0) # ensure all requests are done.
return nbFuzzedTotal, nbExceptions
def checkMaxConcurrentRequests(self, futures, maxRequests):
"""
Blocking function that waits until we can add more futures.
It is in charge of marking requests as fuzzed once completed.
Args:
futures: futures as defined in `fuzzButtonClicked`
maxRequests: maximum requests that should be pending at this time. If we have more futures than this number, this function will block until the situation changes. We check for changes by calling `isDone()` on each of the available futures.
Return:
int: number of exceptions thrown during scan. 0 means no errors.
"""
nbExceptions = 0
while len(futures) > maxRequests:
utility.sleep(self.state, 0.5)
for tuple in futures:
endpoint, request, frm_futures = tuple
request_done = False
for future in frm_futures:
if future.isDone():
try:
future.get()
except ExecutionException as exc:
logging.error("Failure fuzzing %s" % endpoint.url, exc_info=True)
sendMessageToSlack(self.callbacks, "Failure fuzzing %s, exception: %s" % (endpoint.url, exc.cause))
nbExceptions += 1
frm_futures.remove(future)
if len(frm_futures) == 0:
request_done = True
if request_done:
futures.remove(tuple)
resend_request_model(self.state, self.callbacks, request)
textAreaText = self.state.sessionCheckTextarea.text
sessionCheckReproducible, _ = resend_session_check(self.state, self.callbacks, textAreaText)
if request.wasReproducible() and sessionCheckReproducible:
self.state.endpointTableModel.setFuzzed(endpoint, True)
log("Finished fuzzing %s" % endpoint.url)
elif not request.wasReproducible():
log("Fuzzing complete but did not mark as fuzzed because no longer reproducible at %s." % endpoint.url)
else:
log("Fuzzing complete but did not mark as fuzzed because the session check request is no longer reproducible.")
raise SessionCheckNotReproducibleException("Base request no longer reproducible.")
break
return nbExceptions
def fuzzRequestModel(self, request):
"""
Sends a RequestModel to be fuzzed by burp.
Burp has a helper function for running active scans, however I am not using it for two reasons. Firstly, as of 2.x the mechanism for configuring scans got broken in a re-shuffle of burp code. Secondly, burp's session handling for large scans is not perfect, once the session expires the scan continues to fuzz requests with an expired session, and implementing my own session handling on top of IScannerCheck objects is not possible due to a bug in getStatus() where requests that have errored out still have a "scanning" status. If these issues are resolved we can get rid of this workaround.
We work around this by importing extensions' JAR files and interacting with them using the same APIs that burp uses.
Args:
request: an instance of RequestModel.
"""
utility.sleep(self.state, 0.2)
insertionPointsGenerator = InsertionPointsGenerator(self.callbacks)
futures = []
for name, extension in self.extensions:
for activeScanner in extension.getScannerChecks():
if name == "shelling":
onlyParameters = True
else:
onlyParameters = False
insertionPoints = insertionPointsGenerator.getInsertionPoints(request, onlyParameters)
for insertionPoint in insertionPoints:
runnable = PythonFunctionRunnable(self.doActiveScan, args=[activeScanner, request.repeatedHttpRequestResponse, insertionPoint])
futures.append(self.state.fuzzExecutorService.submit(runnable))
for factory in extension.getContextMenuFactories():
if name == "paramminer":
menuItems = factory.createMenuItems(ContextMenuInvocation([request.repeatedHttpRequestResponse]))
for menuItem in menuItems:
# trigger "Guess headers/parameters/JSON!" functionality.
runnable = PythonFunctionRunnable(menuItem.doClick)
futures.append(self.state.fuzzExecutorService.submit(runnable))
return futures
def doActiveScan(self, scanner, httpRequestResponse, insertionPoint):
"""
Performs an active scan and stores issues found.
Because the scanner fails sometimes with random errors when HTTP requests timeout and etcetera, we retry a couple of times. This allows us to scan faster because we can be more resilient to errors.
Args:
scanner: a IScannerCheck object as returned by extension.getActiveScanners().
httpRequestResponse: the value to pass to doActiveScan. This should be the modified request, i.e. repeatedHttpRequestResponse.
insertionPoint: the insertionPoint to scan.
"""
retries = 5
while retries > 0:
utility.sleep(self.state, 1)
try:
issues = scanner.doActiveScan(httpRequestResponse, insertionPoint)
with self.lock:
if issues:
for issue in issues:
self.callbacks.addScanIssue(issue)
break
except (java.lang.Exception, java.lang.NullPointerException):
retries -= 1
logging.error("Java exception while fuzzing individual param, retrying it. %d retries left." % retries, exc_info=True)
except:
retries -= 1
logging.error("Exception while fuzzing individual param, retrying it. %d retries left." % retries, exc_info=True)
class InsertionPointsGenerator(object):
"""
Generates insertion points given a request.
"""
def __init__(self, callbacks):
"""
Main constructor.
Args:
callbacks: the burp callbacks object.
"""
self.callbacks = callbacks
def getInsertionPoints(self, request, onlyParameters):
"""
Gets IScannerInsertionPoint for indicating active scan parameters. See https://portswigger.net/burp/extender/api/burp/IScannerInsertionPoint.html
Uses a custom implementation of the IScannerInsertionPoint because the default helper function at `makeScannerInsertionPoint` doesn't let you specify the parameter type. The parameter type is necessary to perform modifications to the payload in order to perform proper injection, such as not using unescaped quotes when inserting into a JSON object as this will result in a syntax error.
Args:
request: the request to generate insertion points for.
onlyParameters: whether to fuzz only get and body parameters. Doesn't fuzz cookies, path parameters nor headers. This saves time when running shelling which takes a long time due to a long payload list.
"""
parameters = request.repeatedAnalyzedRequest.parameters
insertionPoints = []
for parameter in parameters:
if parameter.type == IParameter.PARAM_COOKIE and onlyParameters:
continue
insertionPoint = ScannerInsertionPoint(self.callbacks, request.repeatedHttpRequestResponse.request, parameter.name, parameter.value, parameter.type, parameter.valueStart, parameter.valueEnd)
insertionPoints.append(insertionPoint)
if onlyParameters:
return insertionPoints
for pathInsertionPoint in self.getPathInsertionPoints(request):
insertionPoints.append(pathInsertionPoint)
for headerInsertionPoint in self.getHeaderInsertionPoints(request):
insertionPoints.append(headerInsertionPoint)
return insertionPoints
def getHeaderInsertionPoints(self, request):
"""
Gets header insertion points.
This means that for a header like:
```
GET / HTTP/1.1
Host: header.com
Random-header: lel-value
```
It would generate two insertion points corresponding to the headers.
Args:
request: the request to analyze.
"""
headers = request.repeatedAnalyzedRequest.headers
lineStartOffset = 0
insertionPoints = []
for nb, header in enumerate(headers):
if nb > 0:
headerSeparator = ":"
splat = header.split(headerSeparator)
headerName = splat[0]
headerValue = splat[1]
startedWithSpace = headerValue.startswith(" ")
headerValue = headerValue.lstrip()
startOffset = lineStartOffset + len(headerName) + len(headerSeparator)
if startedWithSpace:
startOffset += 1
endOffset = startOffset + len(headerValue)
insertionPoint = ScannerInsertionPoint(self.callbacks, request.repeatedHttpRequestResponse.request, headerName, headerValue, IScannerInsertionPoint.INS_HEADER, startOffset, endOffset)
insertionPoints.append(insertionPoint)
lineStartOffset += len(header) + len("\r\n")
return insertionPoints
def getPathInsertionPoints(self, request):
"""
Gets folder insertion points.
This means that for a URL such as /folder/folder/file.php it would generate three insertion points: one for each folder and one for the filename.
Args:
request: the request to generate the insertion points for.
Return:
list: the IScannerInsertionPoint objects.
"""
firstLine = request.repeatedAnalyzedRequest.headers[0]
startOffset = None
endOffset = None
insertionPoints = []
if " / " in firstLine:
return []
for offset, char in enumerate(firstLine):
if char in ["/", " ", "?"]:
if not startOffset:
if char == "/":
startOffset = offset + 1
else:
endOffset = offset
value = firstLine[startOffset:endOffset]
type = IScannerInsertionPoint.INS_URL_PATH_FOLDER if char == "/" else IScannerInsertionPoint.INS_URL_PATH_FILENAME
insertionPoint = ScannerInsertionPoint(self.callbacks, request.repeatedHttpRequestResponse.request, "pathParam", value, type, startOffset, endOffset)
insertionPoints.append(insertionPoint)
startOffset = offset + 1
if char in [" ", "?"]:
break
return insertionPoints