diff --git a/CHANGELOG b/CHANGELOG index 41a0e6474..01246e109 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,11 @@ # Nmap Changelog ($Id$); -*-text-*- +o [NSE] Added the script membase-brute that performs password brute force + password guessing against the Membase TAP protocol. [Patrik] + +o [NSE] Added the script membase-http-info that retrieves information from the + Couchbase distributed key-value pair server. [Patrik] + o [NSE] Fixed a race condition in broadcast-dhcp-discover.nse that could cause responses to be missed on fast networks. It was noticed by Vasiliy Kulikov. [David] diff --git a/nselib/membase.lua b/nselib/membase.lua new file mode 100644 index 000000000..ddbd64b15 --- /dev/null +++ b/nselib/membase.lua @@ -0,0 +1,330 @@ +--- +-- A smallish implementation of the Couchbase Membase TAP protocol +-- Based on the scarce documentation from the Couchbase Wiki: +-- x http://www.couchbase.org/wiki/display/membase/SASL+Authentication+Example +-- +-- @author "Patrik Karlsson " +-- + + +module(... or "membase", package.seeall) + +require 'match' +require 'sasl' + + +-- A minimalistic implementation of the Couchbase Membase TAP protocol +TAP = { + + -- Operations + Op = { + LIST_SASL_MECHS = 0x20, + AUTHENTICATE = 0x21, + }, + + -- Requests + Request = { + + -- Header breakdown + -- Field (offset) (value) + -- Magic (0): 0x80 (PROTOCOL_BINARY_REQ) + -- Opcode (1): 0x00 + -- Key length (2-3): 0x0000 (0) + -- Extra length (4): 0x00 + -- Data type (5): 0x00 + -- vbucket (6-7): 0x0000 (0) + -- Total body (8-11): 0x00000000 (0) + -- Opaque (12-15): 0x00000000 (0) + -- CAS (16-23): 0x0000000000000000 (0) + Header = { + + -- Creates a new instance of Header + -- @param opcode number containing the operation + -- @return o new instance of Header + new = function(self, opcode) + local o = { + magic = 0x80, + opcode = tonumber(opcode), + keylen = 0x0000, + extlen = 0x00, + data_type = 0x00, + vbucket = 0x0000, + total_body = 0x00000000, + opaque = 0x00000000, + CAS = 0x0000000000000000, + } + setmetatable(o, self) + self.__index = self + return o + end, + + -- Converts the header to string + -- @return string containing the Header as string + __tostring = function(self) + return bin.pack(">CCSCCSIIL", self.magic, self.opcode, self.keylen, + self.extlen, self.data_type, self.vbucket, self.total_body, + self.opaque, self.CAS) + end, + }, + + -- List SASL authentication mechanism + SASLList = { + + -- Creates a new instance of the request + -- @return o instance of request + new = function(self) + local o = { + -- 0x20 SASL List Mechs + header = TAP.Request.Header:new(TAP.Op.LIST_SASL_MECHS) + } + setmetatable(o, self) + self.__index = self + return o + end, + + -- Converts the request to string + -- @return string containing the request as string + __tostring = function(self) + return tostring(self.header) + end, + }, + + -- Authenticates using SASL + Authenticate = { + + -- Creates a new instance of the request + -- @param username string containing the username + -- @param password string containing the password + -- @param mech string containing the SASL mechanism, currently suppored: + -- PLAIN - plain-text authentication + -- @return o instance of request + new = function(self, username, password, mech) + local o = { + -- 0x20 SASL List Mechs + header = TAP.Request.Header:new(TAP.Op.AUTHENTICATE), + username = username, + password = password, + mech = mech, + } + setmetatable(o, self) + self.__index = self + return o + end, + + -- Converts the request to string + -- @return string containing the request as string + __tostring = function(self) + if ( self.mech == "PLAIN" ) then + local mech_params = { self.username, self.password } + local auth_data = sasl.Helper:new(self.mech):encode(unpack(mech_params)) + + self.header.keylen = #self.mech + self.header.total_body = #auth_data + #self.mech + return tostring(self.header) .. self.mech .. auth_data + end + end, + + } + + }, + + -- Responses + Response = { + + -- The response header + -- Header breakdown + -- Field (offset) (value) + -- Magic (0): 0x81 (PROTOCOL_BINARY_RES) + -- Opcode (1): 0x00 + -- Key length (2-3): 0x0000 (0) + -- Extra length (4): 0x00 + -- Data type (5): 0x00 + -- Status (6-7): 0x0000 (SUCCESS) + -- Total body (8-11): 0x00000005 (5) + -- Opaque (12-15): 0x00000000 (0) + -- CAS (16-23): 0x0000000000000000 (0) + Header = { + + -- Creates a new instance of Header + -- @param data string containing the raw data + -- @return o new instance of Header + new = function(self, data) + local o = { + data = data + } + setmetatable(o, self) + self.__index = self + if ( o:parse() ) then + return o + end + end, + + -- Parse the raw header and populates the class members + -- @return status true on success, false on failure + parse = function(self) + if ( 24 > #self.data ) then + stdnse.print_debug("%s: Header packet too short (%d bytes)", SCRIPT_NAME, #self.data) + return false, "Packet to short" + end + local pos + pos, self.magic, self.opcode, self.keylen, self.extlen, + self.data_type, self.status, self.total_body, self.opaque, + self.CAS = bin.unpack(">CCSCCSIIL", self.data) + return true + end + + }, + + -- Decoders + Decoder = { + + -- TAP.Op.LIST_SASL_MECHS + [0x20] = { + -- Creates a new instance of the decoder + -- @param data string containing the raw response + -- @return o instance if successfully parsed, nil on failure + -- the member variable mechs contains the + -- supported authentication mechanisms. + new = function(self, data) + local o = { data = data } + setmetatable(o, self) + self.__index = self + if ( o:parse() ) then + return o + end + end, + + -- Parses the raw response + -- @return true on success + parse = function(self) + self.mechs = self.data + return true + end + }, + + -- Login response + [0x21] = { + -- Creates a new instance of the decoder + -- @param data string containing the raw response + -- @return o instance if successfully parsed, nil on failure + -- the member variable status contains the + -- servers authentication response. + new = function(self, data) + local o = { data = data } + setmetatable(o, self) + self.__index = self + if ( o:parse() ) then + return o + end + end, + + -- Parses the raw response + -- @return true on success + parse = function(self) + self.status = self.data + return true + end + } + + } + + }, + +} + +-- The Helper class is the main script interface +Helper = { + + -- Creates a new instance of the helper + -- @param host table as received by the action method + -- @param port table as received by the action method + -- @param options table including options to the helper, currently: + -- timeout - socket timeout in milliseconds + new = function(self, host, port, options) + local o = { + host = host, + port = port, + mech = stdnse.get_script_args("membase.authmech"), + options = options or {} + } + setmetatable(o, self) + self.__index = self + return o + end, + + -- Connects the socket to the server + -- @return true on success, false on failure + connect = function(self) + self.socket = nmap.new_socket() + self.socket:set_timeout(self.options.timeout or 10000) + return self.socket:connect(self.host, self.port) + end, + + -- Closes the socket + close = function(self) + return self.socket:close() + end, + + -- Sends a request to the server, receives and parses the response + -- @param req a Request instance + -- @return status true on success, false on failure + -- @return response instance of Response + exch = function(self, req) + local status, err = self.socket:send(tostring(req)) + if ( not(status) ) then + return false, "Failed to send data" + end + + local data + status, data = self.socket:receive_buf(match.numbytes(24), true) + if ( not(status) ) then + return false, "Failed to receive data" + end + + local header = TAP.Response.Header:new(data) + + if ( header.opcode ~= req.header.opcode ) then + stdnse.print_debug("WARNING: Received invalid op code, request contained (%d), response contained (%d)", req.header.opcode, header.opcode) + end + + if ( not(TAP.Response.Decoder[tonumber(header.opcode)]) ) then + return false, ("No response handler for opcode: %d"):format(header.opcode) + end + + local status, data = self.socket:receive_buf(match.numbytes(header.total_body), true) + if ( not(status) ) then + return false, "Failed to receive data" + end + + local response = TAP.Response.Decoder[tonumber(header.opcode)]:new(data) + if ( not(response) ) then + return false, "Failed to parse response from server" + end + return true, response + end, + + -- Gets list of supported SASL authentication mechanisms + getSASLMechList = function(self) + return self:exch(TAP.Request.SASLList:new()) + end, + + -- Logins to the server + -- @param username string containing the username + -- @param password string containing the password + -- @param mech string containing the SASL mechanism to use + -- @return status true on success, false on failure + -- @return respons string containing "Auth failure" on failure + login = function(self, username, password, mech) + mech = mech or self.mech or "PLAIN" + local status, response = self:exch(TAP.Request.Authenticate:new(username, password, mech)) + if ( not(status) ) then + return false, "Auth failure" + end + if ( response.status == "Auth failure" ) then + return false, response.status + end + return true, response.status + end, +} + + diff --git a/scripts/membase-brute.nse b/scripts/membase-brute.nse new file mode 100644 index 000000000..2a8f34d6c --- /dev/null +++ b/scripts/membase-brute.nse @@ -0,0 +1,110 @@ +description = [[ +Performs brute force password guessing against Couchbase Membase servers. +]] + +--- +-- @usage +-- nmap -p 11211 --script membase-brute +-- +-- @output +-- PORT STATE SERVICE +-- 11211/tcp open unknown +-- | membase-brute: +-- | Accounts +-- | buckettest:toledo - Valid credentials +-- | Statistics +-- |_ Performed 5000 guesses in 2 seconds, average tps: 2500 +-- +-- @args membase-brute.bucketname if specified, password guessing is performed +-- only against this bucket. +-- + +author = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"intrusive", "brute"} + +require 'brute' +require 'shortport' +require 'membase' + +portrule = shortport.port_or_service({11210,11211}, "couchbase-tap", "tcp") + +local arg_bucketname = stdnse.get_script_args(SCRIPT_NAME..".bucketname") + + +Driver = { + + new = function(self, host, port, options) + local o = { host = host, port = port, options = options } + setmetatable(o, self) + self.__index = self + return o + end, + + connect = function(self) + self.helper = membase.Helper:new(self.host, self.port) + return self.helper:connect() + end, + + login = function(self, username, password) + local status, response = self.helper:login(arg_bucketname or username, password) + if ( not(status) and "Auth failure" == response ) then + return false, brute.Error:new( "Incorrect password" ) + elseif ( not(status) ) then + local err = brute.Error:new( response ) + err:setRetry( true ) + return false, err + end + return true, brute.Account:new( arg_bucketname or username, password, creds.State.VALID) + end, + + disconnect = function(self) + return self.helper:close() + end + +} + + +local function fail(err) return ("\n ERROR: %s"):format(err) end + +local function getMechs(host, port) + local helper = membase.Helper:new(host, port) + local status, err = helper:connect() + if ( not(status) ) then + return false, "Failed to connect to server" + end + + local status, response = helper:getSASLMechList() + if ( not(status) ) then + stdnse.print_debug(2, "%s: Received unexpected response: %s", SCRIPT_NAME, response) + return false, "Received unexpected response" + end + + helper:close() + return true, response.mechs +end + +action = function(host, port) + + local status, mechs = getMechs(host, port) + + if ( not(status) ) then + return fail(mechs) + end + if ( not(mechs:match("PLAIN") ) ) then + return fail("Unsupported SASL mechanism") + end + + local result + local engine = brute.Engine:new(Driver, host, port ) + + engine.options.script_name = SCRIPT_NAME + engine.options.firstonly = true + + if ( arg_bucketname ) then + engine.options:setOption( "passonly", true ) + end + + status, result = engine:start() + return result +end \ No newline at end of file diff --git a/scripts/membase-http-info.nse b/scripts/membase-http-info.nse new file mode 100644 index 000000000..322605710 --- /dev/null +++ b/scripts/membase-http-info.nse @@ -0,0 +1,135 @@ +description = [[ +Retrieves information from the CouchBase Web Administration port. +The information retrieved by this script does not require any credentials. +]] + +--- +-- @usage +-- nmap -p 8091 --script membase-http-info +-- +-- @output +-- PORT STATE SERVICE +-- 8091/tcp open unknown +-- | membase-http-info: +-- | Hostname 192.168.0.5:8091 +-- | OS x86_64-unknown-linux-gnu +-- | Version 1.7.2r-20-g6604356 +-- | Kernel version 2.14.4 +-- | Mnesia version 4.4.19 +-- | Stdlib version 1.17.4 +-- | OS mon version 2.2.6 +-- | NS server version 1.7.2r-20-g6604356 +-- | SASL version 2.1.9.4 +-- | Status healthy +-- | Uptime 21465 +-- | Total memory 522022912 +-- | Free memory 41779200 +-- |_ Server list 192.168.0.5:11210 +-- + +author = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"discovery", "safe"} + +require 'http' +require 'json' +require 'shortport' +require 'tab' + +portrule = shortport.port_or_service(8091, "http", "tcp") + +local function fail(err) return ("\n ERROR: %s"):format(err) end + +local filter = { + ["parsed[1]['nodes'][1]['os']"] = { name = "OS" }, + ["parsed[1]['nodes'][1]['version']"] = { name = "Version" }, + ["parsed[1]['nodes'][1]['hostname']"] = { name = "Hostname" }, + ["parsed[1]['nodes'][1]['status']"] = { name = "Status" }, + ["parsed[1]['nodes'][1]['uptime']"] = { name = "Uptime" }, + ["parsed[1]['nodes'][1]['memoryTotal']"] = { name = "Total memory" }, + ["parsed[1]['nodes'][1]['memoryFree']"] = { name = "Free memory" }, + ["parsed[1]['vBucketServerMap']['serverList']"] = { name = "Server list" }, + ["parsed['componentsVersion']['kernel']"] = { name = "Kernel version" }, + ["parsed['componentsVersion']['mnesia']"] = { name = "Mnesia version" }, + ["parsed['componentsVersion']['stdlib']"] = { name = "Stdlib version" }, + ["parsed['componentsVersion']['os_mon']"] = { name = "OS mon version" }, + ["parsed['componentsVersion']['ns_server']"] = { name = "NS server version" }, + ["parsed['componentsVersion']['sasl']"] = { name = "SASL version" }, +} + +local order = { + "parsed[1]['nodes'][1]['hostname']", + "parsed[1]['nodes'][1]['os']", + "parsed[1]['nodes'][1]['version']", + "parsed['componentsVersion']['kernel']", + "parsed['componentsVersion']['mnesia']", + "parsed['componentsVersion']['stdlib']", + "parsed['componentsVersion']['os_mon']", + "parsed['componentsVersion']['ns_server']", + "parsed['componentsVersion']['sasl']", + "parsed[1]['nodes'][1]['status']", + "parsed[1]['nodes'][1]['uptime']", + "parsed[1]['nodes'][1]['memoryTotal']", + "parsed[1]['nodes'][1]['memoryFree']", + "parsed[1]['vBucketServerMap']['serverList']", +} + +local function cmdReq(host, port, url, result) + local response = http.get(host, port, url) + + if ( 200 ~= response.status ) then + return false + end + + if ( response.header['server'] and + not(response.header['server']:match("^Membase Server")) ) then + return false + end + + local status, parsed = json.parse(response.body) + if ( not(status) ) then + return false, "Failed to parse response from server" + end + + result = result or {} + for item in pairs(filter) do + local var, val = "" + for x in item:gmatch("(.-%])") do + var = var .. x + local func = loadstring("return " .. var) + setfenv(func, setmetatable({ parsed=parsed }, {__index = _G})) + + if ( not(func()) ) then + val = nil + break + end + val = func() + end + + if ( val ) then + local name = filter[item].name + val = ( "table" == type(val) and stdnse.strjoin(",", val) or val ) + result[item] = { name = name, value = val } + end + end + return result +end + +action = function(host, port) + + local urls = { "/pools/default/buckets", "/pools" } + + local result + for _, u in ipairs(urls) do + result = cmdReq(host, port, u, result) + end + + local output = tab.new(2) + for _, item in ipairs(order) do + if ( result[item] ) then + tab.addrow(output, result[item].name, result[item].value) + end + end + + return stdnse.format_output(true, tab.dump(output)) +end \ No newline at end of file diff --git a/scripts/script.db b/scripts/script.db index 8076d20a0..10daefeab 100644 --- a/scripts/script.db +++ b/scripts/script.db @@ -163,6 +163,8 @@ Entry { filename = "ldap-search.nse", categories = { "discovery", "safe", } } Entry { filename = "lexmark-config.nse", categories = { "discovery", "safe", } } Entry { filename = "lltd-discovery.nse", categories = { "broadcast", "discovery", "safe", } } Entry { filename = "maxdb-info.nse", categories = { "default", "version", } } +Entry { filename = "membase-brute.nse", categories = { "brute", "intrusive", } } +Entry { filename = "membase-http-info.nse", categories = { "discovery", "safe", } } Entry { filename = "memcached-info.nse", categories = { "discovery", "safe", } } Entry { filename = "metasploit-xmlrpc-brute.nse", categories = { "brute", "intrusive", } } Entry { filename = "modbus-discover.nse", categories = { "discovery", "intrusive", } }