Skip to content

Commit 79ca0a5

Browse files
committed
Land rapid7#4171, Steam protocol support
2 parents f762873 + 61b6a14 commit 79ca0a5

File tree

5 files changed

+242
-0
lines changed

5 files changed

+242
-0
lines changed

lib/rex/proto/steam.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# -*- coding: binary -*-
2+
3+
require 'rex/proto/steam/message'

lib/rex/proto/steam/message.rb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# -*- coding: binary -*-
2+
3+
module Rex
4+
module Proto
5+
##
6+
#
7+
# Steam protocol support, taken from https://developer.valvesoftware.com/wiki/Server_queries
8+
#
9+
##
10+
module Steam
11+
# The Steam header ussed when the message is fragmented.
12+
FRAGMENTED_HEADER = 0xFFFFFFFE
13+
# The Steam header ussed when the message is not fragmented.
14+
UNFRAGMENTED_HEADER = 0xFFFFFFFF
15+
16+
# Decodes a Steam response message.
17+
#
18+
# @param message [String] the message to decode
19+
# @return [Array] the message type and body
20+
def decode_message(message)
21+
# minimum size is header (4) + type (1)
22+
return if message.length < 5
23+
header, type = message.unpack('NC')
24+
# TODO: handle fragmented responses
25+
return if header != UNFRAGMENTED_HEADER
26+
[type, message[5, message.length]]
27+
end
28+
29+
# Encodes a Steam message.
30+
#
31+
# @param type [String, Fixnum] the message type
32+
# @param body [String] the message body
33+
# @return [String] the encoded Steam message
34+
def encode_message(type, body)
35+
if type.is_a? Fixnum
36+
type_num = type
37+
elsif type.is_a? String
38+
type_num = type.ord
39+
else
40+
fail ArgumentError, 'type must be a String or Fixnum'
41+
end
42+
43+
[UNFRAGMENTED_HEADER, type_num ].pack('NC') + body
44+
end
45+
46+
# Builds an A2S_INFO message
47+
#
48+
# @return [String] the A2S_INFO message
49+
def a2s_info
50+
encode_message('T', "Source Engine Query\x00")
51+
end
52+
53+
# Decodes an A2S_INFO response message
54+
#
55+
# @parameter response [String] the A2S_INFO resposne to decode
56+
# @return [Hash] the fields extracted from the response
57+
def a2s_info_decode(response)
58+
# abort if it is impossibly short
59+
return nil if response.length < 19
60+
message_type, body = decode_message(response)
61+
# abort if it isn't a valid Steam response
62+
return nil if message_type != 0x49 # 'I'
63+
info = {}
64+
info[:version], info[:name], info[:map], info[:folder], info[:game_name],
65+
info[:game_id], players, players_max, info[:bots],
66+
type, env, vis, vac, info[:game_version], _edf = body.unpack("CZ*Z*Z*Z*SCCCCCCCZ*C")
67+
68+
# translate type
69+
case type
70+
when 100 # d
71+
server_type = 'Dedicated'
72+
when 108 # l
73+
server_type = 'Non-dedicated'
74+
when 112 # p
75+
server_type = 'SourceTV relay (proxy)'
76+
else
77+
server_type = "Unknown (#{type})"
78+
end
79+
info[:type] = server_type
80+
81+
# translate environment
82+
case env
83+
when 108 # l
84+
server_env = 'Linux'
85+
when 119 # w
86+
server_env = 'Windows'
87+
when 109 # m
88+
when 111 # o
89+
server_env = 'Mac'
90+
else
91+
server_env = "Unknown (#{env})"
92+
end
93+
info[:environment] = server_env
94+
95+
# translate visibility
96+
case vis
97+
when 0
98+
server_vis = 'public'
99+
when 1
100+
server_vis = 'private'
101+
else
102+
server_vis = "Unknown (#{vis})"
103+
end
104+
info[:visibility] = server_vis
105+
106+
# translate VAC
107+
case vac
108+
when 0
109+
server_vac = 'unsecured'
110+
when 1
111+
server_vac = 'secured'
112+
else
113+
server_vac = "Unknown (#{vac})"
114+
end
115+
info[:VAC] = server_vac
116+
117+
# format players/max
118+
info[:players] = "#{players}/#{players_max}"
119+
120+
# TODO: parse EDF
121+
info
122+
end
123+
end
124+
end
125+
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
##
2+
# This module requires Metasploit: http://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
require 'msf/core'
7+
require 'rex/proto/steam'
8+
9+
class Metasploit3 < Msf::Auxiliary
10+
include Msf::Auxiliary::Report
11+
include Msf::Auxiliary::UDPScanner
12+
include Rex::Proto::Steam
13+
14+
def initialize(info = {})
15+
super(
16+
update_info(
17+
info,
18+
'Name' => 'Gather Steam Server Information',
19+
'Description' => %q(
20+
This module uses the A2S_INFO request to obtain information from a Steam server.
21+
),
22+
'Author' => 'Jon Hart <jon_hart[at]rapid7.com>',
23+
'References' =>
24+
[
25+
# TODO: add more from https://developer.valvesoftware.com/wiki/Server_queries,
26+
# perhaps in different modules
27+
['URL', 'https://developer.valvesoftware.com/wiki/Server_queries#A2S_INFO']
28+
],
29+
'License' => MSF_LICENSE
30+
)
31+
)
32+
33+
register_options(
34+
[
35+
Opt::RPORT(27015)
36+
], self.class)
37+
end
38+
39+
def build_probe
40+
@probe ||= a2s_info
41+
end
42+
43+
def scanner_process(response, src_host, src_port)
44+
info = a2s_info_decode(response)
45+
return unless info
46+
@results[src_host] ||= []
47+
if datastore['VERBOSE']
48+
print_good("#{src_host}:#{src_port} found '#{info.inspect}'")
49+
else
50+
print_good("#{src_host}:#{src_port} found '#{info[:name]}'")
51+
end
52+
@results[src_host] << info
53+
end
54+
55+
def scanner_postscan(_batch)
56+
@results.each_pair do |host, info|
57+
report_host(host: host)
58+
report_service(
59+
host: host,
60+
proto: 'udp',
61+
port: rport,
62+
name: 'Steam',
63+
info: info
64+
)
65+
end
66+
end
67+
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# -*- coding: binary -*-
2+
require 'spec_helper'
3+
require 'rex/proto/steam/message'
4+
5+
describe Rex::Proto::Steam do
6+
subject(:steam) do
7+
mod = Module.new
8+
mod.extend described_class
9+
mod
10+
end
11+
12+
describe '#encode_message' do
13+
it 'properly encodes messages' do
14+
message = steam.encode_message('T', 'Test')
15+
expect(message).to eq("\xFF\xFF\xFF\xFF\x54Test")
16+
end
17+
end
18+
19+
describe '#decode_message' do
20+
it 'does not decode overly short messages' do
21+
expect(steam.decode_message('foo')).to eq(nil)
22+
end
23+
24+
it 'does not decode unknown messages' do
25+
expect(steam.decode_message("\xFF\xFF\xFF\x01blahblahblah")).to eq(nil)
26+
end
27+
28+
it 'properly decodes valid messages' do
29+
type, message = steam.decode_message("\xFF\xFF\xFF\xFF\x54Test")
30+
expect(type).to eq(0x54)
31+
expect(message).to eq('Test')
32+
end
33+
end
34+
35+
describe '#a2s_info_decode' do
36+
it 'extracts a2s_info fields properly' do
37+
expected_info = {
38+
version: 17, name: "-=THE BATTLEGROUNDS *HARDCORE*=-", map: "aoc_battleground",
39+
folder: "ageofchivalry", game_name: "Age of Chivalry", game_id: 17510,
40+
players: "22/32", bots: 0, game_version: "1.0.0.6", type: "Dedicated",
41+
environment: "Linux", visibility: "public", VAC: "secured"
42+
}
43+
actual_info = steam.a2s_info_decode(IO.read(File.join(File.dirname(__FILE__), 'steam_info.bin')))
44+
expect(actual_info).to eq(expected_info)
45+
end
46+
end
47+
end
155 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)