diff --git a/decode_modbus.json b/decode_modbus.json new file mode 100644 index 0000000..187237c --- /dev/null +++ b/decode_modbus.json @@ -0,0 +1,226 @@ +{ + "log_file": "logs\\failover_day_09042025\\test-scada-s3tos1-views3.pcapng", + "ip_ats": "10.110.1.142", + "ip_srvr": "10.113.23.6", + "scada_ene": true, + "scada_eqpt": false, + "plc": false, + "registersNames": { + "1000": "TCD_900V_774 (Beekkant )", + "1001": "TCD_900V_29 (Etangs Noirs )", + "1002": "TCD_900V_28 (Cte de Flandre )", + "1003": "TCD_900V_27 (St Catherine )", + "1004": "TCD_900V_01 (De Brouckere L1 )", + "1005": "TCD_900V_02 (Centrale )", + "1006": "TCD_900V_03 (Parc )", + "1007": "TCD_900V_04 (Arts-Loi L1 )", + "1008": "TCD_900V_05 (Maelbeek )", + "1009": "TCD_900V_06A (Schuman A )", + "1010": "TCD_900V_07A (Merode A )", + "1011": "TCD_900V_08 (Montgomery L1 )", + "1012": "TCD_900V_09 (Josephine Charlotte)", + "1013": "TCD_900V_10 (Gribaumont )", + "1014": "TCD_900V_11 (Tomberg )", + "1015": "TCD_900V_12 (Roodebeek )", + "1016": "TCD_900V_13 (Vandervelde )", + "1017": "TCD_900V_14 (Alma )", + "1018": "TCD_900V_15 (Kraainem )", + "1019": "TCD_900V_16 (Stockel )", + "1020": "TCD_900V_20 (Thieffry )", + "1021": "TCD_900V_21 (Petillon )", + "1022": "TCD_900V_22 (Hankar )", + "1023": "TCD_900V_23 (Delta )", + "1024": "TCD_900V_24 (Beaulieu )", + "1025": "TCD_900V_25 (Demey )", + "1026": "TCD_900V_26 (H.Debroux )", + "1027": "TCD_900V_773 (G. Ouest E.O. )", + "1028": "TCD_900V_772 (J. Brel )", + "1029": "TCD_900V_771 (Aumale )", + "1030": "TCD_900V_770 (Saint Guidon )", + "1031": "TCD_900V_769 (Veeweyde )", + "1032": "TCD_900V_768 (Bizet )", + "1033": "TCD_900V_767 (La Roue )", + "1034": "TCD_900V_766 (CERIA )", + "1035": "TCD_900V_765 (E.Merckx )", + "1036": "TCD_900V_764 (Erasme )", + "1037": "TCD_900V_775 (L2-6 - Osseghem )", + "1038": "TCD_900V_776 (L2-6 - Simonis EO )", + "1039": "TCD_900V_777 (L2-6 - Belgica )", + "1040": "TCD_900V_778 (L2-6 - Pannenhuis )", + "1041": "TCD_900V_779 (L2-6 - Bockstael )", + "1042": "TCD_900V_780 (L2-6 - Stuyvenberg )", + "1043": "TCD_900V_781 (L2-6 - H. Brugmann )", + "1044": "TCD_900V_782 (L2-6 - Heysel )", + "1045": "TCD_900V_783 (L2-6 - R. Baudouin )", + "2000": "SIMONET (A) ", + "2001": "SIMONET (B) ", + "2002": "VOGELZANG (A) ", + "2003": "VOGELZANG (B) ", + "2004": "EDELWEISS (A) ", + "2005": "EDELWEISS (B) ", + "2006": "GRYSON ", + "2007": "DEBUSSY (A) ", + "2008": "DEBUSSY (B) ", + "2009": "DEBUSSY (C) ", + "2010": "DELEERS (A) ", + "2011": "DELEERS (B) ", + "2012": "DOUVRES (A) ", + "2013": "DOUVRES (B) ", + "2014": "JANSON (A) ", + "2015": "JANSON (B) ", + "2016": "SCHEUT (A) ", + "2017": "SCHEUT (B) ", + "2018": "MALHERBE (A) ", + "2019": "MALHERBE (B) ", + "2020": "MALHERBE (C) ", + "2021": "PIERMEZ (A) ", + "2022": "PIERMEZ (B) ", + "2023": "MENIN (A) ", + "2024": "MENIN (B) ", + "2025": "MENIN (C) ", + "2026": "MOMAERTS ", + "2027": "TAZIEAUX ", + "2028": "EVEQUE ", + "2029": "Q. BRIQUES ", + "2030": "ECUYER ", + "2031": "ARENBERG ", + "2032": "DUCALE ", + "2033": "COLONIES (A) ", + "2034": "COLONIES (B) ", + "2035": "CHARLEMAGNE (A)", + "2036": "CHARLEMAGNE (B)", + "2037": "CHARLEMAGNE (C)", + "2038": "CHARLEMAGNE (D)", + "2039": "TREVES (A) ", + "2040": "TREVES (B) ", + "2041": "TREVES (C) ", + "2042": "P.TERVUERN (A) ", + "2043": "P.TERVUERN (B) ", + "2044": "50NAIR (A) ", + "2045": "50NAIR (B) ", + "2046": "MENAPIENS (A) ", + "2047": "MENAPIENS (B) ", + "2048": "MENAPIENS (C) ", + "2049": "BATAVES (A) ", + "2050": "BATAVES (B) ", + "2051": "BATAVES (C) ", + "2052": "BATAVES (D) ", + "2053": "ROI CHEVALIER ", + "2054": "DE BROQUEVILLE ", + "2055": "SOLLEVELD (A) ", + "2056": "SOLLEVELD (B) ", + "2057": "*Reserved* ", + "2058": "VERVLOESEM (A) ", + "2059": "VERVLOESEM (B) ", + "2060": "*Reserved* ", + "2061": "MOUNIER (A) ", + "2062": "MOUNIER (B) ", + "2063": "ASSOMPTION (A) ", + "2064": "ASSOMPTION (B) ", + "2065": "BORNIVAL (A) ", + "2066": "BORNIVAL (B) ", + "2067": "BORNIVAL (C) ", + "2068": "AOUT (A) ", + "2069": "AOUT (B) ", + "2070": "DE DEKEN (A) ", + "2071": "DE DEKEN (B) ", + "2072": "LEBON (A) ", + "2073": "LEBON (B) ", + "2074": "LEBON (C) ", + "2075": "LEBON (D) ", + "2076": "PAEPEDELLE ", + "2077": "BELLE-VUE ", + "2078": "MEUNIERS (A) ", + "2079": "MEUNIERS (B) ", + "2080": "POELS (A) ", + "2081": "POELS (B) ", + "2082": "WYBRAN ", + "2083": "BOLET ", + "2084": "REMISE 5 ", + "2085": "REMISE 6 ", + "2500": "REMISE 5 ", + "2501": "REMISE 5 TCV 11", + "2502": "REMISE 5 TCV 31", + "2503": "REMISE 5 TCV 32", + "2504": "REMISE 5 TCV 33", + "2505": "REMISE 6 ", + "2506": "REMISE 6 TCV 41", + "2507": "REMISE 6 TCV 42", + "2508": "REMISE 6 TCV43A", + "2509": "REMISE 6 TCV43B", + "2510": "40AINE ", + "2511": "40AINE TCV 51 ", + "2512": "40AINE TCV 52A ", + "2513": "40AINE TCV 52B ", + "2514": "40AINE TCV 54 ", + "2515": "40AINE TCV 55 ", + "2516": "SCHOLLE ", + "2517": "TEST TRACK ", + "2600": "REMISE 2 TCV 1 ", + "2601": "REMISE 2 TCV 2 ", + "2602": "REMISE 2 TCV 3 ", + "2603": "REMISE 2 TCV 4 ", + "2604": "REMISE 2 TCV 5 ", + "2605": "REMISE 2 TCV 6 ", + "2606": "VOIE D'ESSAIS ", + "2607": "REMISE 1 TCV 7 ", + "2608": "REMISE 1 TCV 8 ", + "2609": "REMISE 1 TCV 9 ", + "2610": "REMISE 1 TCV 10", + "2611": "REMISE 1 TCV 11", + "2612": "REMISE 1 TCV 12", + "2613": "PEIGNE A ", + "2614": "PEIGNE B ", + "2615": "PEIGNE C ", + "2616": "PEIGNE D ", + "2617": "PEIGNE E TCV 30", + "2618": "PEIGNE F ", + "2619": "PEIGNE G TCV 13", + "2620": "PEIGNE H ", + "2621": "PEIGNE I ", + "2622": "PEIGNE J ", + "2623": "PEIGNE K ", + "2624": "*Reserved* ", + "2625": "FOSSES TCV 16 ", + "2626": "FOSSES TCV 17 ", + "2627": "FOSSES TCV 18 ", + "2628": "FOSSES TCV 19 ", + "2629": "FOSSES TCV 20 ", + "2630": "FOSSES TCV 21 ", + "2631": "FOSSES TCV 22 ", + "2632": "FOSSES TCV 23 ", + "2633": "FOSSES TCV 24 ", + "2634": "FOSSES TCV 25 ", + "2635": "*Reserved* ", + "10004": "MERODE_TC ", + "10005": "ALMA_TC ", + "10006": "MONTGOMERY_TC ", + "10007": "THIEFFRY_TC ", + "10008": "PETILLON_TC ", + "10009": "DELTA_TC ", + "10010": "TOMBERG_TC ", + "10011": "ST-CATHERINE_TC ", + "10012": "NOT_USED ", + "10013": "NOT_USED ", + "10014": "COMTE_DE_FLANDRE_TC", + "10015": "AUMALE_TC ", + "10016": "NOT_USED ", + "10017": "NOT_USED ", + "10018": "BREL_TC ", + "11004": "MERODE_TS ", + "11005": "ALMA_TS ", + "11006": "MONTGOMERY_TS ", + "11007": "THIEFFRY_TS ", + "11008": "PETILLON_TS ", + "11009": "DELTA_TS ", + "11010": "TOMBERG_TS ", + "11011": "ST-CATHERINE_TS ", + "11012": "NOT_USED ", + "11013": "NOT_USED ", + "11014": "COMTE_DE_FLANDRE_TS", + "11015": "AUMALE_TS ", + "11016": "NOT_USED ", + "11017": "NOT_USED ", + "11018": "BREL_TS " + } +} \ No newline at end of file diff --git a/decode_modbus.py b/decode_modbus.py new file mode 100644 index 0000000..8c3a542 --- /dev/null +++ b/decode_modbus.py @@ -0,0 +1,303 @@ +import os +from scapy.all import * +os.system('cls') +from art import * +import json +import datetime +import binascii + +class Settings: + def __init__(self, file): + self.file = file + with open(self.file) as f: + self.data = json.load(f) + self.log_file = self.data['log_file'] + self.output_file = self.data['log_file']+'.out' + self.ip_ats = self.data['ip_ats'] + self.ip_srvr = self.data['ip_srvr'] + self.scada_ene = self.data['scada_ene'] + self.scada_eqpt = self.data['scada_eqpt'] + self.plc = self.data['plc'] + self.registersNames = self.data['registersNames'] + +class ModbusPacket: + def __init__(self, isTx, src, dst, src_p, dst_p, infotime, data, registersNames, scada_ene, scada_eqpt, plc): + self.isTx = isTx + self.src = src + self.dst = dst + self.src_p = src_p + self.dst_p = dst_p + self.infotime = infotime + self.data = data + self.registersNames = registersNames + self.scada_ene = scada_ene + self.scada_eqpt = scada_eqpt + self.plc = plc + self.transaction_id = None + self.proto_id = None + self.length = None + self.size = None + self.addr_slave = None + self.function_id = None + self.nb_octets = None + self.registers = None + self.fst_register = None + self.nb_registers = None + self.request = None + self.extractData() + def __str__(self): + txt = str(self.infotime)+": "+self.src+"["+self.src_p+"] -> "+self.dst+"["+self.dst_p+"]\n" + txt += "PACKET: "+' '.join(self.data[i:i+2] for i in range(0, len(self.data), 2))+"\n" + if self.isTx: + txt += "Modbus TX:{\n" + txt += "\ttransaction_id = "+str(self.transaction_id)+"\n" + txt += "\tproto_id = "+str(self.proto_id)+"\n" + txt += "\tlength (bytes) = "+str(self.length)+"\n" + txt += "\taddr_slave = "+str(self.addr_slave)+"\n" + if self.function_id == 1: + txt += "\tfunction_id = "+str(self.function_id)+" -> Read Coil\n" + elif self.function_id == 2: + txt += "\tfunction_id = "+str(self.function_id)+" -> Read Discrete Input\n" + elif self.function_id == 3: + txt += "\tfunction_id = "+str(self.function_id)+" -> Read Holding Registers\n" + txt += "\tfst_r_register = "+str(self.fst_register)+"\n" + txt += "\tnb_registers = "+str(self.nb_registers)+"\n" + elif self.function_id == 4: + txt += "\tfunction_id = "+str(self.function_id)+" -> Read Input Registers\n" + elif self.function_id == 5: + txt += "\tfunction_id = "+str(self.function_id)+" -> Write Single Coil\n" + elif self.function_id == 6: + txt += "\tfunction_id = "+str(self.function_id)+" -> Write Single Holding Register\n" + elif self.function_id == 16: + txt += "\tfunction_id = "+str(self.function_id)+" -> Write Multiple Registers\n" + if str(self.fst_register) in self.registersNames.keys(): + txt += "\tfst_w_register = "+str(self.fst_register)+" - "+self.registersNames[str(self.fst_register)]+"\n" + else: + txt += "\tfst_w_register = "+str(self.fst_register)+" -> NOT LISTED\n" + txt += "\tnb_registers = "+str(self.nb_registers)+"\n" + txt += "\tnb_octets = "+str(self.nb_octets)+"\n" + txt += "\tvalues = 0x"+str(self.registers)+"\n" + if self.nb_registers > 0: + txt += "\t\t{\n" + v = [(self.registers[i:i+4]) for i in range(0, len(self.registers), 4)] + for i in range(self.nb_registers): + if int(v[i]) == 0: + txt+="\t\t\t"+str(self.fst_register+i)+" - "+self.registersNames[str(self.fst_register+i)]+" -> 0x"+v[i]+" (RESET)\n" + else: + txt+="\t\t\t"+str(self.fst_register+i)+" - "+self.registersNames[str(self.fst_register+i)]+" -> 0x"+v[i]+" (DECLENCHEMENT)\n" + txt += "\t\t}\n" + elif self.function_id == 15: + txt += "\tfunction_id = "+str(self.function_id)+" -> Write Multiple Coils\n" + txt += "\tfst_w_register = "+str(self.fst_register)+"\n" + txt += "\tnb_registers = "+str(self.nb_registers)+"\n" + txt += "\tnb_octets = "+str(self.nb_octets)+"\n" + txt += "\tvalues = "+str(self.registers)+"\n" + elif self.function_id == 129: + txt += "\tfunction_id = "+str(self.function_id)+" -> Exception\n" + elif self.function_id == 130 or self.function_id == 131 or self.function_id == 132 or self.function_id == 133 or self.function_id == 134 or self.function_id == 143 or self.function_id == 144: + txt += "\tfunction_id = "+str(self.function_id)+" -> Functional error code in response\n" + else: + txt += "\tfunction_id = "+str(self.function_id)+" -> ILLEGAL_FUNCTION | NON-MODBUS PACKET\n" + txt += "}\n" + else: + txt += "Modbus RX:{\n" + txt += "\ttransaction_id = "+str(self.transaction_id)+"\n" + txt += "\tproto_id = "+str(self.proto_id)+"\n" + txt += "\tlength (bytes) = "+str(self.length)+"\n" + txt += "\taddr_slave = "+str(self.addr_slave)+"\n" + if self.function_id == 1: + txt += "\tfunction_id = "+str(self.function_id)+" -> Read Coil\n" + elif self.function_id == 2: + txt += "\tfunction_id = "+str(self.function_id)+" -> Read Discrete Input\n" + elif self.function_id == 3: + txt += "\tfunction_id = "+str(self.function_id)+" -> Read Holding Registers\n" + txt += "\tnb_octets = "+str(self.nb_octets)+"\n" + txt += "\tvalue(s) = {" + if self.request is not None and self.request.fst_register is not None and self.nb_octets is not None and self.request.function_id == self.function_id: + dic = self.decodeValuesForRead() + for i in range(int(self.request.fst_register), int(self.nb_octets/2)+int(self.request.fst_register)): + if i%1==0: + txt += '\n' + txt += '\t\t\t'+str(i)+' ('+str(self.registersNames[str(i)])+') -> '+dic[i]+" ("+self.computeState(dic[i])+")" + txt += "\n\t\t}\n" + elif self.function_id == 4: + txt += "\tfunction_id = "+str(self.function_id)+" -> Read Input Registers\n" + elif self.function_id == 5: + txt += "\tfunction_id = "+str(self.function_id)+" -> Write Single Coil\n" + elif self.function_id == 6: + txt += "\tfunction_id = "+str(self.function_id)+" -> Write Single Holding Register\n" + elif self.function_id == 16: + txt += "\tfunction_id = "+str(self.function_id)+" -> Write Multiple Registers\n" + if str(self.fst_register) in self.registersNames.keys(): + txt += "\tfst_w_register = "+str(self.fst_register)+" - "+self.registersNames[str(self.fst_register)]+"\n" + else: + txt += "\tfst_w_register = "+str(self.fst_register)+" -> NOT LISTED\n" + txt += "\tnb_registers = "+str(self.nb_registers)+"\n" + elif self.function_id == 15: + txt += "\tfunction_id = "+str(self.function_id)+" -> Write Multiple Coils\n" + txt += "\tfst_w_register = "+str(self.fst_register)+"\n" + txt += "\tnb_registers = "+str(self.nb_registers)+"\n" + txt += "\tnb_octets = "+str(self.nb_octets)+"\n" + txt += "\tvalues = "+str(self.registers)+"\n" + elif self.function_id == 129: + txt += "\tfunction_id = "+str(self.function_id)+" -> Exception\n" + elif self.function_id == 130 or self.function_id == 131 or self.function_id == 132 or self.function_id == 133 or self.function_id == 134 or self.function_id == 143 or self.function_id == 144: + txt += "\tfunction_id = "+str(self.function_id)+" -> Functional error code in response\n" + else: + txt += "\tfunction_id = "+str(self.function_id)+" -> ILLEGAL_FUNCTION | NON-MODBUS PACKET\n" + txt += "}\n" + return(txt) + + def decodeValuesForRead(self): + dicRegisters = {} + j=0 + for i in range(self.request.fst_register, int(self.nb_octets/2)+self.request.fst_register): + dicRegisters[i]=self.registers[j:j+4] + j+=4 + return dicRegisters + + def computeState(self, state): + s = "" + if state[:2] == "00": + s += "KNOWN-COMMANDABLE" + elif state[:2] == "01": + s += "UNKNOWN-COMMANDABLE" + elif state[:2] == "02": + s += "KNOWN-UNCOMMANDABLE" + elif state[:2] == "03": + s += "UNKNOWN-UNCOMMANDABLE" + s += "-" + if state[2:] == "00": + if self.scada_ene and self.scada_eqpt: + s += "0V | PARC" + elif self.scada_ene and not self.scada_eqpt: + s += "0V" + else: + s += "PARC" + elif state[2:] == "01": + if self.scada_ene and self.scada_eqpt: + s += "900V | BREL" + elif self.scada_ene and not self.scada_eqpt: + s += "900V" + else: + s += "BREL" + return s + + def extractData(self): + self.cleanData() + try: + if len(self.data) > 16: + self.transaction_id = int(self.data[:4],16) + self.proto_id = int(self.data[4:8],16) + self.length = int(self.data[8:12],16) + self.addr_slave = int(self.data[12:14],16) + self.function_id = int(self.data[14:16],16) + if self.isTx: + self.fst_register = int(self.data[16:20],16) + if self.function_id == 3: + self.nb_registers = int(self.data[20:],16) + elif self.function_id == 16: + self.nb_registers = int(self.data[20:24],16) + self.nb_octets = int(self.data[24:26],16) + self.registers = self.data[26:] + elif self.function_id == 15: + self.nb_registers = int(self.data[20:24],16) + self.nb_octets = int(self.data[24:26],16) + self.registers = self.data[26:] + else: + if self.function_id == 3: + self.nb_octets = int(self.data[16:18],16) + self.registers = self.data[18:] + elif self.function_id == 16: + self.fst_register = int(self.data[16:20],16) + self.nb_registers = int(self.data[20:],16) + elif self.function_id == 15: + self.fst_register = int(self.data[16:20],16) + self.nb_registers = int(self.data[20:],16) + except Exception as e: + print('Wrong data to make a modbus message') + print(e) + # scapy is crap script .. he's sending Hex string with char when it's detected. E.g. you can have \x00V ... V is 56, then you can miss a byte :) + # So, we need to clean that shit + def cleanData(self): + chars="0123456789abcdef" + temp = "" + for b in self.data: + if b not in chars: + temp += str(binascii.hexlify(bytes(b, 'utf-8')))[1:] + else: + temp += b + self.data = temp + self.data = str(self.data).replace("'","") +def getListOfPacketsFromPcapng(file, ip_ats, ip_srvr): + packets = rdpcap(file) + listofpackets = [] + layer = "" + for packet in packets: + sortedPacket = {} + sortedPacket['timestamp']=packet.time + raw_modbus ="" + if 'Raw' in packet.show(dump=True): + for b in str(bytes_hex(packet['Raw'].load)).replace('x','').replace('\\','')[2:-1]: + raw_modbus+=str(b) + sortedPacket['raw_modbus']=raw_modbus + try: + for line in packet.show2(dump=True).split('\n'): + if '###' in line: + layer = line.strip('#[] ') + sortedPacket[layer] = {} + elif '=' in line: + key, val = line.split('=', 1) + sortedPacket[layer][key.strip()] = val.strip() + else: + pass + #Delete packet where IP are not correct and where there is no modbus data + if 'IP' in sortedPacket.keys(): + if (sortedPacket['IP']['src'] == ip_ats and sortedPacket['IP']['dst'] == ip_srvr) or (sortedPacket['IP']['src'] == ip_srvr and sortedPacket['IP']['dst'] == ip_ats): + if 'TCP' in sortedPacket.keys() and 'Raw' in sortedPacket.keys(): + listofpackets.append(sortedPacket) + except Exception as e: + print('***EXCEPTION***') + print(e) + return listofpackets + +def buildModbusObjects(packets, settings): + ListOfModbusObjects = [] + # All packets are taken in ATS sys (ATS = source of all) + for packet in packets: + if packet['IP']['src'] == settings.ip_ats and packet['IP']['dst'] == settings.ip_srvr: + tx = ModbusPacket(True, packet['IP']['src'], packet['IP']['dst'], packet['TCP']['sport'], packet['TCP']['dport'], datetime.datetime.fromtimestamp(int(packet['timestamp'])), packet['raw_modbus'], settings.registersNames, settings.scada_ene, settings.scada_eqpt, settings.plc) + ListOfModbusObjects.append(tx) + elif packet['IP']['src'] == settings.ip_srvr and packet['IP']['dst'] == settings.ip_ats: + tx = ModbusPacket(False, packet['IP']['src'], packet['IP']['dst'], packet['TCP']['sport'], packet['TCP']['dport'], datetime.datetime.fromtimestamp(int(packet['timestamp'])), packet['raw_modbus'], settings.registersNames, settings.scada_ene, settings.scada_eqpt, settings.plc) + ListOfModbusObjects.append(tx) + else: + pass + return ListOfModbusObjects + +if __name__ == '__main__': + print(text2art("DecodeModbusLog")) + print("Getting settings ...") + s = Settings('decode_modbus.json') + print("Parsing PCAPNG file: "+s.log_file+" ...") + packets = getListOfPacketsFromPcapng(s.log_file, s.ip_ats, s.ip_srvr) + print("Building Modbus Objetcs ...") + objects = buildModbusObjects(packets, s) + print("Printing all packets in output file:"+ s.output_file+" ...") + output_log = open(s.output_file, 'w') + output_log.write(text2art("DecodeModbusLog")) + if len(objects) == 0 : + output_log.write(str(len(objects))+" MODBUS PACKETS WERE FOUND :( !\n\n") + output_log.write("PLEASE CHECK JSON SETTINGS FILE (Are IP correct ?)") + else: + output_log.write(str(len(objects))+" MODBUS PACKETS WERE FOUND :) !\n\n") + for o in objects: + if not o.isTx: + for i in objects: + if i.transaction_id == o.transaction_id: + o.request = i + break + output_log.write(str(o)) + output_log.close() + print("OK.") + input() \ No newline at end of file