Non-Canonical Encodings in On-Chain Post-Quantum Signatures

Contents

1. Start from a result that has already been verified

Fix a public key, a message and one valid signature. Can someone who does not hold the private key change only the bytes of that signature and have the verifier still accept it?

For an ETHFALCON verifier deployed on Sepolia, yes. Take one coefficient of a legitimate signature, change it from 157 to 12446 (that is, add the modulus q=12289q = 12289), leave every other byte alone, and the on-chain verification still returns true:

Transactionlabelacceptedkeccak(s2)coeff₀
0xa990c588…canonicaltrue0x390f246f…157
0x3041516d…modq-aliastrue0x6bcfe455…12446

The object under test is ZKNOX ETHFALCON V0_0_2 at 0x328C56D4…de9C. The two transactions carry different keccak(s2) values, which is the proof that two genuinely different byte strings went in.

An ML-DSA verifier shows the same family of behaviour: a legitimate 2420-byte signature with eight non-zero bytes appended, 2428 bytes in total, is still accepted by the same deployment (section 6 has the evidence table).

What these experiments show is the acceptance behaviour of specific implementations at specific entry points. I did not obtain a valid authorization for any new message, and I did not demonstrate theft or repeated execution. The rest of this post explains where these byte variants come from, and why four different things — the specification text, a source-code version, a real deployment, and the application on top — have to be kept apart.

2. Threat model and terminology

2.1 What the attacker can do

One setting holds throughout:

  • the attacker already holds a signature σ\sigma that is valid for (pk,m)(pk, m);
  • the attacker does not hold the private key, and is not trying to obtain authorization for any new message mm^{*};
  • the attacker modifies only the byte representation of σ\sigma, hoping to find σσ\sigma' \ne \sigma that the verifier still accepts.

I call such a σ\sigma' an alias signature or a byte variant, and deliberately not a “forgery”, because that word is too easily read as “obtained a new authorization”.

2.2 EUF-CMA and SUF-CMA

A signature scheme is three algorithms, (KeyGen,Sign,Verify)(\mathsf{KeyGen}, \mathsf{Sign}, \mathsf{Verify}). The standard security target, EUF-CMA (existential unforgeability under chosen-message attack), promises that an attacker who can request signatures on messages of their choosing still cannot produce a verifying signature on a new message they never queried.

EUF-CMA on its own does not rule out producing a second valid signature on an already-signed message. That constraint comes from the stronger notion, SUF-CMA (strong unforgeability).

A common misstatement needs correcting here: it is not the case that “post-quantum schemes lack SUF”. FIPS 204 (ML-DSA) is designed for strong unforgeability and puts the decoding-side checks directly in the standard text — Algorithm 21, HintBitUnpack, is required to return \bot on a malformed hint encoding, and the standard requires public keys and signatures of the wrong length to be rejected. The Falcon specification likewise discusses unique encodings and the corresponding decoding checks.

So the subject of this post is not “the standards have a hole”. It is this: some implementations relaxed the parsing rules and therefore accept extra byte representations that the standards do not. The security guarantee of a standardized scheme does not carry over automatically to an implementation that changed what it accepts.

Three things are easy to conflate and are not the same:

  1. The signer signs the same message again with the private key and gets a different signature (the ordinary behaviour of a randomized scheme; no conflict with strong unforgeability).
  2. A third party derives a mathematically different signature object from an existing one. ECDSA’s (r,s)(r,ns)(r, s) \mapsto (r, n - s) is the ready-made example, and it shows that “a different mathematical object” is not the same as “unusable by a third party”.
  3. A third party changes the encoding, so that the verifier accepts a different representation of the same object.

Every case in this post is of the third kind. Falcon’s random salt belongs to the first kind; it appears only as background and is not listed as a defect.

2.3 Encoding non-canonicality, stated precisely

Fix a parameter set, an encoding format and the semantic map. For a fixed (pk,m)(pk, m) the verifier induces an acceptance set

