以太坊硬分叉漏洞的分析与复现 CVE-2021-39137
在公链中,硬分叉漏洞的严重性是仅次于RCE的,因为它不仅会给共识网络带来破坏,对基于其区块链的应用也会产生经济上面影响,例如造成双花交易(double spending)。这个漏洞是出现在以太坊的官方客户端GETH上,是以太坊协议主流的客户端,所以这个漏洞对于以太坊来说,影响还是比较大的。
0x1 攻击
在7天前,以太坊的开发团队就公告了该漏洞(EVM flaw during block processing),并且说明了严重性,但是并未受到社区重视,直到有攻击者以此漏洞发动了攻击。
https://etherscan.io/tx/0x1cb6fb36633d270edefc04d048145b4298e67b8aa82a9e5ec4aa1435dd770ce4
0x2 分析
我们来分析一下这笔交易,首先用etherscan的vmtrace看看opcode的情况
可以看到,步骤10 也就是STATICCALL操作码的gas消耗最多,那么攻击的核心肯定就在这里面,不过先了解一下STATICCALL操作码的作用再说。
大概用途就是STATICCALL只能做合约可读调用,不能修改任何状态。了解这个操作码的用途之后,我们再从GETH源码里看看这个操作码大概需要哪些参数。
一共需要5个参数:
- addr:要调用的合约地址
- inOffset:输入的偏移量
- inSize:输入的长度
- retOffset:输出的偏移量
- retSize:输出的长度
既然知道了参数作用,我们再来看看攻击者的具体的参数值是什么,我使用etherscan的REMIX VM Debugger来查看
https://etherscan.io/remix?txhash=0x1cb6fb36633d270edefc04d048145b4298e67b8aa82a9e5ec4aa1435dd770ce4
我这里直接跳到第10步执行完,因为之前已经知道了第10步就是STATICCALL,然后看栈数据,依次对应的就是上面5个参数(0x4,0x,0x20,0x7,0x20)。
首先第一个参数就很奇怪,0x4是一个合约地址吗?没错,还真是。
0x1到0x8都是合约地址,只不过他们叫预编译合约。
OK,来看看0x4预编译合约,也就是dataCopy()的作用
0x4的主要作用是做数据复制,比常规方法gas消耗更小,更便宜,有意思的是0x4之前就出过漏洞。
不过经过后续分析,问题主要不是在0x4上面
复现
既然知道了攻击手法,和具体的攻击参数,那么把代码还原到漏洞修复前便可以复现。
有两种方式可以复现漏洞:
- 使用存在漏洞的版本编译出客户端,然后搭建一条私链,来进行仿真实验。
- 对存在漏洞的点,进行单元测试。
我这里使用第二种方案,虽然第一种方案比较真实,但是太费劲了。
单元测试代码:
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.go中StaticCall函数的代码
主要注释了3行关于evm.StateDB的代码,因为我在单元测试里没有初始化evm.StateDB,有点麻烦,所以直接注释掉,但是不会影响功能,也不会影响测试的准确性,可以看到官方在注释里面也有所解释,StaicCall本身就不能对状态进行修改,留有关于evm.StateDB的代码是历史遗留原因。
改完之后就可以进行单元测试了,先使用正常参数(inOffset=0x0、inSize=0x20、retOffset=0x20、inSize=0x20)进行测试,结果如下
可以看到单元测试通过了
再使用攻击者的参数(inOffset=0x0、inSize=0x20、retOffset=0x7、inSize=0x20)试试
单元测试失败了,原因是输入和输出不一致!
根据dataCopy的代码,使用dataCopy()预编译合约,也就是0x4合约拷贝数据,只是简单的把输入返回出来了,那么拷贝的结果一定也是1-32数列,但是现在输出产生了误差。
经过调试,发现了问题的关键点
在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],不存在重叠部分。
补丁
关键commit:https://github.com/holiman/go-ethereum/commit/4d4879cafd1b3c906fc184a8c4a357137465128f
这个commit后来也被合并到GETH的master分支中了,补丁主要通过以下代码进行修复
ret = common.CopyBytes(ret)
在进行scope.Memory.Set前将ret重新复制一份,那么ret就不是指向scope.Memory.Data()了,而是重新生成了一份,对scope.Memory.Data()做任何修改都不影响ret。
正是因为这个bug,导致修复后的GETH客户端,与未升级修复的GETH客户端,存在对同一笔交易的处理结果不一致,所以才产生了硬分叉。
参考
https://mp.weixin.qq.com/s/ciOHk4qSrCCCA07_l3t3eg
最新评论