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.
In the SPDZ protocol, parties hold BDOZ MACs $[\alpha \cdot a]$ on every wire under a 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):
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):
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.
Before v2.0.0, bnb-chain/tss-lib used a single SHA512_256i helper for every proof challenge: Schnorr, MtA, DLN, commitments, with no tag distinguishing which protocol context a hash was produced in (source).
The fix (PR #256) introduced SHA512_256i_TAGGED, which prepends a per-session, per-proof-type tag and length-prefixes every input (source):
1// common/hash.go — bnb-chain/tss-lib v2.0.0 (fixed) 2// SHA512_256i_TAGGED prepends a session-specific tag, providing domain 3// separation between different proof types and sessions. 4func SHA512_256i_TAGGED(tag []byte, in ...*big.Int) *big.Int { 5 data := tag // unique per proof type and session 6 for _, v := range in { 7 data = append(data, v.Bytes()...) 8 data = append(data, hashInputDelimiter) 9 dataLen := make([]byte, 8) 10 binary.LittleEndian.PutUint64(dataLen, uint64(len(v.Bytes()))) 11 data = append(data, dataLen...) 12 } 13 return new(big.Int).SetBytes(crypto.SHA512_256(data)) 14}
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 fix (PR #256) added a Session []byte parameter prepended to every proof challenge via the domain-separating SHA512_256i_TAGGED (source):
Lindell’s two-party ECDSA (Lindell, 2017) splits the signing key between a client and a server using Paillier homomorphic encryption, with no oblivious transfer involved. Its security analysis requires that a signatory abort and stop signing the moment a produced signature fails to verify; the abort must be terminal.
Fireblocks found that real deployments deviated from this, treating a failed signature as an ordinary, retryable error and continuing to sign with the same key. A party that has compromised its counterparty crafts a malformed Paillier ciphertext so that signature generation succeeds only when the least-significant bit of the honest party’s secret share is zero. Each request then leaks one bit through success-or-abort, and the full key is recovered after a few hundred signatures.
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).
Two bugs were found and patched in MP-SPDZ.
Bug 1 — Missing MAC check in multi-threaded POpen (commit 5e714b2). The SubProcessor<T>::POpen() function opens secret values. The MAC verification call check() was only triggered by an explicit output-gate condition (inst.get_n()), so in multi-threaded programs, some opened values could be used without the MAC checks needed around the open:
1// FILE: Processor/Processor.hpp — MP-SPDZ (vulnerable, prior to fix) 2template <class T> 3void SubProcessor<T>::POpen(const Instruction& inst) 4{ 5 if (inst.get_n()) 6 check(); // ← MAC check only before the loop, only if inst.get_n() is truthy 7 // ... batched open setup ... 8 for (auto it = reg.begin(); it < reg.end(); it += 2) 9 for (int i = 0; i < size; i++) 10 C[*it + i] = MC.finalize_open(); 11 // ← no MAC check after the loop, even when nthreads > 0 12} The fix widens the pre-loop gate and adds a new post-loop MAC check with the same gate, so multi-threaded opens trigger both checks:
The audit finding KS-IOF-F-02 pointed out that bnb-chain’s tss-lib applied an ambiguous encoding by using a single '$' delimiter with no per-element length tag (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):
Multichain’s anyswap/FastMulThreshold-DSA, a fork of bnb-chain/tss-lib, reduced the DLN proof iteration constant from thse spec-mandated 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 submits parallel signing requests, forges a valid DLN proof on roughly half of them, and uses the forged proof to extract a signing key share in a single signing ceremony.
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 channel, instantiated in the GG18 paper with Paillier encryption keyed to the recipient. The Coinbase library’s GG20 implementation drops the encryption step 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 Paillier encryption applied 5} 6// ... 7p2PSend[id] = &DkgRound2P2PSend{ xij: dp.state.X[id-1] }
Both failures appear in bnb-chain/tss-lib’s crypto/vss/feldman_vss.go and were disclosed together by Trail of Bits. They were fixed in a single PR #149.
Failure 1: zero index mod $q$. Before the fix, Create checked the party index against the integer literal 0 without reducing modulo $q$ first (source):
1// crypto/vss/feldman_vss.go, bnb-chain/tss-lib (vulnerable, pre-PR #149) 2for i := 0; i < num; i++ { 3 if indexes[i].Cmp(big.NewInt(0)) == 0 { 4 return nil, nil, fmt.Errorf("party index should not be 0") 5 } 6 // indexes[i] == q passes the check; evaluatePolynomial(q) ≡ f(0) = secret 7 share := evaluatePolynomial(ec, threshold, poly, indexes[i]) 8 shares[i] = &Share{Threshold: threshold, ID: indexes[i], Share: share} 9} A malicious party submits index $i = q$. The literal-zero check passes, but evaluatePolynomial(q) ≡ evaluatePolynomial(0) = f(0) = s, handing the attacker the shared secret as their share.
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.
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 attempted echo-broadcast as the mitigation; Kudelski noted it “might actually make things worse” without a true reliable-broadcast layer underneath. If an application realizes broadcast as $N$ separate point-to-point sends, a malicious sender can equivocate.
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):
FKOS15 is the MPC-with-preprocessing protocol underlying MASCOT and SPDZ2k. 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, but for a 1-bit T that path did not place fresh PRG output across every byte of the underlying buffer (source):
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.
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}$.
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):
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/proof.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. A malicious party fixes a desired response, recomputes the challenge on values of its choosing, and solves for a consistent $u$ after the fact, forging a valid-looking proof without a witness.
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)
2pubenumDomainPurpose {
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).
8CKD,
9}
1011pubstructDomainConfig {
12pub id: DomainId,
13pub scheme: SignatureScheme,
14pub 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}
1112// 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}
2021// 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}
In the SPDZ protocol, parties hold
BDOZ MACs $[\alpha \cdot a]$ on every wire under a 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):
Each party’s commitment is $c_i = H(z_i \,\|\, r_i)$, with no opener identity in the
hash input. In a two-party SPDZ MAC check over $\mathbb{F}_{2^k}$, a corrupt $P_2$ copies
$P_1$’s commitment byte-for-byte, then copies the opening $(z_1, r_1)$. Because the
field has characteristic 2, the reconstructed $z = z_1 + z_1 = 0$ and the MAC check
passes regardless of what $a'$ was reconstructed. 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 4publicbyte[]commit(int myId, Drbg rand, byte[] value) {
5if (commitmentVal !=null) {
6thrownew IllegalStateException("Already committed");
7 }
8byte[] randomness =newbyte[DIGEST_LENGTH];
9 rand.nextBytes(randomness);
10// Party ID is now the first ID_LENGTH bytes of the hashed input.11byte[] openingInfo =newbyte[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);
17return openingInfo;
18}
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):
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):
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.
Before v2.0.0, bnb-chain/tss-lib used a single SHA512_256i helper for every proof challenge: Schnorr, MtA, DLN, commitments, with no tag distinguishing which protocol context a hash was produced in (source).
The fix (PR #256) introduced SHA512_256i_TAGGED, which prepends a per-session, per-proof-type tag and length-prefixes every input (source):
1// common/hash.go — bnb-chain/tss-lib v2.0.0 (fixed) 2// SHA512_256i_TAGGED prepends a session-specific tag, providing domain 3// separation between different proof types and sessions. 4funcSHA512_256i_TAGGED(tag []byte, in...*big.Int) *big.Int {
5data:=tag// unique per proof type and session 6for_, v:=rangein {
7data = append(data, v.Bytes()...)
8data = append(data, hashInputDelimiter)
9dataLen:= make([]byte, 8)
10binary.LittleEndian.PutUint64(dataLen, uint64(len(v.Bytes())))
11data = append(data, dataLen...)
12 }
13return new(big.Int).SetBytes(crypto.SHA512_256(data))
14}
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) 4funcNewZKProof(x*big.Int, X*crypto.ECPoint) (*ZKProof, error) {
5ifx==nil||X==nil|| !X.ValidateBasic() {
6returnnil, errors.New("ZKProof constructor received nil or invalid value(s)")
7 }
8ec:=X.Curve()
9ecParams:=ec.Params()
10q:=ecParams.N11g:=crypto.NewECPointNoCurveCheck(ec, ecParams.Gx, ecParams.Gy) // already on the curve.1213a:=common.GetRandomPositiveInt(q)
14alpha:=crypto.ScalarBaseMult(ec, a)
1516varc*big.Int17 {
18// Challenge includes only public key X and commitment alpha — no session ID,19// no party identity, no protocol context.20cHash:=common.SHA512_256i(X.X(), X.Y(), g.X(), g.Y(), alpha.X(), alpha.Y())
21c = common.RejectionSample(q, cHash)
22 }
23t:= new(big.Int).Mul(c, x)
24t = common.ModInt(q).Add(a, t)
2526return&ZKProof{Alpha: alpha, T: t}, nil27}
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 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) 4funcNewZKProof(Session []byte, x*big.Int, X*crypto.ECPoint) (*ZKProof, error) {
5ifx==nil||X==nil|| !X.ValidateBasic() {
6returnnil, errors.New("ZKProof constructor received nil or invalid value(s)")
7 }
8ec:=X.Curve()
9ecParams:=ec.Params()
10q:=ecParams.N11g:=crypto.NewECPointNoCurveCheck(ec, ecParams.Gx, ecParams.Gy) // already on the curve.1213a:=common.GetRandomPositiveInt(q)
14alpha:=crypto.ScalarBaseMult(ec, a)
1516varc*big.Int17 {
18// Session is prepended via the domain-separating tagged hash, binding the19// challenge to the protocol session (and, by convention, the participant set).20cHash:=common.SHA512_256i_TAGGED(Session, X.X(), X.Y(), g.X(), g.Y(), alpha.X(), alpha.Y())
21c = common.RejectionSample(q, cHash)
22 }
23t:= new(big.Int).Mul(c, x)
24t = common.ModInt(q).Add(a, t)
2526return&ZKProof{Alpha: alpha, T: t}, nil27}
Lindell’s two-party ECDSA (Lindell, 2017) splits the
signing key between a client and a server using Paillier homomorphic encryption, with no
oblivious transfer involved. Its security analysis requires that a signatory abort and
stop signing the moment a produced signature fails to verify; the abort must be terminal.
Fireblocks
found that real deployments deviated from this, treating a failed signature as an ordinary,
retryable error and continuing to sign with the same key. A party that has compromised its
counterparty crafts a malformed Paillier ciphertext so that signature generation succeeds
only when the least-significant bit of the honest party’s secret share is zero. Each request
then leaks one bit through success-or-abort, and the full key is recovered after a few
hundred signatures.
The fix makes the failed-signature abort terminal and distinguishable from benign aborts such as timeouts, or adds a zero-knowledge proof on the client’s final message.
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):
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$.
MP-SPDZ `POpen` and `Commit_And_Open_` race conditions
Bug 1 — Missing MAC check in multi-threaded POpen
(commit 5e714b2). The
SubProcessor<T>::POpen() function opens secret values. The MAC verification call
check() was only triggered by an explicit output-gate condition (inst.get_n()), so
in multi-threaded programs, some opened values could be used without the MAC checks
needed around the open:
1// FILE: Processor/Processor.hpp — MP-SPDZ (vulnerable, prior to fix)
2template<classT> 3void SubProcessor<T>::POpen(const Instruction& inst)
4{
5if (inst.get_n())
6 check(); // ← MAC check only before the loop, only if inst.get_n() is truthy
7// ... batched open setup ...
8for (auto it = reg.begin(); it < reg.end(); it +=2)
9for (int i =0; i < size; i++)
10 C[*it + i] = MC.finalize_open();
11// ← no MAC check after the loop, even when nthreads > 0
12}
The fix widens the pre-loop gate and adds a new post-loop MAC check with the
same gate, so multi-threaded opens trigger both checks:
1// FILE: Processor/Processor.hpp — MP-SPDZ (fixed, commit 5e714b2)
2template<classT> 3void SubProcessor<T>::POpen(const Instruction& inst)
4{
5if (inst.get_n() or BaseMachine::s().nthreads >0)
6 check(); // ← gate widened to also fire under multi-threading
7// ... batched open setup ...
8for (auto it = reg.begin(); it < reg.end(); it +=2)
9for (int i =0; i < size; i++)
10 C[*it + i] = MC.finalize_open();
11if (inst.get_n() or BaseMachine::s().nthreads >0)
12 check(); // ← NEW: post-loop MAC check, same gate
13}
Bug 2 — Race condition in Commit_And_Open_
(commit b86f29b). Inside
Tools/Subroutines.cpp, a shared coordinator object lets one thread signal to the
others that its commitment phase is complete. That signal was raised before the
commitment-opening validation loop ran, so a second thread waiting on the coordinator
could observe the “finished” state and proceed with values that had not yet been
verified:
1// FILE: Tools/Subroutines.cpp — MP-SPDZ (vulnerable)
23P.Broadcast_Receive(Open_data);
4coordinator.finished(); // ← signals completion before verifying
56for (int i =0; i < P.num_players(); i++)
7if (!Open(datas[i], Comm_data[i], Open_data[i], i))
8throw invalid_commitment();
The fix moves the signal to after the validation loop:
1// FILE: Tools/Subroutines.cpp — MP-SPDZ (fixed)
23P.Broadcast_Receive(Open_data);
4for (int i =0; i < P.num_players(); i++)
5if (!Open(datas[i], Comm_data[i], Open_data[i], i))
6throw invalid_commitment();
78coordinator.finished(); // ← now after verifying
The attack exploits the race by having a malicious party controlling Thread B observe
that Thread A’s coordinator has finished and immediately proceed to use the opened
values in its own MAC check instance, before A has confirmed those values are
authenticated. By carefully timing two concurrent MAC check instances the adversary
extracts information about $\alpha$ through the unauthenticated intermediate state,
then uses this to forge MACs on arbitrary output values.
The audit finding KS-IOF-F-02 pointed out that bnb-chain’s tss-lib applied an ambiguous encoding by using a single '$'
delimiter with no per-element length tag
(source):
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):
Multichain’s anyswap/FastMulThreshold-DSA, a fork of bnb-chain/tss-lib, reduced the DLN proof iteration constant from thse spec-mandated 128 down to 1 in commit 4e543437c6, collapsing the soundness margin to a coin flip per attempt (source):
Verichains demonstrated the TSSHOCK c-guess attack against this configuration: the adversary submits parallel signing requests, forges a valid DLN proof on roughly half of them, and uses the forged proof to extract a signing key share in a single signing ceremony.
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 channel, instantiated in the GG18 paper
with Paillier encryption keyed to the recipient.
The Coinbase library’s GG20 implementation drops the encryption step and returns the
share as a bare struct field
(source):
Both failures appear in bnb-chain/tss-lib’s crypto/vss/feldman_vss.go and were disclosed together by Trail of Bits. They were fixed in a single PR #149.
Failure 1: zero index mod $q$. Before the fix, Create checked the party index against the integer literal 0 without reducing modulo $q$ first (source):
1// crypto/vss/feldman_vss.go, bnb-chain/tss-lib (vulnerable, pre-PR #149)2fori:=0; i < num; i++ {
3ifindexes[i].Cmp(big.NewInt(0)) ==0 {
4returnnil, nil, fmt.Errorf("party index should not be 0")
5 }
6// indexes[i] == q passes the check; evaluatePolynomial(q) ≡ f(0) = secret7share:=evaluatePolynomial(ec, threshold, poly, indexes[i])
8shares[i] = &Share{Threshold: threshold, ID: indexes[i], Share: share}
9}
A malicious party submits index $i = q$. The literal-zero check passes, but evaluatePolynomial(q) ≡ evaluatePolynomial(0) = f(0) = s, handing the attacker the shared secret as their share.
Failure 2: duplicate indices mod $q$. The same file’s ReConstruct performs Lagrange interpolation by inverting the index difference $x_j - x_k$ via ModInverse (source):
1// crypto/vss/feldman_vss.go, bnb-chain/tss-lib (Lagrange step in ReConstruct)2sub:=modN.Sub(xs[j], share.ID)
3subInv:=modN.ModInverse(sub) // nil if sub ≡ 0 mod q4div:=modN.Mul(xs[j], subInv) // nil-pointer dereference5times = modN.Mul(times, div)
A malicious party submits $x_j = x_k + q$ for some honest party $k$. The raw integers differ, so any non-modular != check passes; modular reduction makes $x_j \equiv x_k$, sub is zero, ModInverse returns nil, and the next operation panics, DoS-ing the signing ceremony.
Unified fix: CheckIndexes. PR #149 added a single validation helper called at the start of Create. It reduces each index modulo $q$, rejects zero, and rejects duplicates in one pass (source):
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() {
3let traffic = chan.receiver.next().await.ok_or(...)?;
4let 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.
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
attempted echo-broadcast as the mitigation; Kudelski noted it “might actually make
things worse” without a true reliable-broadcast layer underneath. 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 ACK to $A$ and $B$.
$E$ sends not ACK to $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.
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):
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):
FKOS15 is the MPC-with-preprocessing protocol underlying MASCOT and SPDZ2k. 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, but for a 1-bit T that path did not place fresh PRG output across every byte of the underlying buffer (source):
1// Tools/BitVector.h — data61/MP-SPDZ (vulnerable, pre-99c5efc)
2template<classT> 3inlinevoid BitVector::randomize_blocks(PRNG& G)
4{
5 T tmp;
6for (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}
A malicious party who observes the masked input transcript can narrow the search space for the honest party’s bit-input by exploiting the mask’s reduced effective entropy: the soundness of FKOS15’s input authentication assumed the mask hid the input information-theoretically up to $2^{-s}$, but the under-randomized mask collapsed that guarantee to a smaller margin.
The fix special-cases the 1-bit case to fill the buffer directly from the PRG, so the mask gets full per-bit entropy rather than the under-populated bits the original loop produced (source):
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):
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):
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):
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):
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.
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):
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 3gofunc(chchan<- []*common.GermainSafePrime) {
4sgps, err:=common.GetRandomSafePrimesConcurrent(safePrimeBitLen, 2, timeout, concurrency/2)
5iferr!=nil {
6ch<-nil 7return 8 }
9ch<-sgps10}(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$.
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/proof.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 randomness6)
Because $u$ is absent, the challenge $e$ is independent of the prover’s randomness commitment. A malicious party fixes a desired response, recomputes the challenge on values
of its choosing, and solves for a consistent $u$ after the fact, forging a valid-looking proof without a witness.
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 hash2eHash = common.SHA512_256i(
3 append(pk.AsInts(), X.X(), X.Y(), c1, c2, u.X(), u.Y(), z, zPrm, t, v, w)...4)
Category
Select a category to inspect it here.
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.
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.
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.
Subprotocols assumed by the protocol design, such as broadcast channels and
authenticated or confidential peer-to-peer transport, must be realized by the deployment.
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.
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.
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.