Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
THORChain vulnerability TSS

TSS Churn with 2 evil nodes

Currently TSS works by the system auto-generating a set of TSS invitees that collectively generate a new vault pubkey outside of process. Each node that participates in the signing ceremony then posts in their results into THORChain as a MsgTssPool.

Two evil nodes are able to front-run a TSS signing ceremony by posting in a fake TSS result and voting twice, which achieves consensus and creates a vault controlled by attacker, stealing funds (before the valid tx arrives).

Note: #thorsec team found a similar bug allowing spoofing ID which was patched in https://gitlab.com/thorchain/thornode/-/merge_requests/1922 - this vulnerability is similar but works even with the original ID spoof patch. After disclosure, MR 1922 also incorporated fixes to stop this attack presented below.

Difficulty

Hard. Requires 600k RUNE, two active validators in the set (or churning in). Custom tx sent to endpoint, manually signed. Needs to be scripted to beat the real TSS votes (about a few seconds window).

Funds at risk

Currently Zero - churning is disabled, and this is only exploitable at the moment of churn. Will be patched before churning.

If network was mainnet and fully operational, potentially entire funds (or half, if network has sharded into two asgard vaults, which would require 4 evil validators to steal the two halves).

Given stolen funds are 'tainted', and the cost of bonding in is (best-case for attacker) 2/39 (5%) of validators, and validators securing 50% over-collateralised over funds, an attacker could 10x their funds with this attack. But in doing so, receive tainted funds which are "worth" a lot less than their original "clean" funds. The risk of spending $5m clean to steal $50m tainted, may be too high risk for an attacker.

Attack

At the moment of churn, an attacker who controls two nodes can hand-craft their own MsgTssPool and post to the /txs endpoint. The practicalities of doing this are ommitted here for brevity, but it's similar to how MsgVersion (etc) works.

In the attackers MsgTssPool they have the following parameters set:

type MsgTssPool struct {
	ID         string            <-- Generated by hashing parameters below (this will be different to the 'Real' TSS).
	PoolPubKey common.PubKey     <-- Attacker controlled address (fake) for receiving all the funds. 
	KeygenType KeygenType        <-- AsgardKeygen (1)
	PubKeys    common.PubKeys    <-- {AttackerNode1, AttackerNode2}
	Height     int64             <-- Block height
	Blame      blame.Blame       <-- Empty, so we pass msg.IsSuccess() 
	Chains     common.Chains     <-- [All the chains]
	Signer     cosmos.AccAddress <-- AttackerNode1
}

Normally for Real (honest) TSS, .PoolPubKey is the legitimate address generated by TSS ceremony, and .PubKeys is all of the participants in signing ceremony.

What the attacker does instead is send in a fake PoolPubKey to their own (exclusively attacker controlled) vault, and only two PubKeys they control.

When the attacker first posts this, it passes ValidateBasic (len(PubKeys) >= 2) and ValidateCurrent.

It also passes Validate because this only requires msg.Signer to be in the keygenBlock list. Which our attacker is, because it is one of two active nodes.

for _, keygen := range keygenBlock.Keygens {
	for _, member := range keygen.Members {
		addr, err := member.GetThorAddress()
		if addr.Equals(msg.Signer) && err == nil {
			return nil    <-- PASS
		}
	}
}

In handleCurrent it creates a voter record with a different ID than the real messages that are soon about to arrive:

voter, err := h.keeper.GetTssVoter(ctx, msg.ID) <-- Will be empty
...
if voter.PoolPubKey.IsEmpty() {
  voter.PoolPubKey = msg.PoolPubKey <-- Attackers Vault
  voter.PubKeys = msg.PubKeys       <-- Here we only specify the two Attacker controlled vaults
}
...
h.keeper.SetTssVoter(ctx, voter)  <-- Save

Then immediately, attacker posts identical fake message signed by their second evil node keys. This also passes the Validate, and pulls the fake voter record and signs it:

voter.Sign(msg.Signer, msg.Chains)
...
h.keeper.SetTssVoter(ctx, voter)

Then it asks whether it has consensus:

if !voter.HasConsensus() {
  ctx.Logger().Info("not having consensus yet, return")
  return &cosmos.Result{}, nil
}
// Logic after here creates vaults etc...

Inside the HasConsensus():

func (tss *TssVoter) HasConsensus() bool {
  return HasSuperMajority(len(tss.Signers), len(tss.PubKeys))   <-- PASS with 2/2 votes
}

Attacker has specified tss.PubKeys as their two vaults, and signed twice. This passes consensus.

A vault is created and all funds are churned into the attackers vault.

Fix

TSS Validate needs to check the message .PubKeys set passed in exactly matches the expected TSS Signing ceremony set in the system-generated keygenBlock Members.

Disclosure

Reported to Heimdall and #thorsec team 20 Sep 2021. Patch was incorporated by Heimdall very soon after into the existing MR1922

Will be released publicly when 100% validators running 0.68

@HildisviniOttar
Copy link
Author

HildisviniOttar commented Sep 25, 2021

Network on 0.68 - releasing public

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment