Point a protocol fuzzer at an industrial device and it will happily report hundreds of crashes. Most of that number tells you more about your test harness than about the device, and separating the two is most of the work.

Over the past couple of months I built an ICS/OT protocol fuzzer and ran it against real control-systems gear: an ESP32 running two Modbus stacks, OpenPLC v3 on a Raspberry Pi 4, and OpenPLC v4 in Docker. This post is less a list of vulnerabilities than an account of that separation work — how 716 reported crashes resolved to a single bug, how reading source kept one finding from being overstated, and how five maintainers responded when the survivors were reported.

A single idea runs underneath all of it. A fuzzer surfaces symptoms; the value is in turning a symptom into a diagnosis a maintainer can act on, and, where possible, confirming the fix once it lands. Finding is the easy half. One of the bugs below went from fuzzer hit to merged upstream fix in a single day, largely because the report did the diagnostic work first.

The tool

icsFuzzer is a modular fuzzer covering ten ICS protocols: Modbus, DNP3, OPC-UA, EtherNet/IP, IEC 61850/MMS, BACnet, IEC 104, PROFINET, S7comm, and FINS. Each protocol module generates malformed and out-of-spec traffic, classifies the responses, and ships with a software responder — a spec-faithful mock server that logs every request it handles to a sidecar file.

That responder is what makes the rest trustworthy. On a live device, a dropped connection is ambiguous: the device may have crashed, or it may have correctly rejected a bad frame. When the mock records that it closed a connection deliberately — because the protocol ID was wrong, say — each crash indicator resolves to either an expected close or a genuine gap. You calibrate against the mock first, then trust the differences you see on real hardware.

What I tested: coverage at a glance

Not every protocol had a live target. Five were exercised only against the software responder for calibration, because I had no real device that spoke them. The honest map:

Protocol Live target(s) Outcome
Modbus ESP32 (emelianov), ESP32 (ESP-IDF), OpenPLC v3, OpenPLC v4 Findings on all four — see deep dives
S7comm OpenPLC v3 & v4 (snap7) Crash (CWE-787) + connection-exhaustion DoS
OPC-UA OpenPLC v4 Two findings — one conditional access-control, one low-severity interop
EtherNet/IP OpenPLC v3 Low: CIP silent-drop of unsupported services (spec non-compliance)
DNP3 OpenPLC v3 (RPi4) Robust — no findings across the fuzz corpus
IEC 61850 / MMS libiec61850 (RPi4) Robust — no real crashes (173 cases)
IEC 104 software responder only Robust in calibration (no live target)
BACnet software responder only Robust in calibration (no live target)
GE-SRTP software responder only Robust in calibration (no live target)
PROFINET software responder only Robust in calibration (no live target)
FINS software responder only Robust in calibration (no live target)

The sections below cover the four rows that produced something worth reading. The robust rows matter too — “we fuzzed it and it held up” is a legitimate result — but they do not each need a section.

Deep dive 1 — ESP32 Modbus, and the 716 “crashes” that were one bug

The first substantial run targeted Espressif’s esp-modbus stack on an ESP32. The fuzzer reported 716 crashes across nearly every category. That figure is close to meaningless on its own, and it is worth walking through why.

The tell was the distribution. The crashes appeared evenly across unrelated categories, and genuine bugs tend to cluster rather than spread uniformly. To test that, I added a cold-boot isolation step: reboot the device, send a single known-good request before any fuzz traffic, and observe the result.

The cause was the fuzzer itself. Its default unit ID was 0xFF, which falls inside ESP-IDF’s 0xF8–0xFF hang range. Roughly 657 of the 716 crashes traced back to that one interaction, which left each connection in a state that made the following case look like a crash as well. Adding a --unit-id flag and rerunning brought the number down to a deterministic 175 across repeated runs — five genuine, unauthenticated denial-of-service classes.

The general lesson is worth stating plainly: a fuzzer’s raw crash count measures the hygiene of your harness, not the security of the target. The useful artifact was never 716; it was the method that reduced 716 to one contaminating bug plus 175 real ones.

The same board also ran a second stack, the widely used emelianov/modbus-esp8266 library, which had three distinct MBAP-parsing hang bugs of its own: a bare function code that hangs and desynchronizes the frame stream, a non-zero protocol ID, and a length-1 frame. Those went to the maintainer as a public issue with a proof of concept.

Deep dive 2 — snap7 / S7comm: the one memory-safety bug

The most serious finding was in snap7, the S7comm library OpenPLC bundles. A single Read Variable request with count=0xFFFF crashes the server.

Reading the source replaced a vague “unchecked allocation” guess with a precise cause: a signed/unsigned comparison defeats the PDU-size guard. PDURemainder is a signed int (around 480); Size is an unsigned longword (2 × 65535 = 131070). In the check PDURemainder - Size <= 0, the int is promoted to unsigned, the subtraction wraps to a large positive value, and the guard never triggers. Execution then copies roughly 131 KB into a few-hundred-byte response buffer — an out-of-bounds write (CWE-787), CVSS 7.5. The same code is vendored into OpenPLC v4, not only the archived v3.

It is worth being precise about scope. I demonstrated the crash reproducibly (5 of 5) and identified the out-of-bounds write by reading the source. I did not attempt to develop it into anything beyond denial of service, so the memory-safety characterization is root-cause analysis, not a demonstrated exploit.

