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:
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.
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.
Heimdall has fixed in
0.80.0
.https://gitlab.com/thorchain/thornode/-/merge_requests/2102