diff --git a/nselib/codesys3.lua b/nselib/codesys3.lua new file mode 100644 index 000000000..2c6223ca0 --- /dev/null +++ b/nselib/codesys3.lua @@ -0,0 +1,246 @@ +local math = require "math" +local unicode = require "unicode" +local ipOps = require "ipOps" +local stdnse = require "stdnse" +local string = require "string" +_ENV = stdnse.module("codesys3", stdnse.seeall); + +CodesysV3 = { + ServiceIDs = { + AddressServiceRequest = 0x01, + AddressServiceResponse = 0x02, + NameServiceRequest = 0x03, + NameServiceResponse = 0x04, + ChannelService = 0x40, + }, + + NameServicePackageTypes = { + ResolveName = 0xc201, + ResolveAddr = 0xc202, + ResolveGateway = 0xc203, + Identification = 0xc280, + }, + + -- The NameServiceRequest class contains functions to build a Codesys v3 Name Service Request as a broadcast + NameServiceRequest = { + + -- Creates a new NameServiceRequest instance + -- + -- @param portindex The codesys UDP port index + -- @param interface The interface this broadcast will be sent on - needed for codesys address calculation + -- @return o instance of request + new = function(self, portindex, address, netmask_cidr) + local o = { + --- data needed for dynamic calculations of fields + portindex = portindex, + address = address, + netmask_cidr = netmask_cidr, + + --- static header + magic = 0xc5, + hop_count = 0x0f, + header_length = 0x4, -- in 16 bit words + + priority = 0x01, + signal = 0, + address_type = 0, -- ABSOLUTE address for a broadcast + data_length = 0, + + service_id = CodesysV3.ServiceIDs.NameServiceRequest, + message_id = 0x00, + broadcast_id = math.random(0xffff), + + --- payload + package_type = CodesysV3.NameServicePackageTypes.ResolveAddr, + version = 0x0400, + request_id = math.random(math.pow(2, 32) - 1), + } + setmetatable(o, self) + self.__index = self + return o + end, + + -- Converts the whole request to a string + __tostring = function(self) + -- Build the address information - this is specific to the UDP broadcast packets in the Codesys V3 protocol + + -- N bits for the address in our subnet, 2 bits for the port index, rounded up to a multiple of 16 bits + local local_bits = 32 - self.netmask_cidr + local port_bits = 2 + local codesys_net_addr_words = ((local_bits + port_bits) + 15) // 16 + + -- We are broadcasting, so we don't have a receiver address, only our sender address + -- From here on all the lengths for the address fields are handled in 16 bit words + local sender_address_length = codesys_net_addr_words + local receiver_address_length = 0 + + -- Sanity check the address length fields - they need to be even (because they are in multiples of 16 bits) and need to fit in a 4 bit field + assert(sender_address_length <= 0xF, "Sender address length too big to fit in the field") + assert(receiver_address_length <= 0xF, "Receiver address length too big to fit in the field") + + local address_lengths = ((sender_address_length & 0xF) << 4) | (receiver_address_length & 0xF) + + -- mask off the local part of the address and add the port index in the front + local my_address = ipOps.todword(self.address) + local sender_address = ((self.portindex & 3) << local_bits) | (my_address & ((1 << local_bits) - 1)) + + -- Build a more or less static header for the packet including the length field for the sender and receiver address + local packet_header = string.pack(">BBBBBBH", + self.magic, + ((self.hop_count & 0x1F) << 3) | (self.header_length & 7), + ((self.priority & 3) << 6) | ((self.signal & 1) << 5) | ((self.address_type & 1) << 4) | (self.data_length & 0xF), + self.service_id, + self.message_id, + address_lengths, + self.broadcast_id + ) + + -- add the sender address to the packet + for i=0, sender_address_length-1 do + local j = sender_address_length - 1 - i; + packet_header = packet_header .. string.pack(">H", (sender_address >> (16 * j)) & 0xFFFF) + end + + -- pad the packet if necessary + if ( #packet_header % 4 ~= 0 ) then + packet_header = packet_header .. string.rep("\x00", #packet_header % 4) + end + + stdnse.debug1("Sending codesys v3 name service broadcast packet with header: %s", stdnse.tohex(packet_header)) + + -- append the name service request packet payload + local packet_payload = string.pack("BBBBBB", data) + + -- parse hopinfo field + o.hop_count = hopinfo >> 3 + o.header_length = hopinfo & 7 + + -- parse packetinfo field + o.priority = packetinfo >> 6 + o.signal = (packetinfo >> 5) & 1 + o.address_type = (packetinfo >> 4) & 1 + o.data_length = packetinfo & 0xf + + -- sanity check a few fields to determine if this is a name service broadcast response + if o.magic ~= 0xc5 or o.service_id ~= CodesysV3.ServiceIDs.NameServiceResponse then + return false, "Response has the wring magic value or isn't a Name Service Response" + end + + -- skip ahead to the address fields by skipping the length of the header + pos = o.header_length * 2 + + -- skip over the address fields using the address length fields in the header + pos = pos + (address_lengths & 0xf) * 2 + pos = pos + (address_lengths >> 4) * 2 + + -- skip the padding bytes if necessary + if pos % 4 ~= 0 then + pos = pos + (pos % 4) + end + + -- lua strings are 1-indexed, so adjust the position pointer... + pos = pos + 1 + + -- at this point we are at the packet payload + + -- parse the payload header + o.package_type, o.version, o.request_id, pos = string.unpack(" +-- + +author = "Andreas Galauner" +license = "Same as Nmap--See https://nmap.org/book/man-legal.html" +categories = {"broadcast", "safe"} + + +prerule = function() + if nmap.address_family() ~= 'inet' then + stdnse.debug1("is IPv4 compatible only.") + return false + end + + if not nmap.is_privileged() then + stdnse.verbose1("Not running due to lack of privileges.") + return false + end + + return true +end + +--- +-- Gets a list of available interfaces based on link and up filters +-- Interfaces are only added if they've got an ipv4 address +-- +-- @param link string containing the link type to filter +-- @param up string containing the interface status to filter +-- @return result table containing tables of interfaces +-- each interface table has the following fields: +-- name containing the device name +-- address containing the device address +-- netmask containing the device netmask +-- broadcast containing the device broadcast address +getInterfaces = function(link, up) + local interfaces, err = nmap.list_interfaces() + local result = {} + if ( not(err) ) then + for _, iface in ipairs(interfaces) do + if ( iface.link == link and + iface.up == up and + iface.address ) then + + -- exclude ipv6 addresses for now + if ( not(iface.address:match(":")) ) then + table.insert(result, { + name = iface.device, + address = iface.address, + netmask = iface.netmask, + broadcast = iface.broadcast + }) + end + end + end + end + return result +end + +local function codesys_listener(sock, iface, timeout, result) + local condvar = nmap.condvar(result) + + local start_time = nmap.clock_ms() + local now = start_time + while( now - start_time < timeout ) do + sock:set_timeout(timeout - (now - start_time)) + local status, _, _, data = sock:pcap_receive() + + if ( status ) then + local p = packet.Packet:new( data, #data ) + if ( p and p.udp_dport ) then + local data = data:sub(p.udp_offset + 9) + local status, response = codesys3.CodesysV3.NameServiceResponse:new(data) + if ( status ) then + response.iface = iface.name + response.ip = p.ip_src + table.insert(result, response) + end + end + end + now = nmap.clock_ms() + end + sock:close() + condvar "signal" +end + +local function fail (err) return stdnse.format_output(false, err) end + +action = function() + local timeout = stdnse.parse_timespec(stdnse.get_script_args('broadcast-codesys-discover.timeout')) + timeout = (timeout or 3) * 1000 + + local iface = nmap.get_interface() + local interfaces = {} + + -- was an interface supplied using the -e argument? + if ( iface ) then + local iinfo, err = nmap.get_interface_info(iface) + + if ( not(iinfo.address) ) then + return fail("The IP address of the interface could not be determined") + end + + interfaces = { { name = iface, address = iinfo.address, netmask = iinfo.netmask, broadcast = iinfo.broadcast } } + else + -- no interface was supplied, attempt autodiscovery + interfaces = getInterfaces("ethernet", "up") + end + + -- make sure we have at least one interface to run discovery on + if ( #interfaces == 0 ) then + return fail("Could not determine any valid interfaces, try to set one explicitly using -e") + end + + stdnse.debug1("Determined the following interfaces to run discovery on:") + for _, iface in ipairs(interfaces) do + stdnse.debug1("%s: IP: %s - Netmask: %s - Broadcast: %s", iface.name, iface.address, iface.netmask, iface.broadcast) + end + + local result = {} + local threads = {} + local condvar = nmap.condvar(result) + + -- start a listening thread for each interface + for _, iface in ipairs(interfaces) do + local sock, co + sock = nmap.new_socket() + sock:pcap_open(iface.name, 1500, false, "ip && udp && port 1743") + co, info = stdnse.new_thread(codesys_listener, sock, iface, timeout, result) + threads[co] = info + end + + -- Send out probes on all interfaces + for _, iface in ipairs(interfaces) do + local source_port = 1743 + + local socket = nmap.new_socket("udp") + socket:set_timeout(timeout) + + -- Send name service requests to all 4 codesys UDP ports + for i=0,3 do + local destination_port = 1740+i + local cs = codesys3.CodesysV3.NameServiceRequest:new(3, iface.address, iface.netmask) + local packet = tostring(cs) + + socket:bind(iface.address, source_port) + local status, err = socket:sendto(iface.broadcast, destination_port, packet) + + if ( not(status) ) then + return false, string.format("Failed to send broadcast packet to UDP port %d", destination_port) + end + end + end + + -- wait until all threads are done + repeat + for thread, info in pairs(threads) do + if info() == "dead" then threads[thread] = nil end + end + if ( next(threads) ) then + condvar "wait" + end + until next(threads) == nil + + if not next(result) then + return nil + end + + -- Display the results + local response = stdnse.output_table() + local ips = stdnse.output_table() + + for _, r in ipairs(result) do + local out = stdnse.output_table() + + out["interface"] = r.iface + out["targetVendor"] = r.vendorName + out["targetName"] = r.deviceName + out["deviceName"] = r.nodeName + out["targetID"] = string.format("0x%x", r.targetId) + out["targetType"] = string.format("0x%x", r.targetType) + out["targetVersion"] = codesys3.version_to_str(r.targetVersion) + + response[r.ip] = out + ips[#ips+1] = r.ip + end + + -- sort by IP address and reorder the results to get a stable output between different runs + ipOps.ip_sort(ips) + for _, ip in ipairs(ips) do + local tmp = response[ip] + response[ip] = nil + response[ip] = tmp + end + + return response +end diff --git a/scripts/codesys-plc-info.nse b/scripts/codesys-plc-info.nse new file mode 100644 index 000000000..36ea4347e --- /dev/null +++ b/scripts/codesys-plc-info.nse @@ -0,0 +1,148 @@ +local shortport = require "shortport" +local stdnse = require "stdnse" +local nmap = require "nmap" +local string = require "string" +local table = require "table" +local codesys3 = require "codesys3" + +description = [[ +Identifies a Codesys V3 PLC on the LAN by sending a Codesys V3 Device Discovery request. +]] + +--- +-- @usage +-- nmap --script codesys-plc-info +-- +-- @output +-- 1740/udp open|filtered encore +-- | codesys-plc-info: +-- | targetVendor: WAGO +-- | targetName: WAGO 750-8206 PFC200 2ETH RS CAN DPS +-- | deviceName: PFC200-438F4C +-- | targetID: 0x10061204 +-- | targetType: 0x1000 +-- |_ targetVersion: 5.15.4.0 +-- +-- @args codesys-plc-info.timeout timespec defining how long to wait for a +-- response. (default 3s) + +-- +-- Version 0.1 +-- Created 23/06/2021 - v0.1 - created by Andreas Galauner +-- + +author = "Andreas Galauner" +license = "Same as Nmap--See https://nmap.org/book/man-legal.html" +categories = {"default", "discovery", "safe"} +portrule = shortport.portnumber({1740,1741,1742,1743}, "udp") + +--- Returns the network interface used to send packets to a target host. +-- @param target host to which the interface is used. +-- @return interface Network interface used for target host. +local getInterface = function(target) + -- Check if we've been called by a host discovery scan + -- if this is the case, host.interface will be set and we will use this + if target.interface then + stdnse.debug1("Target interface has been passed to us from nmap - using %s", target.interface) + local interface, err = nmap.get_interface_info(target.interface) + + if err then + return fail(string.format("Couldn't get interface info for %s", target.interface)) + end + + stdnse.debug1("Using interface %s", interface.shortname) + return interface + end + + -- If not, create dummy UDP connection to get interface + stdnse.debug1("Target interface has NOT been passed to us from nmap - trying to detect the proper interface using the target") + + local sock = nmap.new_socket() + local status, err = sock:connect(target, "12345", "udp") + if not status then + stdnse.verbose1("%s", err) + return + end + + local status, address = sock:get_info() + if not status then + stdnse.verbose1("%s", err) + return + end + + for _, interface in pairs(nmap.list_interfaces()) do + if interface.address == address then + stdnse.debug1("Detected interface %s with address %s", interface.shortname, address) + return interface + end + end +end + +local function fail (err) return stdnse.format_output(false, err) end + +action = function(host, port) + local timeout = stdnse.parse_timespec(stdnse.get_script_args(SCRIPT_NAME .. ".timeout")) + timeout = (timeout or 3) * 1000 + + -- Try to determine the interface we can reach our target with. We need this for the name service request + local iface = getInterface(host) + if not iface then + return fail(string.format("Couldn't get interface for target IP address %s", host)) + end + + local socket = nmap.new_socket("udp") + socket:set_timeout(timeout) + + -- Bind to source port 1740, because we are using port index 0 in the name service request + -- We need to send the packet from this source port, otherwise the PLC doesn't seem to reply + local status, err = socket:bind(iface.address, 1740) + if not status then + return fail(string.format("Bind failed: %s", err)) + end + + -- Connect the UDP socket to the target to be able to use send/recv + local status, err = socket:connect(host, port) + if not status then + return fail(string.format("Connect failed: %s", err)) + end + + -- Generate the name service request to send and send it out + local cs = codesys3.CodesysV3.NameServiceRequest:new(0, iface.address, iface.netmask) + local packet = tostring(cs) + + local status, err = socket:send(packet) + if not status then + return fail(string.format("Send failed: %s", err)) + end + + -- Receive the responses from the PLCs and parse them + local result = {} + repeat + local data + status, data = socket:receive() + if ( status ) then + local status, response = codesys3.CodesysV3.NameServiceResponse:new(data) + if ( status ) then + result = response + + -- One valid unicast response is enough for us, we can stop receiving more + break + end + end + until( not(status) ) + + socket:close() + + -- Display the results + local out = stdnse.output_table() + + out["deviceAddress"] = result.ip + out["targetVendor"] = result.vendorName + out["targetName"] = result.deviceName + out["deviceName"] = result.nodeName + out["targetID"] = string.format("0x%x", result.targetId) + out["targetType"] = string.format("0x%x", result.targetType) + out["targetVersion"] = codesys3.version_to_str(result.targetVersion) + + return out +end