##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
include Msf::Auxiliary::Report
include Msf::Exploit::Remote::HttpClient
LEAKIX_API_HOST = 'leakix.net'.freeze
def initialize(info = {})
super(
update_info(
info,
'Name' => 'LeakIX Search',
'Description' => %q{
This module uses the LeakIX API to search for exposed services and data leaks.
LeakIX is a search engine focused on indexing internet-exposed services and
leaked credentials/databases.
An API key is required (free at https://leakix.net).
Actions:
SEARCH - Query LeakIX with a search string and scope (leak or service).
Paginated, 20 results per page, max 500 pages (10000 results).
Free accounts have lower page limits.
HOST - Retrieve all known services and leaks for a given IP
DOMAIN - Retrieve all known services and leaks for a given domain
SUBDOMAINS - List known subdomains for a given domain
PLUGINS - List all available LeakIX scanner plugins
BULK - Stream all leak results via the bulk API (Pro only, leak scope only).
Use MAXRESULTS to limit the number of collected events.
Query examples:
+country:"France"
+port:3306 +country:"Germany"
plugin:HttpOpenProxy
+software.name:"nginx" +country:"US"
},
'Author' => [
'Valentin Lobstein <chocapikk[at]leakix.net>',
'LeakIX <support[at]leakix.net>'
],
'References' => [
['URL', 'https://leakix.net'],
['URL', 'https://docs.leakix.net']
],
'License' => MSF_LICENSE,
'Notes' => {
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS],
'Reliability' => []
},
'Actions' => [
['SEARCH', { 'Description' => 'Search LeakIX for services or leaks' }],
['HOST', { 'Description' => 'Get details for a specific IP address' }],
['DOMAIN', { 'Description' => 'Get details for a specific domain' }],
['SUBDOMAINS', { 'Description' => 'List subdomains for a domain' }],
['PLUGINS', { 'Description' => 'List available LeakIX plugins' }],
['BULK', { 'Description' => 'Bulk search via streaming API (Pro only, leak scope only)' }]
],
'DefaultAction' => 'SEARCH'
)
)
register_options([
OptString.new('LEAKIX_APIKEY', [true, 'The LeakIX API key']),
OptString.new('QUERY', [false, 'The LeakIX search query'], conditions: ['ACTION', 'in', %w[SEARCH BULK]]),
OptEnum.new('SCOPE', [true, 'Search scope (BULK only supports leak)', 'leak', ['leak', 'service']], conditions: ['ACTION', 'in', %w[SEARCH BULK]]),
OptInt.new('MAXPAGE', [true, 'Max pages to collect (1-500, 20 results/page)', 1], conditions: %w[ACTION == SEARCH]),
OptInt.new('MAXRESULTS', [false, 'Stop after collecting this many results (0 = unlimited)', 0], conditions: ['ACTION', 'in', %w[SEARCH BULK]]),
OptString.new('TARGET_IP', [false, 'Target IP for HOST action'], conditions: %w[ACTION == HOST]),
OptString.new('TARGET_DOMAIN', [false, 'Target domain for DOMAIN/SUBDOMAINS actions'], conditions: ['ACTION', 'in', %w[DOMAIN SUBDOMAINS]]),
OptString.new('OUTFILE', [false, 'Path to the file to store results']),
OptBool.new('DATABASE', [false, 'Add search results to the database', false])
])
register_advanced_options([
OptString.new('UserAgent', [false, 'The User-Agent header to use for all requests', 'LeakIX/Metasploit'])
])
deregister_http_client_options
end
# ========================================================================
# HTTP HELPERS
# ========================================================================
def resolve_host
return @resolved_ip if @resolved_ip
@resolved_ip = ::Addrinfo.getaddrinfo(LEAKIX_API_HOST, nil, :INET, :STREAM).first&.ip_address
fail_with(Failure::Unreachable, "Unable to resolve #{LEAKIX_API_HOST}") unless @resolved_ip
vprint_status("Resolved #{LEAKIX_API_HOST} to #{@resolved_ip}")
@resolved_ip
rescue ::SocketError => e
fail_with(Failure::Unreachable, "Unable to resolve #{LEAKIX_API_HOST}: #{e}")
end
def leakix_headers
{
'Host' => LEAKIX_API_HOST,
'api-key' => datastore['LEAKIX_APIKEY'],
'Accept' => 'application/json'
}
end
def leakix_connection_opts
{
'rhost' => resolve_host,
'rport' => 443,
'SSL' => true,
'vhost' => LEAKIX_API_HOST
}
end
def leakix_request(uri, params = {})
res = send_request_cgi(
leakix_connection_opts.merge(
'method' => 'GET',
'uri' => uri,
'headers' => leakix_headers,
'vars_get' => params
)
)
handle_response_errors(res)
return nil unless res&.code == 200
begin
ActiveSupport::JSON.decode(res.body)
rescue StandardError
nil
end
end
def handle_response_errors(res)
fail_with(Failure::Unreachable, 'No response from LeakIX API') unless res
case res.code
when 401
fail_with(Failure::BadConfig, '401 Unauthorized. Your LEAKIX_APIKEY is invalid')
when 429
wait_seconds = res.headers['x-limited-for'] || 'unknown'
print_warning("Rate limited. Wait #{wait_seconds} seconds before retrying.")
end
end
# ========================================================================
# EVENT PARSING & OUTPUT
# ========================================================================
def extract_event_fields(event)
{
ip: event['ip'] || '',
port: event['port'] || '',
host: event['host'] || '',
protocol: event['protocol'] || '',
event_type: event['event_type'] || '',
event_source: event['event_source'] || '',
country: event.dig('geoip', 'country_name') || '',
org: event.dig('network', 'organization_name') || '',
software: event.dig('service', 'software', 'name') || '',
version: event.dig('service', 'software', 'version') || ''
}
end
def software_label(fields)
fields[:software].to_s.empty? ? '' : "#{fields[:software]} #{fields[:version]}".strip
end
def report_event(fields)
return unless datastore['DATABASE']
report_host(host: fields[:ip], name: fields[:host], comments: 'Added from LeakIX')
return unless fields[:port].to_s =~ /^\d+$/ && fields[:port].to_i > 0
report_service(host: fields[:ip], port: fields[:port], proto: 'tcp', name: fields[:protocol], info: software_label(fields))
end
def events_table(events)
tbl = Rex::Text::Table.new(
'Header' => 'LeakIX Results',
'Indent' => 1,
'Columns' => ['IP:Port', 'Protocol', 'Host', 'Country', 'Organization', 'Software', 'Type', 'Source']
)
events.each do |event|
next unless event.is_a?(Hash)
fields = extract_event_fields(event)
tbl << [
"#{fields[:ip]}:#{fields[:port]}",
fields[:protocol],
fields[:host],
fields[:country],
fields[:org],
software_label(fields),
fields[:event_type],
fields[:event_source]
]
report_event(fields)
end
tbl
end
def save_output(data)
return unless datastore['OUTFILE']
::File.open(datastore['OUTFILE'], 'wb') do |f|
f.write(data)
print_status("Saved results in #{datastore['OUTFILE']}")
end
end
def print_table(tbl)
print_line(tbl.to_s)
save_output(tbl)
end
def empty_array?(data)
data.nil? || !data.is_a?(Array) || data.empty?
end
def display_events(events, label = nil)
if events.empty?
print_error('No results found.')
return
end
print_status("#{label || 'Total'}: #{events.length} results")
print_table(events_table(events))
end
def collect_host_events(data)
events = []
%w[Services Leaks services leaks].each do |key|
events.concat(data[key]) if data[key].is_a?(Array)
end
events
end
def apply_maxresults(events, maxresults)
return events unless maxresults > 0 && events.length >= maxresults
print_status("Reached MAXRESULTS limit (#{maxresults})")
events.first(maxresults)
end
# ========================================================================
# ACTIONS
# ========================================================================
def action_search
query = datastore['QUERY']
scope = datastore['SCOPE']
maxpage = datastore['MAXPAGE']
maxresults = datastore['MAXRESULTS'].to_i
all_events = []
maxpage.times do |page|
print_status("Fetching page #{page + 1}/#{maxpage}...")
data = leakix_request('/search', { 'q' => query, 'scope' => scope, 'page' => page.to_s })
if data.is_a?(Hash) && data['Error'] == 'Page limit'
print_error("Page limit reached at page #{page + 1}")
break
end
if empty_array?(data)
print_warning("No more results at page #{page + 1}")
break
end
all_events.concat(data)
print_good("Got #{data.length} results from page #{page + 1} (total: #{all_events.length})")
if maxresults > 0 && all_events.length >= maxresults
all_events = apply_maxresults(all_events, maxresults)
break
end
Rex.sleep(1.2) if page < maxpage - 1
end
display_events(all_events)
end
def action_bulk
query = datastore['QUERY']
maxresults = datastore['MAXRESULTS'].to_i
print_status('Streaming bulk results (Pro API required, leak scope)...')
cli = connect(leakix_connection_opts)
req = cli.request_cgi(
'method' => 'GET',
'uri' => '/bulk/search',
'headers' => leakix_headers,
'vars_get' => { 'q' => query }
)
cli.send_request(req)
head, body_start = read_stream_headers(cli.conn)
status = head[%r{HTTP/[\d.]+ (\d+)}, 1].to_i
case status
when 401 then fail_with(Failure::BadConfig, '401 Unauthorized - invalid LEAKIX_APIKEY')
when 429 then fail_with(Failure::NoAccess, '429 Rate limited')
when 200 then nil
else fail_with(Failure::UnexpectedReply, "HTTP #{status} - Pro API key required")
end
chunked = head =~ /transfer-encoding:\s*chunked/i
all_events = []
limit_reached = false
stream_ndjson(cli.conn, body_start, chunked) do |line|
break if limit_reached
obj = ActiveSupport::JSON.decode(line)
next unless obj.is_a?(Hash) && obj['events'].is_a?(Array)
all_events.concat(obj['events'])
obj['events'].each { |e| report_event(extract_event_fields(e)) }
print_status("Streamed #{all_events.length} events...") if (all_events.length % 50).zero?
if maxresults > 0 && all_events.length >= maxresults
all_events = apply_maxresults(all_events, maxresults)
limit_reached = true
end
rescue StandardError
next
end
display_events(all_events, 'Bulk results')
ensure
cli&.close
end
def action_host_or_domain(type, target)
print_status("Fetching #{type} details for #{target}...")
data = leakix_request("/#{type}/#{target}")
if data.nil?
print_error("No information found for #{target}")
return
end
display_events(collect_host_events(data), target)
end
def action_subdomains
domain = datastore['TARGET_DOMAIN']
print_status("Fetching subdomains for #{domain}...")
data = leakix_request("/api/subdomains/#{domain}")
if empty_array?(data)
print_error("No subdomains found for #{domain}")
return
end
tbl = Rex::Text::Table.new(
'Header' => "Subdomains for #{domain}",
'Indent' => 1,
'Columns' => ['Subdomain', 'Distinct IPs', 'Last Seen']
)
seen = Set.new
data.each do |entry|
next unless entry.is_a?(Hash)
subdomain = entry['subdomain'] || ''
next if subdomain.empty? || seen.include?(subdomain)
seen.add(subdomain)
tbl << [subdomain, entry['distinct_ips'] || '', entry['last_seen'] || '']
end
print_status("Found #{seen.length} subdomains")
print_table(tbl)
end
def action_plugins
print_status('Fetching available plugins...')
data = leakix_request('/api/plugins')
if empty_array?(data)
print_error('No plugins found')
return
end
tbl = Rex::Text::Table.new(
'Header' => 'LeakIX Plugins',
'Indent' => 1,
'Columns' => ['Plugin Name']
)
data.each do |plugin|
name = plugin.is_a?(Hash) ? plugin['name'] : plugin.to_s
tbl << [name] if name.present?
end
print_status("Found #{tbl.rows.length} plugins")
print_table(tbl)
end
# ========================================================================
# STREAMING HELPERS
# ========================================================================
def read_stream_headers(sock)
buf = ''
loop do
chunk = sock.get_once(4096, 30)
fail_with(Failure::Unreachable, 'Connection closed while reading headers') unless chunk
buf << chunk
break if buf.include?("\r\n\r\n")
end
buf.split("\r\n\r\n", 2)
end
def stream_ndjson(sock, initial, chunked, &block)
if chunked
stream_dechunk(sock, initial || '', &block)
else
stream_lines(sock, initial || '', &block)
end
end
def stream_lines(sock, buf)
loop do
while (idx = buf.index("\n"))
line = buf.slice!(0, idx + 1).strip
yield line unless line.empty?
end
data = begin
sock.get_once(4096, 30)
rescue ::Errno::EPIPE, ::IOError
nil
end
break unless data
buf << data
end
yield buf.strip unless buf.strip.empty?
end
def stream_dechunk(sock, buf)
line_acc = ''
loop do
buf << read_socket(sock) until buf.include?("\r\n")
size_str, buf = buf.split("\r\n", 2)
size = size_str.strip.to_i(16)
break if size == 0
buf << read_socket(sock) while buf.length < size + 2
line_acc << buf.slice!(0, size)
buf = buf[(2)..] || '' # trailing \r\n
while (idx = line_acc.index("\n"))
line = line_acc.slice!(0, idx + 1).strip
yield line unless line.empty?
end
rescue ::Errno::EPIPE, ::IOError
break
end
yield line_acc.strip unless line_acc.strip.empty?
end
def read_socket(sock)
data = sock.get_once(4096, 30)
raise ::EOFError, 'Connection closed' unless data
data
end
# ========================================================================
# MAIN
# ========================================================================
def validate
super
errors = {}
case action.name
when 'SEARCH', 'BULK'
errors['QUERY'] = "QUERY is required for #{action.name} action" if datastore['QUERY'].blank?
when 'HOST'
errors['TARGET_IP'] = 'TARGET_IP is required for HOST action' if datastore['TARGET_IP'].blank?
when 'DOMAIN', 'SUBDOMAINS'
errors['TARGET_DOMAIN'] = "TARGET_DOMAIN is required for #{action.name} action" if datastore['TARGET_DOMAIN'].blank?
end
errors['SCOPE'] = 'BULK action only supports leak scope' if action.name == 'BULK' && datastore['SCOPE'] == 'service'
errors['MAXPAGE'] = 'MAXPAGE must be between 1 and 500' unless datastore['MAXPAGE'].to_i.between?(1, 500)
errors['MAXRESULTS'] = 'MAXRESULTS must be >= 0' if datastore['MAXRESULTS'].to_i < 0
raise Msf::OptionValidateError, errors unless errors.empty?
end
def run
case action.name
when 'SEARCH' then action_search
when 'BULK' then action_bulk
when 'HOST' then action_host_or_domain('host', datastore['TARGET_IP'])
when 'DOMAIN' then action_host_or_domain('domain', datastore['TARGET_DOMAIN'])
when 'SUBDOMAINS' then action_subdomains
when 'PLUGINS' then action_plugins
end
end
end
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation