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.
Mapped Pitfalls
Pitfall
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 proves both prime factors satisfy $p, q > 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.
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$.
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. Without that guarantee, an adversary can choose the modulus so that the DLP is easy.
Security implication. When $\lvert \text{QR}_p \rvert = (p-1)/2$ or $\lvert \text{QR}_N \rvert = (P-1)(Q-1)/4$ are smooth, i.e, factors only into small primes (i.e each $q_i \lt 2^{100}$ bits) $q_1, \ldots, q_k$, 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). For tss-lib’s Pedersen bases $h_1, h_2 \in \text{QR}_{\tilde N}$, an adversary who factors $\tilde N$ recovers $\log_{h_1}(h_2)$ via Pohlig-Hellman and extracts the DLN proof’s witness without performing honest keygen, undermining the zero-knowledge property of every range proof built on those bases.
How to avoid. Generate $p$ (or both factors $P, Q$ of $N = PQ$) as safe primes. For standardized prime groups, prefer the audited safe-prime constructions in RFC 3526 and RFC 7919.
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 3gofunc(chchan<-*rsa.PrivateKey){ 4pk,err:=rsa.GenerateMultiPrimeKey(rand.Reader,2,RSAModulusLen) 5iferr!=nil{ 6common.Logger.Errorf("RSA generation error: %s",err) 7ch<-nil 8} 9ch<-pk10}(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 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$.
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 at the value the specification mandates: at least 128 for CGGMP21 / GG18 / GG20 DLN proofs. If performance is the concern, switch to a compiled non-interactive proof instead of cutting rounds.
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.
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. A Schnorr challenge hashed for one sub-protocol can satisfy the verification equation for another: a proof generated honestly under sub-protocol $A$’s statement is accepted as a proof under sub-protocol $B$’s statement. In threshold-signature implementations this lets the same proof bytes, produced for one context, be replayed as a proof for another.
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.
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}13returnnew(big.Int).SetBytes(crypto.SHA512_256(data))14}
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 knowledge of a discrete log the adversary does not know. Applied to threshold-ECDSA signing, the adversary can forge the MtA range proofs, 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.
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) 2consthashInputDelimiter=byte('$') 3 4funcSHA512_256(in...[]byte)[]byte{ 5inLenBz:=make([]byte,8) 6binary.LittleEndian.PutUint64(inLenBz,uint64(len(in)))// counts inputs, not sizes 7data=append(data,inLenBz...) 8for_,bz:=rangein{ 9data=append(data,bz...)10data=append(data,hashInputDelimiter)// no length tag per element11}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:=rangein{3data=append(data,bz...)4data=append(data,hashInputDelimiter)5dataLen:=make([]byte,8)6binary.LittleEndian.PutUint64(dataLen,uint64(len(bz)))7data=append(data,dataLen...)// length tag makes encoding injective8}
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.
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> 3inlinevoidBitVector::randomize_blocks(PRNG&G) 4{ 5Ttmp; 6for(size_ti=0;i<(nbytes/T::size());i++) 7{ 8tmp.randomize(G);// biased for 1-bit T
9memcpy(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):