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}