├── LICENSE ├── README.rst ├── __init__.py ├── cip.py ├── enip_cpf.py ├── enip_tcp.py ├── enip_udp.py ├── plc.py └── utils.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 SCy-Phy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Ethernet/IP dissectors for Scapy 3 | ================================ 4 | 5 | This repository contains a Python library which can be used to interact with components of a network using ENIP (Ethernet/IP) and CIP (Common Industrial Protocol) protocols. 6 | It uses scapy (http://www.secdev.org/projects/scapy/) to implement packet dissectors which are able to decode a part of the network traffic. 7 | These dissectors can also be used to craft packets, which allows directly communicating with the PLCs (Programmable Logic Controllers) of the network. 8 | 9 | This project has been created to help analyzing the behavior of SWaT, a water treatment testbed built at SUTD (Singapore University of Technology and Design). For more information on our work, visit http://scy-phy.net 10 | 11 | Therefore, it mostly implements a subset of CIP specification, which is used in this system. 12 | 13 | 14 | Requirements 15 | ============ 16 | 17 | * Python 2.7 18 | * Scapy (http://www.secdev.org/projects/scapy/) 19 | 20 | 21 | Example of packet decoding 22 | ========================== 23 | 24 | Here is the raw content of a packet sent to a PLC to query a tag (in SWaT), as seen by an hexadecimal viewer:: 25 | 26 | 00000000: 801d 9cc8 bde7 001d 9cc6 72e8 0800 4500 ..........r...E. 27 | 00000010: 005e 2f95 4000 8006 4746 c0a8 0164 c0a8 .^/.@...GF...d.. 28 | 00000020: 010a c203 af12 8e7a 4387 01bd 1e5e 5018 .......zC....^P. 29 | 00000030: 829c 2a07 0000 7000 1e00 0200 1600 0000 ..*...p......... 30 | 00000040: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 31 | 00000050: 0000 0000 0200 a100 0400 2042 b5ff b100 .......... B.... 32 | 00000060: 0a00 8a07 4c03 20b2 2500 2200 ....L. .%.". 33 | 34 | This packet can be decoded using this Python script: 35 | 36 | .. code-block:: python 37 | 38 | #!/usr/bin/env python2 39 | import binascii 40 | from scapy.all import * 41 | import cip 42 | 43 | rawpkt = binascii.unhexlify( 44 | '801d9cc8bde7001d9cc672e808004500005e2f95400080064746c0a80164' 45 | 'c0a8010ac203af128e7a438701bd1e5e5018829c2a07000070001e000200' 46 | '1600000000000000000000000000000000000000000000000200a1000400' 47 | '2042b5ffb1000a008a074c0320b225002200') 48 | pkt = Ether(rawpkt) 49 | pkt.show() 50 | 51 | This script prints the structure of the packet with every protocol layer (Ethernet, IP, ENIP and CIP):: 52 | 53 | ###[ Ethernet ]### 54 | dst = 00:1d:9c:c8:bd:e7 55 | src = 00:1d:9c:c6:72:e8 56 | type = 0x800 57 | ###[ IP ]### 58 | version = 4L 59 | ihl = 5L 60 | tos = 0x0 61 | len = 94 62 | id = 12181 63 | flags = DF 64 | frag = 0L 65 | ttl = 128 66 | proto = tcp 67 | chksum = 0x4746 68 | src = 192.168.1.100 69 | dst = 192.168.1.10 70 | \options \ 71 | ###[ TCP ]### 72 | sport = 49667 73 | dport = EtherNet_IP_2 74 | seq = 2390377351 75 | ack = 29171294 76 | dataofs = 5L 77 | reserved = 0L 78 | flags = PA 79 | window = 33436 80 | chksum = 0x2a07 81 | urgptr = 0 82 | options = [] 83 | ###[ ENIP_TCP ]### 84 | command_id= SendUnitData 85 | length = 30 86 | session = 1441794 87 | status = success 88 | sender_context= 0 89 | options = 0 90 | ###[ ENIP_SendUnitData ]### 91 | interface_handle= 0 92 | timeout = 0 93 | count = 2 94 | \items \ 95 | |###[ ENIP_SendUnitData_Item ]### 96 | | type_id = conn_address 97 | | length = 4 98 | |###[ ENIP_ConnectionAddress ]### 99 | | connection_id= 4290069024 100 | |###[ ENIP_SendUnitData_Item ]### 101 | | type_id = conn_packet 102 | | length = 10 103 | |###[ ENIP_ConnectionPacket ]### 104 | | sequence = 1930 105 | |###[ CIP ]### 106 | | direction = request 107 | | service = Read_Tag_Service 108 | | \path \ 109 | | |###[ CIP_Path ]### 110 | | | wordsize = 3 111 | | | path = class 0xb2,instance 0x22 112 | | \status \ 113 | 114 | Moreover, each component of the packet is accessible in Python. 115 | For example, adding ``print(pkt[cip.CIP].path)`` at the end of the script shows the path of the tag being queried in this CIP request:: 116 | 117 | [] 118 | 119 | 120 | Interfacing with a PLC 121 | ====================== 122 | 123 | The scapy dissectors can be used to craft packet and therefore communicate with a PLC using ENIP and CIP. 124 | These communications require several handshakes: 125 | 126 | * a TCP handshake to establish a communication channel, 127 | * an ENIP handshake to register an ENIP session, 128 | * an optional CIP handshake (with ForwardOpen messages). 129 | 130 | The file ``plc.py`` provides ``PLCClient`` class, which implements an abstraction level of the state of a communication with a PLC. 131 | Here is for example how to use this class to read tag ``HMI_LIT101`` on the PLC sitting at address ``192.168.1.10``: 132 | 133 | .. code-block:: python 134 | 135 | import logging 136 | import sys 137 | 138 | from cip import CIP, CIP_Path 139 | import plc 140 | 141 | logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.DEBUG) 142 | 143 | # Connect to PLC 144 | client = plc.PLCClient('192.168.1.10') 145 | if not client.connected: 146 | sys.exit(1) 147 | print("Established session {}".format(client.session_id)) 148 | 149 | if not client.forward_open(): 150 | sys.exit(1) 151 | 152 | # Send a CIP ReadTag request 153 | cippkt = CIP(service=0x4c, path=CIP_Path.make_str("HMI_LIT101")) 154 | client.send_unit_cip(cippkt) 155 | 156 | # Receive the response and show it 157 | resppkt = client.recv_enippkt() 158 | resppkt[CIP].show() 159 | 160 | # Close the connection 161 | client.forward_close() 162 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scy-phy/scapy-cip-enip/89bc10382e8f0c79802950d7b9fca6f8591c1e90/__init__.py -------------------------------------------------------------------------------- /cip.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | # Copyright (c) 2015 Nicolas Iooss, SUTD; David I. Urbina, UTD 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | """Common Industrial Protocol dissector 23 | 24 | Documentation: 25 | * http://literature.rockwellautomation.com/idc/groups/literature/documents/pm/1756-pm020_-en-p.pdf 26 | 27 | Wireshark implementation: 28 | https://code.wireshark.org/review/gitweb?p=wireshark.git;a=blob;f=epan/dissectors/packet-cip.c 29 | """ 30 | import struct 31 | import sys 32 | 33 | from scapy import all as scapy_all 34 | 35 | import enip_tcp 36 | import utils 37 | 38 | 39 | class CIP_RespSingleAttribute(scapy_all.Packet): 40 | """An attribute... not much information about it""" 41 | fields_desc = [scapy_all.StrField("value", None)] 42 | 43 | 44 | class CIP_RespAttributesAll(scapy_all.Packet): 45 | """Content of Get_Attribute_All response""" 46 | fields_desc = [ 47 | scapy_all.StrField("value", None), 48 | ] 49 | 50 | 51 | class CIP_RespAttributesList(scapy_all.Packet): 52 | """List of attributes in Get_Attribute_List responses 53 | 54 | There are "count" attributes in the "content" field, in the following format: 55 | * attribute ID (INT, LEShortField) 56 | * status (INT, LEShortField, 0 means success) 57 | * value, type and length depends on the attribute and thus can not be known here 58 | """ 59 | fields_desc = [ 60 | scapy_all.LEShortField("count", 0), 61 | scapy_all.StrField("content", ""), 62 | ] 63 | 64 | def split_guess(self, attr_list, verbose=False): 65 | """Split the content of the Get_Attribute_List response with the known attribute list 66 | 67 | Return a list of (attr, value) tuples, or None if an error occured 68 | """ 69 | content = self.content 70 | offset = 0 71 | idx = 0 72 | result = [] 73 | while offset < len(content): 74 | attr, status = struct.unpack("> 2) & 7 184 | result.append((seg_type, seg_value)) 185 | return result 186 | 187 | @classmethod 188 | def tuplelist2repr(cls, val_tuplelist): 189 | """Represent a path tuplelist into a human-readable text""" 190 | if -1 in val_tuplelist and list(val_tuplelist.keys()) == [-1]: 191 | # String path 192 | return repr(val_tuplelist[-1]) 193 | 194 | descriptions = [] 195 | for type_id, value in val_tuplelist: 196 | desc = cls.SEGMENT_TYPES.get(type_id, "type{}".format(type_id)) 197 | desc += " 0x{:x}".format(value) 198 | if type_id == 0 and value in cls.KNOWN_CLASSES: 199 | desc += "({})".format(cls.KNOWN_CLASSES[value]) 200 | descriptions.append(desc) 201 | return ",".join(descriptions) 202 | 203 | @classmethod 204 | def i2repr(cls, pkt, val): 205 | """Decode the path "val" as human-readable text""" 206 | return cls.tuplelist2repr(cls.to_tuplelist(val)) 207 | 208 | 209 | class CIP_Path(scapy_all.Packet): 210 | name = "CIP_Path" 211 | fields_desc = [ 212 | scapy_all.ByteField("wordsize", None), 213 | CIP_PathField("path", None, length_from=lambda p: 2 * p.wordsize), 214 | ] 215 | 216 | def extract_padding(self, p): 217 | return "", p 218 | 219 | @classmethod 220 | def make(cls, class_id=None, instance_id=None, member_id=None, attribute_id=None): 221 | """Create a CIP_Path from its attributes""" 222 | content = b"" 223 | if class_id is not None: 224 | if class_id < 256: # 8-bit class ID 225 | content += b"\x20" + struct.pack("B", class_id) 226 | else: # 16-bit class ID 227 | content += b"\x21\0" + struct.pack("".format(self.ERROR_CODES[self.status]) 332 | 333 | # Simple status 334 | if self.additional_size == 0: 335 | return "" % self.status 336 | 337 | # Forward Open failure 338 | if self.status == 1 and self.additional == b"\x00\x01": 339 | return "" 340 | return scapy_all.Packet.__repr__(self) 341 | 342 | 343 | class CIP(scapy_all.Packet): 344 | name = "CIP" 345 | 346 | SERVICE_CODES = { 347 | 0x01: "Get_Attribute_All", 348 | 0x02: "Set_Attribute_All", 349 | 0x03: "Get_Attribute_List", 350 | 0x04: "Set_Attribute_List", 351 | 0x05: "Reset", 352 | 0x06: "Start", 353 | 0x07: "Stop", 354 | 0x08: "Create", 355 | 0x09: "Delete", 356 | 0x0a: "Multiple_Service_Packet", 357 | 0x0d: "Apply_attributes", 358 | 0x0e: "Get_Attribute_Single", 359 | 0x10: "Set_Attribute_Single", 360 | 0x4b: "Execute_PCCC_Service", # PCCC = Programmable Controller Communication Commands 361 | 0x4c: "Read_Tag_Service", 362 | 0x4d: "Write_Tag_Service", 363 | 0x4e: "Read_Modify_Write_Tag_Service", 364 | 0x4f: "Read_Other_Tag_Service", # ??? 365 | 0x52: "Read_Tag_Fragmented_Service", 366 | 0x53: "Write_Tag_Fragmented_Service", 367 | 0x54: "Forward_Open?", 368 | } 369 | 370 | fields_desc = [ 371 | scapy_all.BitEnumField("direction", None, 1, {0: "request", 1: "response"}), 372 | utils.XBitEnumField("service", 0, 7, SERVICE_CODES), 373 | scapy_all.PacketListField("path", [], CIP_Path, 374 | count_from=lambda p: 1 if p.direction == 0 else 0), 375 | scapy_all.PacketListField("status", [], CIP_ResponseStatus, 376 | count_from=lambda p: 1 if p.direction == 1 else 0), 377 | ] 378 | 379 | def post_build(self, p, pay): 380 | is_response = (self.direction == 1) 381 | if self.direction is None and not self.path: 382 | # Transform the packet into a response 383 | p = "\x01" + p[1:] 384 | is_response = True 385 | 386 | if is_response: 387 | # Add a success status field if there was none 388 | if not self.status: 389 | p = p[0:1] + b"\0\0\0" + p[1:] 390 | return p + pay 391 | 392 | 393 | class _CIPMSPPacketList(scapy_all.PacketListField): 394 | """The list of packets in a CIP MultipleServicePacket message""" 395 | 396 | def getfield(self, pkt, remain): 397 | lst = [] 398 | pkt_count = pkt.count 399 | cur_offset = 2 + 2 * pkt_count 400 | shift = pkt.offsets[0] - cur_offset 401 | if shift > 0: 402 | # There is some padding between the CIP MSP header and the first packet 403 | lst.append(scapy_all.conf.raw_layer(load=remain[:shift])) 404 | remain = remain[shift:] 405 | cur_offset += shift 406 | for off in pkt.offsets[1:]: 407 | # Decode packet remain[:off - cur_offset] 408 | try: 409 | p = self.m2i(pkt, remain[:off - cur_offset]) 410 | except Exception: 411 | if scapy_all.conf.debug_dissector: 412 | raise 413 | p = scapy_all.conf.raw_layer(load=remain[:off - cur_offset]) 414 | remain = remain[off - cur_offset:] 415 | lst.append(p) 416 | cur_offset = off 417 | 418 | if remain: 419 | # Last packet contains all the remaining data 420 | try: 421 | p = self.m2i(pkt, remain) 422 | except Exception: 423 | if scapy_all.conf.debug_dissector: 424 | raise 425 | p = scapy_all.conf.raw_layer(load=remain) 426 | lst.append(p) 427 | return "", lst 428 | 429 | 430 | class CIP_ConnectionParam(scapy_all.Packet): 431 | """CIP Connection parameters""" 432 | name = "CIP_ConnectionParam" 433 | fields_desc = [ 434 | scapy_all.BitEnumField("owner", 0, 1, {0: "exclusive", 1: "multiple"}), 435 | scapy_all.BitEnumField("connection_type", 2, 2, 436 | {0: "null", 1: "multicast", 2: "point-to-point", 3: "reserved"}), 437 | scapy_all.BitField("reserved", 0, 1), 438 | scapy_all.BitEnumField("priority", 0, 2, {0: "low", 1: "high", 2: "scheduled", 3: "urgent"}), 439 | scapy_all.BitEnumField("connection_size_type", 0, 1, {0: "fixed", 1: "variable"}), 440 | scapy_all.BitField("connection_size", 500, 9), 441 | ] 442 | 443 | def pre_dissect(self, s): 444 | b = struct.unpack('H', int(b)) + s[2:] 446 | 447 | def do_build(self): 448 | p = '' 449 | return p 450 | 451 | def extract_padding(self, s): 452 | return '', s 453 | 454 | 455 | class CIP_ReqForwardOpen(scapy_all.Packet): 456 | """Forward Open request""" 457 | name = "CIP_ReqForwardOpen" 458 | fields_desc = [ 459 | scapy_all.BitField("priority", 0, 4), 460 | scapy_all.BitField("tick_time", 0, 4), 461 | scapy_all.ByteField("timeout_ticks", 249), 462 | scapy_all.LEIntField("OT_network_connection_id", 0x80000031), 463 | scapy_all.LEIntField("TO_network_connection_id", 0x80fe0030), 464 | scapy_all.LEShortField("connection_serial_number", 0x1337), 465 | scapy_all.LEShortField("vendor_id", 0x004d), 466 | scapy_all.LEIntField("originator_serial_number", 0xdeadbeef), 467 | scapy_all.ByteField("connection_timeout_multiplier", 0), 468 | scapy_all.X3BytesField("reserved", 0), 469 | scapy_all.LEIntField("OT_rpi", 0x007a1200), # 8000 ms 470 | scapy_all.PacketField('OT_connection_param', CIP_ConnectionParam(), CIP_ConnectionParam), 471 | scapy_all.LEIntField("TO_rpi", 0x007a1200), 472 | scapy_all.PacketField('TO_connection_param', CIP_ConnectionParam(), CIP_ConnectionParam), 473 | scapy_all.XByteField("transport_type", 0xa3), # direction server, application object, class 3 474 | scapy_all.ByteField("path_wordsize", None), 475 | CIP_PathField("path", None, length_from=lambda p: 2 * p.path_wordsize), 476 | ] 477 | 478 | 479 | class CIP_RespForwardOpen(scapy_all.Packet): 480 | """Forward Open response""" 481 | name = "CIP_RespForwardOpen" 482 | fields_desc = [ 483 | scapy_all.LEIntField("OT_network_connection_id", None), 484 | scapy_all.LEIntField("TO_network_connection_id", None), 485 | scapy_all.LEShortField("connection_serial_number", None), 486 | scapy_all.LEShortField("vendor_id", None), 487 | scapy_all.LEIntField("originator_serial_number", None), 488 | scapy_all.LEIntField("OT_api", None), 489 | scapy_all.LEIntField("TO_api", None), 490 | scapy_all.ByteField("application_reply_size", None), 491 | scapy_all.XByteField("reserved", 0), 492 | ] 493 | 494 | 495 | class CIP_ReqForwardClose(scapy_all.Packet): 496 | """Forward Close request""" 497 | name = "CIP_ReqForwardClose" 498 | fields_desc = [ 499 | scapy_all.XByteField("priority_ticktime", 0), 500 | scapy_all.ByteField("timeout_ticks", 249), 501 | scapy_all.LEShortField("connection_serial_number", 0x1337), 502 | scapy_all.LEShortField("vendor_id", 0x004d), 503 | scapy_all.LEIntField("originator_serial_number", 0xdeadbeef), 504 | scapy_all.ByteField("path_wordsize", None), 505 | scapy_all.XByteField("reserved", 0), 506 | CIP_PathField("path", None, length_from=lambda p: 2 * p.path_wordsize), 507 | ] 508 | 509 | 510 | class CIP_MultipleServicePacket(scapy_all.Packet): 511 | """Multiple_Service_Packet request or response""" 512 | name = "CIP_MultipleServicePacket" 513 | fields_desc = [ 514 | utils.LEShortLenField("count", None, count_of="packets"), 515 | scapy_all.FieldListField("offsets", [], scapy_all.LEShortField("", 0), 516 | count_from=lambda pkt: pkt.count), 517 | # Assume the offsets are increasing, and no padding. FIXME: remove this assumption 518 | _CIPMSPPacketList("packets", [], CIP) 519 | ] 520 | 521 | def do_build(self): 522 | """Build the packet by concatenating packets and building the offsets list""" 523 | # Build the sub packets 524 | subpkts = [str(pkt) for pkt in self.packets] 525 | # Build the offset lists 526 | current_offset = 2 + 2 * len(subpkts) 527 | offsets = [] 528 | for p in subpkts: 529 | offsets.append(struct.pack(" 0: 211 | cippkt = CIP(service=0x4c, path=CIP_Path.make(class_id=class_id, instance_id=instance_id)) 212 | cippkt /= CIP_ReqReadOtherTag(start=offset, length=remaining_size) 213 | self.send_rr_cm_cip(cippkt) 214 | if self.sock is None: 215 | return 216 | resppkt = self.recv_enippkt() 217 | 218 | cipstatus = resppkt[CIP].status[0].status 219 | received_data = str(resppkt[CIP].payload) 220 | if cipstatus == 0: 221 | # Success 222 | assert len(received_data) == remaining_size 223 | elif cipstatus == 6 and len(received_data) > 0: 224 | # Partial response (size too big) 225 | pass 226 | else: 227 | logger.error("Error in Read Tag response: %r", resppkt[CIP].status[0]) 228 | return 229 | 230 | # Remember the chunk and continue 231 | data_chunks.append(received_data) 232 | offset += len(received_data) 233 | remaining_size -= len(received_data) 234 | return b''.join(data_chunks) 235 | 236 | @staticmethod 237 | def attr_format(attrval): 238 | """Format an attribute value to be displayed to a human""" 239 | if len(attrval) == 1: 240 | # 1-byte integer 241 | return hex(struct.unpack('B', attrval)[0]) 242 | elif len(attrval) == 2: 243 | # 2-byte integer 244 | return hex(struct.unpack('