Proof/CVEs & advisories/GHSA-v3qq-3xvg-m77g
Reads were confined. Writes were not.
python-statemachine’s restricted evaluator correctly refused to read __class__. The SCXML <assign location> path walked the same segment as an intermediate destination and mutated process-shared class state.
__class__.__init__Release 3.2.0 introduced a declarative IO layer that loads SCXML, JSON and YAML statecharts with a restricted evaluator enabled by default through trusted=False.
Every path segment traversed during a restricted write must satisfy the same attribute policy as a restricted read, including intermediate objects and facade aliases.
The read side got this right. A representative rule blocks private and dunder reads outright:
case ast.Attribute(attr=attr) if allow_value_nodes:
if attr.startswith("_"):
raise ValueError(f"Attribute access to '{attr}' is not allowed")
return build_attribute(recurse(node.value), attr)That creates a clear expectation: an untrusted statechart may work with ordinary model fields, but must not walk into Python runtime internals such as classes, interpreters, engines or other shared implementation state.
The write path split the document-controlled location on dots and followed every intermediate element with raw getattr():
*path, attr = self.action.location.split(".")
obj = machine.model
for p in path:
obj = getattr(obj, p)
if not attr.isidentifier() or not (hasattr(obj, attr) or attr in kwargs):
raise ValueError(...)
if attr in protected_attrs:
raise ValueError(...)
setattr(obj, attr, value)Only the final attribute name is checked. The dangerous __class__ component is intermediate, so it reaches the shared class before the final-name checks ever execute.
obj starts as the model instance
Ordinary, expected starting point for an assignment destination.
getattr(obj, "__class__") returns the shared class
The traversal has now left the per-machine model boundary.
__init__ passes the final-name checks
It is a valid identifier and it already exists, so both guards are satisfied.
The restricted evaluator supplies a data value
No attacker-defined callable is constructed — this is a data write.
setattr(Model, "__init__", value)
Later model construction uses the corrupted class attribute and raises TypeError.
A second public demonstration targets __class__.__name__. Changing the class name through one loaded machine is visible through other references to the same Model class — confirming the write crossed into process-shared state.
Same path as a read
The analogous dunder path is denied by the restricted evaluator, proving the read policy is active.
Write through __class__
Succeeds in 3.2.0; class-level state changes are visible process-wide.
3.2.1
Rejects the original path and the adjacent alias variants covered by upstream tests; ordinary public writes still work.
The destructive base trigger was not rerun merely for publication packaging.
Read policy and write policy were implemented separately. The evaluator enforced a strong rule for reading attribute paths, while <assign location> treated intermediate path components as ordinary Python traversal and protected only the final name.
That asymmetry is dangerous because a destination path is just as expressive as a read path. If an attacker can walk through a protected intermediate object during assignment, checking only the final field does not preserve the confinement model.
A sandboxed data model needs symmetric read and write confinement. Protecting sensitive objects from lookup is insufficient if destination traversal can still reach and mutate them.
A follow-on variant hardened under the same advisory family involved facade/system aliases offering another route into protected runtime objects. It is recorded as a distinct hardening case, not as a separate execution claim.
If the read is confined, check the write.
A restricted evaluator advertises a boundary. The productive hypothesis was that the boundary had been implemented once, on the side everyone thinks about, and that assignment destinations were reviewed as plumbing rather than as policy.
Source mapping
Read the restricted evaluator’s attribute policy, then find every place the library performs a write on its behalf.
Symmetry hypothesis
Ask whether destination traversal enforces the rule that lookup traversal enforces.
Bounded construction
Use observable, non-destructive state changes rather than attacker-defined executable Python.
Skeptic gate
Hold the claim at integrity and availability; do not promote a data write into code execution.
Promotion and disclosure
Preserve shared reporter attribution and confirm the fix covers alias variants.
The demonstrated capability is unauthorized mutation of shared Python class and runtime state from a document that is supposed to be restricted. Concrete public outcomes:
- changing shared class metadata, observable through unrelated references to the same class; and
- replacing
Model.__init__with a non-callable data value, so subsequent model construction raisesTypeError.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:HNo confidentiality impact is claimed for this advisory, and the write-side primitive should not be described as arbitrary code execution without separate evidence of attacker-controlled callable construction.
| Scope | Value | Basis |
|---|---|---|
| Public package range | ≥ 3.2.0, < 3.2.1 | advisory scope |
| Introducing commit | 9492f3f7d543da52368fe2e1416ba7f0f0d67730 | generalized IO layer, first shipped in 3.2.0 |
| Fixing commit | f7159a8549c5c26de0babb7b67c882804037a24e | released in 3.2.1 |
The public v3.2.0 and v3.2.1 tags and the corresponding PyPI source distributions were inspected, and the security-relevant source matched.
Upgrade to python-statemachine 3.2.1 or later.
Restricted write paths should validate every traversed segment before calling getattr() or setattr(). The same private/dunder restrictions used for reads should apply to destinations, aliases, wrappers and facade objects.
Regression coverage should include:
- private/dunder names as the first segment;
- private/dunder names in the middle of a path;
- protected names as the final segment;
- facade aliases that resolve to runtime objects;
- allowed public nested writes, which must keep working;
- assertions that failed assignments leave shared classes unchanged.
- GHSA-v3qq-3xvg-m77g — python-statemachine advisory and public PoC
- python-statemachine 3.2.1 security release notes
- python-statemachine repository
Credit: The public advisory credits multiple reporters, including Charles Vosburgh. This page preserves that shared attribution and does not claim sole discovery. Research was AI-assisted through source mapping, hypothesis generation and release comparison; final validation and disclosure review remained human.