400 lines
12 KiB
Ruby
400 lines
12 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Auxiliary
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Auxiliary::Report
|
|
include Msf::Module::Deprecated
|
|
|
|
moved_from 'auxiliary/scanner/elasticsearch/indices_enum'
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Elasticsearch Enumeration Utility',
|
|
'Description' => %q{
|
|
This module enumerates Elasticsearch instances. It uses the REST API
|
|
in order to gather information about the server, the cluster, nodes,
|
|
in the cluster, indices, and pull data from those indices.
|
|
},
|
|
'Author' => [
|
|
'Silas Cutler <Silas.Cutler[at]BlackListThisDomain.com>', # original indices enum module
|
|
'h00die' # generic enum module
|
|
],
|
|
'References' => [
|
|
['URL', 'https://www.elastic.co/guide/en/elasticsearch/reference/current/rest-apis.html']
|
|
],
|
|
'License' => MSF_LICENSE,
|
|
'DefaultOptions' => {
|
|
'SSL' => true
|
|
},
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [],
|
|
'SideEffects' => [IOC_IN_LOGS]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options(
|
|
[
|
|
Opt::RPORT(9200),
|
|
OptString.new('USERNAME', [false, 'A specific username to authenticate as', '']),
|
|
OptString.new('PASSWORD', [false, 'A specific password to authenticate as', '']),
|
|
OptInt.new('DOWNLOADROWS', [true, 'Number of beginning and ending rows to download per index', 5])
|
|
]
|
|
)
|
|
end
|
|
|
|
def get_results(index)
|
|
vprint_status("Downloading #{datastore['DOWNLOADROWS']} rows from index #{index}")
|
|
body = { 'query' => { 'query_string' => { 'query' => '*' } }, 'size' => datastore['DOWNLOADROWS'], 'from' => 0, 'sort' => [] }
|
|
request = {
|
|
'uri' => normalize_uri(target_uri.path, index, '_search/'),
|
|
'method' => 'POST',
|
|
'headers' => {
|
|
'Accept' => 'application/json'
|
|
},
|
|
'ctype' => 'application/json',
|
|
'data' => body.to_json
|
|
}
|
|
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'] || datastore['PASSWORD']
|
|
|
|
res = send_request_cgi(request)
|
|
vprint_error('Unable to establish connection') if res.nil?
|
|
|
|
if res && res.code == 200 && !res.body.empty?
|
|
json_body = res.get_json_document
|
|
if json_body.empty?
|
|
vprint_error('Unable to parse JSON')
|
|
return
|
|
end
|
|
else
|
|
vprint_error('Timeout or unexpected response...')
|
|
return
|
|
end
|
|
|
|
columns = json_body.dig('hits', 'hits')[0]['_source'].keys
|
|
elastic_table = Rex::Text::Table.new(
|
|
'Header' => "#{index} Data",
|
|
'Indent' => 2,
|
|
# we know at least 1 row since we wouldn't query an index w/o a row
|
|
'Columns' => columns
|
|
)
|
|
json_body.dig('hits', 'hits').each do |hash|
|
|
elastic_table << columns.map { |column| hash['_source'][column] }
|
|
end
|
|
|
|
l = store_loot('elasticserch.index.data', 'application/csv', rhost, elastic_table.to_csv, "#{index}_data.csv", nil, @service)
|
|
print_good("#{index} data stored to #{l}")
|
|
end
|
|
|
|
def get_indices
|
|
vprint_status('Querying indices...')
|
|
request = {
|
|
'uri' => normalize_uri(target_uri.path, '_cat', 'indices/'),
|
|
'method' => 'GET',
|
|
'headers' => {
|
|
'Accept' => 'application/json'
|
|
},
|
|
'vars_get' => {
|
|
# this is the query https://github.com/cars10/elasticvue uses for the chrome browser extension
|
|
'h' => 'index,health,status,uuid,docs.count,store.size',
|
|
'bytes' => 'mb'
|
|
}
|
|
}
|
|
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'] || datastore['PASSWORD']
|
|
|
|
res = send_request_cgi(request)
|
|
vprint_error('Unable to establish connection') if res.nil?
|
|
|
|
if res && res.code == 200 && !res.body.empty?
|
|
json_body = res.get_json_document
|
|
if json_body.empty?
|
|
vprint_error('Unable to parse JSON')
|
|
return
|
|
end
|
|
else
|
|
vprint_error('Timeout or unexpected response...')
|
|
return
|
|
end
|
|
|
|
elastic_table = Rex::Text::Table.new(
|
|
'Header' => 'Indicies Information',
|
|
'Indent' => 2,
|
|
'Columns' =>
|
|
[
|
|
'Name',
|
|
'Health',
|
|
'Status',
|
|
'UUID',
|
|
'Documents',
|
|
'Storage Usage (MB)'
|
|
]
|
|
)
|
|
|
|
indices = []
|
|
|
|
json_body.each do |index|
|
|
next if datastore['VERBOSE'] == false && index['index'].starts_with?('.fleet')
|
|
|
|
indices << index['index'] if index['docs.count'].to_i > 0 # avoid querying something with no data
|
|
elastic_table << [
|
|
index['index'],
|
|
index['health'],
|
|
index['status'],
|
|
index['uuid'],
|
|
index['docs.count'],
|
|
"#{index['store.size']}MB"
|
|
]
|
|
report_note(
|
|
host: rhost,
|
|
port: rport,
|
|
proto: 'tcp',
|
|
type: 'elasticsearch.index',
|
|
data: index[0],
|
|
update: :unique_data
|
|
)
|
|
end
|
|
|
|
print_good(elastic_table.to_s)
|
|
indices.each do |index|
|
|
get_results(index)
|
|
end
|
|
end
|
|
|
|
def get_cluster_info
|
|
vprint_status('Querying cluster information...')
|
|
request = {
|
|
'uri' => normalize_uri(target_uri.path, '_cluster', 'health'),
|
|
'method' => 'GET'
|
|
}
|
|
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'] || datastore['PASSWORD']
|
|
|
|
res = send_request_cgi(request)
|
|
|
|
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
|
|
fail_with(Failure::NoAccess, 'Credentials required, or incorrect') if res.code == 401
|
|
|
|
if res.code == 200 && !res.body.empty?
|
|
json_body = res.get_json_document
|
|
if json_body.empty?
|
|
vprint_error('Unable to parse JSON')
|
|
return
|
|
end
|
|
end
|
|
|
|
elastic_table = Rex::Text::Table.new(
|
|
'Header' => 'Cluster Information',
|
|
'Indent' => 2,
|
|
'Columns' =>
|
|
[
|
|
'Cluster Name',
|
|
'Status',
|
|
'Number of Nodes'
|
|
]
|
|
)
|
|
|
|
elastic_table << [
|
|
json_body['cluster_name'],
|
|
json_body['status'],
|
|
json_body['number_of_nodes']
|
|
]
|
|
print_good(elastic_table.to_s)
|
|
end
|
|
|
|
def get_node_info
|
|
vprint_status('Querying node information...')
|
|
request = {
|
|
'uri' => normalize_uri(target_uri.path, '_cat', 'nodes'),
|
|
'method' => 'GET',
|
|
'headers' => {
|
|
'Accept' => 'application/json'
|
|
},
|
|
'vars_get' => {
|
|
'h' => 'ip,port,version,http,uptime,name,heap.current,heap.max,ram.current,ram.max,node.role,master,cpu,disk.used,disk.total'
|
|
}
|
|
}
|
|
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'] || datastore['PASSWORD']
|
|
|
|
res = send_request_cgi(request)
|
|
|
|
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
|
|
fail_with(Failure::NoAccess, 'Credentials required, or incorrect') if res.code == 401
|
|
|
|
if res.code == 200 && !res.body.empty?
|
|
json_body = res.get_json_document
|
|
if json_body.empty?
|
|
vprint_error('Unable to parse JSON')
|
|
return
|
|
end
|
|
end
|
|
|
|
elastic_table = Rex::Text::Table.new(
|
|
'Header' => 'Node Information',
|
|
'Indent' => 2,
|
|
'Columns' =>
|
|
[
|
|
'IP',
|
|
'Transport Port',
|
|
'HTTP Port',
|
|
'Version',
|
|
'Name',
|
|
'Uptime',
|
|
'Ram Usage',
|
|
'Node Role',
|
|
'Master',
|
|
'CPU Load',
|
|
'Disk Usage'
|
|
]
|
|
)
|
|
json_body.each do |node|
|
|
report_service(
|
|
host: node['ip'],
|
|
port: node['port'],
|
|
proto: 'tcp',
|
|
name: 'elasticsearch'
|
|
)
|
|
report_service(
|
|
host: node['ip'],
|
|
port: node['http'].split(':')[1],
|
|
proto: 'tcp',
|
|
name: 'elasticsearch'
|
|
)
|
|
elastic_table << [
|
|
node['ip'],
|
|
node['port'],
|
|
node['http'],
|
|
node['version'],
|
|
node['name'],
|
|
node['uptime'],
|
|
"#{node['ram.current']}/#{node['ram.max']}",
|
|
node['node.role'],
|
|
node['master'],
|
|
"#{node['cpu']}%",
|
|
"#{node['disk.used']}/#{node['disk.total']}"
|
|
]
|
|
end
|
|
print_good(elastic_table.to_s)
|
|
end
|
|
|
|
def get_version_info
|
|
vprint_status('Querying version information...')
|
|
request = {
|
|
'uri' => normalize_uri(target_uri.path),
|
|
'method' => 'GET'
|
|
}
|
|
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'] || datastore['PASSWORD']
|
|
|
|
res = send_request_cgi(request)
|
|
|
|
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
|
|
fail_with(Failure::NoAccess, 'Credentials required, or incorrect') if res.code == 401
|
|
|
|
# leaving this here for future travelers, this header was added in 7.14.0 https://www.elastic.co/guide/en/elasticsearch/reference/7.17/release-notes-7.14.0.html
|
|
# so it isn't too reliable to check for
|
|
# fail_with(Failure::Unreachable, "#{peer} - Elasticsearch not detected in X-elastic-product header") unless res.headers['X-elastic-product'] == 'Elasticsearch'
|
|
|
|
if res.code == 200 && !res.body.empty?
|
|
json_body = res.get_json_document
|
|
if json_body.empty?
|
|
vprint_error('Unable to parse JSON')
|
|
return
|
|
end
|
|
end
|
|
|
|
fail_with(Failure::Unreachable, "#{peer} - Elasticsearch cluster name not found, likely not Elasticsearch server") unless json_body['cluster_name']
|
|
|
|
elastic_table = Rex::Text::Table.new(
|
|
'Header' => 'Elastic Information',
|
|
'Indent' => 2,
|
|
'Columns' =>
|
|
[
|
|
'Name',
|
|
'Cluster Name',
|
|
'Version',
|
|
'Build Type',
|
|
'Lucene Version'
|
|
]
|
|
)
|
|
|
|
elastic_table << [
|
|
json_body['name'],
|
|
json_body['cluster_name'],
|
|
json_body.dig('version', 'number'),
|
|
json_body.dig('version', 'build_type'),
|
|
json_body.dig('version', 'lucene_version'),
|
|
]
|
|
print_good(elastic_table.to_s)
|
|
|
|
@service = report_service(
|
|
host: rhost,
|
|
port: rport,
|
|
proto: 'tcp',
|
|
name: 'elasticsearch'
|
|
)
|
|
end
|
|
|
|
def get_users
|
|
vprint_status('Querying user information...')
|
|
request = {
|
|
'uri' => normalize_uri(target_uri.path, '_security', 'user/'),
|
|
'method' => 'GET'
|
|
}
|
|
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'] || datastore['PASSWORD']
|
|
|
|
res = send_request_cgi(request)
|
|
|
|
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
|
|
fail_with(Failure::NoAccess, 'Credentials required, or incorrect') if res.code == 401
|
|
|
|
if res.code == 200 && !res.body.empty?
|
|
json_body = res.get_json_document
|
|
if json_body.empty?
|
|
vprint_error('Unable to parse JSON')
|
|
return
|
|
end
|
|
end
|
|
|
|
if json_body.nil?
|
|
print_bad('Unable to pull user data')
|
|
return
|
|
end
|
|
|
|
elastic_table = Rex::Text::Table.new(
|
|
'Header' => 'User Information',
|
|
'Indent' => 2,
|
|
'Columns' =>
|
|
[
|
|
'Name',
|
|
'Roles',
|
|
'Email',
|
|
'Metadata',
|
|
'Enabled'
|
|
]
|
|
)
|
|
|
|
json_body.each do |username, attributes|
|
|
elastic_table << [
|
|
username,
|
|
attributes['roles'],
|
|
attributes['email'],
|
|
attributes['metadata'],
|
|
attributes['enabled'],
|
|
]
|
|
end
|
|
print_good(elastic_table.to_s)
|
|
end
|
|
|
|
def run
|
|
get_version_info
|
|
get_node_info
|
|
get_cluster_info
|
|
get_indices
|
|
get_users
|
|
end
|
|
end
|