
Proof-of-concept reproducing CVE-2021-22681's hardcoded-key flaw and validating a per-device mutual TLS/CRL fix over simulated EtherNet/IP, with IEC 62443-4-2 mapping.
Six runnable scripts — real EtherNet/IP protocol traffic (Test 1), real TLS/PKI mechanics (Tests 3–6) — zero Rockwell software or licensing anywhere in the chain. Built to test a claim before writing it up, not to argue for it on faith.
Provenance: built 2026-07-31, alongside a parallel investigation into the Braham, MN WWTF — one of the four utilities publicly disclosed in the coordinated Minnesota water-sector incident of July 26–27, 2026. Context for that incident lives in CISA advisory AA26-097A (joint FBI/CISA/NSA/EPA/DOE/USCYBERCOM/Treasury; issued 2026-04-07, expanded 2026-07-22), which covers the ongoing IRGC-affiliated CyberAv3ngers campaign. Attribution caveat, held precisely: no agency has formally attributed the Minnesota incident specifically to that group — only the broader ongoing campaign is. This folder is the technical-fix side, kept separate from the incident investigation on purpose.
Rockwell PSIRT ([email protected]) and RA Secure Mail ([email protected]) were contacted 2026-07-31, before this repository and writeup went live (~30 minutes before, by file timestamps). Rockwell's security architecture team reviewed the repository and replied 2026-08-03. Quoted directly, not paraphrased into a stronger claim than they made:
Rockwell Automation does not endorse or validate your interpretation, your IEC 62443-4-2 mapping, or any conclusions drawn from the proof of concept. Please do not represent the work as reviewed, approved, or endorsed by Rockwell Automation... the scripts demonstrate general cryptographic and authentication principles rather than anything specific to CIP Security or to CVE-2021-22681.
This work has not been reviewed, approved, or endorsed by Rockwell Automation, full stop. Their
technical characterization — general principles, not CIP-Security-specific — is the same distinction
"The hard boundary" table below already draws about this repo's own claims; their review confirms it
independently rather than contesting it. For utilities on hardware that can't reach CIP Security,
Rockwell pointed to their own Converged Plantwide Ethernet (CPwE) Design and Implementation Guide
(cited in PHASED_ROLLOUT.md Phase 1) — the existing vendor resource this project tries to route
people toward rather than duplicate.
Test 1's finding — no authentication in the protocol's default state — is not unique to EtherNet/IP. Modbus TCP, still one of the most widely deployed protocols in water/wastewater control systems, has no authentication concept in the protocol specification at all; it dates to 1979 serial communications and was never designed with security in mind. CISA has repeatedly named exactly this failure across ICS advisories (e.g. Mitsubishi Electric's MELSEC iQ-F series: "MODBUS/TCP lacks proper authentication," allowing unauthorized read/write/halt). The Modbus Organization's own answer, Modbus/TCP Security, is TLS encapsulation with X.509 certificates — structurally the same fix category Test 3 demonstrates here, standardized at the protocol-organization level instead of one vendor's. DNP3 has an optional Secure Authentication extension (SAv5, standardized 2012); independent analysis and implementer accounts both describe it as rarely configured in practice, citing interoperability gaps between OT manufacturers and genuine protocol complexity — leaving the same exposure Test 1 demonstrates for EtherNet/IP.
The architectural point, stated precisely so it isn't oversold: Test 3's fix — mutual TLS, per-device identity bound in the certificate (not just CA validity), revocation via CRL — operates at the transport layer, not the ICS application protocol. The principle is equally applicable underneath Modbus, DNP3, or a proprietary protocol; what changes is the wrapper, not the shape of the fix. This repo has not built or run a Modbus- or DNP3-specific PoC — this is an architectural generalization from public documentation, held to the same "demonstrated vs. sourced" tiering as everything else here, not a new tested claim.
Sources: CISA ICS advisories on Modbus/TCP authentication gaps — Industrial Cyber · Modbus/TCP Security overview — Veridify · DNP3 SAv5/SAv6 adoption challenges — Step Function I/O
A disclosure packet lives or dies on not smearing these together, because each has a different fix:
The fix demonstrated here — per-device identity binding (Test 3) — addresses the fleet-key failure.
| Claim | Tier | Why | |
|---|---|---|---|
| The architectural principle | "a single shared secret across a fleet is compromised fleet-wide by one leak; per-device identity-bound authentication closes that" | PROVEN | demonstrated with real running code including the negative control that proves the check is necessary, not merely that it fires: on a strict endpoint Device B's genuinely CA-valid cert is rejected on identity (Test 3 · case 3), but on a CA-validity-only endpoint the same cert is accepted (case 4 — the control) → "validly CA-signed alone == fleet-wide access == Test 2 in TLS clothing." The binding also holds in reverse: a rogue server presenting a valid fleet cert is rejected by the client (case 5). Test 1 separately shows the broader no-auth baseline. |
| Rockwell's specific CIP Security implementation behaves identically | "enabling CIP Security on real Rockwell hardware remediates CVE-2021-22681 exactly this way" | LEAD, sourced not verified | this is Rockwell's own advisory (PN1550) language — "When properly deployed, CIP Security remediates this vulnerability... does not make use of any hardcoded keys" — not something we've independently confirmed against real Logix hardware. We tested the principle their advisory describes, not their exact wire-level implementation. |
Do not conflate the two rows. The principle is proven. The vendor's specific implementation of it is credible (it's their own stated design intent) but untested by us against real equipment.
test1_baseline_vulnerable.py — the no-auth baseline, liveSpins up a real EtherNet/IP PLC simulator (cpppo, emulating an Allen-Bradley ControlLogix) and
reads + writes a control tag with zero credentials. (Scope: this is the broad
no-authentication baseline the campaign leaned on — not the specific hardcoded-key mechanism
of CVE-2021-22681. Kept distinct on purpose; see "Three different failures" above.)
python test1_baseline_vulnerable.py
test2_shared_secret_fails.py — the shape of the fleet-key flaw (narrative bridge, not a test)Two endpoints hold one static key; a credential from Device A opens Device B unchanged — the closest structural analog to CVE-2021-22681's one-key-fits-all flaw. But it is tautological: both handlers are constructed to accept that key, so there is no execution path in which it can fail. It demonstrates nothing the code doesn't define into existence. Kept as the narrative bridge from Test 1 to Test 3; it carries no evidential weight and is deliberately not a proof-leg.
python test2_shared_secret_fails.py
test3_mutual_tls_fix.py — the fix, with its negative control and both directionsReal CA, two individually-unique device certs — identity bound in the SubjectAlternativeName, not the deprecated CommonName. Five cases, all executed:
device-a (check_hostname against a real SAN). Mutual — both ends bind identity.python test3_mutual_tls_fix.py
test4_revocation.py — the lifecycle leg: revocationA real CA-signed CRL. A client credential (engineer-1) is granted; then its serial is added to
the CRL, and the same still-valid, unexpired, CA-signed credential is denied — the empirical
content of the CR 1.8 / 1.9 revocation clause. Uniqueness (Test 3) ≠ revocability; this shows a
credential can be taken back.
python test4_revocation.py
test5_rotation.py — the lifecycle leg Test 4 didn't close: rotationIssues a replacement credential (v2) for the same identity (engineer-1) already holding a
valid one (v1). THE CONTROL (case 3): v1 is presented again after v2 exists but before
v1 is explicitly retired → still GRANTED — proving re-issuance alone does not retire the old
credential. Only after v1 is explicitly added to the CRL (case 4) is it denied; v2 is unaffected
throughout (case 5) — the identity never loses access during the transition. CR 1.8's "issue a
replacement and retire the prior one" is two actions, and this shows both, separately.
python test5_rotation.py
test6_tamper_injection.py — the leg CR 3.1 named as "by construction" only: a dedicated integrity testA record-level relay sits between a real mutual-TLS client and server, forwarding TLS records by
parsing only the 5-byte header — it never sees the encrypted payload's plaintext. Control: every
byte forwarded unmodified → message delivered intact. Tamper: one bit flipped inside a live
Application Data record's ciphertext → the receiving TLS stack's AEAD check fails
(SSLV3_ALERT_BAD_RECORD_MAC) and the connection is torn down — corrupted data is never delivered as
if it were valid.
Which byte, and why it's specified: a TLS 1.2 AEAD record body is
explicit_nonce(8) ‖ ciphertext ‖ auth_tag(16), so byte 0 is the nonce, not the payload. Flipping
the nonce also trips the AEAD check — but by garbling decryption rather than by the tag catching an
altered payload. CR 3.1 is about unauthorised modification of transmitted information, so the
flip targets a byte in the middle of the ciphertext, and the test then demonstrates exactly the
sentence it claims.
python test6_tamper_injection.py
maximum_version = TLSv1_2 for two reasons that are about observability, not
security: under TLS 1.2 a missing or rejected client certificate fails during the handshake, so
the test gets a deterministic, attributable error rather than TLS 1.3's post-handshake failure; and
the record content type stays visible in the clear, which Test 6's relay needs in order to identify
an Application Data record at all. Deploy the highest TLS version your devices support — TLS 1.3
where available. Nothing in this repo should be read as advice to cap a production system at 1.2.presented == KEY, identity in SAN) are not constant-time. Not exploitable here — the compared
values are public-ish identity strings and TLS has already done the real cryptographic
authentication before the compare runs — but flagged because the pattern gets copied into places
where it does matter.python -m venv venv
venv\Scripts\activate # or: source venv/bin/activate
pip install -r requirements.txt
62443-4-2_SL2_MAPPING.md (CR 1.2 / 1.8 / 1.9 / 1.14 / 3.1,
honestly tiered, every gap named; CR 1.2, CR 1.9, and CR 1.8's issuance/validation/revocation leg
are now demonstrated). Before PSIRT ([email protected] / [email protected]):
verify each CR's normative text against a purchased copy of IEC 62443-4-2:2019.PHASED_ROLLOUT.md (Phase 0 stop-the-bleeding · 1 segment · 2
compensating controls · 3 CIP Security/PKI, hardware-permitting · 4 operate). Right-sized for a small
utility; honest that CIP Security is hardware-gated, so Phases 0–2 carry the risk reduction regardless.PHASE0_INVENTORY_WORKSHEET.md (a fillable device inventory, not just the instruction to make
one), RESOURCES.md (free CISA / EPA / WaterISAC / AWWA assistance, verified live, not recalled),
and INCIDENT_RESPONSE_QUICK_REFERENCE.md (a first-60-minutes card, explicitly not a full IR
plan — safety of operations always comes first in it).test5_rotation.py moves CR 1.8
from "enabled" to fully demonstrated (rotation, with its own control); test6_tamper_injection.py
moves CR 3.1 from "by construction" to demonstrated (a real bit-flip, rejected by TLS's AEAD check).
Both re-run repeatedly with no flakiness before being added here. Also added:
PHASE3_CA_QUICKSTART.md (the "few lines of code" CA, as real tested openssl commands) and a
concrete allowlist example in PHASED_ROLLOUT.md Phase 1.GLOSSARY.md, INTEGRATOR_CHECKLIST.md, PHASE3_CA_QUICKSTART.md) is now
actually linked from the plan instead of sitting unreferenced. Nothing on this list is still
DRAFT — everything above and below is pushed and live on the public repo.Every external identifier here was pulled from a live source on 2026-07-31, not recalled from
training: AA26-097A (multi-source, incl. WaterISAC / Tenable / SecurityWeek), Braham as one of the
four disclosed victims, CyberAv3ngers/IRGC, PN1550 confirmed the real Rockwell advisory with its
Row-2 quote checked verbatim ("When properly deployed, CIP Security remediates this vulnerability"
ICSA-21-056-03, and 62443-4-2 CR 1.8 (PKI) + CR 3.1
(comm integrity) confirmed exact.
Discipline for the packet: re-pull every identifier from primary sources at submission time.
Advisories get renumbered, expanded, and superseded — AA26-097A already shows one expansion — so
"verified on 2026-07-31" is not "verified at submit." The full formal CR-by-CR 62443-4-2 mapping is
now written (62443-4-2_SL2_MAPPING.md) — what's left is re-pulling its cited normative text against
a purchased standard copy before submission, not writing the mapping itself.l0gic — Patrick Crosby · 2026-07-31.
Hardening log, 2026-08-03: test5_rotation.py and test6_tamper_injection.py added, closing the
two technical items named open since 2026-07-31 — each re-run three times with no flakiness before
being written up here. PHASE3_CA_QUICKSTART.md (real, tested openssl commands), a concrete
Phase 1 firewall-allowlist example, PHASE0_INVENTORY_WORKSHEET.md, RESOURCES.md,
INCIDENT_RESPONSE_QUICK_REFERENCE.md, and the Modbus/DNP3 generalization were also added the same
day.
Hardening log, 2026-08-03 (continued) — two independent review passes, both applied: a security
red-team found and fixed a real PKI vulnerability in the CA quickstart (-copy_extensions copyall
let a malicious certificate request self-declare CA:TRUE; fixed via explicit -extfile, verified
against a deliberately malicious request both ways) and corrected test6 to flip the ciphertext
specifically rather than byte 0 (the nonce), plus a real thread-safety fix and dependency pinning.
A separate structural audit — reading the phases as a system across time, not a checklist — found
and fixed: Phase 3's "few lines" headline hid that revocation/rotation aren't simple; CRL
fail-open/fail-closed was never decided (now is, with a default and reasoning); certificate expiry
was a new, unnamed outage mode (Phase 4 now carries Test 5's own safe-overlap lesson); Phase 3
silently blinds Phase 2's write-alerter (now named, with replacements suggested); NTP was an
unstated prerequisite; CR 1.14 was framed as "not met" where "not applicable to the remediated
design" is the accurate statement; and INTEGRATOR_CHECKLIST.md/GLOSSARY.md were orphaned
documents nothing linked to (now linked from Phase 3 and the top of this plan). Every fix verified
by re-running the affected tests, not just re-reading the diff. Pushed and live — all five phases
of the rollout plan are done, reviewed twice over, and nothing from today is sitting local anymore.
Hardening log: Test 3 was strengthened to include the negative control (case 4, proving necessity not just firing), the reverse-direction case (case 5, mutual binding), and SAN-based identity (not CN), then re-verified by re-running all five cases; Test 4 (CRL revocation) was added. Test 1/2 captions were right-sized to keep the three failure classes distinct; Test 2 was demoted from "test" to narrative bridge; revocation and constant-time scope boundaries were added. The citation chain was retrieved from primary sources and stamped for re-pull at submission.