We're constantly trying to push the boundaries of what AI can do for defense. And to understand the best defense, you often have to understand how the offense works. That's what we set out to do.
CVE-2026-25632 is a critical deserialization vulnerability in EPyT-Flow, a Python toolkit for simulating water distribution networks. We started here on purpose. It had no public proof-of-concept and predated current AI training data, making it a clean test of whether AI could reason about exploitation from first principles.
The original advisory, reported by @syphonetic, identified the unsafe __type__ field and flagged subprocess.run as the gadget. CVSS 10.0. Fixed in version 0.16.1.
That report was accurate, but it was also just the surface.
Using Claude, we mapped the entire Python standard library for callables that could be weaponized through the same primitive. We found 79 gadgets, not one. Twelve give you remote code execution. The CVE already gives full RCE. More gadgets don't make it harder to patch, but they make it much harder to secure if you don't. Twenty-five give you SSRF. And the deployment environment, water network simulation infrastructure, puts SCADA systems one network hop away.
More importantly, we discovered two previously unknown bypasses in the OWASP Core Rule Set. We responsibly disclosed both, worked with the CRS maintainers on fixes, and waited for those fixes to ship before publishing.
This post documents the full attack surface we mapped, the CRS bypasses we disclosed, and how defenders should be thinking about this class of vulnerability.
This research was part of a broader project on how AI accelerates exploit development. Claude built the proof-of-concept exploits, the vulnerable Docker lab environment, and identified the WAF bypass techniques. Read more about how AI is changing the speed of exploit development here.
What is EPyT-Flow?
EPyT-Flow is a Python toolkit for water distribution network simulation, published on PyPI and maintained by the WaterFutures research group. It's used in utility planning, infrastructure research, and operational technology environments. The project is peer-reviewed (published in JOSS), actively maintained with 27 releases since May 2024, and exposes a REST API for external application integration.
That REST API is where the problem lives.
The OT context matters here. An EPyT-Flow application server typically sits on a network segment with access to SCADA systems. Modbus, DNP3, and OPC-UA services are one hop away. An attacker who can make that server open a network connection to the wrong place doesn't need remote code execution to cause damage. They just need SSRF.
How it works
The vulnerable code lives in epyt_flow/serialization.py. When the REST API deserializes a JSON body, it looks for a __type__ key and does this:
type_info = data["__type__"] # e.g. ["subprocess", "run"] module = importlib.import_module(type_info[0]) # imports subprocess cls = getattr(module, type_info[1]) # gets subprocess.run result = cls(**remaining_kwargs) # calls it with the rest of the JSON keys
An attacker controls which module to import, which callable to invoke, and which keyword arguments to pass. All from a single JSON field. It's an arbitrary constructor call primitive: any Python callable that accepts **kwargs is fair game.
No authentication required, no multi-step chain, just one JSON field.
This was honestly pretty easy
The first exploit writes itself once you see the __type__ pattern:
{"__type__": ["subprocess", "run"], "args": ["id"], "capture_output": true}
Send that to the API endpoint. Result:
uid=0(root) gid=0(root) groups=0(root)
That's full remote code execution, running as root, from a single HTTP request.
Any engineer reading the source code would get here. This isn't the interesting part. The interesting part is what happens when you stop looking at just subprocess.run and start asking: what else can we call?
The full attack surface: 79 gadgets
subprocess.run is the path everyone would reach for first. It's also the path the original advisory already documented, and it already gives you full RCE. But the __type__ primitive doesn't care what you call. It imports any module, grabs any attribute, and invokes it with your keyword arguments. So the real question isn't how many paths exist. It's how many survive your defenses.
We built a scanner to find out. 51 stdlib modules. 1,156 callables tested through a four-phase enumeration pipeline. Each callable classified not by its name or module, but by what an attacker can actually achieve with it.
The scanner was built with Claude using AI-assisted heuristics. Read more about how we used AI to accelerate this research here.
| Category | Count | What it means | Examples |
|---|---|---|---|
| SSRF | 25 | Force the server to open a network connection to any reachable host | http.client.HTTPConnection, smtplib.SMTP, ftplib.FTP, logging.handlers.SocketHandler |
| FILE_WRITE | 18 | Create or move files on the server's filesystem | logging.FileHandler, sqlite3.connect, shutil.copy, os.rename |
| RCE | 12 | Execute arbitrary commands | subprocess.run, subprocess.Popen, os.popen, imaplib.IMAP4_stream |
| FILE_READ | 10 | Read files from the server | builtins.open, zipfile.ZipFile, tarfile.open, gzip.open |
| INFO | 7 | Leak system information | shutil.disk_usage |
| FILE_DELETE | 5 | Delete files | shutil.rmtree, os.remove, os.unlink |
| LIB_LOAD | 2 | Load a native .so/.dll library | ctypes.CDLL, ctypes.PyDLL |
79 total from scanner output. 16 live-verified after manual curation, at least one per category. All paths are pre-authentication.
A few things worth noting: the FILE_WRITE paths (logging.FileHandler, sqlite3.connect) create attacker-named files but don't write attacker-controlled content, making them useful for staging a path but not for writing a web shell directly. shutil.copy is the exception: it copies an existing file to an attacker-chosen destination, which is useful for moving something into a web root or an executable path.
The Ones That Surprised Us
imaplib.IMAP4_stream is Python's IMAP-over-subprocess client. It opens an IMAP connection by spawning a process, and its command parameter feeds directly into subprocess.Popen. Call it through the deserializer and you get RCE with zero mention of "subprocess" anywhere in your payload. A hidden execution path inside a mail protocol library.
ctypes.CDLL and shelve.open don't touch subprocess at all. ctypes.CDLL loads a native .so file and runs its initialization code. shelve.open deserializes a Python pickle file, which executes arbitrary Python on load. Both need a prior FILE_WRITE step to stage the malicious file first (use shutil.copy to move it into place), then invoke the loader. Two-step chains, no subprocess involved.
Why This Matters for Defenders
Your first instinct might be to blocklist subprocess. That eliminates 7 of the 12 RCE paths. It eliminates zero of the 25 SSRF paths. This is why the gadget mapping matters: not because 79 is a scarier number than 1, but because it shows the defensive gap.
The SSRF paths, smtplib.SMTP, ftplib.FTP, logging.handlers.SocketHandler, and 22 others, contain no dangerous-sounding keywords. No signature-based rule will flag "smtplib" as malicious. But on a network where SCADA controllers are reachable, making the server open an outbound connection to an internal host is often all an attacker needs.
And 79 is the stdlib floor. A deployment with third-party packages installed has a larger surface.
How it was fixed
The EPyT-Flow maintainers addressed CVE-2026-25632 in version 0.16.1 by removing the __type__ dispatch entirely. One patch, all 79 gadgets eliminated at once.
For the CRS bypasses we found, we worked directly with the OWASP Core Rule Set maintainers:
CRS 932236: The maintainers added uconv and piconv to the command blocklist in PR #4544, merged March 13, 2026. This bypass is fixed in CRS versions after that date.
CRS 932200: The maintainers confirmed the glob regex blind spot but decided not to patch at PL2, as the existing PL3 rule 932190 already covers the pattern.
If you're running EPyT-Flow, upgrade to 0.16.1 immediately. If upgrading isn't immediate, allowlist valid __type__ values at the application layer.
Do not rely on WAF rules alone. We achieved a full bypass of ModSecurity 3.0.14 + OWASP CRS v4.24.0 at Paranoia Level 2 with custom deserialization rules. Read more about the full WAF bypass methodology here.
Our disclosure: two novel OWASP CRS bypasses
The CVE itself was reported by @syphonetic. Our original contribution is what we found during the research: two previously unknown gaps in the OWASP Core Rule Set, the most widely used open-source WAF ruleset.
We reported both to the CRS project, worked with their maintainers on fixes, and waited until the fixes shipped before publishing this post.
CRS 932236 (Issue #4542): The PL2 command blocklist was missing uconv and piconv, both of which can read and output file contents like cat. An attacker could use either command to exfiltrate data through the WAF without triggering any detection rules. Confirmed by CRS maintainers and fixed in PR #4544, merged March 13, 2026.
CRS 932200 (Issue #4543): A regex blind spot in glob detection. The rule's lazy quantifier fails to detect glob characters at the start of filenames (/etc/?asswd bypasses detection while /etc/passw? is caught). This isn't a regex bug. It's a fundamental limitation of syntactic pattern matching: the rule was written to catch globs at the end of path components and never anticipated one at the start. Confirmed by CRS maintainers. Accepted without a PL2 fix, as PL3 rule 932190 already covers the pattern.
Read more about the WAF bypass chain and how AI reasoned through each defensive layer here.
Disclosure timeline
| Date | Who | Event |
|---|---|---|
| February 4, 2026 | EPyT-Flow maintainers | Version 0.16.1 released with fix |
| March 3, 2026 | Maze (Nuno Lopes) | Attack surface mapping completed: 79 exploitation paths identified, two CRS bypasses discovered |
| March 11, 2026 | Maze (Nuno Lopes) | CRS issues #4542 and #4543 reported to OWASP |
| March 13, 2026 | OWASP CRS maintainers | PR #4544 merged (uconv/piconv added to 932236) |
| April 14, 2026 | Maze | Public disclosure |