diff --git a/lib/rex/proto/steam.rb b/lib/rex/proto/steam.rb new file mode 100644 index 0000000000..12552c0449 --- /dev/null +++ b/lib/rex/proto/steam.rb @@ -0,0 +1,3 @@ +# -*- coding: binary -*- + +require 'rex/proto/steam/message' diff --git a/lib/rex/proto/steam/message.rb b/lib/rex/proto/steam/message.rb new file mode 100644 index 0000000000..a384ed39f0 --- /dev/null +++ b/lib/rex/proto/steam/message.rb @@ -0,0 +1,125 @@ +# -*- coding: binary -*- + +module Rex +module Proto +## +# +# Steam protocol support, taken from https://developer.valvesoftware.com/wiki/Server_queries +# +## +module Steam + # The Steam header ussed when the message is fragmented. + FRAGMENTED_HEADER = 0xFFFFFFFE + # The Steam header ussed when the message is not fragmented. + UNFRAGMENTED_HEADER = 0xFFFFFFFF + + # Decodes a Steam response message. + # + # @param message [String] the message to decode + # @return [Array] the message type and body + def decode_message(message) + # minimum size is header (4) + type (1) + return if message.length < 5 + header, type = message.unpack('NC') + # TODO: handle fragmented responses + return if header != UNFRAGMENTED_HEADER + [type, message[5, message.length]] + end + + # Encodes a Steam message. + # + # @param type [String, Fixnum] the message type + # @param body [String] the message body + # @return [String] the encoded Steam message + def encode_message(type, body) + if type.is_a? Fixnum + type_num = type + elsif type.is_a? String + type_num = type.ord + else + fail ArgumentError, 'type must be a String or Fixnum' + end + + [UNFRAGMENTED_HEADER, type_num ].pack('NC') + body + end + + # Builds an A2S_INFO message + # + # @return [String] the A2S_INFO message + def a2s_info + encode_message('T', "Source Engine Query\x00") + end + + # Decodes an A2S_INFO response message + # + # @parameter response [String] the A2S_INFO resposne to decode + # @return [Hash] the fields extracted from the response + def a2s_info_decode(response) + # abort if it is impossibly short + return nil if response.length < 19 + message_type, body = decode_message(response) + # abort if it isn't a valid Steam response + return nil if message_type != 0x49 # 'I' + info = {} + info[:version], info[:name], info[:map], info[:folder], info[:game_name], + info[:game_id], players, players_max, info[:bots], + type, env, vis, vac, info[:game_version], _edf = body.unpack("CZ*Z*Z*Z*SCCCCCCCZ*C") + + # translate type + case type + when 100 # d + server_type = 'Dedicated' + when 108 # l + server_type = 'Non-dedicated' + when 112 # p + server_type = 'SourceTV relay (proxy)' + else + server_type = "Unknown (#{type})" + end + info[:type] = server_type + + # translate environment + case env + when 108 # l + server_env = 'Linux' + when 119 # w + server_env = 'Windows' + when 109 # m + when 111 # o + server_env = 'Mac' + else + server_env = "Unknown (#{env})" + end + info[:environment] = server_env + + # translate visibility + case vis + when 0 + server_vis = 'public' + when 1 + server_vis = 'private' + else + server_vis = "Unknown (#{vis})" + end + info[:visibility] = server_vis + + # translate VAC + case vac + when 0 + server_vac = 'unsecured' + when 1 + server_vac = 'secured' + else + server_vac = "Unknown (#{vac})" + end + info[:VAC] = server_vac + + # format players/max + info[:players] = "#{players}/#{players_max}" + + # TODO: parse EDF + info + end +end +end +end diff --git a/modules/auxiliary/scanner/steam/server_info.rb b/modules/auxiliary/scanner/steam/server_info.rb new file mode 100644 index 0000000000..398e0fb038 --- /dev/null +++ b/modules/auxiliary/scanner/steam/server_info.rb @@ -0,0 +1,67 @@ +## +# This module requires Metasploit: http://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'msf/core' +require 'rex/proto/steam' + +class Metasploit3 < Msf::Auxiliary + include Msf::Auxiliary::Report + include Msf::Auxiliary::UDPScanner + include Rex::Proto::Steam + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Gather Steam Server Information', + 'Description' => %q( + This module uses the A2S_INFO request to obtain information from a Steam server. + ), + 'Author' => 'Jon Hart ', + 'References' => + [ + # TODO: add more from https://developer.valvesoftware.com/wiki/Server_queries, + # perhaps in different modules + ['URL', 'https://developer.valvesoftware.com/wiki/Server_queries#A2S_INFO'] + ], + 'License' => MSF_LICENSE + ) + ) + + register_options( + [ + Opt::RPORT(27015) + ], self.class) + end + + def build_probe + @probe ||= a2s_info + end + + def scanner_process(response, src_host, src_port) + info = a2s_info_decode(response) + return unless info + @results[src_host] ||= [] + if datastore['VERBOSE'] + print_good("#{src_host}:#{src_port} found '#{info.inspect}'") + else + print_good("#{src_host}:#{src_port} found '#{info[:name]}'") + end + @results[src_host] << info + end + + def scanner_postscan(_batch) + @results.each_pair do |host, info| + report_host(host: host) + report_service( + host: host, + proto: 'udp', + port: rport, + name: 'Steam', + info: info + ) + end + end +end diff --git a/spec/lib/rex/proto/steam/message_spec.rb b/spec/lib/rex/proto/steam/message_spec.rb new file mode 100644 index 0000000000..1682dad924 --- /dev/null +++ b/spec/lib/rex/proto/steam/message_spec.rb @@ -0,0 +1,47 @@ +# -*- coding: binary -*- +require 'spec_helper' +require 'rex/proto/steam/message' + +describe Rex::Proto::Steam do + subject(:steam) do + mod = Module.new + mod.extend described_class + mod + end + + describe '#encode_message' do + it 'properly encodes messages' do + message = steam.encode_message('T', 'Test') + expect(message).to eq("\xFF\xFF\xFF\xFF\x54Test") + end + end + + describe '#decode_message' do + it 'does not decode overly short messages' do + expect(steam.decode_message('foo')).to eq(nil) + end + + it 'does not decode unknown messages' do + expect(steam.decode_message("\xFF\xFF\xFF\x01blahblahblah")).to eq(nil) + end + + it 'properly decodes valid messages' do + type, message = steam.decode_message("\xFF\xFF\xFF\xFF\x54Test") + expect(type).to eq(0x54) + expect(message).to eq('Test') + end + end + + describe '#a2s_info_decode' do + it 'extracts a2s_info fields properly' do + expected_info = { + version: 17, name: "-=THE BATTLEGROUNDS *HARDCORE*=-", map: "aoc_battleground", + folder: "ageofchivalry", game_name: "Age of Chivalry", game_id: 17510, + players: "22/32", bots: 0, game_version: "1.0.0.6", type: "Dedicated", + environment: "Linux", visibility: "public", VAC: "secured" + } + actual_info = steam.a2s_info_decode(IO.read(File.join(File.dirname(__FILE__), 'steam_info.bin'))) + expect(actual_info).to eq(expected_info) + end + end +end diff --git a/spec/lib/rex/proto/steam/steam_info.bin b/spec/lib/rex/proto/steam/steam_info.bin new file mode 100644 index 0000000000..52b726c931 Binary files /dev/null and b/spec/lib/rex/proto/steam/steam_info.bin differ