1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# This file is part of the Wapiti project (http://wapiti.sourceforge.io)
4# Copyright (C) 2008-2020 Nicolas Surribas
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
19import os
20import sys
21from os.path import splitext, join as path_join
22from urllib.parse import quote
23from collections import defaultdict
24from enum import Enum
25from math import ceil
26import random
27from types import GeneratorType, FunctionType
28from binascii import hexlify
29
30from requests.exceptions import RequestException, ReadTimeout
31
32from wapitiCore.net.web import Request
33
34
35modules = [
36    "mod_crlf",
37    "mod_exec",
38    "mod_file",
39    "mod_sql",
40    "mod_xss",
41    "mod_backup",
42    "mod_htaccess",
43    "mod_blindsql",
44    "mod_permanentxss",
45    "mod_nikto",
46    "mod_delay",
47    "mod_buster",
48    "mod_shellshock",
49    "mod_methods",
50    "mod_ssrf",
51    "mod_redirect",
52    "mod_xxe"
53]
54
55commons = ["blindsql", "exec", "file", "permanentxss", "redirect", "sql", "xss", "ssrf"]
56
57
58class PayloadType(Enum):
59    pattern = 1
60    time = 2
61    get = 3
62    post = 4
63    file = 5
64
65
66COMMON_ANNOYING_PARAMETERS = (
67    "__VIEWSTATE",
68    "__VIEWSTATEENCRYPTED",
69    "__VIEWSTATEGENERATOR",
70    "__EVENTARGUMENT",
71    "__EVENTTARGET",
72    "__EVENTVALIDATION",
73    "ASPSESSIONID",
74    "ASP.NET_SESSIONID",
75    "JSESSIONID",
76    "CFID",
77    "CFTOKEN"
78)
79
80
81class Attack:
82    """This class represents an attack, it must be extended	for any class which implements a new type of attack"""
83
84    name = "attack"
85
86    do_get = True
87    do_post = True
88
89    # List of modules (strings) that must be launched before the current module
90    # Must be defined in the code of the module
91    require = []
92
93    BASE_DIR = os.path.dirname(sys.modules["wapitiCore"].__file__)
94    CONFIG_DIR = os.path.join(BASE_DIR, "config", "attacks")
95    PAYLOADS_FILE = None
96
97    # Color codes
98    STD = "\033[0;0m"
99    RED = "\033[0;31m"
100    GREEN = "\033[0;32m"
101    ORANGE = "\033[0;33m"
102    YELLOW = "\033[1;33m"
103    BLUE = "\033[1;34m"
104    MAGENTA = "\033[0;35m"
105    CYAN = "\033[0;36m"
106    GB = "\033[0;30m\033[47m"
107
108    allowed = [
109        'php', 'html', 'htm', 'xml', 'xhtml', 'xht', 'xhtm',
110        'asp', 'aspx', 'php3', 'php4', 'php5', 'txt', 'shtm',
111        'shtml', 'phtm', 'phtml', 'jhtml', 'pl', 'jsp', 'cfm',
112        'cfml', 'py'
113    ]
114
115    # The priority of the module, from 0 (first) to 10 (last). Default is 5
116    PRIORITY = 5
117
118    def __init__(self, crawler, persister, logger, attack_options):
119        super().__init__()
120        self._session_id = "".join([random.choice("0123456789abcdefghjijklmnopqrstuvwxyz") for __ in range(0, 6)])
121        self.crawler = crawler
122        self.persister = persister
123        self.add_vuln = persister.add_vulnerability
124        self.add_anom = persister.add_anomaly
125        self.payload_reader = PayloadReader(attack_options)
126        self.options = attack_options
127
128        # List of attack urls already launched in the current module
129        self.attacked_get = []
130        self.attacked_post = []
131
132        self.verbose = 0
133        self.color = 0
134
135        # List of modules (objects) that must be launched before the current module
136        # Must be left empty in the code
137        self.deps = []
138
139        self._logger = logger
140        self.log = self._logger.log
141        self.log_blue = self._logger.log_blue
142        self.log_cyan = self._logger.log_cyan
143        self.log_green = self._logger.log_green
144        self.log_magenta = self._logger.log_magenta
145        self.log_orange = self._logger.log_orange
146        self.log_red = self._logger.log_red
147        self.log_white = self._logger.log_white
148        self.log_yellow = self._logger.log_yellow
149
150    def set_verbose(self, verbose):
151        self.verbose = verbose
152
153    def set_color(self):
154        self.color = 1
155
156    @property
157    def payloads(self):
158        """Load the payloads from the specified file"""
159        if self.PAYLOADS_FILE:
160            return self.payload_reader.read_payloads(path_join(self.CONFIG_DIR, self.PAYLOADS_FILE))
161        return []
162
163    def load_require(self, dependencies: list = None):
164        self.deps = dependencies
165
166    @property
167    def attack_level(self):
168        return self.options.get("level", 1)
169
170    @property
171    def internal_endpoint(self):
172        return self.options.get("internal_endpoint", "https://wapiti3.ovh/")
173
174    @property
175    def external_endpoint(self):
176        return self.options.get("external_endpoint", "http://wapiti3.ovh")
177
178    @property
179    def must_attack_query_string(self):
180        return self.attack_level == 2
181
182    def attack(self):
183        raise NotImplementedError("Override me bro")
184
185    def get_mutator(self):
186        methods = ""
187        if self.do_get:
188            methods += "G"
189        if self.do_post:
190            methods += "PF"
191
192        return Mutator(
193            methods=methods,
194            payloads=self.payloads,
195            qs_inject=self.must_attack_query_string,
196            skip=self.options.get("skipped_parameters")
197        )
198
199    def does_timeout(self, request):
200        try:
201            self.crawler.send(request)
202        except ReadTimeout:
203            return True
204        except RequestException:
205            pass
206        return False
207
208
209class Mutator:
210    def __init__(
211            self, methods="FGP", payloads=None, qs_inject=False, max_queries_per_pattern: int = 1000,
212            parameters=None,  # Restrict attack to a whitelist of parameters
213            skip=None  # Must not attack those parameters (blacklist)
214    ):
215        self._mutate_get = "G" in methods.upper()
216        self._mutate_file = "F" in methods.upper()
217        self._mutate_post = "P" in methods.upper()
218        self._payloads = payloads
219        self._qs_inject = qs_inject
220        self._attacks_per_url_pattern = defaultdict(int)
221        self._max_queries_per_pattern = max_queries_per_pattern
222        self._parameters = parameters if isinstance(parameters, list) else []
223        self._skip_list = skip if isinstance(skip, set) else set()
224        self._attack_hashes = set()
225        self._skip_list.update(COMMON_ANNOYING_PARAMETERS)
226
227    def iter_payloads(self):
228        # raise tuples of (payloads, flags)
229        if isinstance(self._payloads, tuple):
230            yield self._payloads
231        elif isinstance(self._payloads, list) or isinstance(self._payloads, GeneratorType):
232            yield from self._payloads
233        elif isinstance(self._payloads, FunctionType):
234            result = self._payloads()
235            if isinstance(result, GeneratorType):
236                yield from result
237            else:
238                yield result
239
240    def estimate_requests_count(self, request: Request):
241        estimation = len(request) if isinstance(self._payloads, tuple) else len(request) * len(self._payloads)
242        if self._qs_inject and request.method == "GET" and len(request) == 0:
243            # Injection directly in query string is made only on GET requests with no parameters in URL
244            estimation += len(self._payloads)
245        return estimation
246
247    def mutate(self, request: Request):
248        get_params = request.get_params
249        post_params = request.post_params
250        file_params = request.file_params
251        referer = request.referer
252
253        # estimation = self.estimate_requests_count(request)
254        #
255        # if self._attacks_per_url_pattern[request.hash_params] + estimation > self._max_queries_per_pattern:
256        #     # Otherwise (pattern already attacked), make sure we don't exceed maximum allowed
257        #     return
258        #
259        # self._attacks_per_url_pattern[request.hash_params] += estimation
260
261        for params_list in [get_params, post_params, file_params]:
262            if params_list is get_params and not self._mutate_get:
263                continue
264
265            if params_list is post_params and not self._mutate_post:
266                continue
267
268            if params_list is file_params and not self._mutate_file:
269                continue
270
271            for i in range(len(params_list)):
272                param_name = quote(params_list[i][0])
273
274                if self._skip_list and param_name in self._skip_list:
275                    continue
276
277                if self._parameters and param_name not in self._parameters:
278                    continue
279
280                saved_value = params_list[i][1]
281                if saved_value is None:
282                    saved_value = ""
283
284                if params_list is file_params:
285                    params_list[i][1] = ["__PAYLOAD__", params_list[i][1][1]]
286                else:
287                    params_list[i][1] = "__PAYLOAD__"
288
289                attack_pattern = Request(
290                    request.path,
291                    method=request.method,
292                    get_params=get_params,
293                    post_params=post_params,
294                    file_params=file_params
295                )
296
297                if hash(attack_pattern) not in self._attack_hashes:
298                    self._attack_hashes.add(hash(attack_pattern))
299
300                    for payload, original_flags in self.iter_payloads():
301
302                        # no quoting: send() will do it for us
303                        payload = payload.replace("[FILE_NAME]", request.file_name)
304                        payload = payload.replace("[FILE_NOEXT]", splitext(request.file_name)[0])
305
306                        if isinstance(request.path_id, int):
307                            payload = payload.replace("[PATH_ID]", str(request.path_id))
308
309                        payload = payload.replace(
310                            "[PARAM_AS_HEX]",
311                            hexlify(param_name.encode("utf-8", errors="replace")).decode()
312                        )
313
314                        # Flags from iter_payloads should be considered as mutable (even if it's ot the case)
315                        # so let's copy them just to be sure we don't mess with them.
316                        flags = set(original_flags)
317
318                        if params_list is file_params:
319                            if "[EXTVALUE]" in payload:
320                                if "." not in saved_value[0][:-1]:
321                                    # Nothing that looks like an extension, skip the payload
322                                    continue
323                                payload = payload.replace("[EXTVALUE]", saved_value[0].rsplit(".", 1)[-1])
324
325                            payload = payload.replace("[VALUE]", saved_value[0])
326                            payload = payload.replace("[DIRVALUE]", saved_value[0].rsplit('/', 1)[0])
327                            params_list[i][1][0] = payload
328                            flags.add(PayloadType.file)
329                        else:
330                            if "[EXTVALUE]" in payload:
331                                if "." not in saved_value[:-1]:
332                                    # Nothing that looks like an extension, skip the payload
333                                    continue
334                                payload = payload.replace("[EXTVALUE]", saved_value.rsplit(".", 1)[-1])
335
336                            payload = payload.replace("[VALUE]", saved_value)
337                            payload = payload.replace("[DIRVALUE]", saved_value.rsplit('/', 1)[0])
338                            params_list[i][1] = payload
339                            if params_list is get_params:
340                                flags.add(PayloadType.get)
341                            else:
342                                flags.add(PayloadType.post)
343
344                        evil_req = Request(
345                            request.path,
346                            method=request.method,
347                            get_params=get_params,
348                            post_params=post_params,
349                            file_params=file_params,
350                            referer=referer,
351                            link_depth=request.link_depth
352                        )
353                        yield evil_req, param_name, payload, flags
354
355                params_list[i][1] = saved_value
356
357        if not get_params and request.method == "GET" and self._qs_inject:
358            attack_pattern = Request(
359                "{}?__PAYLOAD__".format(request.path),
360                method=request.method,
361                referer=referer,
362                link_depth=request.link_depth
363            )
364
365            if hash(attack_pattern) not in self._attack_hashes:
366                self._attack_hashes.add(hash(attack_pattern))
367
368                for payload, original_flags in self.iter_payloads():
369                    # Ignore payloads reusing existing parameter values
370                    if "[VALUE]" in payload:
371                        continue
372
373                    if "[DIRVALUE]" in payload:
374                        continue
375
376                    payload = payload.replace("[FILE_NAME]", request.file_name)
377                    payload = payload.replace("[FILE_NOEXT]", splitext(request.file_name)[0])
378
379                    if isinstance(request.path_id, int):
380                        payload = payload.replace("[PATH_ID]", str(request.path_id))
381
382                    payload = payload.replace(
383                        "[PARAM_AS_HEX]",
384                        hexlify(b"QUERY_STRING").decode()
385                    )
386
387                    flags = set(original_flags)
388
389                    evil_req = Request(
390                        "{}?{}".format(request.path, quote(payload)),
391                        method=request.method,
392                        referer=referer,
393                        link_depth=request.link_depth
394                    )
395                    flags.add(PayloadType.get)
396
397                    yield evil_req, "QUERY_STRING", payload, flags
398
399
400class FileMutator:
401    def __init__(self, payloads=None, parameters=None, skip=None):
402        self._payloads = payloads
403        self._attack_hashes = set()
404        self._parameters = parameters if isinstance(parameters, list) else []
405        self._skip_list = skip if isinstance(skip, set) else set()
406
407    def iter_payloads(self):
408        # raise tuples of (payloads, flags)
409        if isinstance(self._payloads, tuple):
410            yield self._payloads
411        elif isinstance(self._payloads, list) or isinstance(self._payloads, GeneratorType):
412            yield from self._payloads
413        elif isinstance(self._payloads, FunctionType):
414            result = self._payloads()
415            if isinstance(result, GeneratorType):
416                yield from result
417            else:
418                yield result
419
420    def mutate(self, request: Request):
421        get_params = request.get_params
422        post_params = request.post_params
423        referer = request.referer
424
425        for i in range(len(request.file_params)):
426            new_params = request.file_params
427            param_name = new_params[i][0]
428
429            if self._skip_list and param_name in self._skip_list:
430                continue
431
432            if self._parameters and param_name not in self._parameters:
433                continue
434
435            for payload, original_flags in self.iter_payloads():
436
437                # no quoting: send() will do it for us
438                payload = payload.replace("[FILE_NAME]", request.file_name)
439                payload = payload.replace("[FILE_NOEXT]", splitext(request.file_name)[0])
440
441                if isinstance(request.path_id, int):
442                    payload = payload.replace("[PATH_ID]", str(request.path_id))
443
444                payload = payload.replace(
445                    "[PARAM_AS_HEX]",
446                    hexlify(param_name.encode("utf-8", errors="replace")).decode()
447                )
448
449                # Flags from iter_payloads should be considered as mutable (even if it's ot the case)
450                # so let's copy them just to be sure we don't mess with them.
451                flags = set(original_flags)
452
453                new_params[i][1] = ["content.xml", payload, "text/xml"]
454                flags.add(PayloadType.file)
455
456                evil_req = Request(
457                    request.path,
458                    method=request.method,
459                    get_params=get_params,
460                    post_params=post_params,
461                    file_params=new_params,
462                    referer=referer,
463                    link_depth=request.link_depth
464                )
465                yield evil_req, param_name, payload, flags
466
467
468class PayloadReader:
469    """Class for reading and writing in text files"""
470
471    def __init__(self, options):
472        self._timeout = options["timeout"]
473        self._endpoint_url = options.get("external_endpoint", "http://wapiti3.ovh/")
474
475    def read_payloads(self, filename):
476        """returns a array"""
477        lines = []
478        try:
479            with open(filename, errors="ignore") as f:
480                for line in f:
481                    clean_line, flags = self.process_line(line)
482                    if clean_line:
483                        lines.append((clean_line, flags))
484        except IOError as exception:
485            print(exception)
486        return lines
487
488    def process_line(self, line):
489        flags = set()
490        clean_line = line.strip(" \n")
491        clean_line = clean_line.replace("[TAB]", "\t")
492        clean_line = clean_line.replace("[LF]", "\n")
493        clean_line = clean_line.replace("[FF]", "\f")  # Form feed
494        clean_line = clean_line.replace("[TIME]", str(int(ceil(self._timeout)) + 1))
495        clean_line = clean_line.replace("[EXTERNAL_ENDPOINT]", self._endpoint_url)
496
497        payload_type = PayloadType.pattern
498        if "[TIMEOUT]" in clean_line:
499            payload_type = PayloadType.time
500            clean_line = clean_line.replace("[TIMEOUT]", "")
501
502        clean_line = clean_line.replace("\\0", "\0")
503
504        flags.add(payload_type)
505        return clean_line, flags
506
507
508if __name__ == "__main__":
509
510    mutator = Mutator(payloads=[("INJECT", set()), ("ATTACK", set())], qs_inject=True, max_queries_per_pattern=16)
511    res1 = Request(
512        "http://httpbin.org/post?var1=a&var2=b",
513        post_params=[['post1', 'c'], ['post2', 'd']]
514    )
515
516    res2 = Request(
517        "http://httpbin.org/post?var1=a&var2=z",
518        post_params=[['post1', 'c'], ['post2', 'd']]
519    )
520
521    res3 = Request(
522        "http://httpbin.org/get?login=admin&password=letmein",
523    )
524
525    assert res1.hash_params == res2.hash_params
526
527    for evil_request, param_name, payload, flags in mutator.mutate(res1):
528        print(evil_request)
529        print(flags)
530
531    print('')
532    print("#"*50)
533    print('')
534
535    for evil_request, param_name, payload, flags in mutator.mutate(res2):
536        print(evil_request)
537
538    print('')
539    print("#"*50)
540    print('')
541
542    def iterator():
543        yield "abc", set()
544        yield "def", set()
545
546    mutator = Mutator(payloads=iterator, qs_inject=True, max_queries_per_pattern=16)
547    for evil_request, param_name, payload, flags in mutator.mutate(res3):
548        print(evil_request)
549
550    print('')
551    print("#"*50)
552    print('')
553
554    def random_string():
555        """Create a random unique ID that will be used to test injection."""
556        # doesn't uppercase letters as BeautifulSoup make some data lowercase
557        return "w" + "".join([random.choice("0123456789abcdefghjijklmnopqrstuvwxyz") for __ in range(0, 9)]), set()
558
559    mutator = Mutator(payloads=random_string, qs_inject=True, max_queries_per_pattern=16)
560    for evil_request, param_name, payload, flags in mutator.mutate(res3):
561        print(evil_request)
562        print("Payload is", payload)
563
564    mutator = Mutator(methods="G", payloads=[("INJECT", set()), ("ATTACK", set())], qs_inject=True, parameters=["var1"])
565    assert len(list(mutator.mutate(res1))) == 2
566