OpenPLC v4 introduced a second, separate S7comm problem: its plugin caps snap7’s client pool at 32, where snap7’s own default is 1024. As a result, roughly 32 small unauthenticated connections that stall the parser can lock out every legitimate client. This one is a genuine v4 regression — the same flood does nothing against v3, which never lowers the cap.

Deep dive 3 — OpenPLC v4 Modbus: found by the fuzzer, driven to a same-day fix

This finding is the clearest illustration of the idea that a fuzzer’s job does not end at “here is a crash.”

The fuzzer surfaced the symptom: OpenPLC v4’s Modbus server returned a fixed, incorrect response to any unassigned function code — a hardcoded function code 0x28 and a transaction ID of 0, identical regardless of what the client sent. I traced it out of OpenPLC and into the pymodbus library, narrowed it to a single exception branch, and filed a public issue with a self-contained, runnable reproduction against the latest release, along with a concrete suggested fix.

That last part is what matters. Rather than forwarding a stack trace, the report handed the maintainer a diagnosis they could act on in minutes — and they did. The maintainer agreed within hours, a community contributor landed two merged pull requests the same day implementing essentially the fix I had proposed, and the issue closed as completed. I then reinstalled the development branch and reran the reproduction to confirm the change: the transaction ID is now echoed, and the fixed response is gone.

Find, diagnose, propose, verify — not simply find. I do not claim the patch; another contributor wrote it. But a report good enough to be fixed in a day is its own contribution, and it is the standard I think this work should aim for.

The same Modbus server has a second, lower-severity quirk the fuzzer caught: two well-formed requests pipelined into one TCP segment receive no response at all. Unlike the S7comm case, it does not accumulate into a lockout — a fresh client was still served with 250 such connections held open — so it is an interoperability bug rather than a denial of service.

Deep dive 4 — OpenPLC v4 OPC-UA: the finding I had to right-size

Fuzzing showed that an anonymous OPC-UA client could write a variable configured read-only. My first write-up described it as a blanket failure of per-role write enforcement — HIGH severity, an access-control bypass. Reading the plugin source showed the reality was narrower.

Per-variable enforcement does run for anonymous sessions. The actual mechanism is role elevation: when a project has no configured users, the server deliberately assigns anonymous clients the top engineer/Admin role, and an in-code comment describes this as a single-tenant convenience. A variable whose engineer role has write access therefore becomes writable by any client on the network — but a variable set engineer-read-only stays protected, and adding a single named user restores the intended behavior.

That is a real issue, but it is medium severity, conditional, and documented as intentional — not the unqualified bypass I first described. I corrected the finding, including an edit to the public disclosure thread. Because the behavior is insecure but working as designed, a report on its own tends to draw a shrug, so I also released a scanner that answers the question an operator actually has: can an anonymous client write this variable right now, regardless of the server’s internal reasoning?

It is worth being explicit about getting this wrong the first time. Reading source is what turns a fuzzer’s “something looks off here” into a claim you can defend, and occasionally it tells you the claim was larger than the evidence.

Disclosure status and open threads

The same broad class of finding drew five quite different maintainer responses. That range, more than any individual bug, is the useful part.

Target Reported Where Status
pymodbus Fixed FC / zero-TID exception pymodbus#2990 Fixed same day (merged upstream, verified)
OpenPLC v4 S7comm DoS, two Modbus bugs, OPC-UA gap openplc-runtime#153 Reported; maintainer engagement pending
emelianov Three MBAP parsing hangs modbus-esp8266#387 Public + PoC; three CVE IDs reserved (CANs filed), pending publication
snap7 count=0xFFFF crash (CWE-787) SCADACS/snap7#16 Public issue (dormant repo); pursuing a CVE via MITRE (CNA-of-last-resort)
Espressif Five Modbus DoS classes Private bug-bounty Vendor tested and respectfully declined as robustness, not vulnerabilities

Two entries in that table deserve a note. snap7’s repository has been dormant since 2022 with no security channel, so after a public issue drew no response, I am pursuing the CVE through MITRE as a CNA of last resort. Espressif ran the reproduction, reviewed the source, and declined all five findings — and the reasoning is defensible: most of the hangs are connection-scoped and recover as soon as the socket closes, and the “pool exhaustion” is the stack correctly refusing excess clients. A vendor that engages seriously and disagrees on classification is a legitimate outcome. Not every genuine bug is a vulnerability.

Lessons worth keeping

  • Raw crash counts mislead. 716 reduced to one root cause. Run a cold-boot isolation check before trusting a number.
  • Read the source before assigning severity. It reclassified snap7 (CWE-20 to CWE-787) and, in the other direction, reduced the OPC-UA finding from HIGH to conditional medium.
  • Connection-scoped is not the same as sustained. Whether a denial persists after the attacker stops is the line between a real availability finding and a robustness nit — exactly the line Espressif drew.
  • Confirm where the code actually lives before disclosing. My first OpenPLC target URL pointed at an organization that never existed; the original repo was archived; the live successor was a third organization.
  • A report good enough to be fixed in a day is its own contribution. The mock responder and the diagnostic write-up are what make that possible.

Closing

The through-line here is not any single crash. It is the discipline of deciding which crashes are real, describing them accurately, and routing them to the maintainer who can act on them. A fuzzer that only emits crash counts creates work; one that produces diagnoses a maintainer can act on — as the pymodbus case shows, from first report to merged fix in a day — contributes to the systems it tests. That is the standard I think this kind of work should be held to.

Tooling: icsFuzzer on GitHub. Reach me at alosafuzz@proton.me.