diff --git a/tests/mutation/__init__.py b/tests/mutation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/mutation/test_json_mutator.py b/tests/mutation/test_json_mutator.py new file mode 100644 index 000000000..61894c8b4 --- /dev/null +++ b/tests/mutation/test_json_mutator.py @@ -0,0 +1,308 @@ +import json + +import pytest + +from wapitiCore.attack.attack import Parameter, ParameterSituation +from wapitiCore.model import str_to_payloadinfo, PayloadInfo +from wapitiCore.mutation.json_mutator import find_injectable, set_item, get_item, JSONMutator +from wapitiCore.net import Request + + +@pytest.mark.parametrize( + "obj, paths", + [ + [ + [], + [[0]], + ], + [ + {}, + [], + ], + [ + {"dict_with_string_value": "hello"}, + [["dict_with_string_value"]], + ], + [ + {"dict_with_int_value": 42}, + [["dict_with_int_value"]], + ], + [ + { + "nested_dict": { + "list_of_dicts": [ + {"a": "b"}, + ], + } + }, + [["nested_dict", "list_of_dicts", 0, "a"]] + ], + [ + { + "nested_dict": { + "list_of_words": ["hello", "world"] + } + }, + [["nested_dict", "list_of_words", 0]] + ], + [ + [ + {"a": "b"}, + {"c": "d"}, + ], + [[0, "a"]], + ], + [ + { + "nested_dict": { + "list_of_dicts": [ + { + "a": "b", + "c": "d", + }, + ], + "list_of_words": ["yolo"], + "empty_list": [], + "empty_dict": {}, + }, + "item_string": "hello", + }, + [ + ['nested_dict', 'list_of_dicts', 0, 'a'], + ['nested_dict', 'list_of_dicts', 0, 'c'], + ['nested_dict', 'list_of_words', 0], + ['nested_dict', 'empty_list', 0], + ['item_string'], + ], + ], + [ + [ + [ + {"a": "b"} + ], + 42 + ], + [ + [0, 0, "a"], + ], + ] + ], + ids=[ + "empty list", + "empty dict", + "dict with string value", + "dict with int value", + "nested dict > list > dict", + "nested dict > list", + "nested list > dict", + "nested complex", + "nested list > list > dict", + ] +) +def test_find_injectable(obj, paths): + assert paths == list(find_injectable([], obj)) + + +@pytest.mark.parametrize( + "original, path, expected", + [ + [ + {"a": "b"}, + ["a"], + {"a": "Hello"}, + ], + [ + ["a", "b"], + [0], + ["Hello", "b"], + ], + [ + [], + [0], + ["Hello"], + ], + [ + { + "nested_dict": { + "list_of_dicts": [ + { + "a": "b", + "c": "d", + }, + ], + }, + }, + ['nested_dict', 'list_of_dicts', 0, 'c'], + { + "nested_dict": { + "list_of_dicts": [ + { + "a": "b", + "c": "Hello", + }, + ], + }, + }, + ], + [ + [ + [ + {"a": "b"} + ], + 42 + ], + [0, 0, "a"], + [ + [ + {"a": "Hello"} + ], + 42 + ], + ] + ], + ids=[ + "simple dict", + "simple list", + "empty list", + "nested complex", + "nested list", + ], +) +def test_set_item(original, path, expected): + set_item(original, path, "Hello") + assert expected == original + + +@pytest.mark.parametrize( + "obj, path, expected", + [ + [ + [], + [0], + [], + ], + [ + {"a": "b"}, + ["a"], + "b", + ], + [ + ["a", "b"], + [0], + "a", + ], + [ + { + "nested_dict": { + "list_of_dicts": [ + { + "a": "b", + "c": "d", + }, + ], + }, + }, + ['nested_dict', 'list_of_dicts', 0, 'c'], + "d", + ], + [ + [[{"a": "b"}], 42], + [0, 0, "a"], + "b", + ] + ], + ids=[ + "empty list", + "simple dict", + "simple list", + "nested dict", + "nested list", + ] +) +def test_get_item(obj, path, expected): + assert expected == get_item(obj, path) + + +def test_json_mutator_replace_values(): + mutator = JSONMutator() + # We will ensure we can inject data inside a string value and an int value + request = Request( + "http://perdu.com/api/", + enctype="application/json", + post_params=json.dumps({"a": [{"c": "e"}], "f": 5}) + ) + + expected = [ + ('{"a": [{"c": "eyolo"}], "f": 5}', Parameter(name='a.0.c', situation=ParameterSituation.JSON_BODY), "eyolo"), + ('{"a": [{"c": "e"}], "f": "5yolo"}', Parameter(name='f', situation=ParameterSituation.JSON_BODY), "5yolo"), + ] + + mutated_request: Request + parameter: Parameter + payload_info: PayloadInfo + + for i, (mutated_request, parameter, payload_info) in enumerate(mutator.mutate( + request, + lambda: str_to_payloadinfo(["[VALUE]yolo"]), + )): + assert expected[i] == (mutated_request.post_params, parameter, payload_info.payload) + assert mutated_request.is_json + + +def test_json_mutator_handle_list(): + mutator = JSONMutator() + # We will ensure we can inject data inside a string value and an int value + request = Request( + "http://perdu.com/api/", + enctype="application/json", + post_params=json.dumps({"a": [4]}) + ) + + expected = ( + '{"a": ["4yolo"]}', + Parameter(name='a.0', situation=ParameterSituation.JSON_BODY), + "4yolo" + ) + + mutated_request: Request + parameter: Parameter + payload_info: PayloadInfo + + mutated_request, parameter, payload_info = next(mutator.mutate( + request, + lambda: str_to_payloadinfo(["[VALUE]yolo"]), + )) + assert expected == (mutated_request.post_params, parameter, payload_info.payload) + assert mutated_request.is_json + + +def test_json_mutator_handle_empty_list(): + mutator = JSONMutator() + # We will ensure we can inject data inside an empty list + request = Request( + "http://perdu.com/api/", + enctype="application/json", + post_params=json.dumps({"a": []}) + ) + + expected = ( + '{"a": ["Hello there"]}', + Parameter(name="a.0", situation=ParameterSituation.JSON_BODY), + "Hello there" + ) + + mutated_request: Request + parameter: Parameter + payload_info: PayloadInfo + + mutations = mutator.mutate( + request, + # the first payload should be skipped as it atempts to reuse a valid that doesn't exist + lambda: str_to_payloadinfo(["[VALUE]yolo", "Hello there"]), + ) + mutated_request, parameter, payload_info = next(mutations) + assert expected == (mutated_request.post_params, parameter, payload_info.payload) + assert mutated_request.is_json + + with pytest.raises(StopIteration): + next(mutations) diff --git a/wapitiCore/attack/attack.py b/wapitiCore/attack/attack.py index 951e511fb..54a52379a 100644 --- a/wapitiCore/attack/attack.py +++ b/wapitiCore/attack/attack.py @@ -128,6 +128,7 @@ class ParameterSituation(Flag): POST_BODY = auto() MULTIPART = auto() HEADERS = auto() + JSON_BODY = auto() @dataclasses.dataclass diff --git a/wapitiCore/mutation/__init__.py b/wapitiCore/mutation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wapitiCore/mutation/json_mutator.py b/wapitiCore/mutation/json_mutator.py new file mode 100644 index 000000000..2c5e72bae --- /dev/null +++ b/wapitiCore/mutation/json_mutator.py @@ -0,0 +1,133 @@ +import json +from os.path import splitext +from typing import Generator, List, Union, Iterator, Tuple + +from wapitiCore.attack.attack import Parameter, ParameterSituation +from wapitiCore.model import PayloadInfo, PayloadSource +from wapitiCore.net import Request + + +def find_injectable(parents: List[str], obj) -> Generator[List[Union[str, int]], None, None]: + if isinstance(obj, (str, int)): + yield parents + elif isinstance(obj, list): + # Only consider the first item in the list if not empty + # We assume all objects in the list will be of identical type + if len(obj): + yield from find_injectable(parents + [0], obj[0]) + else: + yield parents + [0] + elif isinstance(obj, dict): + for k, v in obj.items(): + yield from find_injectable(parents + [k], v) + + +def set_item(json_object, injection_point, value): + ptr = json_object + for key in injection_point[:-1]: + ptr = ptr[key] + + if isinstance(ptr, list) and not ptr: + ptr.append(value) + else: + ptr[injection_point[-1]] = value + + +def get_item(json_object, path): + if not path: + return json_object + + ptr = json_object + for key in path[:-1]: + ptr = ptr[key] + + try: + return ptr[path[-1]] + except (KeyError, IndexError): + pass + + return ptr + + +class JSONMutator: + """The JSONMutator will only mutate the JSON object within the body, + it won't change parameters in the query string""" + def __init__( + self, methods="FGP", qs_inject=False, max_queries_per_pattern: int = 1000, + skip=None # Must not attack those parameters (blacklist) + ): + self._attack_hashes = set() + + def mutate(self, + request: Request, + payloads: PayloadSource) -> Iterator[Tuple[Request, Parameter, PayloadInfo]]: + get_params = request.get_params + + referer = request.referer + + if not request.is_json: + raise StopIteration + + try: + data = json.loads(request.post_params) + except json.JSONDecodeError: + raise StopIteration + + injection_points = find_injectable([], data) + + for path in injection_points: + saved_value = get_item(data, path) + + iterator = payloads if isinstance(payloads, list) else payloads() + payload_info: PayloadInfo + for payload_info in iterator: + raw_payload = payload_info.payload + + # We will inject some payloads matching those keywords whatever the type of the object to overwrite + if ("[FILE_NAME]" in raw_payload or "[FILE_NOEXT]" in raw_payload) and not request.file_name: + continue + + # no quoting: send() will do it for us + raw_payload = raw_payload.replace("[FILE_NAME]", request.file_name) + raw_payload = raw_payload.replace("[FILE_NOEXT]", splitext(request.file_name)[0]) + + if isinstance(request.path_id, int): + raw_payload = raw_payload.replace("[PATH_ID]", str(request.path_id)) + + # We don't want to replace certain placeholders reusing the current value if that value is not a string + if any(pattern in raw_payload for pattern in ("[EXTVALUE]", "[DIRVALUE]")): + if not isinstance(saved_value, str): + continue + + if "[EXTVALUE]" in raw_payload: + if "." not in saved_value[:-1]: + # Nothing that looks like an extension, skip the payload + continue + raw_payload = raw_payload.replace("[EXTVALUE]", saved_value.rsplit(".", 1)[-1]) + + raw_payload = raw_payload.replace("[DIRVALUE]", saved_value.rsplit('/', 1)[0]) + + if "[VALUE]" in raw_payload: + if not isinstance(saved_value, (int, str)): + continue + + raw_payload = raw_payload.replace("[VALUE]", str(saved_value)) + + set_item(data, path, raw_payload) + + evil_req = Request( + request.path, + method=request.method, + enctype="application/json", + get_params=get_params, + post_params=json.dumps(data), + referer=referer, + link_depth=request.link_depth + ) + payload_info.payload = raw_payload + yield evil_req, Parameter( + name=".".join([str(key) for key in path]), + situation=ParameterSituation.JSON_BODY + ), payload_info + # put back the previous value + set_item(data, path, saved_value) diff --git a/wapitiCore/net/web.py b/wapitiCore/net/web.py index 59924e944..237e398f2 100644 --- a/wapitiCore/net/web.py +++ b/wapitiCore/net/web.py @@ -669,6 +669,10 @@ def enctype(self) -> str: def is_multipart(self) -> bool: return "multipart" in self._enctype + @property + def is_json(self) -> bool: + return self._enctype == "application/json" + @property def referer(self) -> str: return self._referer