summaryrefslogtreecommitdiffstats
path: root/nselib/upnp.lua
diff options
context:
space:
mode:
Diffstat (limited to 'nselib/upnp.lua')
-rw-r--r--nselib/upnp.lua348
1 files changed, 348 insertions, 0 deletions
diff --git a/nselib/upnp.lua b/nselib/upnp.lua
new file mode 100644
index 0000000..0edbb6b
--- /dev/null
+++ b/nselib/upnp.lua
@@ -0,0 +1,348 @@
+--- A UPNP library based on code from upnp-info initially written by
+-- Thomas Buchanan. The code was factored out from upnp-info and partly
+-- re-written by Patrik Karlsson <patrik@cqure.net> in order to support
+-- multicast requests.
+--
+-- The library supports sending UPnP requests and decoding the responses
+--
+-- The library contains the following classes
+-- * <code>Comm</code>
+-- ** A class that handles communication with the UPnP service
+-- * <code>Helper</code>
+-- ** The helper class wraps the <code>Comm</code> class using functions with a more descriptive name.
+-- * <code>Util</code>
+-- ** The <code>Util</code> class contains a number of static functions mainly used to convert and sort data.
+--
+-- The following code snippet queries all UPnP services on the network:
+-- <code>
+-- local helper = upnp.Helper:new()
+-- helper:setMulticast(true)
+-- return stdnse.format_output(helper:queryServices())
+-- </code>
+--
+-- This next snippet queries a specific host for the same information:
+-- <code>
+-- local helper = upnp.Helper:new(host, port)
+-- return stdnse.format_output(helper:queryServices())
+-- </code>
+--
+--
+-- @author Thomas Buchanan
+-- @author Patrik Karlsson <patrik@cqure.net>
+
+--
+-- Version 0.1
+--
+
+local http = require "http"
+local ipOps = require "ipOps"
+local nmap = require "nmap"
+local stdnse = require "stdnse"
+local string = require "string"
+local table = require "table"
+local target = require "target"
+_ENV = stdnse.module("upnp", stdnse.seeall)
+
+Util = {
+
+ --- Compare function used for sorting IP-addresses
+ --
+ -- @param a table containing first item
+ -- @param b table containing second item
+ -- @return true if a is less than b
+ ipCompare = function(a, b)
+ return ipOps.compare_ip(a, "lt", b)
+ end,
+
+}
+
+Comm = {
+
+ --- Creates a new Comm instance
+ --
+ -- @param host string containing the host name or ip
+ -- @param port number containing the port to connect to
+ -- @return o a new instance of Comm
+ new = function( self, host, port )
+ local o = {}
+ setmetatable(o, self)
+ self.__index = self
+ o.host = host
+ o.port = port
+ o.mcast = false
+ return o
+ end,
+
+ --- Connect to the server
+ --
+ -- @return status true on success, false on failure
+ connect = function( self )
+ if ( self.mcast ) then
+ self.socket = nmap.new_socket("udp")
+ self.socket:set_timeout(5000)
+ else
+ self.socket = nmap.new_socket()
+ self.socket:set_timeout(5000)
+ local status, err = self.socket:connect(self.host, self.port, "udp" )
+ if ( not(status) ) then return false, err end
+ end
+
+ return true
+ end,
+
+ --- Send the UPNP discovery request to the server
+ --
+ -- @return status true on success, false on failure
+ sendRequest = function( self )
+
+ -- for details about the UPnP message format, see http://upnp.org/resources/documents.asp
+ local payload = 'M-SEARCH * HTTP/1.1\r\n\z
+ Host:239.255.255.250:1900\r\n\z
+ ST:upnp:rootdevice\r\n\z
+ Man:"ssdp:discover"\r\n\z
+ MX:3\r\n\r\n'
+
+ local status, err
+
+ if ( self.mcast ) then
+ status, err = self.socket:sendto( self.host, self.port, payload )
+ else
+ status, err = self.socket:send( payload )
+ end
+
+ if ( not(status) ) then return false, err end
+
+ return true
+ end,
+
+ --- Receives one or multiple UPNP responses depending on whether
+ -- <code>setBroadcast</code> was enabled or not.
+ --
+ -- The function returns the
+ -- status and a response containing:
+ -- * an array (table) of responses if broadcast is used
+ -- * a single response if broadcast is not in use
+ -- * an error message if status was false
+ --
+ -- @return status true on success, false on failure
+ -- @return result table or string containing results or error message
+ -- on failure.
+ receiveResponse = function( self )
+ local status, response
+ local result = {}
+ local host_responses = {}
+
+ repeat
+ status, response = self.socket:receive()
+ if ( not(status) and #response == 0 ) then
+ return false, response
+ elseif( not(status) ) then
+ break
+ end
+
+ local status, _, _, ip, _ = self.socket:get_info()
+ if ( not(status) ) then
+ return false, "Failed to retrieve socket information"
+ end
+ if target.ALLOW_NEW_TARGETS then target.add(ip) end
+
+ if ( not(host_responses[ip]) ) then
+ local status, output = self:decodeResponse( response )
+ if ( not(status) ) then
+ return false, "Failed to decode UPNP response"
+ end
+ output = { output }
+ output.name = ip
+ table.insert( result, output )
+ host_responses[ip] = true
+ end
+ until ( not( self.mcast ) )
+
+ if ( self.mcast ) then
+ table.sort(result, Util.ipCompare)
+ return true, result
+ end
+
+ if ( status and #result > 0 ) then
+ return true, result[1]
+ else
+ return false, "Received no responses"
+ end
+ end,
+
+ --- Processes a response from a upnp device
+ --
+ -- @param response as received over the socket
+ -- @return status boolean true on success, false on failure
+ -- @return response table or string suitable for output or error message if status is false
+ decodeResponse = function( self, response )
+ local output = {}
+
+ if response ~= nil then
+ -- We should get a response back that has contains one line for the server, and one line for the xml file location
+ -- these match any combination of upper and lower case responses
+ local server, location
+ server = string.match(response, "[Ss][Ee][Rr][Vv][Ee][Rr]:%s*(.-)\r?\n")
+ if server ~= nil then table.insert(output, "Server: " .. server ) end
+ location = string.match(response, "[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:%s*(.-)\r?\n")
+ if location ~= nil then
+ table.insert(output, "Location: " .. location )
+
+ local v = nmap.verbosity()
+
+ -- the following check can output quite a lot of information, so we require at least one -v flag
+ if v > 0 then
+ local status, result = self:retrieveXML( location )
+ if status then
+ table.insert(output, result)
+ end
+ end
+ end
+ if #output > 0 then
+ return true, output
+ else
+ return false, "Could not decode response"
+ end
+ end
+ end,
+
+ --- Retrieves the XML file that describes the UPNP device
+ --
+ -- @param location string containing the location of the XML file from the UPNP response
+ -- @return status boolean true on success, false on failure
+ -- @return response table or string suitable for output or error message if status is false
+ retrieveXML = function( self, location )
+ local response
+ local options = {}
+ options['header'] = {}
+ options['header']['Accept'] = "text/xml, application/xml, text/html"
+
+ -- if we're in multicast mode, or if the user doesn't want us to override the IP address,
+ -- just use the HTTP library to grab the XML file
+ if ( self.mcast or ( not self.override ) ) then
+ response = http.get_url( location, options )
+ else
+ -- otherwise, split the location into an IP address, port, and path name for the xml file
+ local xhost, xport, xfile
+ xhost = string.match(location, "http://(.-)/")
+ -- check to see if the host portion of the location specifies a port
+ -- if not, use port 80 as a standard web server port
+ if xhost ~= nil and string.match(xhost, ":") then
+ xport = string.match(xhost, ":(.*)")
+ xhost = string.match(xhost, "(.*):")
+ end
+
+ -- check to see if the IP address returned matches the IP address we scanned
+ if xhost ~= self.host.ip then
+ stdnse.debug1("IP addresses did not match! Found %s, using %s instead.", xhost, self.host.ip)
+ xhost = self.host.ip
+ end
+
+ if xport == nil then
+ xport = 80
+ end
+
+ -- extract the path name from the location field, but strip off the \r that HTTP servers return
+ xfile = string.match(location, "http://.-(/.-)\013")
+ if xfile ~= nil then
+ response = http.get( xhost, xport, xfile, options )
+ end
+ end
+
+ if response ~= nil then
+ local output = {}
+
+ -- extract information about the webserver that is handling responses for the UPnP system
+ local webserver = response['header']['server']
+ if webserver ~= nil then table.insert(output, "Webserver: " .. webserver) end
+
+ -- the schema for UPnP includes a number of <device> entries, which can a number of interesting fields
+ for device in string.gmatch(response['body'], "<deviceType>(.-)</UDN>") do
+ local fn, mnf, mdl, nm, ver
+
+ fn = string.match(device, "<friendlyName>(.-)</friendlyName>")
+ mnf = string.match(device, "<manufacturer>(.-)</manufacturer>")
+ mdl = string.match(device, "<modelDescription>(.-)</modelDescription>")
+ nm = string.match(device, "<modelName>(.-)</modelName>")
+ ver = string.match(device, "<modelNumber>(.-)</modelNumber>")
+
+ if fn ~= nil then table.insert(output, "Name: " .. fn) end
+ if mnf ~= nil then table.insert(output,"Manufacturer: " .. mnf) end
+ if mdl ~= nil then table.insert(output,"Model Descr: " .. mdl) end
+ if nm ~= nil then table.insert(output,"Model Name: " .. nm) end
+ if ver ~= nil then table.insert(output,"Model Version: " .. ver) end
+ end
+ return true, output
+ else
+ return false, "Could not retrieve XML file"
+ end
+ end,
+
+ --- Enables or disables multicast support
+ --
+ -- @param mcast boolean true if multicast is to be used, false otherwise
+ setMulticast = function( self, mcast )
+ assert( type(mcast)=="boolean", "mcast has to be either true or false")
+ self.mcast = mcast
+ local family = nmap.address_family()
+ self.host = (family=="inet6" and "FF02::C" or "239.255.255.250")
+ self.port = 1900
+ end,
+
+ --- Closes the socket
+ close = function( self ) self.socket:close() end
+
+}
+
+
+Helper = {
+
+ --- Creates a new helper instance
+ --
+ -- @param host string containing the host name or ip
+ -- @param port number containing the port to connect to
+ -- @return o a new instance of Helper
+ new = function( self, host, port )
+ local o = {}
+ setmetatable(o, self)
+ self.__index = self
+ o.comm = Comm:new( host, port )
+ return o
+ end,
+
+ --- Enables or disables multicast support
+ --
+ -- @param mcast boolean true if multicast is to be used, false otherwise
+ setMulticast = function( self, mcast ) self.comm:setMulticast(mcast) end,
+
+ --- Enables or disables whether the script will override the IP address is the Location URL
+ --
+ -- @param override boolean true if override is to be enabled, false otherwise
+ setOverride = function( self, override )
+ assert( type(override)=="boolean", "override has to be either true or false")
+ self.comm.override = override
+ end,
+
+ --- Sends a UPnP queries and collects a single or multiple responses
+ --
+ -- @return status true on success, false on failure
+ -- @return result table or string containing results or error message
+ -- on failure.
+ queryServices = function( self )
+ local status, err = self.comm:connect()
+ local response
+
+ if ( not(status) ) then return false, err end
+
+ status, err = self.comm:sendRequest()
+ if ( not(status) ) then return false, err end
+
+ status, response = self.comm:receiveResponse()
+ self.comm:close()
+
+ return status, response
+ end,
+
+}
+
+return _ENV;