Skip to content

Instantly share code, notes, and snippets.

@HildisviniOttar
Last active February 16, 2022 20:22
Show Gist options
  • Save HildisviniOttar/ab4942c4d234f480e3691d81a78372c5 to your computer and use it in GitHub Desktop.
Save HildisviniOttar/ab4942c4d234f480e3691d81a78372c5 to your computer and use it in GitHub Desktop.

Version downgrade attack

In order to maintain consensus and allow sync-to-tip from block zero, all old code paths are still present in the system, including vulnerable paths (e.g. Infinite mint via affiliate fees).

Code paths taken are chosen based on the result of GetLowestActiveVersion():

version := nodes[0].GetVersion()
for _, na := range nodes {
	if na.GetVersion().LT(version) {
		version = na.GetVersion()
	}
}

So if one can make even a single active validator appear as an older version, e.g. v0.50.0, then the entire system will switch to start executing old code paths from that block onwards.

Looking in handler_version.go this is actually very robust:

if nodeAccount.GetVersion().LT(version) {
	nodeAccount.Version = version.String()
}

And semver comparison is done on integers in the struct including .Major .Minor and .Patch (all uint64).
A set-version can NEVER go backwards ... only upgrade.

So the next thing to wonder is can we trick it to churn in a lower version standby validator?

Let's see how the lowest version is selected in keeper_node_account.go: GetMinJoinVersion():

func (k KVStore) GetMinJoinVersion(ctx cosmos.Context) semver.Version {
	type tmpVersionInfo struct {
		version semver.Version
		count   int
	}

Above we define the struct used for voting on versions later. Nothing.

  vCount := make(map[string]tmpVersionInfo, 0)
  nodes, err := k.ListActiveValidators(ctx)
  if err != nil {
  	_ = dbError(ctx, "Unable to list active node accounts", err)
  	return semver.Version{}
  }

Here we make a dictionary of votes, and grab all active validator nodes. If we can't get all active validators and that returns an error we will return an empty Version{} (which is equal to version 0.0.0), but looking down that rabbit hole shows nothing that is attacker controllable; just database errors.

  sort.SliceStable(nodes, func(i, j int) bool {
  	return nodes[i].GetVersion().LT(nodes[j].GetVersion())
  })

Sort the nodes from lowest version to highest. e.g. 0.79.0, 0.79.1, 0.80.0.

  for _, na := range nodes {
  	v, ok := vCount[na.Version]
  	if ok {
  		v.count = v.count + 1
  		vCount[na.Version] = v
  	} else {
  		vCount[na.Version] = tmpVersionInfo{
  			version: na.GetVersion(),
  			count:   1,
  		}
  	}
  	// assume all versions are backward compatible
  	// analyze-ignore(map-iteration)
  	for k, v := range vCount {
  		if v.version.LT(na.GetVersion()) {
  			v.count = v.count + 1
  			vCount[k] = v
  		}
  	}
  }

Above is the main vote counting: tally up all the versions. But also make sure 0.80.0 votes on 0.79.1 and 0.79.0.

  totalCount := len(nodes)
  version := semver.Version{}

  // analyze-ignore(map-iteration)
  for _, info := range vCount {
  	// skip those version that doesn't have majority
  	if !HasSuperMajority(info.count, totalCount) {
  		continue
  	}
  	if info.version.GT(version) {
  		version = info.version
  	}
  }
  return version
}

And finally go through all the votes of each version (e.g. "0.80.0" has 3 votes. "0.79.1" has 40 votes. "0.79.0" has 41 votes). In this case both 0.79.0 and 0.79.1 have super-majority, so it picks the higher 0.79.1 as min churn-in.

Notice how if NOTHING reaches consensus, it returns an empty version 0.0.0 - exactly what we're trying to do....

So how to make it return empty?

Let's take a look inside semver package.

It turns out that semver also has other suffixes you can add like pre-release and build numbers. The look something like:

Major.Minor.Patch-PreRelease+Build

If you look at semver String() (to string) function, it prints out everything. So if you have a version with a build number in it, it prints as "0.79.1+build".

But if you take a look at the semver spec and code for equality (.LT(), .LTE(), .EQ() etc.), they only compare Major, Minor, Patch and Pre-release. Build is ignored.

So how can we abuse this? Well first thing we need to do is get 1/3 of nodes to update with a semver such as "0.80.0+evil", and the rest of them specify their version as "0.80.0" like normal.

Then what happens:

for _, na := range nodes {
    v, ok := vCount[na.Version]    <-- .Version is a string, e.g. "0.80.0" or "0.80.0+evil".  
    if ok {
        v.count = v.count + 1
        vCount[na.Version] = v
    } else {
        vCount[na.Version] = tmpVersionInfo{
            version: na.GetVersion(),
            count:   1,
        }
    }
    // assume all versions are backward compatible
    // analyze-ignore(map-iteration)
    for k, v := range vCount {
        if v.version.LT(na.GetVersion()) {  <-- "0.80.0+evil" does not vote on "0.80.0" like usual.  
            v.count = v.count + 1
            vCount[k] = v
        }
    }
}
totalCount := len(nodes)
version := semver.Version{}

// analyze-ignore(map-iteration)
for _, info := range vCount {
    // skip those version that doesn't have majority
    if !HasSuperMajority(info.count, totalCount) {     <-- "0.80.0" does not reach supermajority
        continue
    }
    if info.version.GT(version) {
        version = info.version
    }
}
return version   <-- Return empty "0.0.0" allowing any version to enter the network

So after this, all the attacker needs to do is have a thornode running software 0.80.0 sit in Standby, and hack Bifrost to make sure the make set-version sends off 0.50.0.
This node will be allowed to enter the network at the next churn, which downgrades all code pathways to re-use old exploitable code, then they can steal all the funds.

Difficulty

This requires control over the version setting of 1/3 +1 of the network. Not easy.

But THORChain requires 2/3 of the network to make any super-majority decision about signing transactions and critical decisions, so having 1/3 able to steal all the funds without enough RUNE bond to disincentivise this is a problem that must be fixed.

@HildisviniOttar
Copy link
Author

Fixed in v0.80.0 - making public

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