1--- A UPNP library based on code from upnp-info initially written by 2-- Thomas Buchanan. The code was factored out from upnp-info and partly 3-- re-written by Patrik Karlsson <patrik@cqure.net> in order to support 4-- multicast requests. 5-- 6-- The library supports sending UPnP requests and decoding the responses 7-- 8-- The library contains the following classes 9-- * <code>Comm</code> 10-- ** A class that handles communication with the UPnP service 11-- * <code>Helper</code> 12-- ** The helper class wraps the <code>Comm</code> class using functions with a more descriptive name. 13-- * <code>Util</code> 14-- ** The <code>Util</code> class contains a number of static functions mainly used to convert and sort data. 15-- 16-- The following code snippet queries all UPnP services on the network: 17-- <code> 18-- local helper = upnp.Helper:new() 19-- helper:setMulticast(true) 20-- return stdnse.format_output(helper:queryServices()) 21-- </code> 22-- 23-- This next snippet queries a specific host for the same information: 24-- <code> 25-- local helper = upnp.Helper:new(host, port) 26-- return stdnse.format_output(helper:queryServices()) 27-- </code> 28-- 29-- 30-- @author Thomas Buchanan 31-- @author Patrik Karlsson <patrik@cqure.net> 32 33-- 34-- Version 0.1 35-- 36 37local http = require "http" 38local ipOps = require "ipOps" 39local nmap = require "nmap" 40local stdnse = require "stdnse" 41local string = require "string" 42local table = require "table" 43local target = require "target" 44_ENV = stdnse.module("upnp", stdnse.seeall) 45 46Util = { 47 48 --- Compare function used for sorting IP-addresses 49 -- 50 -- @param a table containing first item 51 -- @param b table containing second item 52 -- @return true if a is less than b 53 ipCompare = function(a, b) 54 return ipOps.compare_ip(a, "lt", b) 55 end, 56 57} 58 59Comm = { 60 61 --- Creates a new Comm instance 62 -- 63 -- @param host string containing the host name or ip 64 -- @param port number containing the port to connect to 65 -- @return o a new instance of Comm 66 new = function( self, host, port ) 67 local o = {} 68 setmetatable(o, self) 69 self.__index = self 70 o.host = host 71 o.port = port 72 o.mcast = false 73 return o 74 end, 75 76 --- Connect to the server 77 -- 78 -- @return status true on success, false on failure 79 connect = function( self ) 80 if ( self.mcast ) then 81 self.socket = nmap.new_socket("udp") 82 self.socket:set_timeout(5000) 83 else 84 self.socket = nmap.new_socket() 85 self.socket:set_timeout(5000) 86 local status, err = self.socket:connect(self.host, self.port, "udp" ) 87 if ( not(status) ) then return false, err end 88 end 89 90 return true 91 end, 92 93 --- Send the UPNP discovery request to the server 94 -- 95 -- @return status true on success, false on failure 96 sendRequest = function( self ) 97 98 -- for details about the UPnP message format, see http://upnp.org/resources/documents.asp 99 local payload = 'M-SEARCH * HTTP/1.1\r\n\z 100 Host:239.255.255.250:1900\r\n\z 101 ST:upnp:rootdevice\r\n\z 102 Man:"ssdp:discover"\r\n\z 103 MX:3\r\n\r\n' 104 105 local status, err 106 107 if ( self.mcast ) then 108 status, err = self.socket:sendto( self.host, self.port, payload ) 109 else 110 status, err = self.socket:send( payload ) 111 end 112 113 if ( not(status) ) then return false, err end 114 115 return true 116 end, 117 118 --- Receives one or multiple UPNP responses depending on whether 119 -- <code>setBroadcast</code> was enabled or not. 120 -- 121 -- The function returns the 122 -- status and a response containing: 123 -- * an array (table) of responses if broadcast is used 124 -- * a single response if broadcast is not in use 125 -- * an error message if status was false 126 -- 127 -- @return status true on success, false on failure 128 -- @return result table or string containing results or error message 129 -- on failure. 130 receiveResponse = function( self ) 131 local status, response 132 local result = {} 133 local host_responses = {} 134 135 repeat 136 status, response = self.socket:receive() 137 if ( not(status) and #response == 0 ) then 138 return false, response 139 elseif( not(status) ) then 140 break 141 end 142 143 local status, _, _, ip, _ = self.socket:get_info() 144 if ( not(status) ) then 145 return false, "Failed to retrieve socket information" 146 end 147 if target.ALLOW_NEW_TARGETS then target.add(ip) end 148 149 if ( not(host_responses[ip]) ) then 150 local status, output = self:decodeResponse( response ) 151 if ( not(status) ) then 152 return false, "Failed to decode UPNP response" 153 end 154 output = { output } 155 output.name = ip 156 table.insert( result, output ) 157 host_responses[ip] = true 158 end 159 until ( not( self.mcast ) ) 160 161 if ( self.mcast ) then 162 table.sort(result, Util.ipCompare) 163 return true, result 164 end 165 166 if ( status and #result > 0 ) then 167 return true, result[1] 168 else 169 return false, "Received no responses" 170 end 171 end, 172 173 --- Processes a response from a upnp device 174 -- 175 -- @param response as received over the socket 176 -- @return status boolean true on success, false on failure 177 -- @return response table or string suitable for output or error message if status is false 178 decodeResponse = function( self, response ) 179 local output = {} 180 181 if response ~= nil then 182 -- We should get a response back that has contains one line for the server, and one line for the xml file location 183 -- these match any combination of upper and lower case responses 184 local server, location 185 server = string.match(response, "[Ss][Ee][Rr][Vv][Ee][Rr]:%s*(.-)\r?\n") 186 if server ~= nil then table.insert(output, "Server: " .. server ) end 187 location = string.match(response, "[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:%s*(.-)\r?\n") 188 if location ~= nil then 189 table.insert(output, "Location: " .. location ) 190 191 local v = nmap.verbosity() 192 193 -- the following check can output quite a lot of information, so we require at least one -v flag 194 if v > 0 then 195 local status, result = self:retrieveXML( location ) 196 if status then 197 table.insert(output, result) 198 end 199 end 200 end 201 if #output > 0 then 202 return true, output 203 else 204 return false, "Could not decode response" 205 end 206 end 207 end, 208 209 --- Retrieves the XML file that describes the UPNP device 210 -- 211 -- @param location string containing the location of the XML file from the UPNP response 212 -- @return status boolean true on success, false on failure 213 -- @return response table or string suitable for output or error message if status is false 214 retrieveXML = function( self, location ) 215 local response 216 local options = {} 217 options['header'] = {} 218 options['header']['Accept'] = "text/xml, application/xml, text/html" 219 220 -- if we're in multicast mode, or if the user doesn't want us to override the IP address, 221 -- just use the HTTP library to grab the XML file 222 if ( self.mcast or ( not self.override ) ) then 223 response = http.get_url( location, options ) 224 else 225 -- otherwise, split the location into an IP address, port, and path name for the xml file 226 local xhost, xport, xfile 227 xhost = string.match(location, "http://(.-)/") 228 -- check to see if the host portion of the location specifies a port 229 -- if not, use port 80 as a standard web server port 230 if xhost ~= nil and string.match(xhost, ":") then 231 xport = string.match(xhost, ":(.*)") 232 xhost = string.match(xhost, "(.*):") 233 end 234 235 -- check to see if the IP address returned matches the IP address we scanned 236 if xhost ~= self.host.ip then 237 stdnse.debug1("IP addresses did not match! Found %s, using %s instead.", xhost, self.host.ip) 238 xhost = self.host.ip 239 end 240 241 if xport == nil then 242 xport = 80 243 end 244 245 -- extract the path name from the location field, but strip off the \r that HTTP servers return 246 xfile = string.match(location, "http://.-(/.-)\013") 247 if xfile ~= nil then 248 response = http.get( xhost, xport, xfile, options ) 249 end 250 end 251 252 if response ~= nil then 253 local output = {} 254 255 -- extract information about the webserver that is handling responses for the UPnP system 256 local webserver = response['header']['server'] 257 if webserver ~= nil then table.insert(output, "Webserver: " .. webserver) end 258 259 -- the schema for UPnP includes a number of <device> entries, which can a number of interesting fields 260 for device in string.gmatch(response['body'], "<deviceType>(.-)</UDN>") do 261 local fn, mnf, mdl, nm, ver 262 263 fn = string.match(device, "<friendlyName>(.-)</friendlyName>") 264 mnf = string.match(device, "<manufacturer>(.-)</manufacturer>") 265 mdl = string.match(device, "<modelDescription>(.-)</modelDescription>") 266 nm = string.match(device, "<modelName>(.-)</modelName>") 267 ver = string.match(device, "<modelNumber>(.-)</modelNumber>") 268 269 if fn ~= nil then table.insert(output, "Name: " .. fn) end 270 if mnf ~= nil then table.insert(output,"Manufacturer: " .. mnf) end 271 if mdl ~= nil then table.insert(output,"Model Descr: " .. mdl) end 272 if nm ~= nil then table.insert(output,"Model Name: " .. nm) end 273 if ver ~= nil then table.insert(output,"Model Version: " .. ver) end 274 end 275 return true, output 276 else 277 return false, "Could not retrieve XML file" 278 end 279 end, 280 281 --- Enables or disables multicast support 282 -- 283 -- @param mcast boolean true if multicast is to be used, false otherwise 284 setMulticast = function( self, mcast ) 285 assert( type(mcast)=="boolean", "mcast has to be either true or false") 286 self.mcast = mcast 287 local family = nmap.address_family() 288 self.host = (family=="inet6" and "FF02::C" or "239.255.255.250") 289 self.port = 1900 290 end, 291 292 --- Closes the socket 293 close = function( self ) self.socket:close() end 294 295} 296 297 298Helper = { 299 300 --- Creates a new helper instance 301 -- 302 -- @param host string containing the host name or ip 303 -- @param port number containing the port to connect to 304 -- @return o a new instance of Helper 305 new = function( self, host, port ) 306 local o = {} 307 setmetatable(o, self) 308 self.__index = self 309 o.comm = Comm:new( host, port ) 310 return o 311 end, 312 313 --- Enables or disables multicast support 314 -- 315 -- @param mcast boolean true if multicast is to be used, false otherwise 316 setMulticast = function( self, mcast ) self.comm:setMulticast(mcast) end, 317 318 --- Enables or disables whether the script will override the IP address is the Location URL 319 -- 320 -- @param override boolean true if override is to be enabled, false otherwise 321 setOverride = function( self, override ) 322 assert( type(override)=="boolean", "override has to be either true or false") 323 self.comm.override = override 324 end, 325 326 --- Sends a UPnP queries and collects a single or multiple responses 327 -- 328 -- @return status true on success, false on failure 329 -- @return result table or string containing results or error message 330 -- on failure. 331 queryServices = function( self ) 332 local status, err = self.comm:connect() 333 local response 334 335 if ( not(status) ) then return false, err end 336 337 status, err = self.comm:sendRequest() 338 if ( not(status) ) then return false, err end 339 340 status, response = self.comm:receiveResponse() 341 self.comm:close() 342 343 return status, response 344 end, 345 346} 347 348return _ENV; 349