Apk,m={b:Verify(pk,m,b)=1},\mathcal{A}_{pk,m} = \{\, b : \mathsf{Verify}(pk, m, b) = 1 \,\},

and let ϕ\phi send a byte string to the abstract signature object it represents. Encoding canonicality is the requirement that ϕ\phi, restricted to the acceptance set, be injective:

b1,b2Apk,m:ϕ(b1)=ϕ(b2)  b1=b2.\forall b_1, b_2 \in \mathcal{A}_{pk,m}:\quad \phi(b_1) = \phi(b_2) \ \Longrightarrow\ b_1 = b_2 .

Violate it and you have an alias signature. Note that this is not a hardness question. Hardness assumptions constrain whether one can produce a valid signature on a new message; here the attacker produces no new signature, only a different spelling of an old one. A plain EUF-CMA reduction is therefore silent about this operation — which is exactly why the standards spell out the decoding checks separately instead of expecting them to fall out of hardness.

(The converse does not hold either: injectivity of the encoding does not imply strong unforgeability. They are separate properties that need separate arguments, not a tidy “orthogonal” pair.)

3. A small lattice toolbox

Falcon and ML-DSA are both lattice-based signatures. The examples in this post need only the following.

Polynomial rings. Both schemes compute in Rq=Zq[x]/(xn+1)R_q = \mathbb{Z}_q[x]/(x^n + 1). Falcon-512: n=512n = 512, q=12289q = 12289. ML-DSA-44: n=256n = 256, q=8380417=223213+1q = 8380417 = 2^{23} - 2^{13} + 1. A polynomial is a coefficient vector of length nn, and the step “pack the coefficient vector into bytes” is precisely where encoding aliases breed.

Two conventions for representatives (the crucial one). A residue class mod qq contains infinitely many integer representatives, and practice uses two conventions:

  • the standard representative, taken from {0,1,,q1}\{0, 1, \dots, q - 1\};
  • the centred representative, taken from (q/2,q/2](-q/2,\, q/2].

Example, with q=12289q = 12289: the value 5-5 has standard representative 1228412284 and centred representative 5-5. The two are congruent mod qq and name the same ring element. This is the whole point of the next section’s bug: reducing mod qq changes no algebra, but “which convention do you measure size under” changes the result.

Norms. Take the centred representative s~i\tilde s_i of each coefficient and set s22=is~i2\|\mathbf{s}\|_2^2 = \sum_i \tilde s_i^2 and s=maxis~i\|\mathbf{s}\|_\infty = \max_i |\tilde s_i|. Verification in both schemes includes a “the solution is short enough” step (Falcon bounds 2\ell_2, ML-DSA bounds \ell_\infty), though full verification is more than that — ML-DSA also recomputes and compares the challenge hash. Only the norm step matters below.

The NTT is a bijection Rq(Zq)nR_q \cong (\mathbb{Z}_q)^n used purely to speed up polynomial multiplication; it introduces no encoding ambiguity of its own.

4. The main case: how a missing range check in a compact encoding produces mod-q aliases

4.1 Scope

This section is about the custom 16-bit compact representation used by ZKNOX ETHFALCON, and about the specific Sepolia deployment above. It is not a general property of standard Falcon compressed signatures — ETHFALCON’s hashing path also differs from standard Falcon.

Falcon is built on NTRU lattices, with public key h=gf1modqh = g f^{-1} \bmod q. A signature is a pair of short polynomials (s1,s2)(s_1, s_2) satisfying

s1+s2h=c(modq),c=HashToPoint(saltm),s_1 + s_2 h = \mathbf{c} \pmod q, \qquad \mathbf{c} = \mathsf{HashToPoint}(\text{salt} \,\|\, m),

and only (salt,s2)(\text{salt}, s_2) is transmitted; the verifier recomputes s1s_1. Verification is roughly three steps: ① compute c\mathbf{c}; ② s1=cs2hmodqs_1 = \mathbf{c} - s_2 h \bmod q; ③ check that s122+s222\|s_1\|_2^2 + \|s_2\|_2^2 is within bound. The implementation under test uses the criterion norm < 34034726.

