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 Symbiotic Relay’s middleware SDK, KeyBlsBn254.wrap() validates an incoming BN254 G1 point as a validator BLS public key. It rejects the zero point, checks that both coordinates lie in $[0, p)$, then derives $Y$ from $X$ via the curve equation and confirms membership. It then stops, with no subgroup-membership check (source):
1// FILE: middleware-sdk/src/contracts/libraries/keys/KeyBlsBn254.sol 2// sherlock-audit/2025-06-symbiotic-relay (vulnerable) 3function wrap( 4 BN254.G1Point memory keyRaw 5) internal view returns (KEY_BLS_BN254 memory key) { 6 if (keyRaw.X == 0 && keyRaw.Y == 0) { 7 return zeroKey(); 8 } 9 if (keyRaw.X >= BN254.FP_MODULUS || keyRaw.Y >= BN254.FP_MODULUS) { 10 revert KeyBlsBn254_InvalidKey(); 11 } 12 (uint256 beta, uint256 derivedY) = BN254.findYFromX(keyRaw.X); 13 if (mulmod(derivedY, derivedY, BN254.FP_MODULUS) != beta) { 14 revert KeyBlsBn254_InvalidKey(); 15 } 16 if (keyRaw.Y != derivedY && keyRaw.Y != BN254.FP_MODULUS - derivedY) { 17 revert KeyBlsBn254_InvalidKey(); 18 } 19 // MISSING: subgroup check, cofactor*keyRaw should equal point at infinity 20 key = KEY_BLS_BN254(keyRaw); 21} BN254 has cofactor $h > 1$, so the curve contains small-order points outside the prime-order subgroup. An attacker registers such a point as their validator key; every subsequent BLS aggregation that includes it operates outside the subgroup the security proof assumes, and the pairing equation becomes satisfiable for crafted signatures without knowledge of the corresponding private key.
In the SPDZ protocol, parties hold additive shares of a global SPDZ MAC $[\alpha \cdot a]$ on every wire under a single global MAC key $\alpha$. To verify that a reconstructed value $a'$ is correct, each party computes $z_i = a' \cdot \alpha_i - (\alpha \cdot a)_i$, commits to $z_i$, and opens; if the reconstructed $z = \sum z_i \ne 0$, they abort. SPDZ also uses the same commitment scheme in coin-tossing and input-sharing subprotocols.
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.
Trail of Bits disclosed a selective-abort vulnerability in an OT-based threshold-ECDSA implementation in the Doerner et al. (DKLS) line. Whether the OT-extension consistency check aborts is itself a function of the sender’s secret choice bits, so a cheating receiver learns “a few bits” per run from the pass/abort signal; because the base OTs are reused, repeating it recovers every secret bit, and in a two-party setting the nonce and the ECDSA signing key.
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 Schnorr PoK in bnb-chain/tss-lib lets party $P_i$ prove knowledge of its secret key share $x_i$ by sending $(R = g^k, s = k + c \cdot x_i)$ where $c$ is a Fiat-Shamir challenge. In v1.x the challenge was derived solely from the public key and the commitment (source):
1// FILE: crypto/schnorr/schnorr_proof.go — bnb-chain/tss-lib v1.3.5 (vulnerable) 2 3// NewZKProof constructs a new Schnorr ZK proof of knowledge of the discrete logarithm (GG18Spec Fig. 16) 4func NewZKProof(x *big.Int, X *crypto.ECPoint) (*ZKProof, error) { 5 if x == nil || X == nil || !X.ValidateBasic() { 6 return nil, errors.New("ZKProof constructor received nil or invalid value(s)") 7 } 8 ec := X.Curve() 9 ecParams := ec.Params() 10 q := ecParams.N 11 g := crypto.NewECPointNoCurveCheck(ec, ecParams.Gx, ecParams.Gy) // already on the curve. 12 13 a := common.GetRandomPositiveInt(q) 14 alpha := crypto.ScalarBaseMult(ec, a) 15 16 var c *big.Int 17 { 18 // Challenge includes only public key X and commitment alpha — no session ID, 19 // no party identity, no protocol context. 20 cHash := common.SHA512_256i(X.X(), X.Y(), g.X(), g.Y(), alpha.X(), alpha.Y()) 21 c = common.RejectionSample(q, cHash) 22 } 23 t := new(big.Int).Mul(c, x) 24 t = common.ModInt(q).Add(a, t) 25 26 return &ZKProof{Alpha: alpha, T: t}, nil 27} As described in CVE-2022-47930, the Schnorr proof of knowledge does not utilize a session id, context, or random nonce in the generation of the challenge. This allows a malicious party to replay a proof generated by an honest party. (The CVE record names the IoFinnet fork as the affected product, but the same flaw and PR #256 fix apply to the bnb-chain upstream shown here.) The fix (PR #256) added a Session []byte parameter prepended to every proof challenge via the domain-separating SHA512_256i_TAGGED (source):
Pre-v2.0.0 bnb-chain/tss-lib stored incoming Paillier keys from co-signers with no biprimality or no-small-factor check. A malicious co-signer could publish a structured $N_A = p_1 \cdots p_{16} \cdot q$ and ride the missing validation through MtA into share extraction (see the parent pitfall for the full attack mechanic).
The MtA call in ecdsa/signing/round_1.go (source) proceeds with Paillier moduli that keygen never required a proof for:
1// FILE: ecdsa/signing/round_1.go — bnb-chain/tss-lib v1.3.5 (vulnerable) 2// Pre-v2.0.0 keygen accepted each co-signer's Paillier modulus with no 3// biprimality or no-small-factor proof, so MtA runs with moduli that were 4// never proven well-formed. 5cA, pi, err := mta.AliceInit(round.Params().EC(), round.key.PaillierPKs[i], k, 6 round.key.NTildej[j], round.key.H1j[j], round.key.H2j[j]) v2.0.0 (GHSA-5cjx-95fx-68q9) added both CGGMP21 proofs to the DKG phase: each party generates a no-small-factor (facproof) and a Paillier-Blum (modproof) proof of its own modulus, which counterparties verify in a later round before accepting the key (source):
Apache Milagro’s MPC library incubator-milagro-MPC implemented GG20-style ECDSA threshold signing and shipped two compounding failures that together produced the most severe variant of the BitForge class:
The same missing biprimality and no-small-factor checks on incoming Paillier moduli as the rest of the affected cohort (CVE-2023-33241). The MtA range-proof blinding parameter $\beta$ sized at only 256 bits, rather than the ~2048 bits that match the Paillier modulus. In the standard BitForge attack against a library with the missing checks alone, the attacker crafts a malicious modulus $N_A = p_1 \cdots p_{16} \cdot q$ and harvests $x \bmod p_i$ over ~16 signing sessions. In Milagro, according to Fireblocks' technical report, the undersized $\beta$ collapsed the work budget so far that the victim’s share could be extracted directly from honest signature transcripts of a single co-signing session, without the attacker needing to craft a malicious modulus at all. The malicious-modulus path remained available as a faster amplification.
BitGo’s TSS implementation in BitGoJS followed the GG18 key-generation protocol but skipped the biprimality phase entirely. Incoming Paillier moduli from co-signers were accepted with no proof that $N = pq$ for primes $p, q$ of safe size, and no proof of knowledge of the underlying share.
Hexens demonstrated a working extraction attack using a chosen-modulus form. The attacker publishes a malicious Paillier public key $(N, V)$ where $N$ has a small smooth factor (e.g. $N = pq$ with $q$ small enough that discrete log in $\mathbb{Z}_q^\ast$ is tractable) and $V$ is an arbitrary quadratic residue. When the victim encrypts their share $x$ under $N$ during signing, the attacker reduces the ciphertext modulo $q$ and computes $C^{p+1} \bmod q$ to isolate $V^x \bmod q$. Brute-force discrete log recovers $x \bmod q$. Repeating across signing sessions and combining residues via CRT reconstructs the full 256-bit share, after which the attacker holds enough material to sign unilaterally under the joint key.
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).
In MP-SPDZ, the concrete synchronization point is Commit_And_Open_, the helper used by the MAC check to commit to local check values and then open them. Before the fix, each thread ran this helper independently. There was no coordinator shared across concurrent MAC checks, so one stalled check did not block another thread using the same global MAC key (source):
1// FILE: Tools/Subroutines.cpp — MP-SPDZ (vulnerable, before 6a42453) 2void Commit_And_Open_(vector<octetStream>& datas, const Player& P) 3{ 4 vector<octetStream> Comm_data(P.num_players()); 5 vector<octetStream> Open_data(P.num_players()); 6 7 Commit(Comm_data[P.my_num()], Open_data[P.my_num()], datas[P.my_num()], 8 P.my_num()); 9 P.Broadcast_Receive(Comm_data); 10 11 P.Broadcast_Receive(Open_data); 12 13 for (int i = 0; i < P.num_players(); i++) 14 { if (i != P.my_num()) 15 { if (!Open(datas[i], Comm_data[i], Open_data[i], i)) 16 { throw invalid_commitment(); } 17 } 18 } 19} The Rushing at SPDZ paper cites commits 6a42453 and b86f29b as the MP-SPDZ fix. The final version passes a shared Coordinator into Commit_And_Open_, waits before the opening phase, validates every opening, and only then calls coordinator.finished() (source):
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.
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):
GG20’s joint key-generation procedure (inherited from GG18) assumes the Round 2 P2P delivery of each Shamir share $x_{ij}$ runs over a confidential point-to-point channel. The GG18/GG20 papers assume this private channel abstractly and leave its instantiation to the deployment; Paillier encryption enters only in the signing-phase MtA, never for the keygen shares. The Coinbase library’s GG20 implementation provides no confidentiality of its own and returns the share as a bare struct field (source):
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.
Chia’s bls-signatures library over BLS12-381 validated incoming G1 public keys via G1Element::CheckValid, which confirmed the on-curve equation but performed no subgroup-membership check. BLS12-381 G1 has cofactor $h > 1$, so the curve contains small-order points outside the prime-order subgroup. A concrete G1 point that satisfies the curve equation but lies outside the subgroup was reported as a witness (source):
1X: 1850443652098619803069679949935703490545934817616361671487073351271435645926537537028144222559542259604367871156773 2Y: 1776970151258755586951871078535415548807448204545244204542330019247278385570277860229537378843413568111354158837149 A non-subgroup public key satisfies the pairing equation for crafted signatures without knowledge of the corresponding private key, giving signature forgery; the same omission also breaks the soundness of any BLS aggregation that combines such a key into the joint public key.
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 implemented the standard “forget-and-forgive” mitigation, a final ACK confirmation round before parties delete their old shares; Kudelski noted that this mitigation “might actually make things worse” if a robust broadcast channel is not available, because the final ACK can itself be equivocated. If an application realizes broadcast as $N$ separate point-to-point sends, a malicious sender can equivocate.
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}$.
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 a binary MPC-with-preprocessing protocol used by MP-SPDZ’s Tinier backend. Party inputs are masked with preprocessed correlated randomness; the security argument requires that mask to carry the full claimed statistical-security parameter of entropy.
In MP-SPDZ pre-fix, Tools/BitVector.h::randomize_blocks produced under-randomized masks for single-bit input types: the loop drove tmp.randomize(G) once per T-sized block, and for a 1-bit T each copied byte carried only one fresh random bit (source):
1// Tools/BitVector.h — data61/MP-SPDZ (vulnerable, pre-99c5efc) 2template<class T> 3inline void BitVector::randomize_blocks(PRNG& G) 4{ 5 T tmp; 6 for (size_t i = 0; i < (nbytes / T::size()); i++) 7 { 8 tmp.randomize(G); // biased for 1-bit T 9 memcpy(bytes + i * T::size(), tmp.get_ptr(), T::size()); 10 } 11} Because masks are read back bit-by-bit but only one bit per byte was randomized, only 1 in every 8 sampled bits was actually random; the other 7 were always 0. The affected values are the party’s own authenticated random inputs (including sacrifice values), so an adversary can predict 7 of every 8 of those bits, far below the intended statistical-security margin.
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.
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):
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 Symbiotic Relay’s middleware SDK, KeyBlsBn254.wrap() validates an incoming
BN254 G1 point as a validator BLS public key. It rejects the zero point, checks
that both coordinates lie in $[0, p)$, then derives $Y$ from $X$ via the curve
equation and confirms membership. It then stops, with no subgroup-membership
check (source):
BN254 has cofactor $h > 1$, so the curve contains small-order points outside
the prime-order subgroup. An attacker registers such a point as their validator
key; every subsequent BLS aggregation that includes it operates outside the
subgroup the security proof assumes, and the pairing equation becomes
satisfiable for crafted signatures without knowledge of the corresponding
private key.
The same omission appears on the verification side, flagged in the Sherlock audit
(Issue #76),
in SigBlsBn254.verify(), which decodes attacker-controlled G1 and G2 points
from calldata and feeds them straight into BN254.safePairing(...) without
checking either is in its respective subgroup.
The audit’s recommended fix is the standard cofactor*P == 0
check before storing the key.
In the SPDZ protocol, parties hold
additive shares of a global SPDZ MAC $[\alpha \cdot a]$ on every wire under a
single global MAC key $\alpha$.
To verify that a reconstructed value $a'$ is correct, each party computes
$z_i = a' \cdot \alpha_i - (\alpha \cdot a)_i$, commits to $z_i$, and opens;
if the reconstructed $z = \sum z_i \ne 0$, they abort. SPDZ also uses the
same commitment scheme in coin-tossing and input-sharing subprotocols.
Fresco’s HashBasedCommitment hashed only the value and the randomness,with no opener identity in the input, allowing a malicious party to replay it. Pre-fix commit method
(source):
Each party’s commitment is $c_i = H(v_i \,\|\, r_i)$, with no opener identity in the
hash input. Fresco does not implement SPDZ over binary fields, so the
characteristic-2 single-MAC-check copy (where a copied $z_1$ gives $z = z_1 + z_1 = 0$)
does not apply to it directly. Fresco is instead hit through the same commitment’s
use in coin-tossing: a corrupt party copies an honest party’s seed commitment
$H(s_i \,\|\, r_i)$ byte-for-byte and later copies the opening $(s_i, r_i)$, so the
two identical seeds cancel in the XOR $s = s_1 \oplus \cdots \oplus s_n$. This strips
the honest party’s entropy from the tossed coin, making it adversarially predictable
and letting the corrupt party pass the batch MAC check on inconsistent values. The fix (PR #433) added the committer’s party ID as the first input to the hash
and required the opener to supply a matching ID at open time
(source):
1// FILE: tools/commitment/src/main/java/dk/alexandra/fresco/tools/commitment/HashBasedCommitment.java 2// aicis/fresco @ fdada93b (fixed) 3 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.
Trail of Bits: selective-abort leakage in OT-extension threshold ECDSA
Trail of Bits disclosed a selective-abort vulnerability in an OT-based threshold-ECDSA
implementation in the Doerner et al. (DKLS) line. Whether the OT-extension consistency
check aborts is itself a function of the sender’s secret choice bits, so a cheating
receiver learns “a few bits” per run from the pass/abort signal; because the base OTs
are reused, repeating it recovers every secret bit, and in a two-party setting the
nonce and the ECDSA signing key.
The fix is to “throw away the setup for a participant that has attempted to cheat
during the OT extension protocol.”
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):
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 CVE record names the IoFinnet fork as the affected product, but the same flaw and PR #256 fix apply to the bnb-chain upstream shown here.) The fix
(PR #256) added a Session []byte parameter prepended to every proof challenge via the
domain-separating SHA512_256i_TAGGED
(source):
1// FILE: crypto/schnorr/schnorr_proof.go — bnb-chain/tss-lib v2.0.0 (fixed) 2 3// NewZKProof constructs a new Schnorr ZK proof of knowledge of the discrete logarithm (GG18Spec Fig. 16) 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}
Pre-v2.0.0 bnb-chain/tss-lib
stored incoming Paillier keys from co-signers with no biprimality or
no-small-factor check. A malicious co-signer could publish a structured
$N_A = p_1 \cdots p_{16} \cdot q$ and ride the missing validation through
MtA into share extraction (see the parent pitfall for the full attack
mechanic).
The MtA call in ecdsa/signing/round_1.go
(source)
proceeds with Paillier moduli that keygen never required a proof for:
1// FILE: ecdsa/signing/round_1.go — bnb-chain/tss-lib v1.3.5 (vulnerable)2// Pre-v2.0.0 keygen accepted each co-signer's Paillier modulus with no3// biprimality or no-small-factor proof, so MtA runs with moduli that were4// never proven well-formed.5cA, pi, err:=mta.AliceInit(round.Params().EC(), round.key.PaillierPKs[i], k,
6round.key.NTildej[j], round.key.H1j[j], round.key.H2j[j])
v2.0.0
(GHSA-5cjx-95fx-68q9)
added both CGGMP21 proofs to the DKG phase:
each party generates a no-small-factor (facproof) and a Paillier-Blum
(modproof) proof of its own modulus, which counterparties verify in a later
round before accepting the key
(source):
1// FILE: ecdsa/keygen/round_2.go — bnb-chain/tss-lib v2.0.0 (fixed)2// Each party proves its Paillier modulus is well-formed; counterparties verify3// these proofs in a later round before accepting the key.4facProof, err = facproof.NewProof(ContextI, round.EC(), round.save.PaillierSK.N, round.save.NTildej[j],
5round.save.H1j[j], round.save.H2j[j], round.save.PaillierSK.P, round.save.PaillierSK.Q) // no-small-factor6// ...7modProof, err = modproof.NewProof(ContextI, round.save.PaillierSK.N,
8round.save.PaillierSK.P, round.save.PaillierSK.Q) // Paillier-Blum modulus
bnb-chain/tss-lib was one of five GG18/GG20 libraries named in
Fireblocks'
BitForge disclosure,
with 15+ wallet providers affected at the time of public release.
Apache Milagro MPC: short range-proof beta + missing biprimality
Apache Milagro’s MPC library
incubator-milagro-MPC
implemented GG20-style ECDSA threshold signing and shipped two compounding
failures that together produced the most severe variant of the BitForge
class:
The same missing biprimality and no-small-factor checks on incoming
Paillier moduli as the rest of the affected cohort
(CVE-2023-33241).
The MtA range-proof blinding parameter $\beta$ sized at only 256 bits,
rather than the ~2048 bits that match the Paillier modulus.
In the standard BitForge attack against a library with the missing checks
alone, the attacker crafts a malicious modulus
$N_A = p_1 \cdots p_{16} \cdot q$ and harvests $x \bmod p_i$ over ~16
signing sessions. In Milagro, according to Fireblocks'
technical report,
the undersized $\beta$ collapsed the work budget so far that the victim’s
share could be extracted directly from honest signature transcripts of a
single co-signing session, without the attacker needing to craft a
malicious modulus at all. The malicious-modulus path remained available
as a faster amplification.
The undersized $\beta$ is visible directly in src/mta.c at tag 0.1
(source):
the blinding scalar z is drawn modulo the secp256k1 curve order
(~256 bits) and BETA is serialized with length EGS_SECP256K1 (32 bytes),
rather than being drawn from the much larger range required for the
range proof to actually hide $x$.
1// FILE: src/mta.c — apache/incubator-milagro-MPC tag 0.1 (vulnerable)
2voidMPC_MTA_SERVER(csprng *RNG, PAILLIER_public_key *PUB, octet *B,
3 octet *CA, octet *ZO, octet *R, octet *CB, octet *BETA)
4{
5 BIG_256_56 q;
6 BIG_256_56 z;
7 ...
8// Curve order
9BIG_256_56_rcopy(q, CURVE_Order_SECP256K1);
10 ...
11// Random z value
12BIG_256_56_randomnum(z, q, RNG); // z drawn mod curve order (~256 bits)
13BIG_256_56_toBytes(Z.val, z);
14 Z.len = EGS_SECP256K1; // 32 bytes, not Paillier-sized
15 ...
16OCT_pad(&Z, FS_2048); // padded to 2048 bits but entropy is still 256 bits
17// beta = -z mod q
18BIG_256_56_sub(z, q, z);
19 ...
20// Output beta
21BIG_256_56_toBytes(BETA->val, z);
22 BETA->len = EGS_SECP256K1; // beta size matches curve order, not N
23}
The Apache Milagro MPC project has since been retired (the repo is
archived and carries a RETIRED.txt pointing to the Apache Incubator
project page).
Properly closing both failures requires (a) the two
CGGMP21 proofs on every co-signer’s
Paillier modulus and (b) sizing the range-proof $\beta$ to match $N$
(typically ~2048 bits), not the curve order.
BitGo’s TSS implementation in BitGoJS followed the GG18 key-generation
protocol but skipped the biprimality phase entirely. Incoming Paillier
moduli from co-signers were accepted with no proof that $N = pq$ for primes
$p, q$ of safe size, and no proof of knowledge of the underlying share.
Hexens demonstrated a working extraction attack using a chosen-modulus
form. The attacker publishes a malicious Paillier public key $(N, V)$
where $N$ has a small smooth factor (e.g. $N = pq$ with $q$ small enough
that discrete log in $\mathbb{Z}_q^\ast$ is tractable) and $V$ is an
arbitrary quadratic residue. When the victim encrypts their share $x$
under $N$ during signing, the attacker reduces the ciphertext modulo $q$
and computes $C^{p+1} \bmod q$ to isolate $V^x \bmod q$. Brute-force
discrete log recovers $x \bmod q$. Repeating across signing sessions and
combining residues via CRT reconstructs the full 256-bit share, after
which the attacker holds enough material to sign unilaterally under the
joint key.
The same root cause was disclosed at scale as
BitForge
(CVE-2023-33241) covering five major GG18/GG20 libraries; BitGo was the
case study in Hexens’ independent
analysis.
BitGo’s remediation implements the Goldberg-Reyzin RSA-modulus certification
proof (eprint 2018/057), not the CGGMP21
Paillier-Blum plus No-Small-Factor proof pair. The verifier landed across
PR #3590,
PR #3602, and
PR #3634, which made the proof
mandatory. The implementation in
modules/sdk-lib-mpc/src/tss/ecdsa/paillierproof.ts
rejects $N$ divisible by any prime up to $\alpha = 319567$, then verifies the
Paillier challenge-response proofs $\sigma_i^N \equiv p_i \pmod N$:
1// FILE: modules/sdk-lib-mpc/src/tss/ecdsa/paillierproof.ts
2// BitGo/BitGoJS (fix landed across PRs #3590, #3602, and #3634)
3 4// Reject N if divisible by any small prime up to alpha = 319567
5for (constprimeofprimesSmallerThan319567) {
6if (n%BigInt(prime) ===BigInt(0)) {
7returnfalse;
8 }
9}
10// Verify the m Paillier challenge proofs
11for (leti=0; i<m; i++) {
12if (p[i] !==modPow(sigma[i], n, n)) {
13returnfalse;
14 }
15}
16returntrue;
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$.
In MP-SPDZ, the concrete synchronization point is Commit_And_Open_, the helper
used by the MAC check to commit to local check values and then open them. Before
the fix, each thread ran this helper independently. There was no coordinator
shared across concurrent MAC checks, so one stalled check did not block another
thread using the same global MAC key
(source):
The Rushing at SPDZ paper cites
commits 6a42453 and
b86f29b as the
MP-SPDZ fix. The final version passes a shared Coordinator into
Commit_And_Open_, waits before the opening phase, validates every opening, and
only then calls coordinator.finished()
(source):
Holding the coordinator until validation completes serializes the MAC-check
opening path: a stalled or invalid MAC check prevents other threads from
continuing under the same key.
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):
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 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.
GG20’s
joint key-generation procedure (inherited from
GG18) assumes the Round 2 P2P delivery of each
Shamir share $x_{ij}$ runs over a confidential point-to-point channel. The GG18/GG20
papers assume this private channel abstractly and leave its instantiation to the
deployment; Paillier encryption enters only in the signing-phase MtA, never for the
keygen shares.
The Coinbase library’s GG20 implementation provides no confidentiality of its own and
returns the share as a bare struct field
(source):
1// FILE: pkg/tecdsa/gg20/participant/dkg_round2.go — coinbase/kryptology23typeDkgRound2P2PSendstruct {
4xij*v1.ShamirShare// raw share — no encryption applied5}
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)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):
Chia’s bls-signatures library over BLS12-381 validated incoming G1 public keys via G1Element::CheckValid, which confirmed the on-curve equation but performed no subgroup-membership check. BLS12-381 G1 has cofactor $h > 1$, so the curve contains small-order points outside the prime-order subgroup. A concrete G1 point that satisfies the curve equation but lies outside the subgroup was reported as a witness (source):
A non-subgroup public key satisfies the pairing equation for crafted signatures without knowledge of the corresponding private key, giving signature forgery; the same omission also breaks the soundness of any BLS aggregation that combines such a key into the joint public key.
The issue was resolved via a Relic submodule bump (commit a5f420c1) that added the missing subgroup check in the upstream pairing library rather than patching CheckValid directly in this repo.
`axelarnetwork/tofnd` accepts spoofed `from` field on the wire
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
implemented the standard “forget-and-forgive” mitigation, a final ACK confirmation
round before parties delete their old shares; Kudelski noted that this mitigation
“might actually make things worse” if a robust broadcast channel is not available,
because the final ACK can itself be equivocated. If an application
realizes broadcast as $N$ separate point-to-point sends, a malicious sender can
equivocate.
Kudelski’s example starts with four peers $(A, B, C, D)$ using a threshold of 3, and a
resharing ceremony that adds a fifth peer $E$ while keeping the threshold at 3. At the
end of the resharing protocol, malicious $E$ sends different final-round messages to
different honest parties:
$E$ sends 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.
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.
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 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> 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}
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):
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):
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/proofs.go — bnb-chain/tss-lib (pre-PR #43, vulnerable)2// u is computed but NOT included in the challenge hash:3eHash = common.SHA512_256i(
4 append(pk.AsInts(), X.X(), X.Y(), c1, c2, z, zPrm, t, v, w)...5// MISSING: u.X(), u.Y() — the EC commitment to the witness randomness6)
Because $u$ is absent, the challenge $e$ is independent of the prover’s randomness commitment, so the proof is malleable: a malicious party can fix a desired response, recompute the challenge on values
of its choosing, and in principle solve for a consistent $u$ after the fact, breaking the proof’s soundness.
The fix (PR #43) added u.X(), u.Y() to the hash input:
1// Fixed: u (the EC commitment to witness randomness) is now in the 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.