Common MPC Pitfalls

Bugs

A searchable list of real-world MPC bugs, mapped to the pitfall taxonomy.

21 documented bugs
21 shown

Category Distribution

    BugPitfallCategoryPrimitivesReferences

    near/mpc

    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.

    Missing Domain Separator Across Signing Contexts
    signature

    aicis/fresco

    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):

    Rushing Adversary Copies an Honest Commitment
    commitment mac

    Trust-Machines/wsts

    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):

    Received Sequence Has the Wrong Length
    secret-sharing commitment

    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.

    Threshold Presignature Reuse (Nonce Reuse)
    signature

    bnb-chain/tss-lib

    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}

    Missing Domain Separation When a Hash Function Is Reused
    hash zkp

    bnb-chain/tss-lib

    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):

    Challenge Hash Missing Prover's Party Identity and Session Identifier
    zkp

    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.

    Selective-Abort Attacks during OT Extension
    signature paillier homomorphic-encryption

    Safeheron/multi-party-ecdsa-cpp

    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).

    Smooth or Non-Biprime Paillier Modulus
    paillier homomorphic-encryption zkp

    data61/MP-SPDZ

    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:

    SPDZ Multi-Threaded MAC Check
    mac commitment
    None listed

    bnb-chain/tss-lib

    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):

    Ambiguous Hash Encoding
    hash zkp

    anyswap/FastMulThreshold-DSA

    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.

    Insufficient Soundness from Reduced Iteration Count
    zkp

    coinbase/kryptology

    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] }

    Unauthenticated or Unencrypted Point-to-Point Channels
    secure-channel paillier homomorphic-encryption

    bnb-chain/tss-lib

    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.

    Parties' Shares Not Validated as Non-Zero and Distinct
    secret-sharing

    axelarnetwork/tofnd

    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.

    Unauthenticated or Unencrypted Point-to-Point Channels
    secure-channel

    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.

    Multicast Masquerading as Broadcast
    broadcast

    bnb-chain/tss-lib

    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):

    Curve Points Not Validated
    elliptic-curve signature

    data61/MP-SPDZ

    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):

    Randomness Has Insufficient Entropy
    randomness secret-sharing

    bnb-chain/tss-lib

    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.

    Group Elements Not Validated in Discrete-Log Groups
    paillier homomorphic-encryption zkp commitment group

    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}$.

    Rogue-Key Attacks
    secret-sharing

    bnb-chain/tss-lib

    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):

    Non-Safe-Prime Modulus
    rsa group paillier homomorphic-encryption

    bnb-chain/tss-lib

    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.

    Challenge Transcript Missing Required Values (Weak Fiat-Shamir)
    zkp