Optional Fields issue

Hello everyone,

I’m currently working on developing a Zeek packet analyzer for ProfinetIO as part of my Master’s thesis.

1-From my understanding, the real-time data exchange in Profinet is not built on traditional transport layer protocols like TCP/UDP, which is why creating a packet analyzer seemed like the right approach.

2-I’ve taken inspiration from(GitHub - kit-dsn/zeek-profinet-analyzer: Profinet packet analyzer plugin for Zeek.) , and my goal is to refine the parser to focus on the most important features that need to be logged. The Spicy ProfinetIO.spicy parser seems to produce the correct results when parsing the packets. However, Zeek is not generating the expected profinet_dcp.log file.

The issue appears specifically when handling DCP Identify Request/Response packets. In the analyzer.log, Zeek reports the following error:

unset optional value

These are the scripts:
#ProfinetIO.spicy:

module ProfinetIO;

import spicy;

%byte-order = spicy::ByteOrder::Network;

public type Packet = unit {
	frame_id: uint16;
	rtc_frame: RTCFrame if ( 0x8000 <= self.frame_id && self.frame_id <= 0xbbff );
	dcp_frame: DCPFrame if ( 0xfefd <= self.frame_id && self.frame_id <= 0xfeff );
            data: bytes &eod;             # Any remaining bytes (optional)
            on %done {print self;}  
};

type RTCFrame = unit {
    # RTC Data (adjust size to match your observed packets)
    data: bytes &size=40;         # 40-byte data block (update if needed)
    cycle_counter: uint16;        # Cycle counter (2 bytes)
    data_status: uint8;           # Data Status (1 byte)
    transfer_status: uint8;       # Transfer Status (1 byte)
};

type ServiceIDType = enum {
	GET = 0x01,
	SET = 0x02,
	IDENTIFY = 0x05,
	HELLO = 0x06
};

type DCPFrame = unit {
	service_id:     uint8; # &convert=ServiceIDType($$);
	service_type:    bitfield(8) {
		stype:   0..1;
		success: 2;
		};
	xid:            uint32;
	response_delay: uint16;
	len:            uint16 { self.rest = $$; }
	blocks:         DCPBlock(self)[] &until-including=(self.rest == 0);

	var rest: uint16 = 0;
};

type DCPBlock = unit(inout frame: DCPFrame) {
    option:       uint8;
    suboption:    uint8;
    len:          uint16 { frame.rest -= ($$ + 4); }
    BlockInfo:    uint16 if (frame.service_type.stype == 1);

    ip_params:    IPParams             if (self.option == 1 && self.suboption == 2);
    vendor_info:  VendorInfo(self.len) if (self.option == 2 && self.suboption == 1);
    station_name: StationName(self.len)if (self.option == 2 && self.suboption == 2);
    device_id:    DeviceID             if (self.option == 2 && self.suboption == 3);
    device_role:  DeviceRole           if (self.option == 2 && self.suboption == 4);
    data: bytes &size=self.len - 2
        if (!((self.option == 1 && self.suboption == 2) ||
             (self.option == 2 && self.suboption == 1) ||
             (self.option == 2 && self.suboption == 2) ||
             (self.option == 2 && self.suboption == 3) ||
             (self.option == 2 && self.suboption == 4) ||
             (self.len == 0)));
             
    padding: int8 if (self.len % 2 == 1);
    on padding { frame.rest -= 1; }
};

type IPParams = unit {
    IPaddress:     addr &ipv4;    # 4 bytes
    Subnetmask:   addr &ipv4;     # 4 bytes
    StandardGateway: addr &ipv4;  # 4 bytes
};

type VendorInfo = unit(len: uint16) {
    DeviceVendorValue: bytes &size=len - 2; 
};

type StationName = unit(len: uint16) {
    NameOfStation: bytes &size=len - 2; 
};

type DeviceID = unit {
    vendor_id: uint16;
    device_id: uint16;
};

type DeviceRole = unit {
    DeviceRoleDetails: uint8;
    Reserved: uint8;
};

#Zeek_ProfinetIO.spicy:

module Zeek_ProfinetIO;

