--
-- (C) 2021 - switch.ch
-- IEC 60870-5-14 expert anlysis PoC for sharkfest Europe 2021
-- Version 1.0
--


local iec_analysis = Proto("iec_analysis", "IEC Packet Analysis")

iec_analysis.fields = {}
iec_analysis.fields.invalid_cp56time = ProtoField.new("Invalid CP56Time", "iec_analysis.fields.invalid_cp56time", ftypes.STRING)

local f_time_epoch         = Field.new("frame.time_epoch")
local f_cp56time_min       = Field.new("iec60870_asdu.cp56time.min")
local f_cp56time_hour      = Field.new("iec60870_asdu.cp56time.hour")
local f_cp56time_day       = Field.new("iec60870_asdu.cp56time.day")
local f_cp56time_month     = Field.new("iec60870_asdu.cp56time.month")
local f_cp56time_year      = Field.new("iec60870_asdu.cp56time.year")

local f_tcplen             = Field.new("tcp.len")
local f_payload            = Field.new("tcp.payload")
local f_src_port           = Field.new("tcp.srcport")
local f_dst_port           = Field.new("tcp.dstport")

local f_asdu_start         = Field.new("iec60870_asdu.start")


-- ###############################################

function iec_analysis.init()

end

-- ###############################################


-- Print contents of `tbl`, with indentation.
-- You can call it as tprint(mytable)
-- The other two parameters should not be set
function tprint(s, l, i)
   l = (l) or 1000; i = i or "";-- default item limit, indent string
   if (l<1) then io.write("ERROR: Item limit reached.\n"); return l-1 end;
   local ts = type(s);
   if (ts ~= "table") then io.write(i..' '..ts..' '..tostring(s)..'\n'); return l-1 end
   io.write(i..' '..ts..'\n');
   for k,v in pairs(s) do
      local indent = ""

      if(i ~= "") then
    indent = i .. "."
      end
      indent = indent .. tostring(k)

      l = tprint(v, l, indent);
      if (l < 0) then break end
   end

   return l
end

-- ###############################################

local function getstring(finfo)
   local ok, val = pcall(tostring, finfo)
   if not ok then val = "(unknown)" end
   return val
end

local function getval(finfo)
   local ok, val = pcall(tostring, finfo)
   if not ok then val = nil end
   return val
end

function dump_pinfo(pinfo)
   local fields = { all_field_infos() }
   for ix, finfo in ipairs(fields) do
      --  output = output .. "\t[" .. ix .. "] " .. finfo.name .. " = " .. getstring(finfo) .. "\n"
      --print(finfo.name .. "\n")
      print("\t[" .. ix .. "] " .. finfo.name .. " = " .. getstring(finfo) .. "\n")
   end
end




-- ###############################################

