以太坊硬分叉漏洞的分析与复现CVE-2021-39137
以太坊硬分叉漏洞的分析与复现CVE-2021-39137
在公链中,硬分叉漏洞的严重性是仅次于RCE的,因为它不仅会给共识网络带来破坏,对基于其区块链的应用也会产生经济上面影响,例如造成双花交易(double spending)。这个漏洞是出现在以太坊的官方客户端GETH上,是以太坊协议主流的客户端,所以这个漏洞对于以太坊来说,影响还是比较大的。
 

0x1 攻击

在7天前,以太坊的开发团队就公告了该漏洞(EVM flaw during block processing),并且说明了严重性,但是并未受到社区重视,直到有攻击者以此漏洞发动了攻击。
 

0x2 分析

我们来分析一下这笔交易,首先用etherscanvmtrace看看opcode的情况
notion image
可以看到,步骤10 也就是STATICCALL操作码的gas消耗最多,那么攻击的核心肯定就在这里面,不过先了解一下STATICCALL操作码的作用再说。
notion image
大概用途就是STATICCALL只能做合约可读调用,不能修改任何状态。了解这个操作码的用途之后,我们再从GETH源码里看看这个操作码大概需要哪些参数。
notion image
一共需要5个参数:
  • addr:要调用的合约地址
  • inOffset:输入的偏移量
  • inSize:输入的长度
  • retOffset:输出的偏移量
  • retSize:输出的长度
 
既然知道了参数作用,我们再来看看攻击者的具体的参数值是什么,我使用etherscan的REMIX VM Debugger来查看
notion image
我这里直接跳到第10步执行完,因为之前已经知道了第10步就是STATICCALL,然后看栈数据,依次对应的就是上面5个参数(0x4,0x,0x20,0x7,0x20)。
首先第一个参数就很奇怪,0x4是一个合约地址吗?没错,还真是。
notion image
0x1到0x8都是合约地址,只不过他们叫预编译合约
notion image
OK,来看看0x4预编译合约,也就是dataCopy()的作用
notion image
0x4的主要作用是做数据复制,比常规方法gas消耗更小,更便宜,有意思的是0x4之前就出过漏洞。
notion image
不过经过后续分析,问题主要不是在0x4上面
 

复现

既然知道了攻击手法,和具体的攻击参数,那么把代码还原到漏洞修复前便可以复现。
有两种方式可以复现漏洞:
  1. 使用存在漏洞的版本编译出客户端,然后搭建一条私链,来进行仿真实验。
  1. 对存在漏洞的点,进行单元测试。
 
我这里使用第二种方案,虽然第一种方案比较真实,但是太费劲了。
单元测试代码:
func TestStaticCallOpWithDataCopy(t *testing.T) {
	var (
		env            = NewEVM(BlockContext{}, TxContext{}, nil, params.TestChainConfig, Config{})
		stack          = newstack()
		pc             = uint64(0)
		evmInterpreter = env.interpreter
		mem            = NewMemory()

	)

	opFn:=opStaticCall
	name:="staticcall"

	temp:= new(uint256.Int).SetBytes(common.Hex2Bytes("0"))
	addr := new(uint256.Int).SetBytes(common.Hex2Bytes("04"))
	inOffset := new(uint256.Int).SetBytes(common.Hex2Bytes("00"))
	inSize := new(uint256.Int).SetBytes(common.Hex2Bytes("20"))
	retOffset := new(uint256.Int).SetBytes(common.Hex2Bytes("07"))
	retSize := new(uint256.Int).SetBytes(common.Hex2Bytes("20"))
	expected := new(uint256.Int).SetBytes(common.Hex2Bytes("01"))
	stack.push(retSize)
	stack.push(retOffset)
	stack.push(inSize)
	stack.push(inOffset)
	stack.push(addr)
	stack.push(temp)

	mem.Resize(64)
	mem.Set(0,64,[]byte{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64})

	in := common.CopyBytes(mem.Data()[inOffset.Uint64():inSize.Uint64()])

	evmInterpreter.evm.callGasTemp = 100000

	ret,_:= opFn(&pc, evmInterpreter, &ScopeContext{mem, stack, NewContract(&account{}, &account{}, big.NewInt(0), 0)})
	t.Logf("in: %d",in)
	t.Logf("ret: %d",ret)
	if len(stack.data) != 1 {
		t.Errorf("Expected one item on stack after %v, got %d: ", name, len(stack.data))
	}
	actual := stack.pop()
	if actual.Cmp(expected) != 0 || !bytes.Equal(in, ret){
		t.Errorf("Testcase %v %v(%x, %x, %x, %x, %x): expected  %x, got %x, in %d, ret %d", name, name, addr, inOffset, inSize, retOffset, retSize, expected, actual,in,ret)
	}
}
除此之外还要修改evm.goStaticCall函数的代码
notion image
主要注释了3行关于evm.StateDB的代码,因为我在单元测试里没有初始化evm.StateDB,有点麻烦,所以直接注释掉,但是不会影响功能,也不会影响测试的准确性,可以看到官方在注释里面也有所解释,StaicCall本身就不能对状态进行修改,留有关于evm.StateDB的代码是历史遗留原因。
 
改完之后就可以进行单元测试了,先使用正常参数(inOffset=0x0、inSize=0x20、retOffset=0x20、inSize=0x20)进行测试,结果如下
notion image
可以看到单元测试通过了
再使用攻击者的参数(inOffset=0x0、inSize=0x20、retOffset=0x7、inSize=0x20)试试
notion image
单元测试失败了,原因是输入和输出不一致!
notion image
根据dataCopy的代码,使用dataCopy()预编译合约,也就是0x4合约拷贝数据,只是简单的把输入返回出来了,那么拷贝的结果一定也是1-32数列,但是现在输出产生了误差。
 
经过调试,发现了问题的关键点
notion image
在760行输出还是正常的,但在经过762行之后,ret发生了变化。
 
原因:ret是引用传递的变量,指向的是scope.Memory.Data()[0:0x20]762行将ret赋值给scope.Memory.Data()[0x7:0x7+0x20]0x7是攻击参数中retOffset的值),存在重叠的部分,对scope.Memory.Data()[0x7:0x20]造成了覆盖,所以ret被修改了。
 
正常参数(inOffset=0x0、inSize=0x20、retOffset=0x20、inSize=0x20)可以测试通过,正是因为refOffset为0x20,那么指向的是scope.Memory.Data()[0x20:0x20+0x20],不存在重叠部分。
 

补丁

notion image
这个commit后来也被合并到GETH的master分支中了,补丁主要通过以下代码进行修复
ret = common.CopyBytes(ret)
在进行scope.Memory.Set前将ret重新复制一份,那么ret就不是指向scope.Memory.Data()了,而是重新生成了一份,对scope.Memory.Data()做任何修改都不影响ret
 
正是因为这个bug,导致修复后的GETH客户端,与未升级修复的GETH客户端,存在对同一笔交易的处理结果不一致,所以才产生了硬分叉。
 

参考