This commit is contained in:
Andreas Galauner 2026-06-26 15:52:44 -04:00 committed by GitHub
commit bbb32a5d94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 643 additions and 0 deletions

246
nselib/codesys3.lua Normal file
View file

@ -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("<HHI",
0xc202, -- Packet type - 0xC202 = Resolve Address
0x0400, -- Version - 0x0400 = Version 4.0
0x04206969 -- "Randome" Request ID
)
stdnse.debug1("Payload of name service broadcast: %s", stdnse.tohex(packet_payload))
return packet_header .. packet_payload
end,
},
-- The NameServiceResponse class contains functions to parse a Codesys v3 Name Service Response
NameServiceResponse = {
-- Creates a new Response instance based on raw socket data
--
-- @param data string containing the raw socket response
-- @return o Response instance
new = function(self, data)
local o = { data = data }
if ( not(data) or #data < 6 ) then
return false, "Response isn't long enough the be a Name Service Response"
end
local hopinfo, packetinfo, address_lengths, pos
o.magic, hopinfo, packetinfo, o.service_id, o.message_id, address_lengths, pos = string.unpack(">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("<HHI", data, pos)
-- check the package type we are expecting
if o.package_type ~= CodesysV3.NameServicePackageTypes.Identification then
return false, "The payload in the response isn't a Name Service Identification"
end
-- TODO: we should handle other versions than v4.00 as well
if o.version ~= 0x0400 then
return false, "The response contained a different version than v4.00 which we don't support right now"
end
stdnse.debug1("Received Codesys V3 Name Service Identification Response packet we can parse")
-- FIMXE: Intel vs Motorola Byte Order - means: Little vs Big endian. Does endianness of fields in the
-- packet later on have different endianness as well? Don't have a Big Endian device to test at hand
-- Start parsing the PLC identification payload
local parentAddrSize, nodeNameLength, deviceNameLength, vendorNameLength,
serialNumberLength, oemDataLength
o.maxChannels, o.intelByteOrder, o.addrDifference, parentAddrSize, pos = string.unpack("<I2 I1 I1 I2", data, pos)
nodeNameLength, deviceNameLength, vendorNameLength, pos = string.unpack("<I2 I2 I2", data, pos)
o.targetType, o.targetId, o.targetVersion, o.flags, pos = string.unpack("<I4 I4 I4 I4", data, pos)
serialNumberLength, oemDataLength, o.blkDrvType, pos = string.unpack("<I1 I1 I1 x xxxx xxxx", data, pos)
stdnse.debug1("Max Channels: %x - Intel Byte Order: %x - Parent Addr. Difference: %x - Parent Addr. Size: %x", o.maxChannels, o.intelByteOrder, o.addrDifference, parentAddrSize)
stdnse.debug1("Node Name Length: %x - Device Name Length: %x - Vendor Name Length: %x", nodeNameLength, deviceNameLength, vendorNameLength)
stdnse.debug1("Target Type: %x - Target ID: %x - Target Version: %x - Flags: %x", o.targetType, o.targetId, o.targetVersion, o.flags)
stdnse.debug1("Serial Number Length: %x - OEM Data Length: %x - Block Driver Type: %x", serialNumberLength, oemDataLength, o.blkDrvType)
-- following the structured data, comes variable length data with the sizes we just parsed
-- following order should work: parent address, node name, device name, vendor name, serial number, OEM specific data
o.addrParent = string.sub(data, pos, pos + parentAddrSize)
pos = pos + parentAddrSize
stdnse.debug1("Parent Address: %s", stdnse.tohex(o.addrParent))
o.nodeName = string.sub(data, pos, pos + nodeNameLength*2 - 1)
o.nodeName = unicode.utf16to8(o.nodeName)
stdnse.debug1("Node Name: %s", o.nodeName)
pos = pos + nodeNameLength*2 + 2
o.deviceName = string.sub(data, pos, pos + deviceNameLength*2 - 1)
o.deviceName = unicode.utf16to8(o.deviceName)
stdnse.debug1("Device Name: %s", stdnse.tohex(o.deviceName))
pos = pos + deviceNameLength*2 +2
o.vendorName = string.sub(data, pos, pos + vendorNameLength*2 - 1)
o.vendorName = unicode.utf16to8(o.vendorName)
stdnse.debug1("Vendor Name: %s", stdnse.tohex(o.vendorName))
pos = pos + vendorNameLength*2 + 2
o.serialNumber = string.sub(data, pos, pos + serialNumberLength - 1)
stdnse.debug1("Serial Number: %s", o.serialNumber)
pos = pos + serialNumberLength + 1
o.oemData = string.sub(data, pos, pos + oemDataLength)
pos = pos + oemDataLength
stdnse.debug1("OEM data: %s", stdnse.tohex(o.oemData))
setmetatable(o, self)
self.__index = self
return true, o
end,
}
}
version_to_str = ipOps.fromdword
return _ENV

View file

@ -0,0 +1,249 @@
local nmap = require "nmap"
local ipOps = require "ipOps"
local packet = require "packet"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"
local codesys3 = require "codesys3"
description=[[
Discovers hosts running a Codesys V3 PLC runtime on the LAN. It does so by
sending a broadcast packet with a device discovery request in a proprietary and
undocumented Codesys network protocol and then collects all responses from
devices on the network.
]]
---
-- @usage
-- nmap --script broadcast-codesys-discover
--
-- @output
-- Pre-scan script results:
-- | broadcast-codesys-discover:
-- | 192.168.20.7:
-- | interface: enp11s0f0.20
-- | targetVendor: 3S - Smart Software Solutions GmbH
-- | targetName: CODESYS Control for Raspberry Pi MC SL
-- | deviceName: raspberrypi
-- | targetID: 0x11
-- | targetType: 0x1006
-- | targetVersion: 3.5.15.10
-- | 192.168.20.10:
-- | interface: enp11s0f0.20
-- | targetVendor: WAGO
-- | targetName: WAGO 750-8215 PFC200 G2 4ETH CAN USB
-- | deviceName: PFC200V3-4538EF
-- | targetID: 0x1006120b
-- | targetType: 0x1000
-- | targetVersion: 5.15.4.0
-- | 192.168.20.9:
-- | interface: enp11s0f0.20
-- | targetVendor: WAGO
-- | targetName: WAGO 750-8206 PFC200 2ETH RS CAN DPS
-- | deviceName: PFC200-438F4C
-- | targetID: 0x10061204
-- | targetType: 0x1000
-- |_ targetVersion: 5.15.4.0
--
-- @args broadcast-codesys-discover.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 <agalauner@rapid7.com>
--
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:
-- <code>name</code> containing the device name
-- <code>address</code> containing the device address
-- <code>netmask</code> containing the device netmask
-- <code>broadcast</code> 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

View file

@ -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 <agalauner@rapid7.com>
--
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