Pitfall

Group Elements Not Validated in Discrete-Log Groups

What can go wrong. In MPC protocols that rely on discrete-log groups (e.g., Pedersen-VSS over safe-prime $\mathbb{Z}_p^*$, or GG18/GG20 range-proof bases in $\mathbb{Z}_{\tilde N}^*$), parties exchange elements that under exponentiation with a secret exponent may leak part or all of the secret if the element does not lie in the intended subgroup. The intended subgroup is almost always a large prime-order subgroup of $\mathbb{Z}_p^*$ (with $p = 2q + 1$ a safe prime) or of $\mathbb{Z}_{\tilde N}^*$ (an RSA-style modulus). Three checks apply to every exchanged group element:

  1. Generator validation in safe-prime groups. A valid generator of the $q$-order subgroup of $\mathbb{Z}_p^*$ must satisfy $g \not\equiv 1 \pmod{p}$, $g \not\equiv p - 1 \pmod{p}$, and $g^{(p-1)/2} \equiv 1 \pmod{p}$. The first two exclude the trivial subgroup and the order-2 subgroup; the third confirms membership in the quadratic-residue subgroup.

  2. Subgroup membership for received bases. For any supplied exponentiation base $x$, you must make sure $x$ lives in the intended secure subgroup by checking $x^q \equiv 1 \pmod{p}$ for a safe-prime $p$, and also check the bounds $1 < x < p - 1$ to rule out the trivial cases. Bounds-only checks catch the most degenerate values, but a generic small-order element can pass bounds and still be outside the subgroup.

  3. Paired bases of equal subgroup order. When two bases $h_1, h_2$ are used together (as in GG18/GG20 Pedersen-style commitments $h_1^s h_2^r$), it is not enough to know each lies in the intended subgroup individually. Both must generate the same subgroup. The standard tool is a DLN proof that the sender knows $\alpha$ with $h_2 = h_1^{\alpha} \bmod \tilde N$, together with the companion proof in the reverse direction ($h_1 = h_2^{\beta} \bmod \tilde N$).

Depending on the protocol, omitting any one of these checks lets a malicious party choose an element $x$ that leaks partial or full bits of a secret exponent on every exponentiation.

Security implication. Concretely in GG18 MtA, as analyzed by Hexens, an adversary can set $h_2 = 1$ so $z = h_1^s \bmod \tilde N$ leaks $h_1^s$. When $\tilde N$ is generated without enforcing safe primes (see non-safe-prime modulus), the attacker can choose $\tilde N$ as a product of small prime factors so that $\phi(\tilde N)$ is smooth, and combining Pohlig-Hellman on each factor with CRT reconstructs the full share. Alternatively, an attacker can choose $\tilde N$ large enough that recovering $s$ reduces to computing integer logarithms, which is trivial with standard algorithms.

How to avoid. Validate every exchanged group element before any exponentiation by a secret touches it. For each element supplied by another party:

  • Candidate generator in safe-prime $\mathbb{Z}_p^*$: reject if $g \in \{1, p-1\}$, then check $g^{(p-1)/2} \equiv 1 \pmod{p}$ to confirm membership in the $q$-order subgroup.
  • Received base $x$ in $\mathbb{Z}_p^*$: for a safe-prime $p=2q + 1$, before any use of $x$, check $1 < x < p - 1$, and check $x^q \equiv 1 \pmod{p}$.
  • Paired bases $h_1, h_2$ in $\mathbb{Z}_{\tilde N}^*$ (the unknown-order case): reject if $h_i \in \{1, \tilde N - 1\}$ or $h_1 = h_2$; require the sender to attach a DLN proof of knowledge of $\alpha$ with $h_2 = h_1^{\alpha} \bmod \tilde N$ together with the reverse-direction proof; and require a structural proof on $\tilde N$ itself (biprimality with safe-prime factors of standard size, see non-safe-prime modulus), with a bound on $|\tilde N|$ to rule out adversarially oversized moduli. Soundness of both DLN proofs forces $\langle h_1 \rangle = \langle h_2 \rangle$; the structural proof and size bound prevent the smooth-$\phi$ and integer-log attacks respectively.