import ProfinetIO;
import spicy;
import zeek;

# Converts a ProfinetIO::DCPFrame into a Zeek type.
public function create_dcp_msg(dcp: ProfinetIO::DCPFrame):
    tuple<
        uint8,
        bool,
        bool,
        bool,
        uint32,
        uint16> {
    return (
        dcp.service_id,
        dcp.service_type.stype == 0,
        dcp.service_type.stype == 1,
        dcp.service_type.success == 0,
        dcp.xid,
        dcp.response_delay
    );
}

# Converts a ProfinetIO::DCPBlock into a Zeek type.
public function create_dcp_block(block: ProfinetIO::DCPBlock):
    tuple<
        uint8,
        uint8,
        uint16,
        bytes,
        addr,
        addr,
        addr,
        bytes,
        uint16,
        uint16,
        bytes> { 
    return (
        block.option,
        block.suboption,
        block.len,
        block?.station_name ? block.station_name.NameOfStation : b"",
        block?.ip_params ? block.ip_params.IPaddress : 0.0.0.0,
        block?.ip_params ? block.ip_params.Subnetmask : 0.0.0.0,
        block?.ip_params ? block.ip_params.StandardGateway : 0.0.0.0,
        block?.vendor_info ? block.vendor_info.DeviceVendorValue : b"",
        block?.device_id ? block.device_id.vendor_id : 0,
        block?.device_id ? block.device_id.device_id : 0,
        block?.data ? block.data : b""
    );
}
# Converts a ProfinetIO::RTCFrame into a Zeek type.
public function create_rtc_msg(rtc: ProfinetIO::RTCFrame):
    tuple<
        bytes,
        uint16,
        uint8,
        uint8> {
    return (
        rtc.data,
        rtc.cycle_counter,
        rtc.data_status,
        rtc.transfer_status
    );
}

#ProfinetIO.evt

packet analyzer spicy::ProfinetIO:
	parse with ProfinetIO::Packet;

import Zeek_ProfinetIO;

on ProfinetIO::Packet::dcp_frame -> event ProfinetIO::dcp_message(self.frame_id, Zeek_ProfinetIO::create_dcp_msg(self.dcp_frame), [Zeek_ProfinetIO::create_dcp_block(block) for block in self.dcp_frame.blocks]);

on ProfinetIO::Packet::rtc_frame -> event ProfinetIO::rtc_message(self.frame_id, Zeek_ProfinetIO::create_rtc_msg(self.rtc_frame));

#ProfinetIO.zeek

@load ./ProfinetIO.hlto
module ProfinetIO;

export {
    redef enum Log::ID += { LOG_DCP, LOG_RTC };

    # DCP Header Structure
    type DCPHeader: record {
        service_id:     count;
        request:        bool;
        response:       bool;
        success:        bool;
        xid:            count;
        response_delay: count;
    };

    type DCPBlock: record {
        opt:            count;
        subopt:         count;
        len:            count;
        NameOfStation:  string;
        IPaddress:      addr;
        Subnetmask:     addr;
        StandardGateway: addr;
        DeviceVendorValue: string;
        VendorID:       count;
        DeviceID:       count;
        data: string;
    };

    type DCPInfo: record {
        ts:             time  &log;
        frame_id:       count &log;
        service_id:     count &log;
        request:        bool  &log;
        response:       bool  &log;
        success:        bool  &log;
        xid:            count &log;
        response_delay: count &log;
        NameOfStation:  string &log;
        IPaddress:      addr &log;
        Subnetmask:     addr &log;
        StandardGateway: addr &log;
        DeviceVendorValue: string &log;
        VendorID:       count &log;
        DeviceID:       count &log;
        data: string &log;
    };


    # RTC Header Structure
    type RTCHeader: record {
        data:             string;
        cycle_counter:    count;
        data_status:      count;
        transfer_status:  count;
    };

    # RTC Log Structure
    type RTCInfo: record {
        ts:               time  &log;
        frame_id:         count &log;
        cycle_counter:    count &log;
        data_status:      count &log;
        transfer_status:  count &log;
    };
}

event zeek_init() &priority=10
    {
    PacketAnalyzer::register_packet_analyzer(PacketAnalyzer::ANALYZER_ETHERNET, 0x8892, PacketAnalyzer::ANALYZER_SPICY_PROFINETIO);
	PacketAnalyzer::register_packet_analyzer(PacketAnalyzer::ANALYZER_VLAN, 0x8892, PacketAnalyzer::ANALYZER_SPICY_PROFINETIO);
    # Create logging streams
    Log::create_stream(ProfinetIO::LOG_DCP, [$columns=DCPInfo, $path="profinet_dcp"]);
    Log::create_stream(ProfinetIO::LOG_RTC, [$columns=RTCInfo, $path="profinet_rtc"]);
    }

# Handle DCP messages
event ProfinetIO::dcp_message(frame_id: count, hdr: DCPHeader, blocks: vector of DCPBlock)
    {
    local NameOfStation: string;
    local IPaddress: addr;
    local Subnetmask: addr;
    local StandardGateway: addr;
    local DeviceVendorValue: string;
    local VendorID: count;
    local DeviceID: count;
    local data: string;

    for (i in blocks)
        {
        local block = blocks[i];

        if (block?$NameOfStation && block$NameOfStation != "")
            NameOfStation = block$NameOfStation;
        if (block?$IPaddress && block$IPaddress != 0.0.0.0)
            IPaddress = block$IPaddress;
        if (block?$Subnetmask && block$Subnetmask != 0.0.0.0)
            Subnetmask = block$Subnetmask;
        if (block?$StandardGateway && block$StandardGateway != 0.0.0.0)
            StandardGateway = block$StandardGateway;
        if (block?$DeviceVendorValue && block$DeviceVendorValue != "")
            DeviceVendorValue = block$DeviceVendorValue;
        if (block?$VendorID && block$VendorID != 0)
            VendorID = block$VendorID;
        if (block?$DeviceID && block$DeviceID != 0)
            DeviceID = block$DeviceID;
        if (block?$data && block$data != "")
            data = block$data;
        }
        

    local rec: DCPInfo = [
        $ts             = network_time(),
        $frame_id       = frame_id,
        $service_id     = hdr$service_id,
        $request        = hdr$request,
        $response       = hdr$response,
        $success        = hdr$success,
        $xid            = hdr$xid,
        $response_delay = hdr$response_delay,
        $NameOfStation  = NameOfStation,
        $IPaddress      = IPaddress,
        $Subnetmask     = Subnetmask,
        $StandardGateway= StandardGateway,
        $DeviceVendorValue = DeviceVendorValue,
        $VendorID       = VendorID,
        $DeviceID       = DeviceID,
        $data           = data,
    ]; 


    Log::write(LOG_DCP, rec);
    }

# Handle RTC messages
event ProfinetIO::rtc_message(frame_id: count, hdr: RTCHeader)
    {
    local rec: RTCInfo = [
        $ts               = network_time(),
        $frame_id         = frame_id,
        $cycle_counter    = hdr$cycle_counter,
        $data_status      = hdr$data_status,
        $transfer_status  = hdr$transfer_status
    ];

    Log::write(LOG_RTC, rec);
    }

This seems to be related to optional attributes used in DCP blocks. I’ve tried several approaches but unfortunately, none of these attempts have resolved the issue.

3-Additionally, if you have any suggestions on how to integrate the parsed Profinet traffic into conn.log or associate it with connection metadata, that would be very helpful.

This is my first experience working with Zeek and Spicy, and it’s a crucial component of my research. I’d be very thankful for your insights or advice.

@Benjamin_Bannier I will be very grateful if you could help me as soon as possible. Any suggestions or feedback from the community would be greatly appreciated. Thanks everyone in advance!

The actual error should also include a Spicy source location so one can see where the unset value was accessed. Depending on the actual value there, this could either be dereferencing of an optional<T>, or access to a field marked &optional. All unit fields are implicitly marked &optional and one cannot access their value before they are parsed (hopefully makes sense :sweat_smile:); in the EVT file in your code above you e.g., invoke field hooks when a field is parsed, so most of the time this just works, but one can check whether a field has a value with ?. if needed.