diff --git a/CHANGELOG b/CHANGELOG index 0ceb07f2c..c3eed6fde 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,8 @@ #Nmap Changelog ($Id$); -*-text-*- +o [NSE][GH#1457] New script, ubiquiti-discovery, which extracts information from + the Ubiquiti Discovery service and assists version detection. [Tom Sellers] + o [GH#1454] New service probes and match lines for v1 and v2 of the Ubiquiti Discovery protocol. Devices often leave the related service open and it exposes significant amounts of information as well as the risk of being used diff --git a/scripts/script.db b/scripts/script.db index 5d2840360..074c3a94f 100644 --- a/scripts/script.db +++ b/scripts/script.db @@ -567,6 +567,7 @@ Entry { filename = "tor-consensus-checker.nse", categories = { "external", "safe Entry { filename = "traceroute-geolocation.nse", categories = { "discovery", "external", "safe", } } Entry { filename = "tso-brute.nse", categories = { "intrusive", } } Entry { filename = "tso-enum.nse", categories = { "brute", "intrusive", } } +Entry { filename = "ubiquiti-discovery.nse", categories = { "default", "discovery", "safe", "version", } } Entry { filename = "unittest.nse", categories = { "safe", } } Entry { filename = "unusual-port.nse", categories = { "safe", } } Entry { filename = "upnp-info.nse", categories = { "default", "discovery", "safe", } } diff --git a/scripts/ubiquiti-discovery.nse b/scripts/ubiquiti-discovery.nse new file mode 100644 index 000000000..d5589afe0 --- /dev/null +++ b/scripts/ubiquiti-discovery.nse @@ -0,0 +1,375 @@ +local nmap = require "nmap" +local shortport = require "shortport" +local stdnse = require "stdnse" +local string = require "string" +local ipOps = require "ipOps" +local tableaux = require "tableaux" + +description = [[ +Extracts information from Ubiquiti networking devices. + +This script leverages Ubiquiti's Discovery Service which is enabled by default +on many products. It will attempt to leverage version 1 of the protocol first +and, if that fails, attempt version 2. +]] + +author = {"Tom Sellers"} + +license = "Same as Nmap--See https://nmap.org/book/man-legal.html" + +categories = {"default", "discovery", "version", "safe"} + +--- +-- @usage +-- nmap -sU -p 10001 --script ubiquiti-discovery.nse +-- +--- +-- @output +-- PORT STATE SERVICE VERSION +-- 10001/udp open ubiquiti-discovery Ubiquiti Discovery Service (v1 protocol, ER-X software ver. v1.10.7) +-- | ubiquiti-discovery: +-- | protocol: v1 +-- | uptime_seconds: 113144 +-- | uptime: 1 days 07:25:44 +-- | hostname: ubnt-router +-- | product: ER-X +-- | firmware: EdgeRouter.ER-e50.v1.10.7.5127989.181001.1227 +-- | version: v1.10.7 +-- | interface_to_ip: +-- | 80:2a:a8:ae:f1:63: +-- | 192.168.0.1 +-- | 172.25.16.1 +-- | 80:2a:a8:ae:f1:5e: +-- | 55.55.55.10 +-- | 55.55.55.11 +-- | 55.55.55.12 +-- | mac_addresses: +-- | 80:2a:a8:ae:f1:63 +-- |_ 80:2a:a8:ae:f1:5e +-- +-- PORT STATE SERVICE REASON VERSION +-- 10001/udp open ubiquiti-discovery udp-response Ubiquiti Discovery Service (v2 protocol, UCK-v2 software ver. 5.9.29) +-- | ubiquiti-discovery: +-- | protocol: v2 +-- | firmware: UCK.mtk7623.v0.12.0.29a26c9.181001.1444 +-- | version: 5.9.29 +-- | model: UCK-v2 +-- | config_status: managed/adopted +-- | interface_to_ip: +-- | 78:8a:20:21:ae:7b: +-- | 192.168.0.30 +-- | mac_addresses: +-- |_ 78:8a:20:21:ae:7b +-- +--@xmloutput +-- v1 +-- 113144 +-- 1 days 07:25:44 +-- ubnt-router +-- ER-X +-- EdgeRouter.ER-e50.v1.10.7.5127989.181001.1227 +-- v1.10.7 +-- +--
+-- 192.168.0.1 +-- 172.25.16.1 +--
+-- +-- 55.55.55.10 +-- 55.55.55.11 +-- 55.55.55.12 +--
+-- +-- +-- 80:2a:a8:ae:f1:63 +-- 80:2a:a8:ae:f1:5e +--
+-- +-- v2 +-- 5.9.29 +-- UCK-v2 +-- managed/adopted +-- +--
+-- 192.168.0.30 +--
+-- +-- +-- 78:8a:20:21:ae:7b +--
+-- + + +portrule = shortport.port_or_service(10001, "ubiquiti-discovery", "udp", {"open", "open|filtered"}) + +local PROBE_V1 = string.pack("BB I2", + 0x01, 0x00, -- version, command + 0x00, 0x00 -- length +) + +local PROBE_V2 = string.pack("BB I2", + 0x02, 0x08, -- version, command + 0x00, 0x00 -- length +) +--- +-- Converts uptime seconds into a human readable string +-- +-- E.g. "86518" -> "1 days 00:01:58" +-- +-- @param uptime number of seconds of uptime +-- @return formatted uptime string (days, hours, minutes, seconds) +local function uptime_str(uptime) + if not uptime then + return nil + end + + local d = uptime // 86400 + local h = uptime // 3600 % 24 + local m = uptime // 60 % 60 + local s = uptime % 60 + + return string.format("%d days %02d:%02d:%02d", d, h, m, s) +end + +--- +-- Parses the full payload of a discovery response +-- +-- There are different fields for v1 and v2 of the protocol but as far as I can +-- tell they don't conflict so we should be safe parsing them both with the same +-- code as long as we sanity check the version and cmd. +-- +-- @param payload containing response +-- @return output_table containing results or nil +local function parse_discovery_response(response) + + local info = stdnse.output_table() + local unique_macs = {} + local mac_ip_table = {} + + if #response < 4 then + return nil + end + + -- Verify header and cmd + if response:byte(1) == 0x01 then + if response:byte(2) ~= 0x00 then + return nil + end + info.protocol = "v1" + elseif response:byte(1) == 0x02 then + -- Known values for cmd are 6,9, and 11 + if response:byte(2) ~= 0x06 and response:byte(2) ~= 0x09 + and response:byte(2) ~= 0x0b then + + return nil + end + info.protocol = "v2" + else + return nil + end + + local config_len = string.unpack(">I2", response, 3) + + -- Do the lengths check out? + if ( not ( #response == config_len + 4) ) then + return nil + end + + -- Response looks legit, start extraction + local config_data = string.sub(response, 5, #response) + + local tlv_type, tlv_len, tlv_value, pos + local mac, mac_raw, ip, ip_raw + pos = 1 + + while pos <= #config_data - 2 do + tlv_type = config_data:byte(pos) + tlv_len = string.unpack(">I2", config_data, pos +1) + pos = pos + 3 + + -- Sanity check that TLV len isn't larger than the data we have left. + -- Has been observed in the wild against protocols just similar enough to + -- make it here. + if tlv_len > (#config_data - pos + 1) then + return nil + end + + tlv_value = config_data:sub(pos, pos + tlv_len - 1) + + -- MAC address + if tlv_type == 0x01 then + mac_raw = tlv_value:sub(1, 6) + mac = stdnse.format_mac(mac_raw) + unique_macs[mac] = true + + -- MAC and IP address + elseif tlv_type == 0x02 then + mac_raw = tlv_value:sub(1, 6) + mac = stdnse.format_mac(mac_raw) + unique_macs[mac] = true + + ip_raw = tlv_value:sub(7, tlv_len) + ip = ipOps.str_to_ip(ip_raw) + if mac_ip_table[mac] == nil then + mac_ip_table[mac] = {} + end + mac_ip_table[mac][ip] = true + + elseif tlv_type == 0x03 then + info.firmware = tlv_value + + local human_version = tlv_value:match("%.(v%d+%.%d+%.%d+)") + if human_version then + info.version = human_version + end + + elseif tlv_type == 0x0a then + if tlv_len == 4 then + local uptime_raw = string.unpack(">I4", tlv_value) + info.uptime_seconds = uptime_raw + info.uptime = uptime_str(uptime_raw) + end + + elseif tlv_type == 0x0b then + info.hostname = tlv_value + + elseif tlv_type == 0x0c then + info.product = tlv_value + + elseif tlv_type == 0x0d then + info.essid = tlv_value + + elseif tlv_type == 0x0f then + -- value also includes bit shifted flag for http vs https but we + -- are ignoring it here. + if tlv_len == 4 then + tlv_value = string.unpack(">I4", tlv_value) + info.mgmt_port = tlv_value & 0xffff + end + + -- model v1 protocol + elseif tlv_type == 0x14 then + info.model = tlv_value + + -- model v2 protocol + elseif tlv_type == 0x15 then + info.model = tlv_value + + elseif tlv_type == 0x16 then + info.version = tlv_value + + elseif tlv_type == 0x17 then + local is_default + if tlv_len == 4 then + is_default = string.unpack("I4", tlv_value) + elseif tlv_len == 1 then + is_default = string.unpack("I1", tlv_value) + end + + if is_default == 1 then + info.config_status = "default/unmanaged" + elseif is_default == 0 then + info.config_status = "managed/adopted" + end + + else + + -- Other known or observed values + -- Some have been seen in code but not observed to test while others have + -- been observed but we don't know how to decode them. + + -- 0x06 - username + -- 0x07 - salt + -- 0x08 - random challenge + -- 0x09 - challenge + -- 0x0e - WMODE - state of config? length 1 value 03 value 02 + -- 0x10 - length 2 value e4b2 value e8a5 e815 + -- 0x12 - SEQ - lenth 4 + -- 0x13 - Source Mac, unused? + -- 0x18 - length 4 and 4 nulls, or length 1 and 0xff + -- 0xff - length 2 value e835 + + stdnse.debug1("Unknown tag: %s - length: %d value: %s", + stdnse.tohex(tlv_type), tlv_len, + stdnse.tohex(tlv_value)) + end + + pos = pos + tlv_len + end + + if next(mac_ip_table) ~= nil then + info.interface_to_ip = {} + for k, _ in pairs(mac_ip_table) do + info.interface_to_ip[k] = tableaux.keys(mac_ip_table[k]) + end + end + + if next(unique_macs) ~= nil then + info.mac_addresses = tableaux.keys(unique_macs) + end + + return info +end + +--- +-- Send probe and handle housekeeping +-- +-- @param host A host table for the target host +-- @param port A port table for the target port +-- @return (status, result) If status is true, result the target's response to +-- a probe. If status is false, result is an error message. +local function send_probe(host, port, probe) + + local socket = nmap.new_socket() + socket:set_timeout(5000) + + local try = nmap.new_try(function() socket:close() end) + + try( socket:connect(host, port) ) + try( socket:send(probe) ) + + local stat, resp = socket:receive_bytes(4) + socket:close() + + return stat, resp +end + +function action(host, port) + + local status, response = send_probe(host, port, PROBE_V1) + + if not status then + status, response = send_probe(host, port, PROBE_V2) + + if not status then + return nil + end + end + + nmap.set_port_state(host, port, "open") + + local result = parse_discovery_response(response) + + if not result then + return nil + end + + port.version.name = "ubiquiti-discovery" + port.version.product = "Ubiquiti Discovery Service" + + local extrainfo = result.protocol .. " protocol" + if result.product then + extrainfo = extrainfo .. ", " .. result.product + elseif result.model then + extrainfo = extrainfo .. ", " .. result.model + end + + if result.version then + port.version.extrainfo = extrainfo .. " software ver. " .. result.version + end + + port.version.ostype = "Linux" + nmap.set_port_version(host, port, "hardmatched") + + return result +end