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
Note: To avoid duplication we created a is_modbus function from the content of the previous can_handle function.
Note: The 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.
Note: We defined the 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.