As an alternative to the bullets above for safe-prime $\mathbb{Z}_p^*$, cofactor exponentiation (RFC 2785, ยง3.4) raises every received element to the cofactor $j = (p-1)/q$ before use, confining all subsequent operations to the prime-order subgroup. In a safe prime $p = 2q + 1$ the cofactor is $2$, so this reduces to squaring every input ($g' = g^2$) and confines exponentiations to the prime-order subgroup without an explicit generator check.

Example tss-lib DLN bases without proofs or bounds (Trail of Bits TOB-BIN-8 fix, CVE-2020-12118, PR #89)

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.

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

 1// FILE: ecdsa/keygen/round_2.go (lines 51-62)
 2// bnb-chain/tss-lib @ c66e035b (post-PR #89, v1.2.0+)
 3go func(j int, msg tss.ParsedMessage, r1msg *KGRound1Message, H1j, H2j, NTildej *big.Int) {
 4    if dlnProof1, err := r1msg.UnmarshalDLNProof1(); err != nil || !dlnProof1.Verify(H1j, H2j, NTildej) {
 5        dlnProof1FailCulprits[j] = msg.GetFrom()
 6    }
 7    wg.Done()
 8}(j, msg, r1msg, H1j, H2j, NTildej)
 9go func(j int, msg tss.ParsedMessage, r1msg *KGRound1Message, H1j, H2j, NTildej *big.Int) {
10    if dlnProof2, err := r1msg.UnmarshalDLNProof2(); err != nil || !dlnProof2.Verify(H2j, H1j, NTildej) {
11        dlnProof2FailCulprits[j] = msg.GetFrom()
12    }
13    wg.Done()
14}(j, msg, r1msg, H1j, H2j, NTildej)

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

 1// FILE: crypto/dlnproof/proof.go
 2// bnb-chain/tss-lib @ c65c3564 (pre-TOB-BIN-8, vulnerable)
 3func (p *Proof) Verify(h1, h2, N *big.Int) bool {
 4    if p == nil {
 5        return false
 6    }
 7    modN := common.ModInt(N)
 8    msg := append([]*big.Int{h1, h2, N}, p.Alpha[:]...)
 9    c := common.SHA512_256i(msg...)
10    // ... Iterations rounds of Sigma-protocol equation checks ...
11    return true
12}

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

 1// FILE: crypto/dlnproof/proof.go
 2// bnb-chain/tss-lib @ c0a1d4e4 (fixed, TOB-BIN-8)
 3func (p *Proof) Verify(h1, h2, N *big.Int) bool {
 4    if p == nil {
 5        return false
 6    }
 7    if N.Sign() != 1 {
 8        return false
 9    }
10    modN := common.ModInt(N)
11    h1_ := new(big.Int).Mod(h1, N)
12    if h1_.Cmp(one) != 1 || h1_.Cmp(N) != -1 {
13        return false
14    }
15    h2_ := new(big.Int).Mod(h2, N)
16    if h2_.Cmp(one) != 1 || h2_.Cmp(N) != -1 {
17        return false
18    }
19    if h1_.Cmp(h2_) == 0 {
20        return false
21    }
22    for i := range p.T {
23        a := new(big.Int).Mod(p.T[i], N)
24        if a.Cmp(one) != 1 || a.Cmp(N) != -1 {
25            return false
26        }
27    }
28    for i := range p.Alpha {
29        a := new(big.Int).Mod(p.Alpha[i], N)
30        if a.Cmp(one) != 1 || a.Cmp(N) != -1 {
31            return false
32        }
33    }
34    // ... Sigma-protocol equation checks (Iterations rounds) unchanged ...
35    return true
36}