]> git.ipfire.org Git - thirdparty/suricata-verify.git/commitdiff
test: dnp3 flood test
authorJason Ish <jason.ish@oisf.net>
Tue, 6 Jan 2026 17:33:40 +0000 (11:33 -0600)
committerVictor Julien <vjulien@oisf.net>
Wed, 28 Jan 2026 20:21:02 +0000 (20:21 +0000)
Test that a DNP3 flood event is raised after 32 in-flight requests.

Ticket: #8181

tests/dnp3/dnp3-flood/README.md [new file with mode: 0644]
tests/dnp3/dnp3-flood/gen_pcap.py [new file with mode: 0644]
tests/dnp3/dnp3-flood/input.pcap [new file with mode: 0644]
tests/dnp3/dnp3-flood/suricata.yaml [new file with mode: 0644]
tests/dnp3/dnp3-flood/test.rules [new file with mode: 0644]
tests/dnp3/dnp3-flood/test.yaml [new file with mode: 0644]

diff --git a/tests/dnp3/dnp3-flood/README.md b/tests/dnp3/dnp3-flood/README.md
new file mode 100644 (file)
index 0000000..a3b981f
--- /dev/null
@@ -0,0 +1 @@
+Test DNP3 flood event.
diff --git a/tests/dnp3/dnp3-flood/gen_pcap.py b/tests/dnp3/dnp3-flood/gen_pcap.py
new file mode 100644 (file)
index 0000000..7d70ff0
--- /dev/null
@@ -0,0 +1,130 @@
+from scapy.all import *
+import struct
+import sys
+
+# Configuration
+NUM_PACKETS = 33
+OUTPUT_FILENAME = f"dnp3-{NUM_PACKETS}-inflight.pcap"
+
+def dnp3_crc(data):
+    crc = 0x0000
+    for byte in data:
+        crc = crc ^ byte
+        for _ in range(8):
+            if crc & 1:
+                crc = (crc >> 1) ^ 0xA6BC
+            else:
+                crc = crc >> 1
+    c = (~crc) & 0xFFFF
+    return c.to_bytes(2, byteorder='little')
+
+def build_dnp3_frame(transport_seq, app_seq, src_addr, dst_addr, is_request=True):
+    # Construct User Data
+    # Transport Header
+    # FIN=1, FIR=1, SEQ
+    th = 0xC0 | (transport_seq & 0x3F)
+    
+    # App Header
+    # FIN=1, FIR=1, CON=0, SEQ
+    ah = 0xC0 | (app_seq & 0x0F)
+    
+    if is_request:
+        # Select Request
+        # Funct: 03 (Select)
+        # Obj 12, Var 1, Qual 28, Range 1, Index 1
+        # Code 3 (Latch On), Count 1, On 100, Off 100, Status 0
+        payload = bytes.fromhex("03 0c 01 28 01 00 01 00 03 01 64 00 00 00 64 00 00 00 00")
+    else:
+        # Response
+        # Funct: 81 (Response)
+        # IIN: 00 00
+        # Then same object data usually
+        payload = bytes.fromhex("81 00 00 0c 01 28 01 00 01 00 03 01 64 00 00 00 64 00 00 00 00")
+
+    user_data = bytes([th, ah]) + payload
+    
+    # Calculate chunks for the wire (with CRCs)
+    chunks = []
+    chunk_size = 16
+    for i in range(0, len(user_data), chunk_size):
+        chunk = user_data[i:i+chunk_size]
+        crc = dnp3_crc(chunk)
+        chunks.append(chunk + crc)
+        
+    full_payload_with_crcs = b"".join(chunks)
+    
+    # Calculate DNP3 Length Field
+    # Length = 5 (Ctrl + Dst + Src) + User Data Length (excluding CRCs)
+    length = 5 + len(user_data)
+    
+    # Header
+    # Start 05 64
+    # Len
+    # Ctrl: DIR=1, PRM=1 (Request) or 0 (Response)?
+    # Template Request: c4 (DIR=1, PRM=1)
+    # Template Response: 44 (DIR=0, PRM=1) -> Outstation to Master.
+    
+    ctrl = 0xC4 if is_request else 0x44
+    
+    # Dst, Src (2 bytes each, LE)
+    dst_bytes = dst_addr.to_bytes(2, byteorder='little')
+    src_bytes = src_addr.to_bytes(2, byteorder='little')
+    
+    header_block = bytes([0x05, 0x64, length, ctrl]) + dst_bytes + src_bytes
+    header_crc = dnp3_crc(header_block)
+    
+    return header_block + header_crc + full_payload_with_crcs
+
+# IP/TCP config
+src_ip = "192.168.1.100"
+dst_ip = "192.168.1.200"
+src_port = 49404
+dst_port = 20000
+
+# Initial Sequence Numbers
+client_seq = 1000
+server_seq = 5000
+
+packets = []
+
+# Handshake
+syn = IP(src=src_ip, dst=dst_ip)/TCP(sport=src_port, dport=dst_port, flags="S", seq=client_seq)
+packets.append(syn)
+client_seq += 1
+
+synack = IP(src=dst_ip, dst=src_ip)/TCP(sport=dst_port, dport=src_port, flags="SA", seq=server_seq, ack=client_seq)
+packets.append(synack)
+server_seq += 1
+
+ack = IP(src=src_ip, dst=dst_ip)/TCP(sport=src_port, dport=dst_port, flags="A", seq=client_seq, ack=server_seq)
+packets.append(ack)
+
+# Requests
+req_dnp_addr_src = 3
+req_dnp_addr_dst = 2
+
+for i in range(NUM_PACKETS):
+    dnp_frame = build_dnp3_frame(i, i, req_dnp_addr_src, req_dnp_addr_dst, is_request=True)
+    
+    pkt = IP(src=src_ip, dst=dst_ip)/TCP(sport=src_port, dport=dst_port, flags="PA", seq=client_seq, ack=server_seq)/Raw(load=dnp_frame)
+    packets.append(pkt)
+    client_seq += len(dnp_frame)
+
+# Server ACKs all requests
+ack_server = IP(src=dst_ip, dst=src_ip)/TCP(sport=dst_port, dport=src_port, flags="A", seq=server_seq, ack=client_seq)
+packets.append(ack_server)
+
+# Teardown
+fin = IP(src=src_ip, dst=dst_ip)/TCP(sport=src_port, dport=dst_port, flags="FA", seq=client_seq, ack=server_seq)
+packets.append(fin)
+client_seq += 1
+
+finack = IP(src=dst_ip, dst=src_ip)/TCP(sport=dst_port, dport=src_port, flags="FA", seq=server_seq, ack=client_seq)
+packets.append(finack)
+server_seq += 1
+
+ack_final = IP(src=src_ip, dst=dst_ip)/TCP(sport=src_port, dport=dst_port, flags="A", seq=client_seq, ack=server_seq)
+packets.append(ack_final)
+
+wrpcap(OUTPUT_FILENAME, packets)
+print(f"Created {OUTPUT_FILENAME}")
diff --git a/tests/dnp3/dnp3-flood/input.pcap b/tests/dnp3/dnp3-flood/input.pcap
new file mode 100644 (file)
index 0000000..2729395
Binary files /dev/null and b/tests/dnp3/dnp3-flood/input.pcap differ
diff --git a/tests/dnp3/dnp3-flood/suricata.yaml b/tests/dnp3/dnp3-flood/suricata.yaml
new file mode 100644 (file)
index 0000000..1c9f19d
--- /dev/null
@@ -0,0 +1,21 @@
+%YAML 1.1
+---
+
+outputs:
+  - eve-log:
+      enabled: yes
+      filetype: regular
+      filename: eve.json
+
+      types:
+        - alert
+        - anomaly
+        - dnp3
+        - flow
+
+app-layer:
+  protocols:
+    dnp3:
+      enabled: yes
+      detection-ports:
+        dp: 20000
diff --git a/tests/dnp3/dnp3-flood/test.rules b/tests/dnp3/dnp3-flood/test.rules
new file mode 100644 (file)
index 0000000..94c79a8
--- /dev/null
@@ -0,0 +1,30 @@
+# DNP3 application decoder event rules.
+#
+# This SIDs fall in the 2270000+ range. See:
+#    http://doc.emergingthreats.net/bin/view/Main/SidAllocation
+
+# Flooded.
+alert dnp3 any any -> any any (msg:"SURICATA DNP3 Request flood detected"; \
+      app-layer-event:dnp3.flooded; classtype:protocol-command-decode; sid:2270000; rev:2;)
+
+# Length to small for PDU type. For example, link specifies the type
+# as user data, but the length field is not large enough for user
+# data.
+alert dnp3 any any -> any any (msg:"SURICATA DNP3 Length too small"; \
+      app-layer-event:dnp3.len_too_small; classtype:protocol-command-decode; sid:2270001; rev:3;)
+
+# Bad link layer CRC.
+alert dnp3 any any -> any any (msg:"SURICATA DNP3 Bad link CRC"; \
+      app-layer-event:dnp3.bad_link_crc; classtype:protocol-command-decode; sid:2270002; rev:2;)
+
+# Bad transport layer CRC.
+alert dnp3 any any -> any any (msg:"SURICATA DNP3 Bad transport CRC"; \
+      app-layer-event:dnp3.bad_transport_crc; classtype:protocol-command-decode; sid:2270003; rev:2;)
+
+# Unknown object.
+alert dnp3 any any -> any any (msg:"SURICATA DNP3 Unknown object"; \
+      app-layer-event:dnp3.unknown_object; classtype:protocol-command-decode; sid:2270004; rev:2;)
+
+# Object count exceeds maximum points per object.
+alert dnp3 any any -> any any (msg:"SURICATA DNP3 max points per object exceeded"; \
+      app-layer-event:dnp3.max_points_per_object; classtype:protocol-command-decode; sid:2270005; rev:1;)
diff --git a/tests/dnp3/dnp3-flood/test.yaml b/tests/dnp3/dnp3-flood/test.yaml
new file mode 100644 (file)
index 0000000..7fc4bd6
--- /dev/null
@@ -0,0 +1,14 @@
+requires:
+  min-version: 9.0.0
+
+checks:
+  - filter:
+      count: 33
+      match:
+        event_type: dnp3
+        dnp3.type: request
+  - filter:
+      count: 1
+      match:
+        event_type: alert
+        alert.signature_id: 2270000