#!/usr/bin/ruby
## httpdumper v0.1 : A tool to display and dump HTTP conversations
## Copyright (C) 2010 Franck GUENICHOT
## franck {dot} guenichot {at} orange {dot} fr
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see .
## Written for the Network Forensics Puzzle #3
## http://forensicscontest.com
require 'rubygems'
require 'packetfu'
require 'zlib'
require 'terminal-table/import'
require 'optparse'
###########################################################
#
# Class def.
#
###########################################################
class HttpConversationArray < Array
def find(host1,port1,host2,port2)
index = nil
self.each { |conv|
index = self.index(conv) if conv.is_member?(host1,port1) && conv.is_member?(host2,port2)
}
index
end
end #class
class HttpConversation
attr_reader :host_1,:host_2,:cumulative_len
attr_accessor :flows
def initialize(flow)
@host_1 = {'ip' => flow.ipsrc, 'port' => flow.srcport }
@host_2 = {'ip' => flow.ipdst, 'port' => flow.dstport }
@flows = []
@cumulative_len = 0
add_flow(flow)
end
def add_flow(httpflow)
@flows << httpflow
@cumulative_len += httpflow.flow_data.length
end
def is_member?(host,port)
found = false
[@host_1,@host_2].each { |h|
if h['ip'] == host && h['port'] == port
found = true
end
}
found
end
def flow_count(type=nil)
res = 0
if type
res = count(type)
else
res = flows.length
end
res
end
#
private
def count(type)
n = 0
flows.each { |flow|
n += 1 if flow.flow_data.message_type == type
}
n
end
end #class
class HttpFlow
attr_reader :ipsrc,:ipdst,:srcport,:dstport
attr_accessor :flow_data
def initialize(ipsrc,srcport,ipdst,dstport)
@ipsrc = ipsrc
@ipdst = ipdst
@srcport = srcport
@dstport = dstport
@flow_data = nil
end
end #class
class HttpMessage
attr_reader :start_line,:message_type,:message_headers
attr_accessor :message_body
alias :headers :message_headers
def initialize()
@message_type = "UNKNOW"
@start_line = ""
@message_headers = ""
@message_body = ""
end
def parse(message)
end_of_headers = false
message.each { |line|
if @start_line == ""
@start_line << line # first line is HTTP message start_line
elsif !end_of_headers
@message_headers << line # Headers are after the start_line till end_of_headers
if line.match(/^\r\n$/)
end_of_headers = true
end
else
@message_body << line # rest is message body
end
}
end
def header(hd)
return @message_headers.match(/#{hd}:\s+(\S+)/).to_s
end
def header_length()
return @message_headers.length
end
def length()
return @message_body.length
end
def content_length()
return self.header('Content-Length').split[1].to_i
end
def content_encoding()
return self.header('Content-Encoding').split[1]
end
def content_type()
return self.header('Content-Type').split[1]
end
end #class
class HttpRequest < HttpMessage
def initialize()
super
@message_type = "REQUEST"
end
def requested_uri()
return @start_line.split[1]
end
end #class
class HttpResponse < HttpMessage
def initialize()
super
@message_type = "RESPONSE"
end
end #class
class HttpHost
attr_reader :hostname
attr_accessor :ip,:uri_list
def initialize(host,ip)
@hostname = host
@ip = ip
@uri_list = []
end
def request_count()
return @uri_list.length
end
end #class
class HttpStats
def initialize(httpconv_array)
@httpconv_array = httpconv_array
end
def uri_list(requester_ip,host)
result = []
st = generate_stats("REQUEST",requester_ip)
st.each { |hostname,httphost|
filter = host.nil? ? true : hostname == host
if filter
result << httphost
end
}
result
end
def request_stats(requester_ip)
result = []
st = generate_stats("REQUEST",requester_ip)
st.each { |hostname,httphost|
result << [hostname,httphost.ip,httphost.uri_list.length]
}
result
end
def generate_stats(type,requester_ip)
result = {}
if type == "REQUEST"
@httpconv_array.each { |conv|
conv.flows.each { |flow|
filter = (requester_ip.nil? && flow.flow_data.message_type == "REQUEST") || (flow.flow_data.message_type == "REQUEST" && flow.ipsrc == requester_ip)
if filter
host = flow.flow_data.header('Host').split[1]
uri = flow.flow_data.requested_uri
host_ip = flow.ipdst # Will be a false value if using a proxy, will store only one IP for the same hostname
if result.has_key?(host)
result[host].uri_list << [@httpconv_array.index(conv),conv.flows.index(flow),uri]
else
new_host = HttpHost.new(host,host_ip)
new_host.uri_list << [@httpconv_array.index(conv),conv.flows.index(flow),uri]
result[host] = new_host
end
end
}
}
end
result
end
end #class
$VERSION = "0.1"
$PROGNAME = "httpdumper"
#################################################################################################
# handle command line args
$options = {}
opts = OptionParser.new do|opts|
opts.banner = "
#{$PROGNAME} version #{$VERSION}
Copyright (C) 2010 Franck GUENICHOT
#{$PROGNAME} comes with ABSOLUTELY NO WARRANTY;
This is free software, and you are welcome
to redistribute it under certain conditions.
(GPL v3)
Usage: #{$PROGNAME} [options] -r "
$options[:infile] = nil
opts.on( '-r', '--read ', 'Read the given pcap file [REQUIRED]' ) do|infile|
$options[:infile] = infile
end
$options[:conv_filter] = nil
opts.on( '-c', '--conversation #', 'List only flows for conversation #' ) do|conv_num|
if conv_num =~ /\b\d+\b/
$options[:conv_filter] = conv_num
else
puts "Error: -c needs a number !"
exit(0)
end
end
$options[:flow_filter] = nil
opts.on( '-f', '--flow #', 'List only flow #' ) do|flow_num|
if flow_num =~ /\b\d+\b/
$options[:flow_filter] = flow_num
else
puts "Error: -c needs a number !"
exit(0)
end
end
$options[:headers] = false
opts.on( '--with-headers', 'For Display ONLY' ) do
$options[:headers] = true
end
$options[:dump2disk] = nil
opts.on( '-d', '--dump', 'Dump the selected conversation or flow' ) do
$options[:dump2disk] = true
end
$options[:port] = nil
opts.on( '-p', '--port ', 'Define custom HTTP port' ) do|port|
$options[:port] = port
end
$options[:stats] = nil
opts.on( '-s', '--stats type,[val1],[val2]', Array, 'Displays statistics',
" Valid options:",
" Request stats: request,[requester_ip],[requested_host]",
" URI list: uri,[requester_ip],[target_hostname]") do|stats|
$options[:stats] = stats
end
opts.on( '-v', '--version', 'Display version information' ) do
puts $VERSION
exit
end
opts.on( '-h', '--help', 'Display this screen' ) do
puts opts
exit
end
end
#parse command line args
opts.parse!
# if no pcap file in input => display help and exit
if $options[:infile] == nil
puts opts
exit(0)
end
#File not found error.
unless File.exist?($options[:infile])
puts "File: #{$options[:infile]} does not exist."
exit(0)
end
def dump_conversation_flows(httpconv_array,conv_index=nil,flow_index=nil,filename=nil)
if conv_index
if flow_index
flow = httpconv_array[conv_index].flows[flow_index]
dump_content_to_file(flow,filename)
else
httpconv_array[conv_index].flows.each { |flow|
dump_content_to_file(flow,filename)
}
end
else
httpconv_array.each { |conv|
conv.flows.each { |flow|
dump_content_to_file(flow)
}
}
end
end
def dump_content_to_file(httpflow_object,filename=nil)
if httpflow_object.flow_data.message_type == "RESPONSE" # By now, dump only http responses to disk
c_type = httpflow_object.flow_data.content_type
c_length = httpflow_object.flow_data.content_length
c_encoding = httpflow_object.flow_data.content_encoding
c_data = httpflow_object.flow_data.message_body
if !filename
t = Time.now.usec #dummy value to avoid same filenames
filename = "#{httpflow_object.ipsrc}_#{httpflow_object.srcport}-#{httpflow_object.ipdst}_#{httpflow_object.dstport}-#{t}.#{c_type.match(/\/\w+/).to_s.gsub(/\//,"")}"
end
puts " "
puts "Dumping data to disk: #{filename}"
#puts "Content type: #{c_type}"
#puts "Length: #{c_length}"
#puts "Encoding: #{c_encoding ? c_encoding : "none"}"
#If content is compressed: decompress it
if c_encoding == "gzip"
puts "Inflating gzipped content"
File.open('tempfile.gz','w+') { |gz| gz.write(httpflow_object.flow_data.message_body)} ## Create tempfile to decompress: Is it really needed ?
Zlib::GzipReader.open('tempfile.gz') { |gzip|
c_data = gzip.read
}
File.delete('tempfile.gz') # delete tempfile after use
end
# Write File (Overwrite if exists)
File.open(filename,'w+') { |f| f.write(c_data)
f.close
}
end
end
def display_conversation_flows(httpconv_array,conv_index,flow_index=nil,headers=nil)
if headers
puts " "
puts "Listing flows for conversation #{conv_index} with full http headers"
puts "----------------------------------------------------------------------"
puts " "
if flow_index
flow = httpconv_array[conv_index].flows[flow_index]
content = flow.flow_data.message_type == "REQUEST" ? flow.flow_data.requested_uri : flow.flow_data.content_type
puts " "
puts "Flow Index: #{flow_index} #{flow.ipsrc}:#{flow.srcport} -> #{flow.ipdst}:#{flow.dstport} #{flow.flow_data.message_type} #{content} Length: #{flow.flow_data.content_length}"
puts " "
puts " -----------"
puts " HTTP HEADER"
puts " -----------"
puts " "
puts flow.flow_data.headers
else
httpconv_array[conv_index].flows.each { |flow|
content = flow.flow_data.message_type == "REQUEST" ? flow.flow_data.requested_uri : flow.flow_data.content_type
puts " "
puts "Flow Index: #{httpconv_array[conv_index].flows.index(flow)} #{flow.ipsrc}:#{flow.srcport} -> #{flow.ipdst}:#{flow.dstport} #{flow.flow_data.message_type} #{content} #{flow.flow_data.content_length}"
puts " "
puts " -----------"
puts " HTTP HEADER"
puts " -----------"
puts " "
puts flow.flow_data.headers
puts "----------------------------------------------------------------------"
}
end
else
puts "FLOWS TABLE"
flows_table = table do |t|
t.headings = ["Flow Index","Hosts","HTTP message type","HTTP Request or Content type", "HTTP Content Length"]
if !flow_index
httpconv_array[conv_index].flows.each { |flow|
content = flow.flow_data.message_type == "REQUEST" ? flow.flow_data.requested_uri : flow.flow_data.content_type
t << ["#{httpconv_array[conv_index].flows.index(flow)}","#{flow.ipsrc}:#{flow.srcport} -> #{flow.ipdst}:#{flow.dstport}", "#{flow.flow_data.message_type}","#{content}","#{flow.flow_data.content_length}"]
}
else
flow = httpconv_array[conv_index].flows[flow_index]
content = flow.flow_data.message_type == "REQUEST" ? flow.flow_data.requested_uri : flow.flow_data.content_type
t << ["#{flow_index}","#{flow.ipsrc}:#{flow.srcport} -> #{flow.ipdst}:#{flow.dstport}", "#{flow.flow_data.message_type}","#{content}","#{flow.flow_data.content_length}"]
end
end
puts flows_table
end
end
if !$options[:port]
$http_port = 80
else
$http_port = $options[:port]
end
$httpconv_a = HttpConversationArray.new
httpflow_a = []
fragments = nil
total_len = 0
if $options[:host]
$host_filter = $options[:host].match(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/).to_s
$port_filter = $options[:host].match(/:\d{1,5}$/).to_s.gsub(/:/,"")
end
puts "Reading file #{$options[:infile]}"
pkt_array = PacketFu::Read.f2a(:file => $options[:infile])
puts "Parsing packets..."
ps_start = Time.now
pkt_array.each { |pkt|
packet = PacketFu::Packet.parse(pkt) # packetfu Initial packet parsing
if packet.is_tcp? and (packet.tcp_sport==$http_port or packet.tcp_dport==$http_port) # only tcp packets matching http_port (src or dst)
if packet.payload.length != 0 # Only tcp packet with payload are interesting
if fragments
fragments += packet.payload
if fragments.length == total_len
reassembled = HttpResponse.new
reassembled.parse(fragments)
httpflow = HttpFlow.new(packet.ip_saddr,packet.tcp_sport,packet.ip_daddr,packet.tcp_dport)
httpflow.flow_data = reassembled
httpflow_a << httpflow
conv_index = $httpconv_a.find(packet.ip_saddr,packet.tcp_sport,packet.ip_daddr,packet.tcp_dport)
if conv_index
$httpconv_a[conv_index].add_flow(httpflow)
else
conv = HttpConversation.new(httpflow)
$httpconv_a << conv
end
fragments = nil
total_len = 0
end
elsif packet.payload =~ /OPTIONS|GET|HEAD|POST|PUT|DELETE|TRACE|CONNECT\s+(\S+)/ # Http request message
http_request = HttpRequest.new
http_request.parse(packet.payload)
httpflow = HttpFlow.new(packet.ip_saddr,packet.tcp_sport,packet.ip_daddr,packet.tcp_dport)
httpflow.flow_data = http_request
httpflow_a << httpflow
conv_index = $httpconv_a.find(packet.ip_saddr,packet.tcp_sport,packet.ip_daddr,packet.tcp_dport)
if conv_index
$httpconv_a[conv_index].add_flow(httpflow)
else
conv = HttpConversation.new(httpflow)
$httpconv_a << conv
end
elsif packet.payload =~ /^(HTTP\/.*)$/
http_response = HttpResponse.new
http_response.parse(packet.payload)
if http_response.message_body.length == http_response.content_length # Single segment content
httpflow = HttpFlow.new(packet.ip_saddr,packet.tcp_sport,packet.ip_daddr,packet.tcp_dport)
httpflow.flow_data = http_response
httpflow_a << httpflow
conv_index = $httpconv_a.find(packet.ip_saddr,packet.tcp_sport,packet.ip_daddr,packet.tcp_dport)
if conv_index
$httpconv_a[conv_index].add_flow(httpflow)
else
conv = HttpConversation.new(httpflow)
$httpconv_a << conv
end
else # multi-segment fragmented content
total_len = http_response.start_line.length + http_response.header_length + http_response.content_length
fragments = packet.payload
end
end
end
end
}
ps_stop = Time.now
ps_time = ps_stop - ps_start
puts "#{pkt_array.length} packets read in %.3f sec." % [ps_time]
puts " "
if $options[:stats]
# stats arguments minimal verification
stats_type, requester_ip, hostname = $options[:stats]
if stats_type.match(/request/)
# Create the HttpStats object
httpstats = HttpStats.new($httpconv_a)
# Generate statistics
stats_result = httpstats.request_stats(requester_ip)
stats_table = table do |t|
t.headings = ["Requested Host", "Requested Host IP", "Request Count"]
stats_result.each { |stat|
t << stat
}
end
#Displays it
puts "HTTP REQUEST STATISTICS"
puts "-----------------------"
begin
puts stats_table
exit(0)
rescue
puts "No stats found for #{host_ip}"
exit(0)
end
elsif stats_type.match(/uri/)
# Create the HttpStats object
httpstats = HttpStats.new($httpconv_a)
# Generate statistics
stats_result = httpstats.uri_list(requester_ip,hostname)
puts "----------------------------------"
puts "Listing URI requested %s" % [ requester_ip.nil? ? "ALL clients": "by #{requester_ip}"]
puts "----------------------------------"
stats_result.each {|httphost|
puts "----------------------------------"
puts "Requested to #{httphost.hostname}"
puts "----------------------------------"
httphost.uri_list.each { |uri|
puts "[conv: %d] [flow: %d] %s" % [uri[0],uri[1],uri[2]]
}
}
exit(0)
end
end
flow_filter = $options[:flow_filter].to_i if $options[:flow_filter]
display_headers = $options[:headers]
if $options[:conv_filter]
conversation_filter = $options[:conv_filter].to_i
if $options[:dump2disk]
# Want to dump conversation data
dump_conversation_flows($httpconv_a,conversation_filter,flow_filter)
else
# Want to display only
display_conversation_flows($httpconv_a,conversation_filter,flow_filter,display_headers)
end
elsif $options[:dump2disk]
#Want to dump all conversation datas
puts "Dumping ALL conversations data to disk !"
dump_conversation_flows($httpconv_a)
else
# Default output : Displays all conversation summary.
puts "Found #{$httpconv_a.length} HTTP conversation(s)"
conv_table = table do |t|
t.headings = ['Conversation Index','Hosts', 'HTTP Flow count',"Request","Response", "Cumulative length"]
$httpconv_a.each { |conv|
t << ["#{$httpconv_a.index(conv)}","#{conv.host_1['ip']}:#{conv.host_1['port']} < - > #{conv.host_2['ip']}:#{conv.host_2['port']}","#{conv.flows.length}","#{conv.flow_count("REQUEST")}","#{conv.flow_count("RESPONSE")}","#{conv.cumulative_len}"]
}
end
puts conv_table
end