Coordinated disclosureGHSA-v3qq-3xvg-m77gpython-statemachine · PyPI9.1 Critical

Proof/CVEs & advisories/GHSA-v3qq-3xvg-m77g

GHSA.03 — Advisory credit

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.

AdvisoryGHSA-v3qq-3xvg-m77g
Packagepython-statemachine
EcosystemPyPI · Python
Affected≥3.2.0 <3.2.1
Fixed3.2.1
WeaknessCWE-915 / CWE-913
Severity9.1 Critical
CreditOne of several reporters
Every path segment traversed during a restricted write must satisfy the same attribute policy as a restricted read — intermediate objects and facade aliases included.
Weakness
CWE-915 / CWE-913
Trigger
__class__.__init__
Control
same path denied as a read
Outcome
shared-class corruption
Shared attribution and scope discipline.The advisory credits multiple reporters, and this page preserves that. It also declines an easy overstatement: the demonstrated restricted-evaluator primitive writes data values. The validated impact is shared-state corruption and denial of service — not arbitrary Python code execution.

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:

Read-side policyrestricted evaluator
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():

Vulnerable write path3.2.0 · assign action
*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.

1

obj starts as the model instance

Ordinary, expected starting point for an assignment destination.

2

getattr(obj, "__class__") returns the shared class

The traversal has now left the per-machine model boundary.

3

__init__ passes the final-name checks

It is a valid identifier and it already exists, so both guards are satisfied.

4

The restricted evaluator supplies a data value

No attacker-defined callable is constructed — this is a data write.

5

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.

Negative control

Same path as a read

The analogous dunder path is denied by the restricted evaluator, proving the read policy is active.

Vulnerable path

Write through __class__

Succeeds in 3.2.0; class-level state changes are visible process-wide.

Fixed control

3.2.1

Rejects the original path and the adjacent alias variants covered by upstream tests; ordinary public writes still work.

CONTROL restricted READ of dunder path → denied CONTROL write to a normal public model attribute → allowed BYPASS assign location __class__.__init__ → accepted SHARED Model.__name__ change visible through other references SUBSEQUENT model construction raises TypeError 3.2.1 28 focused upstream regression tests run: PASS

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.

The SecHive research loop

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.

SK.01

Source mapping

Read the restricted evaluator’s attribute policy, then find every place the library performs a write on its behalf.

SK.02

Symmetry hypothesis

Ask whether destination traversal enforces the rule that lookup traversal enforces.

SK.03

Bounded construction

Use observable, non-destructive state changes rather than attacker-defined executable Python.

SK.04

Skeptic gate

Hold the claim at integrity and availability; do not promote a data write into code execution.

HUMAN

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:

  1. changing shared class metadata, observable through unrelated references to the same class; and
  2. replacing Model.__init__ with a non-callable data value, so subsequent model construction raises TypeError.
Critical
Published advisory severity
9.1
CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H

No 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.

ScopeValueBasis
Public package range≥ 3.2.0, < 3.2.1advisory scope
Introducing commit9492f3f7d543da52368fe2e1416ba7f0f0d67730generalized IO layer, first shipped in 3.2.0
Fixing commitf7159a8549c5c26de0babb7b67c882804037a24ereleased 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:

  1. private/dunder names as the first segment;
  2. private/dunder names in the middle of a path;
  3. protected names as the final segment;
  4. facade aliases that resolve to runtime objects;
  5. allowed public nested writes, which must keep working;
  6. assertions that failed assignments leave shared classes unchanged.
2026-06-17
python-statemachine 3.2.0 published with the new IO layer.
2026-08-01
3.2.1 and GHSA-v3qq-3xvg-m77g published.
2026-08-26
Public tags, PyPI artifacts, fix and regression coverage reviewed.
  1. GHSA-v3qq-3xvg-m77g — python-statemachine advisory and public PoC
  2. python-statemachine 3.2.1 security release notes
  3. 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.