<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://alosafuzz.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://alosafuzz.github.io/" rel="alternate" type="text/html" /><updated>2026-07-31T00:12:36+00:00</updated><id>https://alosafuzz.github.io/feed.xml</id><title type="html">alosafuzz</title><subtitle>Independent ICS/OT security research by Shad Malloy (alosafuzz): protocol fuzzing, vulnerability findings, and coordinated-disclosure notes.</subtitle><author><name>Shad Malloy (alosafuzz)</name><email>alosafuzz@proton.me</email></author><entry><title type="html">Fuzzing ten ICS protocols on real hardware: a coverage map, the findings worth reading, and what happened when I reported them</title><link href="https://alosafuzz.github.io/2026/07/30/fuzzing-ten-ics-protocols-on-real-hardware.html" rel="alternate" type="text/html" title="Fuzzing ten ICS protocols on real hardware: a coverage map, the findings worth reading, and what happened when I reported them" /><published>2026-07-30T18:00:00+00:00</published><updated>2026-07-30T18:00:00+00:00</updated><id>https://alosafuzz.github.io/2026/07/30/fuzzing-ten-ics-protocols-on-real-hardware</id><content type="html" xml:base="https://alosafuzz.github.io/2026/07/30/fuzzing-ten-ics-protocols-on-real-hardware.html"><![CDATA[<p>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.</p>

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

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

<h2 id="the-tool">The tool</h2>

<p><a href="https://github.com/alosafuzz/icsFuzzer"><code class="language-plaintext highlighter-rouge">icsFuzzer</code></a> 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.</p>

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

<h2 id="what-i-tested-coverage-at-a-glance">What I tested: coverage at a glance</h2>

<p>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:</p>

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

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

<h2 id="deep-dive-1--esp32-modbus-and-the-716-crashes-that-were-one-bug">Deep dive 1 — ESP32 Modbus, and the 716 “crashes” that were one bug</h2>

<p>The first substantial run targeted Espressif’s <code class="language-plaintext highlighter-rouge">esp-modbus</code> 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.</p>

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

<p>The cause was the fuzzer itself. Its default unit ID was <code class="language-plaintext highlighter-rouge">0xFF</code>, which falls inside ESP-IDF’s
<code class="language-plaintext highlighter-rouge">0xF8–0xFF</code> 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 <code class="language-plaintext highlighter-rouge">--unit-id</code> flag and rerunning brought the number down to a deterministic 175 across
repeated runs — five genuine, unauthenticated denial-of-service classes.</p>

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

<p>The same board also ran a second stack, the widely used <code class="language-plaintext highlighter-rouge">emelianov/modbus-esp8266</code> 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.</p>

<h2 id="deep-dive-2--snap7--s7comm-the-one-memory-safety-bug">Deep dive 2 — snap7 / S7comm: the one memory-safety bug</h2>

<p>The most serious finding was in snap7, the S7comm library OpenPLC bundles. A single Read
Variable request with <code class="language-plaintext highlighter-rouge">count=0xFFFF</code> crashes the server.</p>

<p>Reading the source replaced a vague “unchecked allocation” guess with a precise cause: a
signed/unsigned comparison defeats the PDU-size guard. <code class="language-plaintext highlighter-rouge">PDURemainder</code> is a signed <code class="language-plaintext highlighter-rouge">int</code>
(around 480); <code class="language-plaintext highlighter-rouge">Size</code> is an unsigned <code class="language-plaintext highlighter-rouge">longword</code> (2 × 65535 = 131070). In the check
<code class="language-plaintext highlighter-rouge">PDURemainder - Size &lt;= 0</code>, the <code class="language-plaintext highlighter-rouge">int</code> 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.</p>

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

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

<h2 id="deep-dive-3--openplc-v4-modbus-found-by-the-fuzzer-driven-to-a-same-day-fix">Deep dive 3 — OpenPLC v4 Modbus: found by the fuzzer, driven to a same-day fix</h2>

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

<p>The fuzzer surfaced the symptom: OpenPLC v4’s Modbus server returned a fixed, incorrect
response to any unassigned function code — a hardcoded function code <code class="language-plaintext highlighter-rouge">0x28</code> and a transaction
ID of <code class="language-plaintext highlighter-rouge">0</code>, identical regardless of what the client sent. I traced it out of OpenPLC and into
the <code class="language-plaintext highlighter-rouge">pymodbus</code> 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.</p>

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

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

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

<h2 id="deep-dive-4--openplc-v4-opc-ua-the-finding-i-had-to-right-size">Deep dive 4 — OpenPLC v4 OPC-UA: the finding I had to right-size</h2>

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

<p>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 <code class="language-plaintext highlighter-rouge">engineer</code>/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.</p>

<p>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?</p>

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

<h2 id="disclosure-status-and-open-threads">Disclosure status and open threads</h2>

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

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

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

<h2 id="lessons-worth-keeping">Lessons worth keeping</h2>

<ul>
  <li><strong>Raw crash counts mislead.</strong> 716 reduced to one root cause. Run a cold-boot isolation
check before trusting a number.</li>
  <li><strong>Read the source before assigning severity.</strong> It reclassified snap7 (CWE-20 to CWE-787)
and, in the other direction, reduced the OPC-UA finding from HIGH to conditional medium.</li>
  <li><strong>Connection-scoped is not the same as sustained.</strong> 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.</li>
  <li><strong>Confirm where the code actually lives before disclosing.</strong> My first OpenPLC target URL
pointed at an organization that never existed; the original repo was archived; the live
successor was a third organization.</li>
  <li><strong>A report good enough to be fixed in a day is its own contribution.</strong> The mock responder
and the diagnostic write-up are what make that possible.</li>
</ul>

<h2 id="closing">Closing</h2>

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

<p><em>Tooling: <a href="https://github.com/alosafuzz/icsFuzzer">icsFuzzer on GitHub</a>. Reach me at alosafuzz@proton.me.</em></p>]]></content><author><name>Shad Malloy (alosafuzz)</name><email>alosafuzz@proton.me</email></author><summary type="html"><![CDATA[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.]]></summary></entry></feed>