alosafuzz
ICS/OT protocol security research — fuzzing, scanning, and coordinated disclosure.

A Researcher's Guide to the CODESYS V3 Name Service

Vendor: codesys · Sector: cross · Transport: codesys-udp · Project: icsfuzzer

Part of the alosafuzz ICS protocol-guide series — written for fuzzing and vulnerability researchers. Defensive posture: this documents the wire format, the fuzz surface, and published bug classes so researchers can build their own test harnesses. It contains no weaponized exploit or DoS code.


1. What it is & where it runs

CODESYS V3 is not a single vendor’s PLC — it is the OEM soft-PLC runtime embedded inside hundreds of PLC/HMI brands: WAGO, Beckhoff, Eaton, Festo, Berghof, ABB, Schneider, and many more all ship a licensed CODESYS Control runtime. That is what makes it a disproportionately valuable research target: a single parser bug in the shared runtime has a blast radius across the entire OEM ecosystem, not one product line.

The name service is CODESYS’s unauthenticated discovery layer. It is how the CODESYS IDE and gateway find runtimes on a network: a client sends a broadcast or unicast NameServiceRequest and a runtime answers with an Identification reply carrying its node name, device name, vendor, and target type/version. No authentication, no session, no token — by design, discovery predates any auth.

Reachability caveat — read this before you plan a campaign. On a modern CODESYS runtime (tested: CODESYS Control for Linux SL 4.22 / SDK 3.5.22.30) the UDP 1740 name service binds but stays silent — it does not answer the classic rapid7-era NameServiceRequest at all, even sent unicast, to the interface broadcast, or to 255.255.255.255, and even after the default enforced security policy is relaxed. See §5 and §9. The legacy “unauth UDP 1740 identity read” assumption still holds on older OEM runtimes and many fielded PLCs, but on current CODESYS the live discovery path has moved to the TCP gateway / OPC UA. Plan your reachability check first.

2. Session & transport model

There is no session. The name service is a single connectionless request/reply:

Client → Runtime (UDP 1740, bcast or unicast):  service_id 0x03  NameServiceRequest
Client ← Runtime:                                service_id 0x04  Identification reply

A healthy endpoint silently drops any datagram it does not like (bad magic, unknown service, truncated body). That silent-drop behavior is central to how you instrument the fuzzer (see §6): on UDP, a timeout is the normal refusal, not a crash.

The embedded gateway on TCP 11740 carries the same identification payload inside a length-prefixed, framed block-driver protocol (CmpBlkDrvTcp). It is reachable where the UDP NS is silent, but its frame grammar is not publicly documented — reaching the deep name-TLV parser through it is a reverse-engineering task (§9).

3. Wire format

The frame is a small block-driver header (big-endian) followed by a little-endian payload. The byte oracle below is reproduced from the rapid7 codesys3.lua dissector and verified byte-for-byte by the icsFuzzer builder.

Header (big-endian):

Off Size Field Researcher’s note
0 1 magic 0xC5
1 1 hopinfo (hop_count << 3) \| header_length; header_length = low 3 bits, counted in 16-bit words
2 1 packetinfo (priority<<6)\|(signal<<5)\|(addr_type<<4)\|(data_length & 0xF)
3 1 service_id 0x03 NameServiceRequest / 0x04 Identification reply — the dispatch byte
4 1 message_id per-message id
5 1 address_lengths (sender_words << 4) \| receiver_words — two nibbles, each a count of 16-bit words
6 2 broadcast_id big-endian
8 var sender address sender_words * 2 bytes (big-endian words)
.. var pad zero-pad up to a 4-byte boundary (faithful to the source’s len % 4 pad)

Payload (little-endian): package_type(H) version(H) request_id(I), then — in the Identification reply only — the identity TLVs:

maxChannels(H) byteorder(B) addrDiff(B) parentAddrSize(H)
node_len(H) dev_len(H) vend_len(H)
target_type(I) target_id(I) target_version(I) target_flags(I)
ser(B) oem(B) blk(B) + pad
parentAddr[parentAddrSize] bytes
node[node_len] / device[dev_len] / vendor[vend_len]  strings

The offset math is the whole game. The device locates the payload at:

pos = header_length*2 + (sender_words + receiver_words)*2, re-aligned up to a 4-byte boundary

So header_length and the two address_lengths nibbles are attacker-controlled inputs to a pointer computation. Over- or under-declare them and the parser reads the payload from an offset you chose — the classic length-vs-offset confusion surface.

Locked seed (NameServiceRequest, sender 192.168.1.50/24, bcast id 0x1234) — 20 bytes:

c5 7c 40 03 00 10 12 34 00 32 00 00 02 c2 00 04 69 69 20 04

This is the golden oracle: the icsFuzzer build_name_request() reproduces it byte-for-byte with default args, asserted in both the module’s unit test and icsScanner’s discovery smoke test.

4. The fuzz surface

The header is tiny and fixed-width, with several enum/dispatch/size fields the runtime must validate before it can even decide to drop a packet — a good fit for a structured generator. icsFuzzer splits it into five categories (524 cases):

The service sweep is deliberately held to 48% of the corpus (under a 50% single-category guideline) so no one dimension drowns the others.

Structured vs. coverage-guided. The interesting bugs are semantic — offset computation, declared-vs-actual length, dispatch of unknown enums — reachable from a single well-formed-enough datagram, which is exactly what a structured generator targets. A coverage-guided in-process fuzzer would be the stronger tool if you can compile a CODESYS component with sanitizers, but the runtime is closed-source; black-box structured generation against a real runtime (or the robustness responder in §6) is the practical path. The 0x04 reply-TLV parser in particular may live on the client/gateway side rather than the runtime’s request handler — see §9 before assuming your TLV cases reach the device.

