Writing a standalone scriptable protocol
The language used to write a scriptable protocol is Lua, please refer to the official Lua documentation (https://www.lua.org/start.html) to learn more.
This is a minimal protocol implementation:
function can_handle()
return true
end
From the example we can see that the only mandatory thing to do is to define a function
called can_handle
which returns true
if it recognize
the target protocol.
Of course this implementation is not very useful and it will try to handle every packet so let's write something more complex to detect and analyze some modbus traffic:
function can_handle()
return packet.source_port() == 502 or packet.destination_port() == 502
end
Here we can see a usage of the application programming interface (API) to retrieve the packet ports. In this way the check is a bit more accurate but it's still insufficient to detect a modbus packet in the real world.
Let's start to do some deep packet inspection:
function can_handle()
if data_size() < 8 then
return false
end
local has_right_port = packet.source_port() == 502 or packet.destination_port() == 502
fwd(2)
local has_right_protocol_id = consume_n_uint16() == 0
local expected_length = consume_n_uint16()
return has_right_port and
has_right_protocol_id and
remaining_size() == expected_length
end
WARNING: don't use global variables. Variables defined outside of the
can_handle
and update_status
functions are global
and their status is shared across every session of the same protocol.
NOTE: the fwd
and consume_*
functions will move forward
the payload pointer.
NOTE: the result of the remaining_size
function depends on the position
of the payload pointer.
In this example we use the API to inspect the content of the payload. First we check that
there are enough bytes, a modbus packet is at least 8 bytes long. Then we check the port
in the same way we did in the previous example, then we skip two bytes with the function
fwd
and we read the next two 16 bit integers. We check that the
protocol id is zero and that the length written in the packet matches the remaining
bytes count in our payload. If every check succeeds true
is returned,
informing Guardian that the next packets in this session should be analyzed by this
protocol decoder.
A protocol with just the can_handle
function implemented will only
create the node and the session in the Network but the link is still missing from the
graph, no additional information will be displayed in the Process information.
To extract more information from the modbus packets we are going to implement the
update_status
function:
function get_protocol_type()
return ProtocolType.SCADA
end
function can_handle()
return is_modbus()
end
function update_status()
if not is_modbus() then
return
end
local is_request = packet.destination_port() == 502
local rtu_id = consume_uint8()
local fc = consume_uint8() & 0x7f
if is_request then
is_packet_from_src_to_dst(true)
set_roles("consumer", "producer")
if fc == 6 then
local address = consume_n_uint16()
local value = DataValue.new()
value.value = read_n_uint16()
value.cause = DataCause.WRITE
value.type = DataType.ANALOG
value.time = packet.time()
execute_update_with_variable(FunctionCode.new(fc), RtuId.new(rtu_id), "r"..tostring(address), value)
return
end
end
execute_update()
end
is_modbus
function from the content
of the previous can_handle
function.is_modbus
function has the effect to advance the payload pointer
by 6 bytes, so we can directly read the rtu_id
without further
payload pointer manipulations.get_protocol_type
function to define the protocol
type.In this example of update_status
we read more data from the payload and
we decode the write single register request. We can understand the direction of the
communication so we call is_packet_from_src_to_dst
with
true
to notify Guardian and create a link and we call
set_roles
to set the roles on the involved nodes.
To insert a variable in Guardian there is the
execute_update_with_variable
function, it takes 4 arguments: the
function code, the rtu id, the variable name and the value. The
FunctionCode
and RtuId
objects can be constructed
from a string or a number, the DataValue object can be constructed with the empty
constructor and then filled with the available information.
With the next example we cover a more complex case and we store some data in the session to handle a request and a response:
local PENDING_FC = 1
local PENDING_START_ADDR = 2
local PENDING_REG_COUNT = 3
function update_status()
if not is_modbus() then
return
end
rwd()
local is_request = packet.destination_port() == 502
local transaction_id = consume_n_uint16()
fwd(4)
local rtu_id = consume_uint8()
local fc = consume_uint8() & 0x7f
if is_request then
is_packet_from_src_to_dst(true)
set_roles("consumer", "producer")
session.set_pending_request_number(transaction_id, PENDING_FC, fc)
if fc == 3 then
if remaining_size() < 4 then
return
end
local start_addr = consume_n_uint16()
local registers_count = consume_n_uint16()
session.set_pending_request_number(transaction_id, PENDING_START_ADDR, start_addr)
session.set_pending_request_number(transaction_id, PENDING_REG_COUNT, registers_count)
end
else
is_packet_from_src_to_dst(false)
local req_fc = session.read_pending_request_number(transaction_id, PENDING_FC)
if fc == req_fc then
if fc == 3 then
local start_addr = session.read_pending_request_number(transaction_id, PENDING_START_ADDR)
local reg_count = session.read_pending_request_number(transaction_id, PENDING_REG_COUNT)
session.close_pending_request(transaction_id)
if remaining_size() < 1 then
return
end
local byte_count = consume_uint8()
if remaining_size() ~= byte_count or
reg_count * 2 ~= remaining_size() then
send_alert_malformed_packet("Packet is too small")
return
end
for i = 0, reg_count - 1, 1 do
local value = DataValue.new()
value.value = consume_n_uint16()
value.cause = DataCause.READ_SCAN
value.type = DataType.ANALOG
value.time = packet.time()
execute_update_with_variable(FunctionCode.new(fc),
RtuId.new(rtu_id),
"r"..tostring(start_addr+i),
value)
end
return
end
end
end
execute_update()
end
This time we are focusing on the read holding register function code, to understand the
communication and create a variable we need to analyze both the request and the response
and we need to keep some data from the request and use it in the response. To achieve
this we can use the functions provided by the session
object.