Input Validation
In MPC protocols, parties exchange data encoded as bitstrings representing mathematical objects such as field elements, group elements, commitments and proofs. A corrupted party may supply anything, so the receiver must verify that each incoming value has the expected shape, decodes to a valid object of the expected algebraic type, and lies in the required domain. The pitfalls below arise when one of these checks is omitted, applied only to the encoding, or performed in the wrong algebraic domain.
Group Elements Not Validated in Discrete-Log Groups
What can go wrong. In MPC protocols that rely on discrete-log groups (e.g., Pedersen-VSS over safe-prime $\mathbb{Z}_p^*$, or GG18/GG20 range-proof bases in $\mathbb{Z}_{\tilde N}^*$), parties exchange elements that under exponentiation with a secret exponent may leak part or all of the secret if the element does not lie in the intended subgroup. The intended subgroup is almost always a large prime-order subgroup of $\mathbb{Z}_p^*$ (with $p = 2q + 1$ a safe prime) or of $\mathbb{Z}_{\tilde N}^*$ (an RSA-style modulus). Three checks apply to every exchanged group element:
Generator validation in safe-prime groups. A valid generator of the $q$-order subgroup of $\mathbb{Z}_p^*$ must satisfy $g \not\equiv 1 \pmod{p}$, $g \not\equiv p - 1 \pmod{p}$, and $g^{(p-1)/2} \equiv 1 \pmod{p}$. The first two exclude the trivial subgroup and the order-2 subgroup; the third confirms membership in the quadratic-residue subgroup.
Subgroup membership for received bases. For any supplied exponentiation base $x$, you must make sure $x$ lives in the intended secure subgroup by checking $x^q \equiv 1 \pmod{p}$ for a safe-prime $p$, and also check the bounds $1 < x < p - 1$ to rule out the trivial cases. Bounds-only checks catch the most degenerate values, but a generic small-order element can pass bounds and still be outside the subgroup.
Paired bases of equal subgroup order. When two bases $h_1, h_2$ are used together (as in GG18/GG20 Pedersen-style commitments $h_1^s h_2^r$), it is not enough to know each lies in the intended subgroup individually. Both must generate the same subgroup. The standard tool is a DLN proof that the sender knows $\alpha$ with $h_2 = h_1^{\alpha} \bmod \tilde N$, together with the companion proof in the reverse direction ($h_1 = h_2^{\beta} \bmod \tilde N$).
Depending on the protocol, omitting any one of these checks lets a malicious party choose an element $x$ that leaks partial or full bits of a secret exponent on every exponentiation.
Security implication. Concretely in GG18 MtA, as analyzed by Hexens, an adversary can set $h_2 = 1$ so $z = h_1^s \bmod \tilde N$ leaks $h_1^s$. When $\tilde N$ is generated without enforcing safe primes (see non-safe-prime modulus), the attacker can choose $\tilde N$ as a product of small prime factors so that $\phi(\tilde N)$ is smooth, and combining Pohlig-Hellman on each factor with CRT reconstructs the full share. Alternatively, an attacker can choose $\tilde N$ large enough that recovering $s$ reduces to computing integer logarithms, which is trivial with standard algorithms.
How to avoid. Validate every exchanged group element before any exponentiation by a secret touches it. For each element supplied by another party:
- Candidate generator in safe-prime $\mathbb{Z}_p^*$: reject if $g \in \{1, p-1\}$, then check $g^{(p-1)/2} \equiv 1 \pmod{p}$ to confirm membership in the $q$-order subgroup.
- Received base $x$ in $\mathbb{Z}_p^*$: for a safe-prime $p=2q + 1$, before any use of $x$, check $1 < x < p - 1$, and check $x^q \equiv 1 \pmod{p}$.
- Paired bases $h_1, h_2$ in $\mathbb{Z}_{\tilde N}^*$ (the unknown-order case): reject if $h_i \in \{1, \tilde N - 1\}$ or $h_1 = h_2$; require the sender to attach a DLN proof of knowledge of $\alpha$ with $h_2 = h_1^{\alpha} \bmod \tilde N$ together with the reverse-direction proof; and require a structural proof on $\tilde N$ itself (biprimality with safe-prime factors of standard size, see non-safe-prime modulus), with a bound on $|\tilde N|$ to rule out adversarially oversized moduli. Soundness of both DLN proofs forces $\langle h_1 \rangle = \langle h_2 \rangle$; the structural proof and size bound prevent the smooth-$\phi$ and integer-log attacks respectively.
As an alternative to the bullets above for safe-prime $\mathbb{Z}_p^*$, cofactor exponentiation (RFC 2785, §3.4) raises every received element to the cofactor $j = (p-1)/q$ before use, confining all subsequent operations to the prime-order subgroup. In a safe prime $p = 2q + 1$ the cofactor is $2$, so this reduces to squaring every input ($g' = g^2$) and confines exponentiations to the prime-order subgroup without an explicit generator check.
GG18/GG20 range proofs instantiate Pedersen commitments under auxiliary bases $h_1, h_2 \in \mathbb{Z}_{\tilde N}^*$ and assume those bases generate the same large subgroup. Two successive bugs hit the tss-lib library on this exact surface.
Original keygen broadcast ships bases with no DLN proof at all. Round 2 of ECDSA keygen stored the incoming triple $(\tilde N, h_1, h_2)$ directly, with no validation (source):
1// FILE: ecdsa/keygen/round_2.go
2// bnb-chain/tss-lib @ 6584db7f (pre-PR #89, vulnerable)
3for j, msg := range round.temp.kgRound1Messages {
4 r1msg := msg.Content().(*KGRound1Message)
5 round.save.PaillierPKs[j] = r1msg.UnmarshalPaillierPK() // used in round 4
6 round.save.NTildej[j] = r1msg.UnmarshalNTilde()
7 round.save.H1j[j], round.save.H2j[j] = r1msg.UnmarshalH1(), r1msg.UnmarshalH2()
8 // ...
9}
A malicious peer sets $h_2 = 1$ so each subsequent MtA range-proof commitment collapses to $z = h_1^s \bmod \tilde N$, revealing $h_1^s$; the attacker then reconstructs $s$ either by choosing $\tilde N$ as a product of small prime factors so $\phi(\tilde N)$ is smooth and applying Pohlig-Hellman on each factor combined with CRT, or by choosing $\tilde N$ large enough that recovery reduces to an integer logarithm problem.
PR #89 wraps the same loop with a $h_1 \ne h_2$ guard, a uniqueness check on $h_1, h_2$ across parties, and two concurrent DLN proof verifications before any party’s bases are accepted (source):
1// FILE: ecdsa/keygen/round_2.go (lines 51-62)
2// bnb-chain/tss-lib @ c66e035b (post-PR #89, v1.2.0+)
3go func(j int, msg tss.ParsedMessage, r1msg *KGRound1Message, H1j, H2j, NTildej *big.Int) {
4 if dlnProof1, err := r1msg.UnmarshalDLNProof1(); err != nil || !dlnProof1.Verify(H1j, H2j, NTildej) {
5 dlnProof1FailCulprits[j] = msg.GetFrom()
6 }
7 wg.Done()
8}(j, msg, r1msg, H1j, H2j, NTildej)
9go func(j int, msg tss.ParsedMessage, r1msg *KGRound1Message, H1j, H2j, NTildej *big.Int) {
10 if dlnProof2, err := r1msg.UnmarshalDLNProof2(); err != nil || !dlnProof2.Verify(H2j, H1j, NTildej) {
11 dlnProof2FailCulprits[j] = msg.GetFrom()
12 }
13 wg.Done()
14}(j, msg, r1msg, H1j, H2j, NTildej)
Follow-on: Verify itself accepted degenerate $h_1, h_2$ (Trail of Bits
TOB-BIN-8). Even with DLN proofs verified at keygen, the Verify routine
inside crypto/dlnproof/proof.go ran the Sigma-protocol equation checks
without any sanity on the inputs themselves. It accepted $h_1, h_2 \in \{0,
1, \tilde N - 1\}$ or $h_1 = h_2$, and likewise accepted arbitrary $T[i],
\mathrm{Alpha}[i]$
(source):
1// FILE: crypto/dlnproof/proof.go
2// bnb-chain/tss-lib @ c65c3564 (pre-TOB-BIN-8, vulnerable)
3func (p *Proof) Verify(h1, h2, N *big.Int) bool {
4 if p == nil {
5 return false
6 }
7 modN := common.ModInt(N)
8 msg := append([]*big.Int{h1, h2, N}, p.Alpha[:]...)
9 c := common.SHA512_256i(msg...)
10 // ... Iterations rounds of Sigma-protocol equation checks ...
11 return true
12}
The TOB-BIN-8 fix (commit c0a1d4e4)
added bounds checks for $h_1, h_2$ in $(1, \tilde N)$, the $h_1 \ne h_2$
guard, and matching per-element bounds on every entry of $T[]$ and $\mathrm{Alpha}[]$ (source):
1// FILE: crypto/dlnproof/proof.go
2// bnb-chain/tss-lib @ c0a1d4e4 (fixed, TOB-BIN-8)
3func (p *Proof) Verify(h1, h2, N *big.Int) bool {
4 if p == nil {
5 return false
6 }
7 if N.Sign() != 1 {
8 return false
9 }
10 modN := common.ModInt(N)
11 h1_ := new(big.Int).Mod(h1, N)
12 if h1_.Cmp(one) != 1 || h1_.Cmp(N) != -1 {
13 return false
14 }
15 h2_ := new(big.Int).Mod(h2, N)
16 if h2_.Cmp(one) != 1 || h2_.Cmp(N) != -1 {
17 return false
18 }
19 if h1_.Cmp(h2_) == 0 {
20 return false
21 }
22 for i := range p.T {
23 a := new(big.Int).Mod(p.T[i], N)
24 if a.Cmp(one) != 1 || a.Cmp(N) != -1 {
25 return false
26 }
27 }
28 for i := range p.Alpha {
29 a := new(big.Int).Mod(p.Alpha[i], N)
30 if a.Cmp(one) != 1 || a.Cmp(N) != -1 {
31 return false
32 }
33 }
34 // ... Sigma-protocol equation checks (Iterations rounds) unchanged ...
35 return true
36}
Curve Points Not Validated
What can go wrong. In MPC protocols that rely on elliptic-curve groups (e.g., threshold ECDSA in GG18/GG20 or BLS aggregate signatures) parties exchange curve points $(X, Y)$ that under any scalar multiplication may leak part or all of the secret, or break the soundness of an aggregation or pairing equation, if the point fails any of the validity conditions below. Three checks apply to every exchanged curve point:
- Canonical encoding. Require $X, Y \in [0, p)$ at the wire boundary.
- On-curve check. $(X, Y)$ must satisfy the curve equation
$Y^2 \equiv X^3 + aX + b \pmod{p}$. A pair on an invalid curve $Y^2 = X^3 + aX + b'$ with
smooth order $n'$ is silently processed by APIs (like Go’s
elliptic.Curve.ScalarMult) that depend only on $p$ and $a$, not on the curve constant $b$. The point $(0, 0)$, which Go represents as the in-memory sentinel for the identity, is a second degenerate case and must be rejected independently. - Subgroup membership. Even an on-curve point may lie outside the intended prime-order subgroup if the curve has cofactor $h > 1$. The standard check is $q \cdot P = \mathcal{O}$ for the prime-order subgroup of order $q$, or equivalently multiplication by the cofactor with rejection of the resulting identity.
Depending on the protocol, omitting any one of these checks lets a malicious party choose a curve point $P$ that leaks bits of a secret scalar on every scalar multiplication, breaks the soundness of an aggregation or pairing-based verification, or desynchronizes the transcript and commitment state across MPC rounds.
Security implication. Missing point validation entails different attacks depending on the protocol. In ECDH protocols, an adversary supplies a point whose order has a small factor $n'$, and every scalar multiplication $k \cdot P$ by an honest secret $k$ reveals $k \bmod n'$. This primitive underlies both invalid curve attacks (Biham, Neumann, SAC 2019) and small-subgroup attacks. In threshold EdDSA over Curve25519, ZenGo’s Baby Sharks analysis showed that an injected torsion component shifts the joint key off the prime-order subgroup, so a forged signature verifies with probability about $1/8$ under some implementations while others reject it; that cross-implementation discrepancy can split honest verifiers and halt consensus. In BLS aggregation over BLS12-381, a non-subgroup public key satisfies the pairing equation for crafted signatures without knowledge of the corresponding private key, giving full signature forgery. A separate failure mode appears when the wire encoding itself is non-canonical: the same point produces different Fiat-Shamir challenges between two honest parties, splitting them across sessions and invalidating commitment openings tied to the encoded representation.
How to avoid. Validate every externally-supplied point at the wire boundary, before any scalar multiplication by a secret touches it. For each $(X, Y)$ received from another party:
- Canonical range: reject if $X \not\in [0, p)$ or $Y \not\in [0, p)$.
- On-curve check: reject if $(X, Y)$ does not satisfy $Y^2 \equiv X^3 + aX + b \pmod{p}$, and reject the identity $(0, 0)$ independently.
- Subgroup membership (when cofactor $h > 1$): check $q \cdot P = \mathcal{O}$, where $q$ is the order of the prime-order subgroup.
For fresh curve designs, prefer a prime-order curve ($h = 1$, e.g. secp256k1 or P-256), where the subgroup check is implied by the on-curve check, or the Ristretto255/Decaf abstraction, which exposes only the prime-order quotient group and makes torsion injection structurally impossible.
Standard EdDSA defends against small-subgroup attacks via bit clamping on the single-party secret scalar. The threshold EdDSA path in tss-lib applied no equivalent defense to supplied points received from peers, so as the ZenGo’s Baby Sharks analysis showed, a malicious party could inject an order-8 torsion component into the joint public key so that $1/8$ of signing ceremonies verify, while the other will reject.
In the pre-fix tss-lib, the received commitment $R_j$ was constructed straight from peer-supplied coordinates and aggregated into the joint $R$ with no subgroup-membership step (source):
1// eddsa/signing/round_3.go — bnb-chain/tss-lib (pre-fix)
2Rj, err := crypto.NewECPoint(tss.EC(), coordinates[0], coordinates[1])
3if err != nil {
4 return round.WrapError(errors.Wrapf(err, "NewECPoint(Rj)"), Pj)
5}
6// ... proof.Verify(Rj) checks knowledge of the discrete log, not subgroup ...
7extendedRj := ecPointToExtendedElement(Rj.X(), Rj.Y())
8R = addExtendedElements(R, extendedRj)
The remediation landed in PR #115. It adds an EightInvEight() helper in crypto/ecpoint.go that multiplies by 8 then by $8^{-1} \bmod N$, projecting any input into the prime-order subgroup (source):
1// crypto/ecpoint.go — bnb-chain/tss-lib (post-fix)
2var (
3 eight = big.NewInt(8)
4 eightInv = new(big.Int).ModInverse(eight, edwards.Edwards().Params().N)
5)
6
7func (p *ECPoint) EightInvEight() *ECPoint {
8 return p.ScalarMult(eight).ScalarMult(eightInv)
9}
The helper is then applied to every received point. The patch at the signing site, mirroring the pre-fix excerpt above (source):
1// eddsa/signing/round_3.go — bnb-chain/tss-lib (post-fix)
2Rj, err := crypto.NewECPoint(tss.EC(), coordinates[0], coordinates[1])
3Rj = Rj.EightInvEight()
4if err != nil {
5 return round.WrapError(errors.Wrapf(err, "NewECPoint(Rj)"), Pj)
6}
Received Sequence Has the Wrong Length
What can go wrong. MPC protocols often handle sequences with an expected length such as Feldman VSS commitment vectors of length $t$, lists of $n-1$ peer signatures, or vectors of DLN proof iterations. Each carries a protocol-specified length that the verifier must check before using the sequence. Accepting a sequence with an unexpected shape is functionally running a different protocol instance from the one the verifier thought it was in. The same bug also appears at the lower bound: an empty proof, signature, or participant list can make a verification loop execute zero times and return success vacuously unless the expected length is checked first.
Security implication. In the context of DKG (Distributed Key Generation), a malicious party can send a Feldman VSS commitment vector of length $t + k$ while the protocol-specified length is $t$. Honest verifiers iterate over all $t + k$ elements without noticing the mismatch, surreptitiously raising the reconstruction threshold from $t$ to $t + k$ and leaving the shared key irrecoverable from the $t$ honest shares alone, unless the DKG is restarted from scratch.
How to avoid. Each party must compare the received vector length against the protocol-specified length before using the vector and abort the protocol on any length mismatch.
WSTS (Weighted Schnorr Threshold Signatures), aka WileyProofs, is based on FROST and was vulnerable to threshold-raise attacks. Before PR #88, the per-signer DKG verification in src/v1.rs only checked the Schnorr ID, not the commitment-vector length
(source):
1// src/v1.rs — Trust-Machines/wsts (vulnerable, before PR #88)
2if !comm.verify() {
3 bad_ids.push(*i);
4}
5self.group_key += comm.poly[0];
A malicious signer could append commitments to its poly to silently raise the
reconstruction threshold. The Trail of Bits length-check fix in Trust-Machines/wsts landed as PR #88 (“Check length of polynomials”). PR #88 added the explicit
equality check at every DKG verification site
(source):
1// src/v1.rs — Trust-Machines/wsts (fixed, PR #88)
2if comm.poly.len() != threshold || !comm.verify() {
3 bad_ids.push(*i);
4} else {
5 self.group_key += comm.poly[0];
6}
Context Binding
Zero-knowledge proofs, commitments, and signatures are important building blocks of MPC protocols, especially in threshold cryptography, which is a major category of MPC. An adversary can try to replay or transplant such artifacts from one context into another: across separate runs of the protocol (sequential or concurrent), or within a single execution (e.g. across rounds, or claiming another party’s message as its own). To prevent this, cryptographic artifacts (transcripts, commitments, signed messages) must bind uniquely to their execution context (session, parties, role, statement), so that witnesses, openings, and proofs cannot be reused across contexts.
Challenge Hash Missing Prover's Party Identity and Session Identifier
What can go wrong. In the Fiat-Shamir transformation, the verifier’s challenge is replaced by a challenge hash that, in the single-prover, single-session case, depends only on the public statement and the prover’s commitment. In a multi-prover or multi-session setting this is not enough, and the hash must also bind to the prover’s party identifier (pid) and to the session identifier (ssid). If the pid is missing, nothing in the hash input identifies which prover computed it, so honest $P_i$ and malicious $P_m$ obtain the same challenge on the same statement and commitment within a single session. A proof $\pi_i$ produced by $P_i$ can then be replayed verbatim by $P_m$, who claims knowledge of the underlying witness without ever holding it. If the ssid is missing, the hash produces the same challenge value across every session running the same statement. Two invocations of the proof, one in key-generation session $A$ and another in signing session $B$, differ only in the surrounding protocol context, which the hash does not see. The proof bytes from session $A$ therefore remain structurally valid in session $B$, allowing replay across sessions.
Security implication. In a DKG (Distributed Key Generation) protocol, a malicious party $P_m$ can adaptively choose its public-key to match an honest party $P_i$’s ($X_m = X_i$). The malicious party then records $P_i$’s Schnorr proof and submits it as its own round contribution, passing the proof-of-knowledge check without holding any secret. The malicious party can also reuse it in later sessions.
How to avoid. Include the prover’s party identifier (pid, public key, or
protocol-assigned role) in every FS challenge hash and derive a session identifier ssid from every public parameter of the current run. In practice many libraries fold the party identifier into the ssid derivation (the participant set is included in ssid).
The Schnorr PoK in
bnb-chain/tss-lib lets party $P_i$ prove knowledge of its secret key share $x_i$ by
sending $(R = g^k, s = k + c \cdot x_i)$ where $c$ is a Fiat-Shamir challenge. In v1.x
the challenge was derived solely from the public key and the commitment
(source):
1// FILE: crypto/schnorr/schnorr_proof.go — bnb-chain/tss-lib v1.3.5 (vulnerable)
2
3// NewZKProof constructs a new Schnorr ZK proof of knowledge of the discrete logarithm (GG18Spec Fig. 16)
4func NewZKProof(x *big.Int, X *crypto.ECPoint) (*ZKProof, error) {
5 if x == nil || X == nil || !X.ValidateBasic() {
6 return nil, errors.New("ZKProof constructor received nil or invalid value(s)")
7 }
8 ec := X.Curve()
9 ecParams := ec.Params()
10 q := ecParams.N
11 g := crypto.NewECPointNoCurveCheck(ec, ecParams.Gx, ecParams.Gy) // already on the curve.
12
13 a := common.GetRandomPositiveInt(q)
14 alpha := crypto.ScalarBaseMult(ec, a)
15
16 var c *big.Int
17 {
18 // Challenge includes only public key X and commitment alpha — no session ID,
19 // no party identity, no protocol context.
20 cHash := common.SHA512_256i(X.X(), X.Y(), g.X(), g.Y(), alpha.X(), alpha.Y())
21 c = common.RejectionSample(q, cHash)
22 }
23 t := new(big.Int).Mul(c, x)
24 t = common.ModInt(q).Add(a, t)
25
26 return &ZKProof{Alpha: alpha, T: t}, nil
27}
As described in CVE-2022-47930, the Schnorr proof of knowledge does not utilize a session id, context, or random nonce in the generation of the challenge. This allows a malicious party to replay a proof generated by an honest party. (The CVE record names the IoFinnet fork as the affected product, but the same flaw and PR #256 fix apply to the bnb-chain upstream shown here.) The fix
(PR #256) added a Session []byte parameter prepended to every proof challenge via the
domain-separating SHA512_256i_TAGGED
(source):
1// FILE: crypto/schnorr/schnorr_proof.go — bnb-chain/tss-lib v2.0.0 (fixed)
2
3// NewZKProof constructs a new Schnorr ZK proof of knowledge of the discrete logarithm (GG18Spec Fig. 16)
4func NewZKProof(Session []byte, x *big.Int, X *crypto.ECPoint) (*ZKProof, error) {
5 if x == nil || X == nil || !X.ValidateBasic() {
6 return nil, errors.New("ZKProof constructor received nil or invalid value(s)")
7 }
8 ec := X.Curve()
9 ecParams := ec.Params()
10 q := ecParams.N
11 g := crypto.NewECPointNoCurveCheck(ec, ecParams.Gx, ecParams.Gy) // already on the curve.
12
13 a := common.GetRandomPositiveInt(q)
14 alpha := crypto.ScalarBaseMult(ec, a)
15
16 var c *big.Int
17 {
18 // Session is prepended via the domain-separating tagged hash, binding the
19 // challenge to the protocol session (and, by convention, the participant set).
20 cHash := common.SHA512_256i_TAGGED(Session, X.X(), X.Y(), g.X(), g.Y(), alpha.X(), alpha.Y())
21 c = common.RejectionSample(q, cHash)
22 }
23 t := new(big.Int).Mul(c, x)
24 t = common.ModInt(q).Add(a, t)
25
26 return &ZKProof{Alpha: alpha, T: t}, nil
27}
Challenge Transcript Missing Required Values (Weak Fiat-Shamir)
What can go wrong. In the Fiat-Shamir transformation, the verifier’s challenge is replaced by a hash. Soundness requires that the challenge $c$ be the hash of every value the verifier’s equation depends on: the public statement, the prover’s first-message commitment(s), and any auxiliary values that appear in the verification relation. Missing any of these lets the prover choose the omitted value after seeing the challenge, enabling forgery. Dao, Miller, Wright, and Grubbs (eprint 2023/691) survey these weak-Fiat-Shamir attacks across modern proof systems.
Security implication. Depending on what is missing: (i) missing the public statement makes the proof valid for any statement with the same structural shape (a cross-statement replay); (ii) missing a commitment lets the prover pick a response first and solve for a consistent commitment backwards, producing a proof with no valid witness; (iii) missing a verification-equation input frees the prover to construct a value that satisfies the omitted constraint post hoc. In every case the verifier accepts a proof that no honest prover could have produced.
How to avoid. When implementing an FS transform, enumerate every value that appears in the verification equation (public statement, all first-round commitments, all auxiliary public inputs) and hash all of them into the challenge. Prepend a constant-length domain-separation tag identifying the specific proof type to prevent cross-proof-type substitutions.
ProofBobWC missing u in hash (Issue #42, PR #43).The MtA “Bob-with-check”
range proof in bnb-chain/tss-lib involves a commitment $u = g^\alpha$ to the prover’s
randomness. Pre-fix, the FS hash omitted u
(source):
1// crypto/mta/proofs.go — bnb-chain/tss-lib (pre-PR #43, vulnerable)
2// u is computed but NOT included in the challenge hash:
3eHash = common.SHA512_256i(
4 append(pk.AsInts(), X.X(), X.Y(), c1, c2, z, zPrm, t, v, w)...
5 // MISSING: u.X(), u.Y() — the EC commitment to the witness randomness
6)
Because $u$ is absent, the challenge $e$ is independent of the prover’s randomness commitment, so the proof is malleable: a malicious party can fix a desired response, recompute the challenge on values of its choosing, and in principle solve for a consistent $u$ after the fact, breaking the proof’s soundness.
The fix (PR #43) added u.X(), u.Y() to the hash input:
1// Fixed: u (the EC commitment to witness randomness) is now in the hash
2eHash = common.SHA512_256i(
3 append(pk.AsInts(), X.X(), X.Y(), c1, c2, u.X(), u.Y(), z, zPrm, t, v, w)...
4)
Missing Domain Separator Across Signing Contexts
What can go wrong. When the same signing key is used in multiple protocol roles, signing round-1 commitments vs round-2 packages in a DKG, authenticating API requests vs producing blockchain transactions, or tagging message types in a single protocol, each role must bind its messages to a unique domain-separation tag. If the tag is missing or identical across roles, a signature produced for one role is valid for the other: the same bytes verify against the same key in both contexts. The tag can live at the signing primitive itself (a context string mixed into the hash, such as RFC 8032’s Ed25519ctx) or at the protocol layer (a per-method or per-key purpose marker that gates which API entry-point a key can serve).
Security implication. A malicious party who obtains a signature in role $A$
presents the same bytes as if they had been produced for role $B$. In an MPC threshold
network that exposes both a generic sign() method and a specialized
verify_foreign_transaction() method against the same distributed key, a bridge that
calls verify_foreign_transaction() to confirm that a foreign-chain transaction was
attested by the threshold network can be defeated by a caller who submits the same
payload to sign() instead: the MPC network produces a valid threshold signature
(since sign() is willing to sign arbitrary bytes), and the attacker replays the
resulting signature into the bridge as evidence of a verified foreign transaction. The
bridge has no way to tell the two apart, both signatures verify under the same
threshold public key over the same bytes.
How to avoid. Bind every signature to its protocol role. Two complementary points of enforcement:
- Primitive-level domain separation. Prepend a unique, version-bearing tag to the
message before signing. For Ed25519, use
RFC 8032’s
Ed25519ctxwith a non-empty context per role; for Schnorr or generic hash-then-sign, hashtag || messagerather thanmessagealone. Rotate tags when the protocol version changes so old-version signatures do not retroactively validate under a new role. - Protocol-level domain separation. Tag each distributed key with the purpose it is allowed to serve, and reject at the API entry-point any request that targets a key whose purpose does not match the call.
DomainPurpose tagging (Issue #2076, PR #2163).The NEAR
MPC node exposes a threshold key to three different methods on the contract: sign()
for arbitrary user-supplied payloads, verify_foreign_transaction() for foreign-chain
(Bitcoin, Ethereum) transaction attestation used by bridges, and
request_app_private_key() for confidential key derivation (CKD). All three call paths
can route to the same set of distributed keys. Before the fix, the contract enforced
only that the curve matched the call: any Secp256k1 key could back either sign() or
verify_foreign_transaction(). A caller could therefore submit a foreign-chain
transaction payload to the generic sign() method, collect a threshold signature, and
then replay it to a bridge calling verify_foreign_transaction() against the same key;
the bridge would accept the signature as proof that the foreign transaction had been
attested.
The fix (PR #2163) introduces an explicit
per-domain DomainPurpose enum:
1// FILE: crates/contract-interface/src/types/state.rs — near/mpc (after PR #2163)
2pub enum DomainPurpose {
3 /// Domain is used by `sign()`.
4 Sign,
5 /// Domain is used by `verify_foreign_transaction()`.
6 ForeignTx,
7 /// Domain is used by `request_app_private_key()` (Confidential Key Derivation).
8 CKD,
9}
10
11pub struct DomainConfig {
12 pub id: DomainId,
13 pub scheme: SignatureScheme,
14 pub purpose: Option<DomainPurpose>, // new: purpose tag per domain
15}
Each contract entry-point now requires the target domain to carry the matching purpose
(crates/contract/src/lib.rs):
1// FILE: crates/contract/src/lib.rs — near/mpc (after PR #2163)
2
3// in sign(...)
4if domain_config.purpose != DomainPurpose::Sign {
5 env::panic_str(
6 &InvalidParameters::WrongDomainPurpose { /* ... */ }
7 .message("sign() may only target domains with purpose Sign")
8 .to_string(),
9 );
10}
11
12// in verify_foreign_transaction(...)
13if domain_config.purpose != DomainPurpose::ForeignTx {
14 env::panic_str(
15 &InvalidParameters::WrongDomainPurpose { /* ... */ }
16 .message("verify_foreign_transaction() requires a domain with purpose ForeignTx")
17 .to_string(),
18 );
19}
20
21// in request_app_private_key(...)
22if domain_config.purpose != DomainPurpose::CKD {
23 env::panic_str(
24 &InvalidParameters::WrongDomainPurpose { /* ... */ }
25 .message("request_app_private_key() may only target domains with purpose CKD")
26 .to_string(),
27 );
28}
Rushing Adversary Copies an Honest Commitment
What can go wrong. In a commit-and-reveal protocol, each party sends a commitment during round 1 and opens it during round 2. If the commitment scheme does not bind each commitment to the identity of its opener (for example, by hashing in the party’s ID and session ID), a rushing adversary, one who observes honest parties’ messages before sending its own in the same round, can copy an honest party’s commitment byte-for-byte, then copy the opening during the reveal phase. Both parties end up revealing the same value.
Security implication. Consider a Blum coinflip: Alice and Bob commit to random bits $v_A, v_B$ and open to produce $v = v_A \oplus v_B$. A corrupt Bob who copies Alice’s commitment, then copies her opening, makes $v_B = v_A$, so the output is always $v_A \oplus v_A = 0$, the coin no longer flips. The same pattern breaks the SPDZ MAC-check sub-protocol in two-party settings over a characteristic-two field: when parties commit to their $z_i$ shares and an honest $P_1$’s commitment is copied, the reconstructed $z = z_1 + z_1 = 0$ and the MAC check passes for any opened value $a'$, defeating the integrity guarantee on every wire of the circuit.
How to avoid. Bind every commitment to its opener’s identity and to the session. Two standard constructions:
- Hash-based commitment with opener ID and session ID:
$c_i = H(\text{pid}_i \,\|\, \text{ssid} \,\|\, v_i \,\|\, r_i)$.
A copied commitment has the wrong
pidand cannot be reopened consistently. - Signed commitment: Contrary to the hash-based commitment, a signed
commitment binds only the PID (through the signing key) and not the SSID.
To bind both the party and the session, you may use one of the following:
- Compute the signature $s = \text{Sign}_{\text{sk}_i}(\text{ssid} \,\|\, c)$, where $c$ is the commitment and $\text{sk}_i$ is the signing key tied to the party.
- Include the session ID inside the commitment, $c_i = \text{Commit}(\text{ssid} \,\|\, v_i \,\|\, r_i)$, and sign it with a key bound to the party. Here the commitment provides the session binding and the key provides the party binding.
- Use a signing key uniquely bound to both the party and the current session. Note that this is quite unusual in practice, as long-term signing keys are typically preferred.
Either construction prevents the rushing-adversary copy because the opener’s identity and the current session are now part of what the commitment binds to.
HashBasedCommitment (Issue #432, PR #433).In the SPDZ protocol, parties hold additive shares of a global SPDZ MAC $[\alpha \cdot a]$ on every wire under a single global MAC key $\alpha$. To verify that a reconstructed value $a'$ is correct, each party computes $z_i = a' \cdot \alpha_i - (\alpha \cdot a)_i$, commits to $z_i$, and opens; if the reconstructed $z = \sum z_i \ne 0$, they abort. SPDZ also uses the same commitment scheme in coin-tossing and input-sharing subprotocols.
Fresco’s HashBasedCommitment hashed only the value and the randomness,with no opener identity in the input, allowing a malicious party to replay it. Pre-fix commit method
(source):
1// FILE: tools/commitment/src/main/java/dk/alexandra/fresco/tools/commitment/HashBasedCommitment.java
2// aicis/fresco @ 2dc80dca (vulnerable, pre-PR #433)
3
4public byte[] commit(Drbg rand, byte[] value) {
5 if (commitmentVal != null) {
6 throw new IllegalStateException("Already committed");
7 }
8 // Sample a sufficient amount of random bits
9 byte[] randomness = new byte[DIGEST_LENGTH];
10 rand.nextBytes(randomness);
11 // Construct an array to contain the bytes to hash
12 byte[] openingInfo = new byte[value.length + randomness.length];
13 System.arraycopy(value, 0, openingInfo, 0, value.length);
14 System.arraycopy(randomness, 0, openingInfo, value.length,
15 randomness.length);
16 commitmentVal = digest.digest(openingInfo);
17 return openingInfo;
18}
Each party’s commitment is $c_i = H(v_i \,\|\, r_i)$, with no opener identity in the hash input. Fresco does not implement SPDZ over binary fields, so the characteristic-2 single-MAC-check copy (where a copied $z_1$ gives $z = z_1 + z_1 = 0$) does not apply to it directly. Fresco is instead hit through the same commitment’s use in coin-tossing: a corrupt party copies an honest party’s seed commitment $H(s_i \,\|\, r_i)$ byte-for-byte and later copies the opening $(s_i, r_i)$, so the two identical seeds cancel in the XOR $s = s_1 \oplus \cdots \oplus s_n$. This strips the honest party’s entropy from the tossed coin, making it adversarially predictable and letting the corrupt party pass the batch MAC check on inconsistent values. The fix (PR #433) added the committer’s party ID as the first input to the hash and required the opener to supply a matching ID at open time (source):
1// FILE: tools/commitment/src/main/java/dk/alexandra/fresco/tools/commitment/HashBasedCommitment.java
2// aicis/fresco @ fdada93b (fixed)
3
4public byte[] commit(int myId, Drbg rand, byte[] value) {
5 if (commitmentVal != null) {
6 throw new IllegalStateException("Already committed");
7 }
8 byte[] randomness = new byte[DIGEST_LENGTH];
9 rand.nextBytes(randomness);
10 // Party ID is now the first ID_LENGTH bytes of the hashed input.
11 byte[] openingInfo = new byte[ID_LENGTH + value.length + randomness.length];
12 System.arraycopy(integerToBytes(myId), 0, openingInfo, 0, ID_LENGTH);
13 System.arraycopy(value, 0, openingInfo, ID_LENGTH, value.length);
14 System.arraycopy(randomness, 0, openingInfo, value.length + ID_LENGTH,
15 randomness.length);
16 commitmentVal = digest.digest(openingInfo);
17 return openingInfo;
18}
Concurrency and State
Many protocols are proven secure in particular ‘models of execution,’ and security can fail when they are run in ways that do not conform to the proof. For instance, protocols proven secure for sequential sessions can break when concurrent sessions are allowed, or preprocessing (such as Beaver triples) can be accidentally reused because a party’s state was restored from a backup.
SPDZ Multi-Threaded MAC Check
What can go wrong. SPDZ (Damgård–Pastro–Smart–Zakarias, 2012) is a maliciously-secure MPC protocol with a dishonest majority, where up to $n-1$ out of $n$ parties can be actively corrupted by an adversary. Shared values are authenticated by an information-theoretic MAC under a global key $\alpha$ that no party knows individually, and openings are verified by a MAC check that aborts if the opened value was tampered with. SPDZ is proven secure in the UC framework, which guarantees security under “concurrent execution” with arbitrary independent protocols. However, this guarantee does not extend to a multithreaded SPDZ implementation, where all threads share the same $\alpha$. In particular, when an implementation runs two MAC check instances concurrently in different threads, a malicious party can cheat in one of them to leak the entire MAC key $\alpha$ and use it in the other to forge MACs on arbitrary values.
Security implication. The paper Rushing at SPDZ: On the Practical Security of Malicious MPC Implementations (IEEE S&P 2025) shows that a malicious party can exploit the multi-thread interleaving to leak the global SPDZ MAC key $\alpha$ in one stalled MAC-check thread before the failure is detected. The adversary then uses the leaked key to manipulate a concurrent thread of the honest parties, e.g. forging MACs on tampered values at will. The paper analyzed three SPDZ implementations and found two, MP-SPDZ and SCALE-MAMBA, vulnerable to this multi-thread MAC interleaving attack. The example below walks through the patches in MP-SPDZ, one of the two.
How to avoid. Treat the MAC check sub-protocol as an atomic critical section across all threads. Three concrete rules:
- Mutual exclusion on the MAC check. A mutex or semaphore prevents two threads from executing overlapping MAC-check instances, including the possible abort path.
- Unconditional verification on every open. The MAC
check()call must fire whenever secret values are opened, regardless of whether the opened values reach an output gate. - Design-level isolation. Where possible, avoid sharing secret state across threads entirely. Fresco’s design of synchronizing output and MAC-check instructions through a global MAC-check thread is a useful reference point.
In MP-SPDZ, the concrete synchronization point is Commit_And_Open_, the helper
used by the MAC check to commit to local check values and then open them. Before
the fix, each thread ran this helper independently. There was no coordinator
shared across concurrent MAC checks, so one stalled check did not block another
thread using the same global MAC key
(source):
1// FILE: Tools/Subroutines.cpp — MP-SPDZ (vulnerable, before 6a42453)
2void Commit_And_Open_(vector<octetStream>& datas, const Player& P)
3{
4 vector<octetStream> Comm_data(P.num_players());
5 vector<octetStream> Open_data(P.num_players());
6
7 Commit(Comm_data[P.my_num()], Open_data[P.my_num()], datas[P.my_num()],
8 P.my_num());
9 P.Broadcast_Receive(Comm_data);
10
11 P.Broadcast_Receive(Open_data);
12
13 for (int i = 0; i < P.num_players(); i++)
14 { if (i != P.my_num())
15 { if (!Open(datas[i], Comm_data[i], Open_data[i], i))
16 { throw invalid_commitment(); }
17 }
18 }
19}
The Rushing at SPDZ paper cites
commits 6a42453 and
b86f29b as the
MP-SPDZ fix. The final version passes a shared Coordinator into
Commit_And_Open_, waits before the opening phase, validates every opening, and
only then calls coordinator.finished()
(source):
1// FILE: Tools/Subroutines.cpp — MP-SPDZ (fixed, commit b86f29b)
2void Commit_And_Open_(vector<octetStream>& datas, const Player& P,
3 Coordinator& coordinator)
4{
5 vector<octetStream> Comm_data(P.num_players());
6 vector<octetStream> Open_data(P.num_players());
7
8 Commit(Comm_data[P.my_num()], Open_data[P.my_num()], datas[P.my_num()],
9 P.my_num());
10 P.Broadcast_Receive(Comm_data);
11
12 coordinator.wait(P.get_id());
13 P.Broadcast_Receive(Open_data);
14
15 for (int i = 0; i < P.num_players(); i++)
16 { if (i != P.my_num())
17 { if (!Open(datas[i], Comm_data[i], Open_data[i], i))
18 { throw invalid_commitment(); }
19 }
20 }
21
22 coordinator.finished();
23}
Holding the coordinator until validation completes serializes the MAC-check opening path: a stalled or invalid MAC check prevents other threads from continuing under the same key.
Threshold Presignature Reuse (Nonce Reuse)
What can go wrong. ECDSA produces signatures $(r, s)$ where
$s = k^{-1}(H(m) + r \cdot x) \bmod n$
with $k$ a fresh random nonce, $r = (k \cdot G)_x$, and $x$ the long-term signing key. This equation is linear in $x$ once $k$ and $r$ are fixed, so reusing the same $k$ across two different messages $m_1 \ne m_2$ produces a pair $(r, s_1), (r, s_2)$ from which any observer recovers $x$ in closed form: solve $k = (H(m_1) - H(m_2)) \cdot (s_1 - s_2)^{-1} \bmod n$, then $x = (s_1 \cdot k - H(m_1)) \cdot r^{-1} \bmod n$. The canonical real-world incident is the 2010 fail0verflow PlayStation 3 ECDSA break, where Sony reused a fixed nonce across game-code signatures and the master key fell out of two signed binaries.
Some threshold ECDSA protocols such as GG20 and CGGMP21 (and, via an offline preprocessing phase, GG18) generate this nonce distributively as a presignature $(k, R = k \cdot G)$ before the message is known, consuming it once a message arrives. The set of unused presignatures is a stateful object, and implementations must ensure that no two executions consume the same presignature. If they do, two or more signatures share a nonce.
Security implication. When two signatures over different messages share a presignature, anyone who observes them can recover the long-term signing key $x$. In threshold deployments the reuse is both easy to trigger and hard to detect: a malicious party can abort a ceremony after the presignature is fixed and force a retry on a different message, or route two non-interactive signing requests to different honest subsets using the same presignature. Honest parties signing non-interactively have no way to notice that the same nonce is being consumed twice.
How to avoid. Atomically (across parallel sessions) consume the presignature before starting the signing, and consume it whether or not the signing protocol completed successfully. Upon failure, never retry signing with the same presignature; generate a fresh one. Beware lifecycle events that can resurrect a consumed presignature: backup-and-restore, process restarts, snapshots, and replication must not reintroduce a presignature that has already been used.
Builder Vault is Blockdaemon’s production MPC threshold-signing platform (powered by the Sepior TSM). Its developer documentation explains that each presignature contains shares of a random signing nonce, and that an MPC node enforces single-use by deleting the presignature in the same transaction in which it consumes its share. The docs additionally warn that backup-and-restore can reintroduce a previously-consumed presignature, turning a routine ops procedure into a key-extraction vector if mishandled. Operators are therefore instructed to delete all presignatures either before taking a database backup or upon restoring.
Concurrent Signing Sessions (ROS Attack)
What can go wrong. Many signature schemes rely on linear structures, and when run sequentially they are proven secure: blind Schnorr, for instance, is one-more unforgeable in the sequential setting (Pointcheval–Stern, 2000), and the GJKR threshold Schnorr scheme is proven secure in a stand-alone sequential setting (GJKR07). The reason is that each session finishes before the next one starts, so partial signatures from different sessions can never be combined. Under concurrent execution, though, their security comes to rest on the hardness of the ROS problem (Random inhomogeneities in an Overdetermined Solvable system of linear equations): Schnorr (2001) could establish blind Schnorr’s concurrent security only by reducing it to ROS hardness. And ROS, it turns out, is not hard. Benhamouda et al. gave an efficient algorithm that solves it, letting an attacker forge signatures in exactly these settings.
The shared structure is this. Across $\ell$ concurrent sessions a forger collects nonce commitments $R_i$ and responses $s_i = k_i + c_i x$, then combines them with coefficients $\rho_i$:
$$ R^* = \sum_{i=1}^{\ell} \rho_i R_i, \qquad s^* = \sum_{i=1}^{\ell} \rho_i s_i = \Big(\sum_i \rho_i k_i\Big) + \Big(\sum_i \rho_i c_i\Big) x. $$The pair $(R^*, s^*)$ is a valid signature on a fresh message $m^*$ exactly when the challenges satisfy the ROS relation
$$\sum_{i=1}^{\ell} \rho_i c_i = c^* = H(R^*, m^*).$$Sequentially the forger never holds all the $R_i$ at once, so it cannot pick challenges to satisfy this; concurrency is what exposes every $R_i$ before the challenges are fixed and hands it the free variables. The three schemes differ only in who steers the $c_i$:
- Blind Schnorr. The signer returns $s_i = k_i + c_i x$ to whatever challenge it is handed, and the requester sends the $c_i$, so it sets them directly to solve the ROS relation.
- GJKR threshold Schnorr. The challenge is $c_i = H(R_i, m_i)$ over a jointly generated nonce $R_i = \sum_j R_{i,j}$, and each honest party returns a partial $s_{i,j} = k_{i,j} + c_i \lambda_j x_j$ (with Lagrange coefficient $\lambda_j$ and share $x_j$). A rushing corrupt party chooses its own share $R_{i,n}$ after seeing the honest ones, so it controls every $R_i$, hence every $c_i$, across the concurrent sessions. GJKR’s security proof reached only the non-concurrent setting, or up to a logarithmic number of concurrent sessions, so this high-concurrency path lies outside what it ever guaranteed.
- Original FROST. Structurally the same, with a single un-bound nonce $D_{i,j}$ per party and partial $z_{i,j} = d_{i,j} + c_i \lambda_j x_j$. The July 2020 revision closes it by adding a second nonce $E_{i,j}$ and a per-participant binding factor $b_j = H(j, B)$ over the whole commitment list $B$, so the group nonce becomes $R_i = \sum_j (D_{i,j} + b_j E_{i,j})$. Every challenge now depends on $B$ through the $b_j$, the $c_i$ can no longer be lined up across sessions, and the ROS relation cannot be set up.
Security implication. Concretely, that algorithm solves ROS over a 256-bit elliptic-curve group with about $\ell = 256$ concurrent sessions in seconds (Benhamouda et al.): the adversary combines the responses it has collected and outputs one signature more than it was granted, breaking one-more unforgeability. The significance varies by primitive. For multi-signatures that claimed concurrent security (the 2018 MuSig, CoSi) it breaks those claims outright; for threshold signatures like GJKR, whose proofs covered only the non-concurrent or bounded-concurrency setting, it contradicts no theorem but rules the scheme out wherever signing happens under load. In a threshold deployment that gap is concrete: unauthorized signatures on attacker-chosen messages, once enough sessions have run concurrently. No public exploit against a deployed implementation has surfaced, but it is worth keeping in mind whenever signing protocols are chosen or composed.
How to avoid. Two complementary approaches.
Structural. Bind each challenge to the session’s specific nonce commitment and message so the adversary cannot freely choose challenges after observing the nonces. FROST achieves this with a per-participant binding factor, standardized in RFC 9591. MuSig2 (Nick et al., CRYPTO 2021) uses two aggregated nonces per session whose specific linear combination is provably secure under concurrent execution.
Application-layer serialization. These schemes are proven secure sequentially, so if the protocol itself cannot be changed, simply run it that way: have the signer complete or abort one session before starting the next. Full serialization is the safe rule, since the polynomial attack needs about $\ell = 256$ open sessions while the sub-exponential variant succeeds with fewer, so capping concurrency at a small bound is not enough.
Insecure Subprotocols
Subprotocols assumed by the protocol design, such as broadcast channels and authenticated or confidential peer-to-peer transport, must be realized by the deployment.
Multicast Masquerading as Broadcast
What Can Go Wrong. MPC protocols such as GG18, GG20, and FROST may rely on a reliable broadcast channel for some rounds, and are often implemented by instantiating the broadcast with multicast: simply having each party send the same message to all others over P2P links. Per Goldwasser and Lindell, 2002, privacy and correctness can be achieved without full broadcast by using echo-broadcast (receivers re-send what they got and abort on mismatch), at the cost of non-unanimous abort. But echo-broadcast only achieves “broadcast with abort”: same value or $\bot$, never two different non-abort values, still strictly weaker than what these protocols assume in their published proofs. A library that cannot tell whether a given round was supposed to be broadcast or point-to-point cannot enforce reliable broadcast, and therefore cannot ensure privacy and correctness.
Security implication. Honest parties end up with different views of the same round, which can cause them to compute incompatible outputs and break correctness. In threshold signing, equivocation in a DKG commitment round leaves honest parties disagreeing on the public key; in a zero-knowledge proof round, it can let an invalid proof pass for one verifier and fail for another.
How to avoid. Implement a reliable broadcast protocol (not just echo-broadcast) for any round whose security proof requires Byzantine agreement. In settings with fewer than $n/3$ corruptions, Bracha broadcast provides the required guarantees. Enforce the per-round broadcast-vs-P2P classification at the library boundary using the protocol specification as reference, rather than delegating the decision to the caller.
Kudelski’s audit of ING’s threshold-ECDSA library identified a communication-layer failure in the GG18 resharing protocol. The issue was a design-level mismatch: the resharing mitigation relies on all honest parties seeing the same final confirmation, but that assumption is not realized by sending separate point-to-point messages. ING implemented the standard “forget-and-forgive” mitigation, a final ACK confirmation round before parties delete their old shares; Kudelski noted that this mitigation “might actually make things worse” if a robust broadcast channel is not available, because the final ACK can itself be equivocated. If an application realizes broadcast as $N$ separate point-to-point sends, a malicious sender can equivocate.
Kudelski’s example starts with four peers $(A, B, C, D)$ using a threshold of 3, and a resharing ceremony that adds a fifth peer $E$ while keeping the threshold at 3. At the end of the resharing protocol, malicious $E$ sends different final-round messages to different honest parties:
- $E$ sends
ACKto $A$ and $B$. - $E$ sends
not ACKto $C$ and $D$.
$A$ and $B$ believe resharing succeeded, discard their old shares, and migrate to the new committee. $C$ and $D$ believe resharing failed, keep the old shares, and do not save the new shares. The honest parties are now split between incompatible old and new committee states. Neither honest subset has enough compatible shares to sign without $E$, so the single malicious participant can lock the wallet.
Unauthenticated or Unencrypted Point-to-Point Channels
What can go wrong. Many MPC protocols such as GG18 and GG20 assume the presence of confidential and authenticated P2P channels. The deployment must realize that assumption, typically through mutual TLS, signed/encrypted application-level messages, or a noise-protocol handshake. Implementations that hand-roll channel security (raw TCP, ad-hoc JSON over HTTP, or implicit trust in a central coordinator that re-signs messages) routinely fail to provide these guarantees.
Security implication. Without per-message authentication, a network attacker can impersonate parties and inject messages honest parties attribute to the wrong source; the victim of the attribution is then blamed for protocol violations it did not commit. Without confidentiality, intermediate values that the ideal functionality hides leak to the network, and downstream secret-dependent computations become vulnerable to offline analysis. In threshold signing this translates to rogue messages causing spurious aborts, silent share exposure, and key-extraction attacks that exploit observed intermediate values.
How to avoid. Instantiate the point-to-point channels with “standard secure channel implementations”, such as mutual TLS or QUIC between each pair of parties. Ensure that the certificates of each party is pinned or issued by a trusted authority.
axelarnetwork/tofnd accepts spoofed from field on the wire (Issue #60).Axelar’s
tofnd is a Rust daemon implementing GG20
(Gennaro–Goldfeder, 2020), a threshold-ECDSA protocol widely deployed in MPC wallet
implementations. Each message is wrapped in a TrafficIn envelope that carries both a
transport-level sender identity (from_party_uid) and an inner MsgMeta with a
protocol-level sender index (from: usize). As reported in Issue #60,
the inner from field is unauthenticated: a malicious party can edit it in the
binary payload and send messages on behalf of any other party.
The vulnerable handler discarded the transport identity and passed the raw payload straight to the cryptographic core (source):
1// FILE: src/gg20/protocol.rs — axelarnetwork/tofnd (pre-fix, lines 106–117)
2while protocol.expecting_more_msgs_this_round() {
3 let traffic = chan.receiver.next().await.ok_or(...)?;
4 let traffic = traffic.unwrap();
5 // Only `traffic.payload` is forwarded to tofn; the transport-level
6 // `traffic.from_party_uid` is discarded. tofn then trusts the inner
7 // `MsgMeta { from: usize, ... }` self-attribution.
8 protocol.set_msg_in(&traffic.payload)?;
9}
A malicious party Alice with subshares {0, 1} could craft a message with
MsgMeta::from = 2 (Bob’s subshare index), and no consistency check linked that index
back to the transport-authenticated from_party_uid. The fix is split across two
repos: tofn (the cryptographic library tofnd wraps) had to first expose the from
field in its public API (Issue #42)
so tofnd could then enforce from_party_uid == MsgMeta::from before dispatch.
coinbase/kryptology GG20 DKG ships secret shares unencrypted (Issue #29).GG20’s joint key-generation procedure (inherited from GG18) assumes the Round 2 P2P delivery of each Shamir share $x_{ij}$ runs over a confidential point-to-point channel. The GG18/GG20 papers assume this private channel abstractly and leave its instantiation to the deployment; Paillier encryption enters only in the signing-phase MtA, never for the keygen shares. The Coinbase library’s GG20 implementation provides no confidentiality of its own and returns the share as a bare struct field (source):
1// FILE: pkg/tecdsa/gg20/participant/dkg_round2.go — coinbase/kryptology
2
3type DkgRound2P2PSend struct {
4 xij *v1.ShamirShare // raw share — no encryption applied
5}
6// ...
7p2PSend[id] = &DkgRound2P2PSend{ xij: dp.state.X[id-1] }
Failure Recovery and Aborts
When a protocol aborts, whether for benign or malicious reasons, the implementation must ensure that the failures are handled securely. What securely means is protocol-specific and may vary from: simply rerunning the protocol, removing a corrupted party, restarting other parts of the protocol, discarding some correlated randomness, or never running the protocol with the same input again.
Selective-Abort Attacks during OT Extension
What can go wrong. OT-extension protocols (Ishai et al., 2003) are made secure against a malicious receiver by a consistency check (Keller-Orsini-Scholl, 2015), in which the sender validates the receiver’s queries against its own secret choices. It is no silver bullet: the check is computed from those choices, so whether it passes or fails leaks one bit of them, letting a cheating receiver force a selective abort to learn a bit of the secret. A single failure may not leak much, and the protocol stays secure as long as a failed check is treated as terminal and the base OT discarded. But if the implementation keeps the setup alive after a failure, for example behind an opaque error the sender simply retries, the receiver reconstructs the secret bit by bit across many calls.
Security implication. An attacker selectively forces an abort to learn one bit of the sender’s secret choices, then repeats the procedure over different executions that reuse the same base OTs. Eventually it learns every secret bit, breaking security. In a threshold signature scheme this lets an attacker recover the signing key, and with more parties the attacker repeats the process against each one.
How to avoid. Exclude the corrupted party, discard the OTs on which the adversary gained leakage, and resample fresh base OTs before continuing. In case of parallel OT-extension instances, replicate this across all instances. The correlation check of KOS 2015 keeps the per-run leakage negligible but does not by itself prevent selective-abort attacks, so the no-reuse discipline above is what actually stops full key recovery.
A closely related selective-abort attack also appears outside OT extension, in Paillier-based two-party ECDSA such as the Lindell17 abort-handling bug, where the abort signal leaks one bit of the honest party’s share per signing attempt. The mechanism is different (no OT is involved), but the same lesson holds: a failed check must be terminal, never a silent retry.
Trail of Bits disclosed a selective-abort vulnerability in an OT-based threshold-ECDSA implementation in the Doerner et al. (DKLS) line. Whether the OT-extension consistency check aborts is itself a function of the sender’s secret choice bits, so a cheating receiver learns “a few bits” per run from the pass/abort signal; because the base OTs are reused, repeating it recovers every secret bit, and in a two-party setting the nonce and the ECDSA signing key.
The fix is to “throw away the setup for a participant that has attempted to cheat during the OT extension protocol.”
Adaptive Inputs
When a party can choose its protocol contribution after observing honest parties' messages, it can bias, cancel, or copy those contributions. Commit-before-reveal, proofs of knowledge, and binding contributions to party/session context prevent this adaptivity from changing shared protocol state.
Rogue-Key Attacks
What can go wrong. A distributed key generation (DKG) protocol lets $n$ parties jointly produce a public key whose corresponding secret is shared among them, with no trusted dealer. In a Feldman-based DKG, each party $P_i$ broadcasts $A_{i,0} = g^{a_{i,0}}$, which is a commitment to its secret contribution $a_{i,0}$. A shared public key is then defined as $Y = \prod_i A_{i,0}$. If the protocol neither requires parties to commit to their first-round messages before seeing others’ contributions nor requires each party to prove knowledge of $a_{i,0}$, a malicious party or coalition may wait to see the honest parties’ commitments and then choose its public contribution as a function of theirs.
Note that at the aggregate-key level, this lets the attacker try to force the shared public key to be a key it controls. In a full Joint-Feldman DKG, the malicious contribution must also pass share verification, which is why the concrete Drand attack below requires a coalition in the relevant threshold regime.
Security implication. Let $Y^\star = g^x$ be the adversary’s target (a key for which it holds the discrete log $x$). After observing $A_{1,0}, \dots, A_{n-1,0}$, $P_m$ announces $A_{m,0} = Y^\star \cdot \left(\prod_{i \ne m} A_{i,0}\right)^{-1}$. Multiplying all commitments yields $\prod_i A_{i,0} = Y^\star$. The shared “threshold” key is now under $P_m$’s sole control.
Note: Joint-Feldman DKG typically assumes an honest majority within $n$ parties, so corrupting more than $n/2$ is taken to be out of scope. The attack requires a coalition of at least $n - t + 1$ malicious users, which exceeds $n/2$ when $t \le n/2$ and falls outside the assumed fault tolerance. The attack is therefore viable only in configurations with polynomial degree $t > n/2$, where the required coalition drops to half the parties or fewer.
How to Avoid. The following two mitigations exist:
Commit-before-reveal: each party first broadcasts a commitment to its round-1 package, and only reveals the package after every other party’s commitment has been seen. The attacker cannot choose its $A_{m,0}$ as a function of the others because the commitment binds it before any other party has opened.
Proof of Knowledge: each round-1 package includes a Schnorr proof of knowledge of $a_{i,0}$, binding the commitment to the sender’s identity and the current session. An attacker that chose $A_{m,0}$ adversarially cannot produce a valid proof without knowing the discrete log.
Drand is a distributed randomness beacon using DKG and threshold BLS, with a threshold above half the participants under its security model (see the protocol specification). With polynomial degree $t > n/2$, a coalition of at least $n - t + 1$ parties can mount a rogue-key attack: after seeing the honest parties’ constant-term commitment ($A_{i,0} = g^{a_{i,0}}$), the colluding parties choose their own so the group public key becomes an attacker-chosen $Y^\star = g^{x^\star}$.
The proposed fix was using commit-before-reveal. Drand instead mitigated the issue by lowering the configured threshold closer to $n/2$, since the rogue-key attack would then require a coalition outside the honest-majority assumption.
Cryptographic Primitives
The preceding classes concern how an MPC protocol wires its primitives together. The pitfalls here concern the primitives themselves: a modulus that is not a safe prime, a Paillier key with small factors, a hash used where it offers no domain separation, randomness drawn from too small a space. Each is a failure in the choice or construction of a building block, independent of the protocol wrapping it. We collect them here because the fix is local to the primitive, and the same checklist applies regardless of which protocol is being audited.
Smooth or Non-Biprime Paillier Modulus
What can go wrong. The Paillier cryptosystem relies on a biprime modulus $N = pq$ where $p$ and $q$ are large primes, often required to be safe primes, $p = 2p' + 1$ with $p'$ prime. When parties in an MPC protocol publish their own modulus, skipping biprimality checks lets a malicious sender pick a structured $N$ that enables key-recovery attacks against the protocols that use it.
Security implication. The BitForge attack refers to a collection of zero-day vulnerabilities discovered by Fireblocks researchers that impact MPC wallets. Part of these vulnerability involves skipping the biprimality and no-small-factor checks on the Paillier modulus in the GG18 & GG20 protocols, which led to a vulnerability on the shared key (CVE-2023-33241, technical report). The attacker chooses $N_A = p_1 \cdots p_{16} \cdot q$ with each $p_i \approx 2^{16}$ (small enough to brute-force the range proof), then crafts an out-of-range plaintext $k = N_A / p_i$ in each MtA call and forges the range proof by brute-forcing a blinding factor in about $p_i \approx 2^{16}$ attempts. The victim’s encrypted share leaks $x \bmod p_i$ per signing session; after 16 sessions, CRT reconstructs the full $x$.
How to avoid. Require every party publishing a Paillier key to accompany it with two ZK proofs from CGGMP21: a Paillier-Blum Modulus proof, which proves $N = pq$ for primes $p \equiv q \equiv 3 \pmod 4$, and a No-Small-Factor proof, which rules out small prime factors (formally $p, q > \sqrt{N}/2^{\ell}$ with $\ell = 256$, i.e. no factor below about $2^{256}$). Some deployments additionally require $p$ and $q$ to be safe primes ($p = 2p' + 1$ with $p'$ prime). Reject the participant if either proof fails to verify, before the modulus is stored anywhere.
multi-party-ecdsa-cpp missing Paillier modulus validation (POC, CVE-2023-33241, PR #7, PR #10).Safeheron’s multi-party-ecdsa-cpp ran GG18/GG20 key generation without checking the structure of each co-signer’s Paillier modulus $N$, so a non-biprime or smooth $N$ flowed through keygen and into the GG20 signing rounds unchecked. One example of vulnerable code is the Round 3 keygen verifier (pre-fix source):
1// FILE: src/multi-party-ecdsa/gg18/key_gen/round3.cpp
2// Safeheron/multi-party-ecdsa-cpp @ b75d125f (pre-fix, vulnerable)
3ok = bc_message_arr_[pos].pail_proof_.Verify(
4 sign_key.remote_parties_[pos].pail_pub_,
5 sign_key.remote_parties_[pos].index_,
6 bc_message_arr_[pos].dlog_proof_x_.pk_.x(),
7 bc_message_arr_[pos].dlog_proof_x_.pk_.y());
A malicious party could then publish $N = p_1 \cdots p_{16} \cdot q$ with each $p_i \approx 2^{16}$. During GG20 signing, the 16-factor structure opens parallel CRT channels and the small factors keep the MtA range-proof brute force at ~$2^{16}$ per channel. The victim’s encrypted share $x$ leaks $x \bmod p_i$ per session; CRT reconstructs the full share over 16 to ~$10^9$ sessions (Fireblocks technical report, POC).
Safeheron’s fix introduces two CGGMP21 proofs:
PR #7
added a no-small-factor proof, and
PR #10 replaced the share-binding pail_proof_ with the Paillier-Blum
Modulus proof ($N = pq$ with $p \equiv q \equiv 3 \pmod 4$) verified directly against $N$.
Non-Safe-Prime Modulus
What can go wrong. MPC protocols built over discrete-log groups such as $\text{QR}_p \subset \mathbb{Z}_p^*$, or $\text{QR}_N$ for an RSA modulus $N = PQ$, rely on the hardness of the discrete logarithm problem (DLP), which holds only when the group’s order has a sufficiently large prime factor. The standard way to guarantee this is to use safe primes: a prime $p = 2q + 1$ where $q$ is also prime. Then $\lvert \text{QR}_p \rvert = q$ is a large prime, and likewise $\lvert \text{QR}_N \rvert = \phi(N)/4 = (P-1)(Q-1)/4$ is a product of two large primes when both $P, Q$ are safe primes. If an implementation feeds ordinary RSA primes into code that assumes safe primes, the generated group no longer satisfies the proof system’s precondition.
Security implication. The downstream proof is no longer instantiated in the group its security argument assumes. When either $\lvert \text{QR}_p \rvert = (p-1)/2$ or $\lvert \text{QR}_N \rvert = (P-1)(Q-1)/4$ is smooth, i.e., factors only into small primes $q_1, \ldots, q_k$ with each $q_i \lt 2^{100}$, Pohlig-Hellman solves the DLP in time roughly $O\bigl(\log M + \sum_i \sqrt{q_i}\bigr)$, where $M \in \{p, N\}$ (see Valenta et al., eprint 2016/995 for the canonical analysis). Protocols that use the resulting bases for DLN or Pedersen-style proofs can lose both binding/soundness and hiding. Discrete-log relations that should be infeasible to compute may become computable, enabling equivocation or forged proofs. Pedersen-style commitments built from those bases can also leak information about the committed value in the smooth-order components of the group, especially when the committed value is range-bounded. Thus the proof may fail both as an integrity check and as a confidentiality mechanism. For instance, in a previous tss-lib version (see example), the $\tilde N$ generation path used ordinary RSA primes even though the helper that derived the DLN bases assumed $\tilde N$ was a product of safe primes, so the DLN parameters were generated outside their documented precondition.
How to avoid. When a protocol or helper requires a safe-prime group, generate $p$ (or both factors $P, Q$ of $N = PQ$) as safe primes. Do not substitute a generic RSA prime generator for safe-prime generation. For standardized prime groups, prefer the audited safe-prime constructions in RFC 3526 and RFC 7919.
NTilde from RSA primes (KS-BTL-F-03) (Issue #67, PR #68, commit 769ccf7).Kudelski Security flagged that pre-fix bnb-chain/tss-lib keygen generated the RSA modulus $\tilde N$ in ecdsa/keygen/round_1.go via Go’s rsa.GenerateMultiPrimeKey, which returns ordinary RSA primes, not safe primes. However, the helper that later derives the DLN bases (common.GetRandomGeneratorOfTheQuadraticResidue) required $\tilde N$ to be a product of safe primes for its output to land in the prime-order QR subgroup (source):
1// FILE: ecdsa/keygen/round_1.go — bnb-chain/tss-lib @ a2c27b4 (vulnerable)
2// 5-7. generate auxiliary RSA primes for ZKPs later on
3go func(ch chan<- *rsa.PrivateKey) {
4 pk, err := rsa.GenerateMultiPrimeKey(rand.Reader, 2, RSAModulusLen)
5 if err != nil {
6 common.Logger.Errorf("RSA generation error: %s", err)
7 ch <- nil
8 }
9 ch <- pk
10}(rsaCh)
The fix introduced by PR #68 moved $\tilde N$ generation into a new ecdsa/keygen/prepare.go backed by a GermainSafePrime generator (source):
1// FILE: ecdsa/keygen/prepare.go — bnb-chain/tss-lib (post-PR #68, fixed)
2// 5-7. generate safe primes for ZKPs used later on
3go func(ch chan<- []*common.GermainSafePrime) {
4 sgps, err := common.GetRandomSafePrimesConcurrent(safePrimeBitLen, 2, timeout, concurrency/2)
5 if err != nil {
6 ch <- nil
7 return
8 }
9 ch <- sgps
10}(sgpCh)
11// ...
12NTildei, h1i, h2i, err := crypto.GenerateNTildei([2]*big.Int{sgps[0].SafePrime(), sgps[1].SafePrime()})
A later commit (769ccf744f) added sanity checks on the generator’s output and stored $p = (P-1)/2$, $q = (Q-1)/2$ as witnesses for the DLN proofs over $\tilde N$.
Insufficient Soundness from Reduced Iteration Count
What can go wrong. Some Fiat-Shamir-transformed proofs, such as the DLN proof of knowledge used in GG18/GG20/CGGMP21, reach their target soundness only by running many parallel challenge-response repetitions. If the iteration count is set such that the soundness error is high, an adversary can simply guess responses and convince the verifier without holding the claimed witness.
Security implication. An adversary brute-forces candidate proofs offline until one passes within the reduced soundness margin. DLN proofs in GG18 are repeated $k$ times and the soundness error is $2^{-k}$. So to forge a proof without knowing the discrete log, an attacker needs to guess all $k$ challenge bits, with a probability of $2^{-k}$. This is the c-guess attack as documented by Verichains. In Multichain’s fork, $k$ was set as low as $k = 1$, where each attempt succeeds with probability $1/2$.
How to avoid. Keep the iteration count high enough to give negligible
soundness error. For DLN proofs in this protocol family, the TSSHOCK authors
cite CGGMP21 as recommending at least 80 repetitions, while tss-lib and its
GG18/GG20 forks use 128 as the implementation default.
Iterations = 1 (TSSHOCK) (Verichains TSSHOCK report, commit 7727e4f).Multichain’s anyswap/FastMulThreshold-DSA, a fork of bnb-chain/tss-lib, reduced the DLN proof iteration constant from the tss-lib default of 128 down to 1 in commit 4e543437c6, collapsing the soundness margin to a coin flip per attempt (source):
1// FILE: smpc-lib/crypto/ec2/ntildeZK.go — anyswap/FastMulThreshold-DSA @ 4e543437 (vulnerable)
2const (
3 // Iterations iter times
4 Iterations = 1
5)
Verichains demonstrated the TSSHOCK c-guess attack against this configuration. The adversary forges the two NtildeProofs offline by guessing the single challenge bit and retrying until Fiat-Shamir returns the guessed bit, then broadcasts the malicious $\tilde N, h_1, h_2$ package during key generation. Once those parameters are accepted, the adversary can recover the TSS private key from MtA range-proof leakage in the first signing ceremony.
The fix in commit 7727e4f833 restored the constant (source):
1// FILE: smpc-lib/crypto/ec2/ntildeZK.go — anyswap/FastMulThreshold-DSA @ 7727e4f8 (fixed)
2const (
3 // Iterations iter times
4 Iterations = 128
5)
Missing Domain Separation When a Hash Function Is Reused
What can go wrong. A single hash function is often reused across distinct purposes inside the same protocol: Fiat-Shamir challenges for different proofs, commitments, key derivation, session-ID generation, even signatures. When the same hash is invoked for these unrelated contexts without anything distinguishing them, it lets an adversary fraudulently pass off a hash output produced honestly in one context as valid in a different context.
Security implication. Without domain separation, a hash output has no unambiguous meaning: the verifier cannot tell which protocol, proof type, session, role, or statement it belongs to. This can enable replay across sessions, cross-context confusion between related protocol steps, or Fiat-Shamir challenges that bind less transcript data than the security proof assumes. In threshold-signature implementations, these failures can let adversarial transcripts verify in the wrong context.
How to avoid. Prepend a constant-length domain-separation tag, distinct per context, to every hash invocation. The tag should encode the protocol name, the specific proof or purpose inside the protocol, a session identifier, and typically a version number.
SHA512_256i (Verichains TSSHOCK disclosure, CVE-2022-47931, PR #256).Fiat-Shamir hashes need to say which execution context they belong to, and they
need an injective encoding of the transcript values. Pre-fix tss-lib was
missing both: proof challenges had no caller-supplied session/context tag, and
individual inputs were concatenated without recording their lengths.
Before v2.0.0, bnb-chain/tss-lib used a shared SHA512_256i helper for proof
challenges across Schnorr, MtA, DLN, and commitment proofs. The helper included
a block-count prefix, but no caller-supplied session/context tag and no
per-input length tag (source).
The fix (PR #256) introduced
SHA512_256i_TAGGED. The tag is supplied by the caller and is typically a
session or party/session context, not a universal proof-type tag; separation
between proof types also depends on the different statement inputs each proof
hashes. The helper hashes the tag into the state and records each input length
before hashing the transcript (source):
1// FILE: common/hash.go - bnb-chain/tss-lib v2.0.0 (fixed excerpt)
2func SHA512_256i_TAGGED(tag []byte, in ...*big.Int) *big.Int {
3 tagBz := SHA512_256(tag)
4 var data []byte
5 state := crypto.SHA512_256.New()
6 state.Write(tagBz)
7 state.Write(tagBz)
8
9 inLen := len(in)
10 inLenBz := make([]byte, 64/8)
11 binary.LittleEndian.PutUint64(inLenBz, uint64(inLen))
12 ptrs := make([][]byte, inLen)
13 for i, n := range in {
14 ptrs[i] = n.Bytes()
15 }
16 data = append(data, inLenBz...)
17
18 for i := range in {
19 data = append(data, ptrs[i]...)
20 data = append(data, hashInputDelimiter)
21 dataLen := make([]byte, 8)
22 binary.LittleEndian.PutUint64(dataLen, uint64(len(ptrs[i])))
23 data = append(data, dataLen...)
24 }
25
26 state.Write(data)
27 return new(big.Int).SetBytes(state.Sum(nil))
28}
Ambiguous Hash Encoding
What can go wrong. Many protocols need to hash several values together, such as group elements, integers, or commitments. In the Fiat-Shamir transform, for example, the challenge is just the hash of the transcript. The naive encoding concatenates the values with a delimiter, $H(m_1 ,|, D ,|, m_2 ,|, \cdots ,|, D ,|, m_n)$, where $D$ is a fixed byte sequence such as 0x00 or ||. This is not injective: because each $m_i$ is an arbitrary byte string that may itself contain $D$, two different input tuples can serialize to the same byte string, and therefore hash to the same value.
Security implication. Because the encoding is ambiguous, an adversary can shift boundaries around, manipulate which parts of the input get interpreted as which values, without changing the hash output. In the context of discrete log proofs, the adversary sends a single commitment stream whose bytes can be parsed several ways, all hashing to the same challenge. After observing the challenge bits, the adversary retroactively chooses the parse that makes the verification equation hold for every bit, producing a valid-looking proof of a discrete-log relation the adversary does not satisfy. Applied to threshold-ECDSA signing, the adversary can forge the dlnproof, the discrete-log relation proof over the auxiliary RSA modulus used in GG18/GG20 setup, leading to recovery of other parties’ secret shares and ultimately the shared key. The attack is documented by Hexens and catalogued as the TSSHOCK α-shuffle attack.
How to avoid. Make the encoding injective: length-prefix each element with a fixed-width tag; an 8-byte little-endian length is enough. Better still, use the protocol’s specified serialization format where one exists.
SHA512_256 (PR #233).The audit finding KS-IOF-F-02 pointed out that bnb-chain’s tss-lib applied an ambiguous encoding by using a single dollar-sign delimiter with no per-element length tag.
The vulnerable helper represented that delimiter as '$'
(source):
1// common/hash.go — bnb-chain/tss-lib v1.3.5 (vulnerable)
2const hashInputDelimiter = byte('$')
3
4func SHA512_256(in ...[]byte) []byte {
5 inLenBz := make([]byte, 8)
6 binary.LittleEndian.PutUint64(inLenBz, uint64(len(in))) // counts inputs, not sizes
7 data = append(data, inLenBz...)
8 for _, bz := range in {
9 data = append(data, bz...)
10 data = append(data, hashInputDelimiter) // no length tag per element
11 }
12}
The collision: SHA512_256([]byte("a$"), []byte("b")) and SHA512_256([]byte("a"), []byte("$b"))
both serialize to a$$b$ and therefore produce the same digest. The
fix (IoFinnet’s commit 369ec50,
imported into bnb-chain/tss-lib as
PR #233) appends an 8-byte length
tag after each delimiter
(source):
1// common/hash.go — bnb-chain/tss-lib v2.0.0 (fixed)
2for _, bz := range in {
3 data = append(data, bz...)
4 data = append(data, hashInputDelimiter)
5 dataLen := make([]byte, 8)
6 binary.LittleEndian.PutUint64(dataLen, uint64(len(bz)))
7 data = append(data, dataLen...) // length tag makes encoding injective
8}
Randomness Has Insufficient Entropy
What can go wrong. MPC protocols rely on high-entropy sources for nonces, masks, and blinding factors, and their output must be fresh for each use. A low-entropy source, one that repeats or is predictable, lets an attacker recover any secret that depends on it.
Security implication. Any part of the system that relies on a low-entropy source lets even an honest-but-curious adversary brute-force it and recover the secrets, if any, after one or a few observations. In Schnorr signatures, reusing the nonce $r$ across two messages exposes the signing key: with $s_1 = r + c_1 x$ and $s_2 = r + c_2 x$, the key is $x = (s_1 - s_2)(c_1 - c_2)^{-1}$.
How to avoid. Draw all protocol randomness from a cryptographically secure RNG with at least a 128-bit security level, and never reuse it across runs. For deterministic nonces, follow the construction in RFC 6979.
randomize_blocks (commit 99c5efc).FKOS15 is a binary MPC-with-preprocessing protocol used by MP-SPDZ’s Tinier backend. Party inputs are masked with preprocessed correlated randomness; the security argument requires that mask to carry the full claimed statistical-security parameter of entropy.
In MP-SPDZ pre-fix, Tools/BitVector.h::randomize_blocks produced
under-randomized masks for single-bit input types: the loop drove
tmp.randomize(G) once per T-sized block, and for a 1-bit T each copied byte
carried only one fresh random bit (source):
1// Tools/BitVector.h — data61/MP-SPDZ (vulnerable, pre-99c5efc)
2template<class T>
3inline void BitVector::randomize_blocks(PRNG& G)
4{
5 T tmp;
6 for (size_t i = 0; i < (nbytes / T::size()); i++)
7 {
8 tmp.randomize(G); // biased for 1-bit T
9 memcpy(bytes + i * T::size(), tmp.get_ptr(), T::size());
10 }
11}
Because masks are read back bit-by-bit but only one bit per byte was randomized, only 1 in every 8 sampled bits was actually random; the other 7 were always 0. The affected values are the party’s own authenticated random inputs (including sacrifice values), so an adversary can predict 7 of every 8 of those bits, far below the intended statistical-security margin.
The fix special-cases the 1-bit case to fill the byte buffer directly from the PRG, so bit-indexed reads see fresh randomness in every bit position (source):
1// Tools/BitVector.h — data61/MP-SPDZ (fixed, 99c5efc)
2template<class T>
3inline void BitVector::randomize_blocks(PRNG& G)
4{
5 if (T::size_in_bits() == 1)
6 {
7 G.get_octets(bytes, nbytes); // raw PRG output
8 }
9 else
10 {
11 T tmp;
12 for (size_t i = 0; i < (nbytes / T::size()); i++)
13 {
14 tmp.randomize(G);
15 memcpy(bytes + i * T::size(), tmp.get_ptr(), T::size());
16 }
17 }
18}