Tiny AI — security model¶
ONNX models are data, not code, but that is no excuse for a lazy runtime. libvmaf's DNN layer applies four layers of defence before any frame touches a graph:
| Layer | What it does | Default |
|---|---|---|
| 1. Operator allowlist | Rejects any graph node whose op is not on a curated list | always on |
| 2. Resource bounds | Size cap, path validation, optional directory jail, output sanity | always on (jail opt-in) |
| 3. ORT sandbox | ONNX Runtime executes the graph with no interpreter or shell-out | always on |
| 4. Signature verification | cosign verify-blob of the model's Sigstore bundle | off; opt in with --tiny-model-verify |
Threat model¶
| Class | Example | Mitigation |
|---|---|---|
Hostile .onnx file | Model with a custom op that exfiltrates via a network syscall | Operator allowlist (layer 1). |
| Memory exhaustion via a huge model | 10 GB .onnx passed to --tiny-model | Size cap VMAF_DNN_DEFAULT_MAX_BYTES, compile-time 50 MB (layer 2). |
Path traversal via --tiny-model | --tiny-model ../../etc/shadow | vmaf_dnn_validate_onnx resolves symlinks, requires S_ISREG and readable, and can enforce the VMAF_TINY_MODEL_DIR jail (layer 2). |
| Silent model substitution | Attacker replaces a signed model with a poisoned one | Opt-in Sigstore (cosign) verification against the workflow identity (layer 4). |
Layer 1: operator allowlist¶
core/src/dnn/op_allowlist.{h,c} holds a curated set of ONNX operator names, 74 entries (73 distinct names; Clip is listed twice). Before creating an ORT session the loader walks the graph and rejects any node whose op is not on the list. Extending the list is a conscious, reviewed act: a change to op_allowlist.c must be called out in the PR description and backed by a concrete model that needs the addition.
What is allowed¶
The list covers the building blocks of C1, C2 and C3 architectures:
Conv,Gemm,MatMul, normalisation layers such asBatchNormalization, and pooling such asGlobalAveragePool;- activations such as
Relu,Sigmoid,SoftmaxandGelu; - arithmetic, reduce, reshape and transpose ops;
- the five quantisation ops
QuantizeLinear,DequantizeLinear,DynamicQuantizeLinear,MatMulIntegerandConvInteger, see quantization.md; - the control-flow ops
LoopandIf(ADR-0169; needed by MUSIQ, RAFT and small-VLM-class architectures); - the spatial sampler
Resize(ADR-0258; needed by U-2-Net, mobilesal, BASNet, PiDiNet and FPN-style detectors).
These are rejected: Scan, unknown names (custom_op_xyz), and anything that could touch the filesystem or network. Scan stays out because its variant-typed input and output binding makes static bound-checking impractical for a wire-format scanner.
Note
Resize has a mode attribute (nearest, linear, cubic). Per ADR-0258 the wire scanner gates the op type, not its attributes. Consumers shipping their own ONNX are expected to keep mode at nearest or linear: cubic is numerically less stable on quantised inputs and no in-tree consumer uses it.
Control-flow subgraphs¶
For Loop and If, the wire-format scanner in onnx_scan.c recurses into the embedded subgraphs (Loop.body, If.then_branch, If.else_branch) and applies the same allowlist check at every depth, capped at 8 levels of nesting (VMAF_DNN_MAX_SUBGRAPH_DEPTH) as a defence-in-depth bound. A forbidden op cannot hide inside a control-flow body.
Bounded-iteration guard¶
ADR-0171 bounds loops at two points:
| Layer | Where | Limit |
|---|---|---|
| Export time | vmaf_train.op_allowlist in vmaf-train | Every Loop.M input must trace back to a Constant int64 scalar in [0, MAX_LOOP_TRIP_COUNT] (default 1024, overridable per call). Rejects a graph-input or non-Constant producer. Recurses into nested subgraphs, so a Loop inside a Loop.body is bounded in its own scope. |
| Load time | libvmaf wire scanner (onnx_scan.c) | VMAF_DNN_MAX_LOOP_NODES = 16 Loop nodes per model across the top-level graph and every embedded subgraph; nesting depth 8. |
A model has to clear both to load. The load-time cap is coarser than the Python data-flow check by design: reproducing producer-map lookup in a wire-format scanner would violate the ADR D39 "bounded-auditable-scope" constraint that keeps the scanner free of libprotobuf-c. Models that bypass the trainer (HTTP-fetched, MCP-uploaded, third-party registries) still hit the load-time cap.
Layer 2: resource bounds¶
Size cap¶
The loader refuses files larger than VMAF_DNN_DEFAULT_MAX_BYTES (50 MB, compile-time constant in core/src/dnn/model_loader.h). The check runs before the file is mapped. The historical VMAF_MAX_MODEL_BYTES env override was retired in T7-12 after two release cycles without a shipped model near the cap. Callers that need a larger envelope must bump the constant and rebuild.
Path validation¶
vmaf_dnn_validate_onnx:
- resolves symlinks;
- asserts
S_ISREG(no devices, pipes or directories); - returns
-errnoon any failure, which the caller must check.
Directory jail¶
VMAF_TINY_MODEL_DIR is the optional deployment jail:
- When set to a directory, the loader canonicalises the jail and the requested
.onnxpath, and requires the model to sit below the jail before any stat or read of the file. - Sibling-prefix escapes (
/modelsagainst/models-evil), symlink escapes, missing jail paths and non-directory jail paths fail closed with-EACCES. - When the variable is unset or empty, the jail is a no-op and the normal symlink, regular-file, size and operator checks still apply.
- MCP callers keep their own path allowlist (mcp/index.md).
Shape sanity¶
The sidecar JSON declares input_name, output_name and expected_output_range. A runtime value outside the range raises a warning to stderr. A persistent violation aborts scoring for the frame.
Layer 3: sandbox via ORT¶
ONNX Runtime sandboxes graph execution: there is no interpreter, no shell-out and no arbitrary file I/O from inside a graph. Layers 1 and 2 harden the envelope around ORT, so that even a clever graph cannot consume unbounded memory or divert through a non-allowlisted op.
Layer 4: signature verification (opt-in)¶
Release artifacts, including models under model/tiny/, are signed by .github/workflows/supply-chain.yml with Sigstore's keyless flow. The workflow produces a single Sigstore bundle per artifact (signature, certificate and Rekor entry in one file), not the legacy split .sig and .pem pair. The registry names a model's bundle <name>.onnx.sigstore.json, see model-registry.md.
To verify a model by hand before loading it:
cosign verify-blob \
--certificate-identity-regexp "https://github.com/VMAFx/vmafx/.github/workflows/supply-chain.yml@.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle model/tiny/vmaf_tiny_v2.onnx.sigstore.json \
model/tiny/vmaf_tiny_v2.onnx
The --tiny-model-verify flag does the same at load time (T6-9, ADR-0211):
- It invokes
cosign verify-blobthroughposix_spawnp(3p)and fails closed when the signature is missing or bad. - It is off by default for dev-friendliness and strongly recommended in production.
- It drives
vmaf_dnn_verify_signature()incore/include/libvmaf/dnn.h, which looks up the model'ssigstore_bundlefield inmodel/tiny/registry.json.
Reporting¶
If you believe a shipped model is hostile or find a way to bypass the allowlist, follow the disclosure process in SECURITY.md. Sensitive reports should be PGP-encrypted; the policy states the response timeline.