-- the dissector function callback
function iec_analysis.dissector(tvb, pinfo, tree)
   -- Wireshark dissects the packet twice. We ignore the first
   -- run as on that step the packet is still undecoded
   -- The trick below avoids to process the packet twice

   if (pinfo.visited == true) then

      -- get raw data
      local tcplenRaw          = { f_tcplen() }
      local payloadRaw         = { f_payload() }
      local dstportRaw         = { f_dst_port() }
      local srcportRaw         = { f_src_port() }
      local asdu_start       = { f_asdu_start() }
   
   
      if ((tcplenRaw ~= nil) and (payloadRaw ~= nil )) and (dstportRaw ~= nil) and (srcportRaw ~= nil) and (asdu_start ~= nil) then


         local cp56time_min       = { f_cp56time_min() } 
         local cp56time_hour      = { f_cp56time_hour()  }
         local cp56time_day       = { f_cp56time_day()   }
         local cp56time_month     = { f_cp56time_month() }
         local cp56time_year      = { f_cp56time_year()  }

         local msgTime = ""


         if((cp56time_day ~= nil)
            and (cp56time_month ~= nil)
            and (cp56time_year ~= nil)
            and (cp56time_hour ~= nil)
            and (cp56time_min ~= nil)) then
               -- The field is present: we now validate CP56time
               local hour  = tonumber(getval(cp56time_hour[#cp56time_hour]))
               local day   = tonumber(getval(cp56time_day[#cp56time_day]))
               local month = tonumber(getval(cp56time_month[#cp56time_month]))
               local year  = tonumber(getval(cp56time_year[#cp56time_year]))
               local min   = tonumber(getval(cp56time_min[#cp56time_min]))

               if((day ~= nil)
                and (month ~= nil)
                and (year ~= nil)
                and (hour ~= nil)
                and (min ~= nil)) then
                   local t = {year=2000+year, month=month, day=day, hour=hour, min=min}
                   local cp56time = os.time(t)
                   local epoch = { f_time_epoch() }
                   local packet_epoch  = tonumber(getval(epoch[#epoch]))

                   local deviation3h = 10800

                  if ((cp56time + deviation3h) < packet_epoch) then
                     msgTime = "CP54time differs more then 3h from epoch time. Difference = " .. os.date("%X", packet_epoch - cp56time)
                  elseif ((cp56time + 10) < packet_epoch) then
                     local msgTime = "CP54time differs more than 10s from epoch time. Difference = " .. os.date("%X", packet_epoch - cp56time)
                  end
               end
         end





 
         local tcplen    = tonumber(getval(tcplenRaw[#tcplenRaw]))
         local srcport    = tonumber(getval(srcportRaw[#srcportRaw]))
         local dstport    = tonumber(getval(dstportRaw[#dstportRaw]))
         local payload    = tostring(getval(payloadRaw[#payloadRaw]))
         
         local APDU_type = {"Length", "Type", "Rx", "Tx", "TypeID", "TestFr", "StartPos", "CauseTx", "IOA", "NumIx"}
         local APDU = APDU_type

         local StartPos = 1
         local i = 1
         local msg = ""
         local msg2 = ""
         local msg3 = ""

         local APDU_length = {}
         local APDU_StartPos = {}

         --read first APDU length and check wheater payload contains multiple APDUs or not
         --additional checks
         if ((payload ~= nil) and (tcplen ~= nil ) and (asdu_start ~= nil ) and ((srcport == 2404) or (dstport == 2404)) ) then
         
            if ((tcplen > 3)  and (tonumber(string.sub(payload,StartPos,StartPos  + 1),16)==104)) then
               --define APDUs start positions, containing 0x68
               if ((tonumber(string.sub(payload,4,5),16) + 2) < tcplen) then
                  --multiple APDUs
                  --loop through all APDU's
                  while StartPos < (tcplen*3-1) do
                     APDU_StartPos[i] = StartPos
                     APDU_length[i] = tonumber(string.sub(payload,StartPos + 3,StartPos + 3 + 1),16)
                     
                     StartPos = StartPos + 5 + APDU_length[i]*3 + 1
                     i = i + 1
                  end

               else
                  --single APDU
                  APDU_length[i] = tonumber(string.sub(payload,StartPos + 3,StartPos + 3 + 1),16)
                  APDU_StartPos[i] = StartPos
               end  
          
               --process all APDUs
               for j=1,#APDU_StartPos do


                  if (APDU_length[j] > 7) then
                     APDU['NumIx'] = tonumber(string.sub(payload,APDU_StartPos[j]+21, APDU_StartPos[j] + 21 + 1),16)
                     if ((APDU['NumIx'] * 6) > (APDU_length[j] - 10) and (APDU['NumIx'] >= 3)) then
                        msg = " APDU object #" .. j  .. msg
                     end
                     APDU["TypeID"] = tonumber(string.sub(payload,APDU_StartPos[j]+ 18, APDU_StartPos[j] + 18 + 1),16)
                     if ( not (APDU["TypeID"] == 9 
                        or APDU["TypeID"] == 13 
                        or APDU["TypeID"] == 36 
                        or APDU["TypeID"] == 45 
                        or APDU["TypeID"] == 46 
                        or APDU["TypeID"] == 48
                        or APDU["TypeID"] == 30 
                        or APDU["TypeID"] == 103 
                        or APDU["TypeID"] == 100 
                        or APDU["TypeID"] == 37 )) then
                        msg3 = "in ASDU #" .. j .. " (TypeID: " .. APDU["TypeID"] .. ")" .. msg3
                     end
                  else
                     APDU['NumIx'] = 0 
                     APDU["TypeID"] = 0
                  end

               -- end for loop   
               end

               if (msg ~= "") then
                  msg = "Possible missing data, check for [] in IOAs in" .. msg
               end

               if #APDU_StartPos > 8 then
                  msg2 = "Payload contains more then 8 APDU objects. Number of APDU objects found: " .. #APDU_StartPos
               end

               if (msg3 ~= "") then
                  msg3 = "Not permitted TypeID(s) " .. msg3
               end

               -- Add analysis information to packet
               if (msg ~= "") or (msg2 ~= "") or (msg3 ~= "") or (msgTime  ~= "") then
                  local iec_subtree = tree:add(iec_analysis, tvb(), "IEC 60870-5-104 Analysis")
                  if (msg ~= "") then
                     iec_subtree:add_expert_info(PI_PROTOCOL, PI_WARN, msg)
                  end
                  if (msg2 ~= "") then
                     iec_subtree:add_expert_info(PI_PROTOCOL, PI_NOTE, msg2)
                  end
                  if (msg3 ~= "") then
                     iec_subtree:add_expert_info(PI_PROTOCOL, PI_NOTE, msg3)
                  end
                  if (msgTime ~= "") then
                     iec_subtree:add_expert_info(PI_PROTOCOL, PI_WARN, msgTime)
                  end
               end

            -- end of: if ((payload ~= nil) and (tcplen > 3 )) then
            end
            
         end
      -- end of: if ((tcplenRaw ~= nil) and (payloadRaw ~= nil )) then
      end

   -- end of: if (pinfo.visited == true) then
   end




         

        

   
   
       

   
   -- ###########################################

   -- As we do not need to add fields to the dissection
   -- there is no need to process the packet multiple times
   if(pinfo.visited == true) then return end

end

register_postdissector(iec_analysis)