5. Known vulnerabilities & bug classes

CODESYS V3’s runtime has a long advisory history in exactly these layers — name service, block driver, channel server, and the broader SDK component set. The directly-relevant verified records (confirmed against NVD / CODESYS advisories / CISA):

CVE Component Class (CWE) Auth Effect
CVE-2022-22508 CmpNameServiceServer improper input-consistency validation (CWE-1288) authenticated crafted packet sets an invalid node name → blocks later logins by node name (DoS). Fixed in V3.5.18.40 (CODESYS Advisory 2022-16).
CVE-2022-30791 CmpBlkDrvTcp uncontrolled resource consumption (CWE-400) unauthenticated connection-table exhaustion → runtime accepts no new TCP connections (DoS; existing connections unaffected).
CVE-2022-30792 CmpChannelServer resource consumption (CWE-400) — blocks new channel connections (DoS).

Note what these 2022 bugs are: availability / logic DoS, not memory corruption, and the name-service one is authenticated. The real memory-safety / RCE cluster is Microsoft’s “CoDe16” (reported to CODESYS Sept 2022, patched 2023): CVE-2022-47378 through CVE-2022-47393 (15–16 CVEs) across multiple runtime components — e.g. CVE-2022-47378 is an invalid-address read in CmpFiletransfer from inconsistent communication requests. Microsoft chained these to remote code execution on CODESYS PLCs; fixed in CODESYS V3.5.19.0 (some SL runtime families below 4.8.0.0). Most individually require authentication, but the research showed device authentication was frequently bypassable — so treat CoDe16 as the practical unauth-RCE surface for the V3 runtime.

CVE IDs and version thresholds above were verified October 2026; re-check the CODESYS Security Advisory portal / CERT@VDE for later additions before citing.

Bug classes this surface rewards:

Class CWE Where
Length/offset confusion CWE-125 / CWE-787 header_length, address_lengths → payload pointer
Declared-length-vs-actual overrun CWE-125 node_len / dev_len / vend_len / parentAddrSize
Dispatch of unknown enum CWE-20 service_id, package_type

A posture finding, not just a parser finding. On the modern tested runtime the UDP name service is silent by default because CODESYS 3.5.17+ ships an enforced security policy (CmpSecurityManager: UserLogin_AuthenticationType = ONLY_ASYMMETRIC, UnsignedApplicationFileTransfer = DENY, “Enforce signed communication”). That gates the legacy unauthenticated reply off — so current CODESYS is not discoverable/attackable via the old rapid7 UDP path out of the box. Relaxing the comm-security keys on a disposable demo runtime did not restore the reply, which points to NS grammar drift from the ~2015-era frame as well as the policy. That silence is itself worth reporting as a defensive improvement, and it reframes where the live attack surface now is (TCP gateway / OPC UA).

6. Building a test harness

There is no open-source CODESYS runtime to instrument, but — unusually for ICS — there is a free, official runtime to validate against: CODESYS Control for Linux SL (store.codesys.com, runs ~2 h in demo mode without a license). The .package is a ZIP containing codesyscontrol_linux_<ver>_amd64.deb; extract and run codesyscontrol.bin directly. It opens UDP 1740, TCP 11740 (gateway), and TCP 4840 (OPC UA). This closes the “no free stack” gap that plagues most ICS fuzzing.

For calibration without a device, use a robustness responder modeling a healthy endpoint: it answers a valid NameServiceRequest and silently drops everything else, writing a sidecar JSONL of each drop reason (bad magic / unknown service / bad package / truncated header / truncated payload).

Response classification — the finding-vs-noise line, UDP edition. Because a healthy name service drops malformed datagrams silently, treating every timeout as a crash would make this module over-report massively. The fix is a liveness heuristic:

This is the same calibration lesson first learned on LwM2M, baked into the CODESYS module from day one rather than discovered live.

Calibration baseline (icsFuzzer, software responder, 524 cases): 0 crash indicators, 0 false positives — 397 malformed drops → timeout → liveness-confirmed-alive → benign; 127 valid replies classified clean. Against a device that stays up, the module is silent, which is the property you want before pointing it at real hardware.

Live validation against the free runtime (CODESYS Control 3.5.22): the runtime survived the full corpus (524), a 5× soak (2620 datagrams), and a TCP-gateway fuzz pass (533 frames) — process alive throughout, gateway reachable post-run, clean log = a robustness result. Because the UDP NS is silent on this build, a reply-based liveness oracle can’t work, so the live harness falls back to a survival oracle: send the corpus raw to UDP 1740 and use a TCP connect to the gateway (11740) as the alive/crash signal, pinpointing the first case-block after which the gateway never recovers. Honest scope: survival is confirmed; because the NS is silent, each case reaching the deep name-TLV (0x04) parser is not confirmed (the early header parser is exercised — the component must parse it to decide to drop).

7. Identifying CODESYS over TCP (when UDP 1740 is silent)

Because modern runtimes answer nothing on UDP 1740, asset identification should lean on TCP. All of these are passive-connect or unauthenticated reads (identification, not exploitation):

To recover the full identity the silent UDP NS would otherwise give (node/device/vendor/target_version), two routes, both scoped follow-ons: OPC UA Server node reads (4840), or the gateway CmpBlkDrvTcp scan request (11740, needs the frame RE’d).

8. Spec references & sources

9. Open questions / under-explored surface

Worked example: protocols/codesys_v3/fuzzer.py in the icsFuzzer project implements all five categories above, with scripts/codesys_v3_responder.py as the robustness testbed and scripts/validate_codesys_live.py --liveness-tcp as the survival oracle for a silent device.