diff --git a/Gemfile.lock b/Gemfile.lock index c6a983bc11..9684bf886d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,6 +12,7 @@ PATH concurrent-ruby (= 1.0.5) dnsruby ed25519 + em-http-request faker filesize jsobfu @@ -119,6 +120,7 @@ GEM builder (3.2.3) coderay (1.1.2) concurrent-ruby (1.0.5) + cookiejar (0.3.3) crass (1.0.4) daemons (1.2.6) diff-lcs (1.3) @@ -126,6 +128,14 @@ GEM addressable (~> 2.5) docile (1.3.1) ed25519 (1.2.4) + em-http-request (1.1.5) + addressable (>= 2.3.4) + cookiejar (!= 0.3.1) + em-socksify (>= 0.3) + eventmachine (>= 1.0.3) + http_parser.rb (>= 0.6.0) + em-socksify (0.3.2) + eventmachine (>= 1.0.0.beta.4) erubis (2.7.0) eventmachine (1.2.7) factory_bot (4.11.1) @@ -140,6 +150,7 @@ GEM filesize (0.2.0) fivemat (1.3.7) hashery (2.1.2) + http_parser.rb (0.6.0) i18n (0.9.5) concurrent-ruby (~> 1.0) jsobfu (0.4.2) diff --git a/lib/msf/core/rpc.rb b/lib/msf/core/rpc.rb index f20dd1a823..db9d2f3c7b 100644 --- a/lib/msf/core/rpc.rb +++ b/lib/msf/core/rpc.rb @@ -17,12 +17,16 @@ module Msf::RPC module JSON + autoload :Client, 'msf/core/rpc/json/client' autoload :Dispatcher, 'msf/core/rpc/json/dispatcher' autoload :DispatcherHelper, 'msf/core/rpc/json/dispatcher_helper' + autoload :Request, 'msf/core/rpc/json/request' + autoload :Response, 'msf/core/rpc/json/response' autoload :RpcCommand, 'msf/core/rpc/json/rpc_command' autoload :RpcCommandFactory, 'msf/core/rpc/json/rpc_command_factory' # exception classes + # server autoload :Error, 'msf/core/rpc/json/error' autoload :ParseError, 'msf/core/rpc/json/error' autoload :InvalidRequest, 'msf/core/rpc/json/error' @@ -31,5 +35,11 @@ module Msf::RPC autoload :InternalError, 'msf/core/rpc/json/error' autoload :ServerError, 'msf/core/rpc/json/error' autoload :ApplicationServerError, 'msf/core/rpc/json/error' + # client + autoload :ClientError, 'msf/core/rpc/json/error' + autoload :InvalidResponse, 'msf/core/rpc/json/error' + autoload :JSONParseError, 'msf/core/rpc/json/error' + autoload :ErrorResponse, 'msf/core/rpc/json/error' + end end diff --git a/lib/msf/core/rpc/json/client.rb b/lib/msf/core/rpc/json/client.rb new file mode 100644 index 0000000000..a3752797f7 --- /dev/null +++ b/lib/msf/core/rpc/json/client.rb @@ -0,0 +1,80 @@ +require 'json' +require 'uri' + +require 'msf/core/rpc' + +module Msf::RPC::JSON + # JSON-RPC Client + # All client method call requests must be dispatched from within an + # EventMachine (reactor) run loop. + class Client + attr_reader :uri + attr_reader :api_token + attr_reader :symbolize_names + attr_accessor :namespace + + # Instantiate a Client. + # @param uri [String] the JSON-RPC service URI + # @param api_token [String] the API token. Default: nil + # @param namespace [String] the namespace for the JSON-RPC method. The namespace will + # be prepended to the method name with a period separator. Default: nil + # @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when + # processing JSON objects; otherwise, strings are used. Default: true + # @param private_key_file [String] the SSL private key file used for the HTTPS request. Default: nil + # @param cert_chain_file [String] the SSL cert chain file used for the HTTPS request. Default: nil + # @param verify_peer [Boolean] indicates whether a server should request a certificate + # from a peer, to be verified by user code. Default: nil + def initialize(uri, api_token: nil, namespace: nil, symbolize_names: true, + private_key_file: nil, cert_chain_file: nil, verify_peer: nil) + @uri = URI.parse(uri) + @api_token = api_token + @namespace = namespace + @symbolize_names = symbolize_names + @private_key_file = private_key_file + @cert_chain_file = cert_chain_file + @verify_peer = verify_peer + end + + private + + # Invoked by Ruby when obj is sent a message it cannot handle, then processes + # the call as an RPC method invocation. + # @param symbol [Symbol] the symbol for the method called + # @param args [Array] any positional arguments passed to the method + # @param keyword_args [Hash] any keyword arguments passed to the method + # @returns [Msf::RPC::JSON::Request] an EM::Deferrable for the RPC method invocation. + def method_missing(symbol, *args, **keyword_args, &block) + # assemble method parameters + if !args.empty? && !keyword_args.empty? + params = args << keyword_args + elsif !args.empty? + params = args + elsif !keyword_args.empty? + params = keyword_args + else + params = nil + end + + process_call_async(symbol, params) + end + + # Asynchronously processes the RPC method invocation. + # @param method [Symbol] the method + # @param params [Array, Hash] any arguments passed to the method + # @returns [Msf::RPC::JSON::Request] an EM::Deferrable for the RPC method invocation. + def process_call_async(method, params) + req = Request.new(@uri, + api_token: @api_token, + method: method, + params: params, + namespace: @namespace, + symbolize_names: @symbolize_names, + private_key_file: @private_key_file, + cert_chain_file: @cert_chain_file, + verify_peer: @verify_peer) + req.send + + req + end + end +end \ No newline at end of file diff --git a/lib/msf/core/rpc/json/dispatcher.rb b/lib/msf/core/rpc/json/dispatcher.rb index bf2bbe26c0..ad5525fac3 100644 --- a/lib/msf/core/rpc/json/dispatcher.rb +++ b/lib/msf/core/rpc/json/dispatcher.rb @@ -120,7 +120,7 @@ module Msf::RPC::JSON # Validate the JSON-RPC request. # @param request [Hash] the JSON-RPC request - # @returns [Boolean] true if the JSON-RPC request is a valid; otherwise, false. + # @returns [Boolean] true if the JSON-RPC request is valid; otherwise, false. def validate_rpc_request(request) # validate request is an object return false unless request.is_a?(Hash) diff --git a/lib/msf/core/rpc/json/error.rb b/lib/msf/core/rpc/json/error.rb index f1db564324..6cb06dafdb 100644 --- a/lib/msf/core/rpc/json/error.rb +++ b/lib/msf/core/rpc/json/error.rb @@ -133,4 +133,94 @@ module Msf::RPC::JSON super(APPLICATION_SERVER_ERROR, ERROR_MESSAGES[APPLICATION_SERVER_ERROR] % {msg: message}, data: data) end end + + # Base class for all Msf::RPC::JSON client exceptions. + class ClientError < StandardError + attr_reader :response + + # Instantiate a ClientError object. + # + # @param message [String] A String providing a short description of the error. + # @param response [Hash] A response hash. The default value is nil. + def initialize(message = nil, response: nil) + super(message) + @response = response + end + end + + class InvalidResponse < ClientError + # Instantiate an InvalidResponse object. + # + # @param message [String] A String providing a short description of the error. + # @param response [Hash] A response hash. The default value is nil. + def initialize(message = 'Invalid response from server', response: nil) + super(message, response: response) + end + end + + class JSONParseError < ClientError + # Instantiate an JSONParseError object. + # + # @param message [String] A String providing a short description of the error. + # @param response [Hash] A response hash. The default value is nil. + def initialize(message = 'Invalid JSON was received from the server', response: nil) + super(message, response: response) + end + end + + class ErrorResponse < ClientError + attr_reader :id + attr_reader :code + attr_reader :message + attr_reader :data + + # Parse response and return a new ErrorResponse instance. + # @param response [Hash] A response hash. + # @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when + # processing JSON objects; otherwise, strings are used. Default: true + # @returns [ErrorResponse] ErrorResponse object that represents the response hash. + def self.parse(response, symbolize_names: true) + id_key = symbolize_names ? :id : :id.to_s + error_key = symbolize_names ? :error : :error.to_s + code_key = symbolize_names ? :code : :code.to_s + message_key = symbolize_names ? :message : :message.to_s + data_key = symbolize_names ? :data : :data.to_s + + id = response[id_key] + error = response[error_key] + + if !error.nil? + code = error[code_key] + message = error[message_key] + data = error[data_key] + else + code = nil + message = nil + data = nil + end + + ErrorResponse.new(id: id, code: code, message: message, data: data, response: response) + end + + # Instantiate an ErrorResponse object. + # + # @param id [Integer, String, NilClass] It MUST be the same as the value of the + # id member in the Request Object. If there was an error in detecting the id + # in the Request object (e.g. Parse error/Invalid Request), it MUST be Null. + # @param code [Integer] A Number that indicates the error type that occurred. + # @param message [String] A String providing a short description of the error. + # The message SHOULD be limited to a concise single sentence. + # @param data [Object] A Primitive or Structured value that contains additional + # information about the error. This may be omitted. The value of this member is + # defined by the Server (e.g. detailed error information, nested errors etc.). + # The default value is nil. + # @param response [Hash] A response hash. The default value is nil. + def initialize(id:, code:, message:, data: nil, response: nil) + super(message, response: response) + @id = id + @code = code + @message = message + @data = data + end + end end diff --git a/lib/msf/core/rpc/json/request.rb b/lib/msf/core/rpc/json/request.rb new file mode 100644 index 0000000000..c5095b20ce --- /dev/null +++ b/lib/msf/core/rpc/json/request.rb @@ -0,0 +1,227 @@ +require 'em-http-request' +require 'json' + +require 'msf/core/rpc' + +module Msf::RPC::JSON + + # Represents a JSON-RPC request. This is an EM::Deferrable class and instances + # respond to #callback and #errback to store callback actions. + class Request + include EM::Deferrable + + JSON_MEDIA_TYPE = 'application/json' + JSON_RPC_VERSION = '2.0' + JSON_RPC_RESPONSE_REQUIRED_MEMBERS = %i(jsonrpc id) + JSON_RPC_RESPONSE_MEMBER_TYPES = { + # A String specifying the version of the JSON-RPC protocol. + jsonrpc: [String], + # An identifier established by the Client that MUST contain a String, + # Number, or NULL value if included. If it is not included it is assumed + # to be a notification. The value SHOULD normally not be Null [1] and + # Numbers SHOULD NOT contain fractional parts [2] + id: [Integer, String, NilClass], + } + JSON_RPC_ERROR_RESPONSE_REQUIRED_MEMBERS = %i(code message) + JSON_RPC_ERROR_RESPONSE_MEMBER_TYPES = { + # A Number that indicates the error type that occurred. + # This MUST be an integer. + code: [Integer], + # A String providing a short description of the error. + # The message SHOULD be limited to a concise single sentence. + message: [String] + } + + # Instantiate a Request. + # @param uri [URI::HTTP] the JSON-RPC service URI + # @param api_token [String] the API token. Default: nil + # @param method [String] the JSON-RPC method name. + # @param params [Array, Hash] the JSON-RPC method parameters. Default: nil + # @param namespace [String] the namespace for the JSON-RPC method. The namespace will + # be prepended to the method name with a period separator. Default: nil + # @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when + # processing JSON objects; otherwise, strings are used. Default: true + # @param is_notification [Boolean] If true, the request is created as a notification; + # otherwise, a standard request. Default: false + # @param private_key_file [String] the SSL private key file used for the HTTPS request. Default: nil + # @param cert_chain_file [String] the SSL cert chain file used for the HTTPS request. Default: nil + # @param verify_peer [Boolean] indicates whether a server should request a certificate + # from a peer, to be verified by user code. Default: nil + def initialize(uri, api_token: nil, method:, params: nil, namespace: nil, + symbolize_names: true, is_notification: false, + private_key_file: nil, cert_chain_file: nil, verify_peer: nil) + @uri = uri + @api_token = api_token + @namespace = namespace + @symbolize_names = symbolize_names + @is_notification = is_notification + @headers = { + 'Accept': JSON_MEDIA_TYPE, + 'Content-Type': JSON_MEDIA_TYPE, + 'Authorization': "Bearer #{@api_token}" + } + + absolute_method_name = @namespace.nil? ? method : "#{@namespace}.#{method}" + request_msg = { + jsonrpc: JSON_RPC_VERSION, + method: absolute_method_name + } + request_msg[:id] = Request.generate_id unless is_notification + request_msg[:params] = params unless params.nil? + + @request_options = { + head: @headers, + body: request_msg.to_json + } + + # add SSL options if specified + if !private_key_file.nil? || !cert_chain_file.nil? || verify_peer.is_a?(TrueClass) || + verify_peer.is_a?(FalseClass) + ssl_options = {} + ssl_options[:private_key_file] = private_key_file unless private_key_file.nil? + ssl_options[:cert_chain_file] = cert_chain_file unless cert_chain_file.nil? + ssl_options[:verify_peer] = verify_peer if verify_peer.is_a?(TrueClass) || verify_peer.is_a?(FalseClass) + @request_options[:ssl] = ssl_options + end + end + + # Sends the JSON-RPC request using an EM::HttpRequest object, then validates and processes + # the JSON-RPC response. + def send + http = EM::HttpRequest.new(@uri).post(@request_options) + + http.callback do + process(http.response) + end + + http.errback do + fail(http.error) + end + end + + private + + # Process the JSON-RPC response. + # @param source [String] the JSON-RPC response + def process(source) + begin + response = JSON.parse(source, symbolize_names: @symbolize_names) + if response.is_a?(Array) + # process batch response + # TODO: implement batch response processing + fail("#{self.class.name}##{__method__} is not implemented for batch response") + else + process_response(response) + end + rescue JSON::ParserError + fail(JSONParseError.new(response: source)) + end + end + + + # Validate and process the JSON-RPC response. + # @param response [Hash] the JSON-RPC response + def process_response(response) + if !valid_rpc_response?(response) + fail(InvalidResponse.new(response: response)) + return + end + + error_key = @symbolize_names ? :error : :error.to_s + if response.key?(error_key) + # process error response + fail(ErrorResponse.parse(response, symbolize_names: @symbolize_names)) + else + # process successful response + succeed(Response.parse(response, symbolize_names: @symbolize_names)) + end + end + + # Validate the JSON-RPC response. + # @param response [Hash] the JSON-RPC response + # @returns [Boolean] true if the JSON-RPC response is valid; otherwise, false. + def valid_rpc_response?(response) + # validate response is an object + return false unless response.is_a?(Hash) + + JSON_RPC_RESPONSE_REQUIRED_MEMBERS.each do |member| + tmp_member = @symbolize_names ? member : member.to_s + return false unless response.key?(tmp_member) + end + + # validate response members are correct types + response.each do |member, value| + tmp_member = @symbolize_names ? member : member.to_sym + return false if JSON_RPC_RESPONSE_MEMBER_TYPES.key?(tmp_member) && + !JSON_RPC_RESPONSE_MEMBER_TYPES[tmp_member].one? { |type| value.is_a?(type) } + end + + return false if response[:jsonrpc] != JSON_RPC_VERSION + + result_key = @symbolize_names ? :result : :result.to_s + error_key = @symbolize_names ? :error : :error.to_s + + return false if response.key?(result_key) && response.key?(error_key) + + if response.key?(error_key) + error_response = response[error_key] + # validate error response is an object + return false unless error_response.is_a?(Hash) + + JSON_RPC_ERROR_RESPONSE_REQUIRED_MEMBERS.each do |member| + tmp_member = @symbolize_names ? member : member.to_s + return false unless error_response.key?(tmp_member) + end + + # validate error response members are correct types + error_response.each do |member, value| + tmp_member = @symbolize_names ? member : member.to_sym + return false if JSON_RPC_ERROR_RESPONSE_MEMBER_TYPES.key?(tmp_member) && + !JSON_RPC_ERROR_RESPONSE_MEMBER_TYPES[tmp_member].one? { |type| value.is_a?(type) } + end + end + + true + end + + # Generates a random id. + # @param n [Integer] Upper boundary for the random id. + # @return [Integer] A random id. If a positive integer is given for n, + # returns an integer: 0 <= id < n. + def self.generate_id(n = (2**(0.size * 8 - 1))-1) + SecureRandom.random_number(n) + end + end + + # Represents a JSON-RPC Notification. This is an EM::Deferrable class and + # instances respond to #callback and #errback to store callback actions. + class Notification < Request + # Instantiate a Notification. + # @param uri [URI::HTTP] the JSON-RPC service URI + # @param api_token [String] the API token. Default: nil + # @param method [String] the JSON-RPC method name. + # @param params [Array, Hash] the JSON-RPC method parameters. Default: nil + # @param namespace [String] the namespace for the JSON-RPC method. The namespace will + # be prepended to the method name with a period separator. Default: nil + # @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when + # processing JSON objects; otherwise, strings are used. Default: true + # @param private_key_file [String] the SSL private key file used for the HTTPS request. Default: nil + # @param cert_chain_file [String] the SSL cert chain file used for the HTTPS request. Default: nil + # @param verify_peer [Boolean] indicates whether a server should request a certificate + # from a peer, to be verified by user code. Default: nil + def initialize(uri, api_token: nil, method:, params: nil, namespace: nil, + symbolize_names: true, private_key_file: nil, + cert_chain_file: nil, verify_peer: nil) + super(uri, + api_token: api_token, + method: method, + params: params, + namespace: namespace, + symbolize_names: symbolize_names, + is_notification: true, + private_key_file: private_key_file, + cert_chain_file: cert_chain_file, + verify_peer: verify_peer) + end + end +end \ No newline at end of file diff --git a/lib/msf/core/rpc/json/response.rb b/lib/msf/core/rpc/json/response.rb new file mode 100644 index 0000000000..8195416da4 --- /dev/null +++ b/lib/msf/core/rpc/json/response.rb @@ -0,0 +1,37 @@ +module Msf::RPC::JSON + + # Represents a JSON-RPC response. + class Response + attr_reader :response + attr_reader :id + attr_reader :result + + # Parse response and return a new Response instance. + # @param response [Hash] A response hash. + # @param symbolize_names [Boolean] If true, symbols are used for the names (keys) when + # processing JSON objects; otherwise, strings are used. Default: true + # @returns [Response] Response object that represents the response hash. + def self.parse(response, symbolize_names: true) + id_key = symbolize_names ? :id : :id.to_s + result_key = symbolize_names ? :result : :result.to_s + + id = response[id_key] + result = response[result_key] + + Response.new(id: id, result: result, response: response) + end + + # Instantiate a Response object. + # + # @param id [Integer, String, NilClass] It MUST be the same as the value of the + # id member in the Request Object. If there was an error in detecting the id + # in the Request object (e.g. Parse error/Invalid Request), it MUST be Null. + # @param result [Integer, String, Array, Hash, NilClass] Result of the method. + # @param response [Hash] A response hash. The default value is nil. + def initialize(id:, result:, response: nil) + @id = id + @result = result + @response = response + end + end +end \ No newline at end of file diff --git a/metasploit-framework.gemspec b/metasploit-framework.gemspec index 1d2b446cd6..3da8238cb6 100644 --- a/metasploit-framework.gemspec +++ b/metasploit-framework.gemspec @@ -105,6 +105,8 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'sinatra' spec.add_runtime_dependency 'sysrandom' spec.add_runtime_dependency 'warden' + # Required for JSON-RPC client + spec.add_runtime_dependency 'em-http-request' # TimeZone info spec.add_runtime_dependency 'tzinfo-data' # Gem for dealing with SSHKeys