4.2 The missing line

The compact path reads each coefficient of s2s_2 as a 16-bit field a[0,216)a \in [0, 2^{16}). A correct parser must check a<qa < q. The deployment above does not.

The per-coefficient logic the implementation uses to accumulate the norm is, in mathematical form,

fold(a)={aaq/2=6144qaa>6144,contribution(a)=fold(a)2mod2256.\mathrm{fold}(a) = \begin{cases} a & a \le \lfloor q/2 \rfloor = 6144 \\[2pt] q - a & a > 6144 \end{cases}, \qquad \text{contribution}(a) = \mathrm{fold}(a)^2 \bmod 2^{256}.

I am calling this “the value folded for squaring” and deliberately not “the centring function”: for an in-range coefficient in the upper half, qaq - a is exactly the negation of the centred value, and the squares agree, so on canonical input the logic is correct. It is not equal to centring for arbitrary integers, though. That property — “correct only on canonical input” — is exactly what breaks once the check is missing.

4.3 Constructing the alias

Take a valid signature, pick a coefficient aa, and replace it by a=a+qa' = a + q. For every canonical coefficient 0a<q0 \le a < q we have a=a+q24577<65536a' = a + q \le 24577 < 65536, so it always fits in the 16-bit field. Two invariants follow.

① The linear relation is unchanged. aa(modq)a' \equiv a \pmod q, and step ② is computed entirely mod qq, so s2hs_2 h is unchanged, so s1s_1 is unchanged, so s122\|s_1\|_2^2 is unchanged.

② The norm contribution is unchanged — when a6144a \le 6144. In that case a>q>6144a' > q > 6144 lands in the second branch:

fold(a)=qa=q(a+q)=a.\mathrm{fold}(a') = q - a' = q - (a + q) = -a .

Hence fold(a)2=(a)2=a2=fold(a)2\mathrm{fold}(a')^2 = (-a)^2 = a^2 = \mathrm{fold}(a)^2. Both invariants hold at once, so aa' yields a signature with different bytes and the same verdict.

On the EVM: in unsigned 256-bit arithmetic a-a is represented as 2256a2^{256} - a, and

(2256a)2=251222256a+a2a2(mod2256),(2^{256} - a)^2 = 2^{512} - 2 \cdot 2^{256} a + a^2 \equiv a^2 \pmod{2^{256}},

because the first two terms are multiples of 22562^{256}. That explains why the unsigned assembly “quietly” computes a2a^2 rather than failing or producing something else, but it is not a necessary ingredient of the alias — in ordinary signed integers (a)2=a2(-a)^2 = a^2 holds just the same.

The root cause is not overflow. It is that the input range was never checked, so norm-handling logic that was only ever correct on canonical input was fed an out-of-range representation.

4.4 The boundary: why the upper half does not work

If a6145a \ge 6145 (upper half, representing a small negative), the original contribution is (qa)2(q - a)^2 while a=a+qa' = a + q contributes a2a^2, and the two differ. But “different contribution” does not yet give “rejected”; one more step is needed:

a6145  a261452=37,761,025 > 34,034,726,a \ge 6145 \ \Longrightarrow\ a^2 \ge 6145^2 = 37{,}761{,}025 \ >\ 34{,}034{,}726,

so this single term already exceeds the acceptance bound, and in the full-accumulation model the variant is rejected. (This is reasoning about the criterion under test, not a claim that every upper-half alias has been rejected empirically on the vendor deployment.)

4.5 How many

If a signature has kk coefficients satisfying the condition above, each can independently take either of {a,a+q}\{a, a + q\}, giving 2k2^k accepted encodings, the original included. This is a combinatorial count, not an exhaustive test; the actual kk depends on the coefficient distribution of the particular signature, and I did not measure it. One correct line, require(a < q), closes all of them at once, upper half or lower.

4.6 The runnable mathematical core

Q = 12289
def fold_sq(x, q=Q): # the per-coefficient logic shown above (unsigned 256-bit)
c = (q - x) % (2**256) if x > (q >> 1) else x
return (c * c) % (2**256)
a = 5 # lower half
assert a % Q == (a + Q) % Q # ① congruent → s1 unchanged
assert fold_sq(a) == fold_sq(a + Q) == 25 # ② same folded square → contribution unchanged
b = Q - 5 # 12284, upper half (represents −5)
assert fold_sq(b) != fold_sq(b + Q) # boundary: fails in the upper half
assert (6145 ** 2) > 34034726 # and the new single term already exceeds the bound

The corresponding source fragment (this coefficient range check is present in the source version I compared against; the older deployment above lacks it):

// ZKNOX_falcon_core.sol (pinned source version)
let s2i := and(shr(shl(4, j), ai), 0xffff) // take the 16-bit coefficient a
outOfRange := or(outOfRange, iszero(lt(s2i, q))) // ← require a < q
let cond := gt(s2i, qs1)
let centered := add(mul(cond, sub(q, s2i)), mul(sub(1, cond), s2i))
norm := add(norm, mul(centered, centered))

5. Two more places: the container layer, and hints

5.1 The container layer: truncating division

The ERC-7913 entry point slices the salt (the first 40 bytes) and s2s_2 out of the signature, computing the word count of s2s_2 with integer division:

mstore(s2LengthSlot, div(sub(mload(sig), 40), 32)) // len = ⌊(sig.len − 40)/32⌋

The layer above requires len == 32, which solves to

sig.len[40+3232, 40+32331]=[1064, 1095].\text{sig.len} \in [\,40 + 32 \cdot 32,\ 40 + 32 \cdot 33 - 1\,] = [1064,\ 1095].

In other words, append 1 to 31 arbitrary bytes to a legitimate 1064-byte signature: the truncating division discards them, s2s_2 is unchanged, the verdict is unchanged, and keccak256(sig)\mathrm{keccak256}(\text{sig}) is different. Local tests against the pinned source version confirm it: with 1, 7 and 31 bytes appended, the returned magic value is accept in every case, and the keccak differs each time.

The evidence boundary has to be stated plainly. This item was not run end to end against the vendor’s deployed ERC-7913 entry point. The vendor’s V0_0_2 setKey returns a uint256[] rather than a 20-byte pointer, which does not match that entry point’s public-key convention, and a direct call reverts. What I did instead was deploy the same wrapper myself on Sepolia (the div/32 logic identical byte for byte) for the on-chain demonstration. The strength of that evidence is “measured on an equivalent deployment”, not the vendor’s literal bytecode.

5.2 Hint encoding in ML-DSA-44

ML-DSA (module-lattice digital signatures, with security resting on module-lattice problems) has to reconstruct the high bits of a commitment during verification. To save bandwidth the signature carries a sparse hint h\mathbf{h} that corrects ±1\pm 1 carries. For ML-DSA-44: k=4k = 4 polynomials, at most ω=80\omega = 80 ones in total, encoded as a “position list” of ω+k=84\omega + k = 84 bytes — the first 80 bytes are the indices set to 1, the last 4 are a cumulative cursor per polynomial.

FIPS 204 Algorithm 21, HintBitUnpack, specifies the decoding checks and returns \bot on a malformed encoding:

OMEGA, K = 80, 4 # ML-DSA-44
def hint_bit_unpack(y): # y: 84 bytes
h = [[0]*256 for _ in range(K)]
idx = 0
for i in range(K):
end = y[OMEGA + i]
if end < idx or end > OMEGA: # check ③: cursors non-decreasing and ≤ ω
return None
first = idx
while idx < end:
if idx > first and y[idx] <= y[idx-1]: # check ①: strictly increasing within a polynomial
return None
h[i][y[idx]] = 1
idx += 1
for j in range(idx, OMEGA):
if y[j] != 0: # check ②: trailing padding all zero
return None
return h

The source of aliases is this: the semantics of a hint is just “which positions are set” — a set — and a position list can spell the same set in more than one way. But different relaxations admit different shapes, and they have to be kept apart. This is the point I got wrong in an earlier draft:

Input shapestrictly increasing (<= rejects)rejects descending only (< rejects)no order check at all
[1, 3]acceptacceptaccept
[1, 1, 3] (duplicate)rejectacceptaccept
[3, 1] (descending)rejectrejectaccept

That is, turning the <= in check ① into < admits duplicates only; it does not admit descending order. The table covers only the ordering condition; the length, cursor and padding constraints still each have to be met, and to build a real duplicate-index variant you also need spare capacity, have to shift the following indices, and have to update the cumulative cursor to keep the total length. The t!t! permutation count holds only in the model where the order check is absent altogether, and non-zero padding is governed separately by check ② — none of these belong to a single defect.

The difference between these shapes is measurable in real implementations. RustCrypto’s ml-dsa had a duplicate-hint-index defect (GHSA-5x2r-hc65-25f9, first fixed in 0.1.0-rc.4). With one set of vectors I measured: 0.1.0-rc.3 accepts the duplicate variant but rejects the out-of-order and non-zero-padding variants; 0.1.1 rejects all three. That matches the table exactly.

6. The evidence table, by object and entry point

Every row is pinned to “which implementation, which entry point, what strength”. Do not assemble these into an inherent property of an algorithm family.

PhenomenonObject and entry pointEvidenceWhat can be claimed
ETHFALCON mod-q aliasVendor Sepolia V0_0_2 0x328C…de9C, four-argument entry point; runtime keccak matches the pinned snapshotRead-only call plus two on-chain probe transactions (section 1)The original signature and a 157→12446 alias are both accepted by this deployment
Falcon trailing-byte truncationLocal pinned-source wrapper; self-deployed Sepolia wrapper, ERC-7913 entry pointLocally: 1 / 7 / 31 appended bytes; on the self-deployed contract: 7 appended bytes (1064 and 1071 both accepted)The truncation mechanism is reproduced; it cannot be written up as having succeeded live on the vendor’s ERC-7913 entry point
ML-DSA hint checksLocal tests of two verified deployment sources; RustCrypto historical versions for comparisonThe deployment source retains all three checks and local tests reject the listed variants; rc.3 alone accepts duplicatesA known library defect and the on-chain implementation under test behave differently; not an inherent ML-DSA defect
ML-DSA trailing bytesVendor Sepolia mldsaeth V0_0_3 0xA7D6…1C34, four-argument verify(bytes,bytes,bytes,bytes)Transactions 0x117d07dd… (2420 bytes, accepted=true) and 0xfdcd3d5f… (2428 bytes after appending deadbeefcafebabe, accepted=true), keccak(sig) differsThis entry point accepts the trailing-byte variant shown; not to be generalized to a working ERC-7913 / UserOp exploit

Two things must be said precisely:

  1. “The source fix is not on chain” is to be restricted to: “the particular older deployment I examined still lacks this check, while the source version I compared against includes it.” It does not license conclusions about every deployment or about the current latest state.
  2. ML-DSA’s trailing bytes should not be blamed on a generic slice that uses >=. A slice requiring its input to be “at least long enough” is normal; what is missing is an equality check on the total length at the verification entry point.

On “delete one check” experiments

I ran a class of ablation experiments: in a pinned source version, remove one check and watch whether the verdict on the same input flips, to confirm that the check really does the rejecting in that version.

That is not a reproduction of the on-chain deployment. The saved source diff shows other differences between the two versions — the loop bound of the s1s_1 norm, the implementation of the s2s_2 norm loop. So the ablation answers “does this line do anything in this version”; the vendor deployment’s acceptance behaviour needs its own independent verification. The two experiments answer different questions, and neither licenses a claim that the two versions are equivalent. The ML-DSA delete-a-check version in particular must be labelled a synthetic control: the deployment source I checked carries all three checks to begin with.

7. What this means for applications

Whether an encoding alias matters to an application depends on how the application identifies an authorization. If a system uses the raw signature bytes, or their hash, as a deduplication key, then different representations of the same signature object can get different identities. But different bytes do not automatically mean repeatable execution: a nonce, the message contents, or already-consumed state may each block it.

Take ERC-4337: the standard userOpHash does not include the signature field, so changing only the signature bytes does not change that hash. What this post has verified is that some entry points accept byte variants; whether those variants affect a particular bundler, cache or account requires a separate experiment against that consumer, and cannot be inferred from the verifier’s acceptance behaviour.

At least four identities need separate discussion: the hash of the raw signature bytes, the hash of the full transaction that contains the signature, the UserOperation’s identifier, and any application-defined deduplication key.

The historical precedent is EIP-2. In 2016 Ethereum imposed the low-ss rule on transaction signatures, removing the (r,s)(r, s) versus (r,ns)(r, n - s) ambiguity. Notably, the behaviour of the ecrecover precompile on high ss was not changed alongside it, and EIP-2 did not equate “a variant transaction hash appears” with “a duplicated transfer”.

A layered checklist

Placing each phenomenon at the stage “between bytes and verification” where it occurs gives a checklist. Its use is to say where to look; it is not a general theorem, and the parties responsible for the stages can overlap:

StageWhat to checkInstance in this post
L1 Mathematics (background)Does the same (m,pk)(m, pk) admit more than one legitimate signature object?Falcon’s random salt — a third party has no trapdoor; outside this post’s attack model
L2 Inner object decodingAre coefficient / index ranges, ordering and padding strictly constrained?The missing mod-q range check; the three hint checks
L3 Outer length and framingDoes the entry point pin the total length exactly; is anything truncated or ignored?div/32 trailing bytes; unchecked total signature length
L4 Upstream use of bytes and hashes (not measured here)How does the layer above consume signature bytes and hashes?Deduplication keys, transaction hashes, how userOpHash is consumed

Where it earns its keep is discrimination: on the same ML-DSA deployment, the L2 (hint) checks are present while the L3 (total length) check is absent — without separating the stages it is easy to conclude, too broadly, that “this implementation is fine”.

8. Closing

What these cases keep telling me is that checking the mathematical relation is not enough. Which bytes the verifier accepts, whether the framing pins the length exactly, and how the application uses signatures and hashes, each need checking on their own. A strict inner decoder does not make the outer entry point strict — the ML-DSA case in this post has exactly that shape.

On cost, I want to write down one idea, but it has to be marked as an uncalibrated bookkeeping sketch, not a conclusion. The cost of one on-chain verification can be roughly split as

C=αXOF+βmulmod+γbytes+δcanonical_check,C = \alpha \cdot \text{XOF} + \beta \cdot \text{mulmod} + \gamma \cdot \text{bytes} + \delta \cdot \text{canonical\_check},

where XOF is the number of absorb and squeeze operations of the extendable-output function (SHAKE / Keccak) used to stretch a message or seed into a challenge polynomial or to expand a public key; mulmod is the number of modular multiplications, mostly from the NTT / inverse NTT and matrix-vector products; bytes is the volume of bytes that must be read, copied and hashed; canonical_check is the extra work of enforcing encoding canonicality, that is, the handful of lines this post keeps returning to — range checks, ordering and padding checks, length-equality assertions; and α,β,γ,δ\alpha, \beta, \gamma, \delta are the coefficients that turn each into gas.

The limits of the formula must be stated: the terms may overlap, a single δ\delta cannot summarize checks of such different shapes, and I have not measured the incremental gas of any of these checks. So this post does not claim that canonicality checks are necessarily expensive for lattice schemes and near-free for hash-based ones, and it certainly does not claim that the missing checks were a deliberate gas trade-off by the implementers — I have no cost data and no evidence about motive. Fixed-length structures need strict validation of lengths, field ranges and outer framing just the same; parsing canonicality cannot be inferred from the signature family. Leaving “how much gas do these checks actually cost” as a measurable follow-up question is more honest than concluding now.

The evidence supports exactly two conclusions: whether a check exists must be settled per version and per entry point; and a fix in source has to be verified against the actual deployment, separately.