#!/usr/bin/ruby -w
#
# Examine an HTTP server for HTTP/1.1 Host header behavior & stuff
#
# January 2009, Jon Hart
require 'socket'
require 'openssl'
require 'uri'
require 'optparse'
require 'ostruct'
require 'resolv.rb'
require 'digest/md5'
class Options# {{{
def self.parse(name, args)
options = OpenStruct.new
options.verbose = false
options.request_methods = %w[GET HEAD]
options.host = nil
options.headers = {'User-Agent' => 'vhinfo', 'Connection' => 'close'}
opts = OptionParser.new do |opts|
opts.banner = "#{File.basename(name)} ( http://spoofed.org )"
opts.banner += "\nUsage: #{name} [IP] [options]"
opts.on("--get", "Use GETs only") do |o|
options.request_methods = %w[GET]
end
opts.on("--head", "Use HEADs only") do |o|
options.request_methods = %w[HEAD]
end
opts.on_tail("-h", "--help", "Show help") do
puts opts
exit
end
opts.on("-H [HEADER]", "Append this header to all requests. May be called multiple times") do |o|
if (o =~ /([^:]*):\s+?(.*)/)
options.headers[$1] = $2
else
options.headers[o] = ""
end
end
opts.on("-v", "--verbose", "Be verbose") do |o|
options.verbose = o
end
end
options.help = opts.help
begin
opts.parse!(args)
rescue OptionParser::ParseError => e
puts "#{e}\n\n#{opts}"
exit(1)
end
options
end
end# }}}
class URICheck# {{{
attr_accessor :ip, :host, :port, :ssl, :checked, :status, :message
def initialize(ip, port, ssl, host)
@ip = ip
@port = port
@ssl = ssl
@host = host
end
def checked?
@checked
end
end# }}}
def print_results(ip, port, ssl, method, version, host, response)# {{{
uri = "#{ssl ? 'https' : 'http'}://#{ip}"
uri += (((ssl && (port == 443)) || (!ssl && (port == 80))) ? "" : ":#{port}") + "/"
host_header = "(" + (host.nil? ? "No Host header" : "Host: #{host}") + ")"
puts "HTTP/#{version} #{method.upcase} #{host_header} on #{uri}: #{response}"
end# }}}
def make_socket(ip, port, ssl)# {{{
begin
s = TCPSocket.new(ip, port)
if (ssl)
begin
ssl = OpenSSL::SSL::SSLSocket.new(s, OpenSSL::SSL::SSLContext.new)
ssl.sync = true
ssl.connect
yield(ssl)
rescue OpenSSL::SSL::SSLError => e
puts "SSL setup on #{ip}:#{port} failed: #{e}"
ensure
ssl.close
end
else
yield(s)
end
rescue Exception => e
puts "Connection to #{ip}:#{port} failed: #{e}"
end
s.close
end# }}}
# This was written because Net::HTTP has a number of things
# that make doing this work impossible or not as fun:
#
# * Can't set arbitrary HTTP versions. 1.1 only
# * Header values are explicitly "capitalized"
# * A "Host" header is set regardless of whether or not
# you want one.
#
# Reopening Net::HTTP is an exercise in futility. Once you figure
# out a way around one feature that it breaks, another crops up.
# F' that noise.
def rawrequest(ip, port, ssl, method, version, headers)# {{{
hstring = ""
headers.each_pair do |k,v|
hstring += "#{k}: #{v}\r\n"
end
res = {}
res['body'] = ""
res['headers'] = {}
res['code'] = nil
res['message'] = ""
req = "#{method} / HTTP/#{version}\r\n#{hstring}\r\n\r\n"
make_socket(ip, port, ssl) do |s|
begin
s.print(req)
inBody = false
res['message'] = s.readline.strip
res['code'] = res['message'].split(/\s+/)[1]
s.readlines.each do |l|
l.strip!
inBody = true if l =~ /^$/
if (inBody)
res['body'] += l unless l.empty?
else
header = l.split(/:\s+?/)
res['headers'][header[0]] = (header[1].nil? ? nil : header[1].strip)
end
end
rescue Exception => e
res['message'] = e
end
end
res
end# }}}
# For a given +ip+, perform a battery of tests using +headers+# {{{
# this +host+ header using +uri+ as the URI to pull port and scheme from# }}}
def check(ip, port, ssl, host, headers)# {{{
return if @checked["#{host}|#{ip}"]
@checked["#{host}|#{ip}"] = 1
if (host.nil?)
headers.delete('Host')
else
headers['Host'] = host
end
uri = URI.parse("#{ssl ? 'https' : 'http'}://#{host.nil? ? ip : host}:#{port}/")
@options.request_methods.each do |method|
["1.1", "1.0"].each do |version|
host_header = "(" + (host.nil? ? "No Host header" : "Host: #{host}") + ")"
verbose_print("Checking HTTP/#{version} #{method} #{host_header} on #{ip}")
res = rawrequest(ip, port, ssl, method, version, headers)
other = []
['Location', 'Content-Location'].each do |u|
if (res['headers'][u])
other_uri = uri.merge(res['headers'][u])
other << "#{u}: #{other_uri}" if res['headers'][u]
@urisToBeChecked << URICheck.new(ip, port, uri.scheme == 'https', other_uri.host)
getCNAMEs(other_uri.host).each do |cname|
cname_uri = other_uri.dup
cname_uri.host = cname
@urisToBeChecked << URICheck.new(ip, port, cname_uri.scheme == 'https', cname)
end
end
end
unless res['body'].empty?
other << "Body MD5: #{Digest::MD5.hexdigest(res['body'])}"
end
if (other.size > 0)
other = " (#{other.join(', ')})"
end
print_results(ip, port, ssl, method, version, host, "#{res['message']}#{other}")
end
end
end# }}}
def getCNAMEs(host)# {{{
if (@CNAMEs[host])
verbose_print("Using cached CNAME records for #{host}")
else
verbose_print("Getting CNAME records for #{host}")
@CNAMEs[host] = []
Resolv::DNS.new.each_resource(host, Resolv::DNS::Resource::IN::CNAME) do |c|
@CNAMEs[host] << c.name.to_s
@CNAMEs[host] << getCNAMEs(c.name.to_s)
end
end
@CNAMEs[host].flatten
end# }}}
def getAs(host)# {{{
if (@As[host])
verbose_print("Using cached A records for #{host}")
else
verbose_print("Getting A records for #{host}")
@As[host] = []
Resolv.each_address(host) do |ip|
@As[host] << ip
if (@PTRs[ip])
@PTRs[ip] << getPTRs(ip)
else
@PTRs[ip] = getPTRs(ip)
end
end
end
@As[host].flatten
end# }}}
def getPTRs(ip)# {{{
if (@PTRs[ip])
verbose_print("Using cached PTR records for #{ip}")
else
verbose_print("Getting PTR records for #{ip}")
@PTRs[ip] = []
Resolv.each_name(ip) do |p|
@PTRs[ip] << p
@PTRs[ip] << getCNAMEs(p)
end
end
@PTRs[ip].flatten
end# }}}
def verbose_print(str)# {{{
puts str if @options.verbose
end# }}}
STDOUT.sync = true
@options = Options.parse($0, ARGV)
@checked = {}
@CNAMEs = {}
@As = {}
@PTRs = {}
case ARGV.size
when 1, 2
uri = ARGV[0]
uri = "http://#{uri}" unless (uri =~ /^https?:\/\//)
uri = URI.parse(uri)
orig_uri = uri.host
if (ARGV.size == 2)
orig_ip = ARGV[1]
end
else
puts @options.help
exit(1)
end
@urisToBeChecked = []
# if an IP is provided, hit ONLY that IP
ips = []
if (orig_ip)
ips << orig_ip
else
ips << getAs(uri.host)
end
# a base list of "Host" headers to try
baseNames = []
baseNames << uri.host
baseNames << 'localhost' << '127.0.0.1'
baseNames << getCNAMEs(uri.host)
baseNames << "A" + Digest::MD5.hexdigest(Time.now.to_s)
baseNames << nil
ips.flatten.each do |ip|
[baseNames, ip, getPTRs(ip)].flatten.each do |n|
new_uri = uri.dup
new_uri.host = n
@urisToBeChecked << URICheck.new(ip, uri.port, uri.scheme == 'https', n)
end
end
while (!@urisToBeChecked.empty?)
u = @urisToBeChecked.shift
check(u.ip, u.port, u.ssl, u.host, @options.headers)
end
# vim: set ts=2 et sw=2: