Help Request: BACnet/IP Rule needs Variable Offset calculation

Hi,
I am looking for some advice on how to calculate a variable offset for a suricata rule. The target protocol is BACnet/IP. I have included a chart that shows the UDP payload of a BACnet/IP message.

My offset calculation needs to solve for the start of the ADPU, which is highlighted in yellow.

The issue is that there are two variable items in the payload prior to the ADPU start (destination and source specifiers (highlighted in red and blue). Thankfully these items have a deterministic size.

There are 2 bits in the Control octet that flag whether the destination (b’00100000) and source (b’00001000) fields are present at all. The first step would be to check those bits. If the control bit is 0, the fields are left out entirely. If the bit is 1 for either dest or src, then we know the following lengths:
The blue items (dest) would be 4 + DLEN value = total length
The red items (src) would be 3 + SLEN value = total length

The minimum offset is 5 bytes if both src and dest identifiers are absent (Version=1, Control=1,Type=1,Vendor=2)

Following the logic above, my pseudo code for my calculation would be:
offset = 5
destflag = controlbyte (bitwise &) b’00001000
srcflag = controlbyte (bitwise &) b’00100000
if destflag:
offset += 4 + DLEN
if srcflag:
offset += 3 + SLEN

I need help converting this pseudocode into a rule with the byte* keywords.

Reference Image

I am thinking my best bet would be to use lua scripting: 8.45. Lua Scripting for Detection — Suricata 8.0.0-dev documentation

Looking at the standard keywords, it doesn’t look like there is anyway to have conditional logic (if,then,else) otherwise.

This is my first attempt at putting together a lua script to calculate the offset to the APDU and then alert on specific data within the APDU. I still need to test and validate this script.

function init (args)
    local needs = {}
    needs["payload"] = tostring(true)
    return needs
end

function match(args)
    a = tostring(args["payload"])

    if #a > 0 then
        offset = 9
        controlbyte = string.byte(a,5)  --control byte is the 6th byte of the udp payload
        dstflag = controlbyte & 32 --bit6, 2^5
        srcflag = controlbyte & 8  --bit4, 2^3
        dlen = 0
        slen = 0
        if dstflag > 0 then
            dlen = string.byte(a,8)
            offset += 4 + dlen
            if srcflag > 0 then
                slen = string.byte(a,11+dlen)
                offset += 3 + slen
            end
        else
            if srcflag > 0 then
                slen = string.byte(a,8)
                offset += 3 + slen
            end
        end

        if(string.byte(a,offset)==16) then --type=unconfirmed req
            if(string.byte(a,offset+1)==8) then --service=who is
                return 1
            end
        else
            return 0
        end
        
    end
    return 0
end

return 0

Due to limitations with the version of Lua used by suricata 7, bitwise operations are not supported. For that reason, the original code had to be adjusted.

This is my final tested solution that successfully alerts on BACnet/IP application layer data by accounting for the network layer variable size. The example I have included is for BACnet/IP error messages. This code can me modified to alert on any BACnet/IP application layer data however.

#bacnet.rules
alert udp ANY 47808 -> $HOME_NET any (msg:"**LUA**BACnet ERROR reported by server"; lua:error.lua; sid:999999500;)
--error.lua
OR, XOR, AND = 1, 3, 4
DEBUG = false

function bitoper(a, b, oper)
    local r, m, s = 0, 2^31
        repeat
            s,a,b = a+b+m, a%m, b%m
            r,m = r + m*oper%(s-a-b), m/2
        until m < 1
    return r
end

function init (args)
    local needs = {}
    needs["payload"] = tostring(true)
    return needs
end

function match(args)
    a = tostring(args["payload"])

    if #a > 0 then
        if DEBUG then
            for i = 1, #a do
                local c = a:sub(i,i)
                io.write(string.byte(c))
                io.write(',')
                if i == #a then
                    io.write('\n')
                end       
            end
        end
        local offset = 7
        local bacnettype = string.byte(a,1)
        if bacnettype ~= 129 then --check if bacnet/ip
            return 0
        end
        local controlbyte = string.byte(a,6)  --control byte is the 6th byte of the udp payload
        local dstflag = bitoper(controlbyte,32, AND) --bit6, 2^5
        local srcflag = bitoper(controlbyte,8, AND)   --bit4, 2^3

        local dlen = 0
        local slen = 0
        if dstflag ~= 0 then
            dlen = string.byte(a,9)
            offset = offset + 4 + dlen
            if srcflag ~= 0 then
                slen = string.byte(a,12+dlen)
                offset = offset + 3 + slen
            end
        else
            if srcflag ~= 0 then
                slen = string.byte(a,9)
                offset = offset + 3 + slen
            end
        end
        local mtype = string.byte(a,offset)
        local serv_offset = 0
        if mtype == 16 then
            serv_offset = 1
        elseif mtype == 0 then
            serv_offset = 3
        end
        local mserv = string.byte(a,offset+serv_offset)
        if DEBUG then
            io.write('CTRL:',controlbyte,' DST:',dstflag,' SRC:',srcflag, ' DLEN:',dlen,' SLEN:',slen,' OFF:',offset,'\n')
            io.write('TYPE:',mtype,' SERV:',mserv,'\n')
        end
        if(mtype==80) then --type=error
            return 1
        else
            return 0
        end    
    end
    return 0
end

return 0