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