Unconstrained Public Input
Detects public input signals that are used in computations but never appear in any constraint, enabling the prover to forge the claimed public input value.
Unconstrained Public Input
Overview
Remediation Guide: How to Fix Unconstrained Public Inputs
The unconstrained public input detector identifies public input signals that are referenced in computations but never appear in any constraint equation (=== or <==). Public inputs are the verifier’s only window into the prover’s computation. If a public input is not referenced by any constraint, the verifier cannot enforce its value, and the prover can claim any value for it while still producing a valid proof.
This is distinct from the “unused public input” detector, which catches inputs not referenced anywhere. This detector targets a subtler and more dangerous case: inputs that are used in witness generation (via <--) but never bound by a constraint the verifier checks.
Why This Is an Issue
Public inputs define the statement being proved. In a Merkle proof circuit, the root is a public input: “I know a leaf in the tree with this root.” If root appears only in unconstrained assignments, the prover can generate a proof for any root — the verifier cannot tell the difference. The proof is technically valid, but the statement it proves is meaningless.
This vulnerability enables complete proof forgery for the affected public input’s semantics. The proof will verify, but the verifier has no guarantee that the claimed public input value matches what was actually computed.
How to Resolve
Ensure every public input appears in at least one <== or === expression.
// Before: Vulnerable -- public input used only in <--
signal input root; // public input
signal input leaf; // public input
signal witness_path;
witness_path <-- root + leaf; // root is used, but only in <--
// After: Fixed -- public input appears in a constraint
signal input root;
signal input leaf;
signal output computed_root;
computed_root <== hash(leaf, witness_path);
computed_root === root; // verifier checks root
Examples
Vulnerable Code
template UnsafeMerkle() {
signal input root; // public -- the claimed tree root
signal input leaf; // public -- the claimed leaf
signal input path[10]; // private -- Merkle proof path
signal output valid;
// Compute the Merkle root from leaf + path
signal computed;
computed <-- leaf;
for (var i = 0; i < 10; i++) {
computed <-- hash2(computed, path[i]);
}
// BUG: root is never constrained!
// The prover can claim any root and the proof verifies.
valid <== 1;
}
The root public input is never referenced in any constraint. The prover can set root to any value — the verifier accepts the proof regardless.
Fixed Code
template SafeMerkle() {
signal input root;
signal input leaf;
signal input path[10];
signal output valid;
component hashers[10];
signal nodes[11];
nodes[0] <== leaf;
for (var i = 0; i < 10; i++) {
hashers[i] = Poseidon(2);
hashers[i].inputs[0] <== nodes[i];
hashers[i].inputs[1] <== path[i];
nodes[i + 1] <== hashers[i].out;
}
// Root is now constrained -- verifier checks it
root === nodes[10];
valid <== 1;
}
Sample Sigvex Output
CRITICAL unconstrained-public-input
Public input `root` is used but never constrained
Public input signal `root` in template `UnsafeMerkle` appears in unconstrained
assignments (`<--`) but is never referenced in any constraint (`===` or `<==`).
The verifier cannot enforce any relationship on this input, allowing the prover
to set it to any value. This enables proof forgery for the claimed public input.
Template: UnsafeMerkle
Signal: root
Confidence: 0.95
Recommendation: Add a constraint that references this public input, e.g.:
`output <== some_function(public_input);`
or add an explicit constraint:
`computed_value === public_input;`
Detection Methodology
The detector operates in two passes per template:
-
Build the constrained set: collect all signal names appearing in any
<==or===expression, on either the LHS or RHS. Signal names are extracted using word-boundary tokenization with keyword filtering. -
Build the used set: collect all signal names appearing in any expression (including
<--).
For each input signal, if it appears in the used set but not in the constrained set, it is flagged. Inputs that appear in neither set are intentionally skipped — those are caught by the separate “unused public input” detector.
Component wiring via <== (e.g., comp.in <== signal) counts as a constraint because the constraining assignment generates an R1CS equation that references the signal.
Limitations
- Template-scoped: the detector checks constraints within a single template. If a public input is constrained only in a parent template that instantiates this one, the finding may be a false positive.
- Does not track constraint transitivity through intermediate signals. If
tmp <-- public_inputand thenresult <== tmp * tmp, the public input is only indirectly constrained throughtmp— the detector will flagpublic_inputbecausepublic_inputitself does not appear in a constraint.
Related Detectors
- under-constrained-signal — general under-constrained signal detection
- unconstrained-output — output signals without constraints
- prover-controlled-loop-bound — unconstrained signals used as loop bounds
References
- Circom Documentation: Public and Private Inputs
- Pailoor, S. et al. “Automated Detection of Under-Constrained Circuits in Zero-Knowledge Proofs.” IEEE S&P 2023.
- 0xPARC: ZK Circuit Security Patterns