From: Jason Ish Date: Tue, 6 Jan 2026 17:33:40 +0000 (-0600) Subject: test: dnp3 flood test X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=7b54a185c4a8306e727d6f0a7396bdffa86b2822;p=thirdparty%2Fsuricata-verify.git test: dnp3 flood test Test that a DNP3 flood event is raised after 32 in-flight requests. Ticket: #8181 --- diff --git a/tests/dnp3/dnp3-flood/README.md b/tests/dnp3/dnp3-flood/README.md new file mode 100644 index 000000000..a3b981fdb --- /dev/null +++ b/tests/dnp3/dnp3-flood/README.md @@ -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 index 000000000..7d70ff02a --- /dev/null +++ b/tests/dnp3/dnp3-flood/gen_pcap.py @@ -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 index 000000000..272939527 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 index 000000000..1c9f19d75 --- /dev/null +++ b/tests/dnp3/dnp3-flood/suricata.yaml @@ -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 index 000000000..94c79a89d --- /dev/null +++ b/tests/dnp3/dnp3-flood/test.rules @@ -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 index 000000000..7fc4bd600 --- /dev/null +++ b/tests/dnp3/dnp3-flood/test.yaml @@ -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