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.
List of 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 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.
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. 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.
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 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.
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):
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.
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.
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):
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.
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 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<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}
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):