Cobo安全团队:ETH硬分叉里的隐藏风险和套利机会

CN
2 years ago

原文作者:Cobo安全团队

原文来源:Cobo Global

前言

随着ETH升级PoS共识系统,原有的PoW机制的ETH链在部分社区的支持下成功硬分叉(下文简称ETHW)。但是,由于某些链上协议在设计之初没有对可能的硬分叉做好准备,导致对应的协议在ETHW分叉链存在一定的安全隐患,其中最为严重的安全隐患则是重放攻击。

在完成硬分叉后,ETHW主网出现了至少2起利用重放机制进行的攻击,分别是OmniBridge的重放攻击和Polygon Bridge的重放攻击。本文将以这两个事件作为案例,分别分析重放攻击对分叉链的影响,以及协议应如何防范此类攻击。

重放的类型

首先,在开始分析之前,我们需要先对重放攻击的类型做一个初步的了解,一般而言,我们对重放攻击分成两类,分别是交易重放签名消息重放。下面,我们来分别说下这两类重放机制的区别

交易重放

交易重放指的是将在原有链的交易原封不动的迁移到目标链的操作,属于是交易层面上的重放,重放过后交易也是可以正常执行并完成交易验证。最著名的案例莫过于WintermuteOptimism上的攻击事件,直接导致了超2000万OP代币的损失。但是在EIP 155实施以后,由于交易的签名本身带有chainId(一种用于链本身区别与其他分叉链的标识符),在重放的目标链chainId不同的情况下,交易本身是无法完成重放的。

签名消息重放

签名消息重放区别于交易重放,是针对的用私钥签名的消息(e.g.Cobo is the best) 进行的重放,在签名消息重放中,攻击者不需要对整个交易进行重放,而只需将签名的消息进行重放即可。在消息签名中,以Cobo is the best为例,由于该消息中并不含任何和链相关的特殊参数,所以该消息在签名后理论上是可以在任意的分叉链中均是有效的,可以验签通过。为了避免该消息在分叉上的重放,可以消息内容中添加chainId,如Cobo is the best + chainId()。在带上特定的链标识符之后,在不同分叉链上的消息内容不同,消息签名不同,因此无法直接进行重放复用。

OmniBridge 和 Polygon Bridge 的攻击原理

下面我们来分析OmniBridgePolygon Bridge的攻击原理。首先抛出结论,这两起攻击事件本身都不是交易重放 攻击,原因在于ETHW使用了区别于ETH主网的chainId,所以直接重放交易无法被验证通过。那么剩下的选项就只有消息重放了,那下面我们就来逐个分析它们各自是如何在ETHW分叉链上被消息重放攻击的。

OmniBridge

OmniBridge是用于在xDAIETH主网之间进行资产转移而使用的桥,主要依赖桥的指定的validator提交跨链消息完成跨链接资产的转移。在OmniBridge中,validator提交的验证消息的逻辑是这样的

functionexecuteSignatures(bytes_data,bytes_signatures)public{
_allowMessageExecution(_data,_signatures);

bytes32msgId;
addresssender;
addressexecutor;
uint32gasLimit;
uint8dataType;
uint256[2]memorychainIds;
bytesmemorydata;

(msgId,sender,executor,gasLimit,dataType,chainIds,data)=ArbitraryMessage.unpackData(_data);

_executeMessage(msgId,sender,executor,gasLimit,dataType,chainIds,data);
}

在这个函数中,首先会根据 #L2 行的签名检查来确定提交的签名是不是由指定的validator进行签名,然后再在 #L11 行对data消息进行解码。从解码内容上看,不难发现,返回字段中包含了chainId字段,那么是不是说明无法进行签名消息重放呢?我们继续分析。

function_executeMessage(
bytes32msgId,
addresssender,
addressexecutor,
uint32gasLimit,
uint8dataType,
uint256[2]memorychainIds,
bytesmemorydata
)internal
{
require(_isMessageVersionValid(msgId));
require(_isDestinationChainIdValid(chainIds[1]));
require(!relayedMessages(msgId));
setRelayedMessages(msgId,true);
processMessage(sender,executor,msgId,gasLimit,dataType,chainIds[0],data);
}

通过追查_executeMessage函数,发现函数在 #L11 行对chaindId进行了合法性的检查function_isDestinationChainIdValid(uint256_chainId)internalreturns(boolres){

return_chainId==sourceChainId();
}

functionsourceChainId()publicviewreturns(uint256){
returnuintStorage[SOURCE_CHAIN_ID];
}

通过继续分析后续的函数逻辑,不难发现其实针对chainId的检查其实并没有使用evm原生的chainId操作码来获取链本身的chainId,而是直接使用存储在uintStorage变量中的值,那这个值很明显是管理员设置进去的,所以可以认为消息本身并不带有链标识,那么理论上就是可以进行签名消息重放的。

由于在硬分叉过程中,分叉前的所有状态在两条链上都会原封不动的保留,在后续xDAI团队没有额外操作的情况下。分叉后ETHWETH主网上Omni Bridge合约的状态是不会有变化的,也就是说合约的validator也是不会有变化的。根据这一个情况,我们就能推断出validator在主网上的签名也是可以在ETHW上完成验证的。那么,由于签名消息本身不包含chainId,攻击者就可以利用签名重放,在ETHW上提取同一个合约的资产。

Polygon Bridge

Omni Bridge一样,Polygon Bridge是用于在PolygonETH主网进行资产转移的桥。与Omni Bridge不同,Polygon Bridge依赖区块证明进行提款,逻辑如下:

functionexit(bytescalldatainputData)externaloverride{
//...省略不重要逻辑
//verifyreceiptinclusion
require(
MerklePatriciaProof.verify(
receipt.toBytes(),
branchMaskBytes,
payload.getReceiptProof(),
payload.getReceiptRoot()
),
"RootChainManager:INVALID_PROOF"
);

//verifycheckpointinclusion
_checkBlockMembershipInCheckpoint(
payload.getBlockNumber(),
payload.getBlockTime(),
payload.getTxRoot(),
payload.getReceiptRoot(),
payload.getHeaderNumber(),
payload.getBlockProof()
);

ITokenPredicate(predicateAddress).exitTokens(
_msgSender(),
rootToken,
log.toRlpBytes()
);
}

通过函数逻辑,不难发现合约通过2个检查确定消息的合法性,分别是通过检查 transactionRootBlockNumber来确保交易真实发生在子链 (Ploygon Chain),第一个检查其实可以绕过,因为任何人都可以通过交易数据来构造属于自己的transactionRoot,但是第二个检查是无法绕过的,因为通过查看 _checkBlockMembershipInCheckpoint 逻辑可以发现:

function_checkBlockMembershipInCheckpoint(
uint256blockNumber,
uint256blockTime,
bytes32txRoot,
bytes32receiptRoot,
uint256headerNumber,
bytesmemoryblockProof
)privateviewreturns(uint256)
{
(
bytes32headerRoot,
uint256startBlock,
,
uint256createdAt,

)=_checkpointManager.headerBlocks(headerNumber);

require(
keccak256(
abi.encodePacked(blockNumber,blockTime,txRoot,receiptRoot)
)
.checkMembership(
blockNumber.sub(startBlock),
headerRoot,
blockProof
),
"RootChainManager:INVALID_HEADER"
);
returncreatedAt;
}

对应的headerRoot是从_checkpointManager合约中提取的,顺着这个逻辑我们查看_checkpointManager设置 headerRoot的地方

functionsubmitCheckpoint(bytescalldatadata,uint[3][]calldatasigs)external{
(addressproposer,uint256start,uint256end,bytes32rootHash,bytes32accountHash,uint256_borChainID)=abi
.decode(data,(address,uint256,uint256,bytes32,bytes32,uint256));
require(CHAINID==_borChainID,"Invalidborchainid");

require(_buildHeaderBlock(proposer,start,end,rootHash),"INCORRECT_HEADER_DATA");

//checkifitisbettertokeepitinlocalstorageinstead
IStakeManagerstakeManager=IStakeManager(registry.getStakeManagerAddress());
uint256_reward=stakeManager.checkSignatures(
end.sub(start).add(1),
/**
prefix01todata
01representspositivevoteondataand00isnegativevote
maliciousvalidatorcantrytosend2/3onnegativevoteso01isappended
*/

keccak256(abi.encodePacked(bytes(hex"01"),data)),
accountHash,
proposer,
sigs
);
//....剩余逻辑省略

不难发现在 #L2 行代码中,签名数据仅对borChianId进行了检查,而没有对链本身的chainId进行检查,由于该消息是由合约指定的proposer进行签名的,那么理论上攻击者也可以在分叉链上重放proposer的消息签名,提交合法的headerRoot,后续再通过Polygon Bridge进行在ETHW链中调用exit函数并提交相应的交易merkle proof后就可以提现成功并通过headerRoot的检查。

以地址0x7dbf18f679fa07d943613193e347ca72ef4642b9为例,该地址就成功通过以下几步操作完成了对ETHW链的套利

  1. 首先依靠钞能力主网交易所提币。

  2. Ploygon链上通过Polygon BridgedepositFor函数进行充币;

  3. ETH主网调用Polygon Bridgeexit函数提币;

  4. 复制提取ETH主网proposer提交的headerRoot;

  5. ETHW中重放上一步提取的proposer的签名消息;

  6. ETHW中的Polygon Bridge上调用exit进行提币

为什么会发生这种情况?

从上面分析的两个例子中,不难发现这两个协议在ETHW上遭遇重放攻击是因为协议本身没有做好防重放的保护,导致协议对应的资产在分叉链上被掏空。但是由于这两个桥本身并不支持ETHW分叉链,所以用户并没有遭受任何损失。但我们要考虑的事情是为什么这两个桥在设计之初就没有加入重放保护的措施呢?其实原因很简单,因为无论是OmniBridge还是Polygon Bridge,他们设计的应用场景都非常单一,只是用于到自己指定的对应链上进行资产转移,并没有一个多链部署的计划,所以没有重放保护而言对协议本身并不造成安全影响。

反观ETHW上的用户,由于这些桥本身并不支持多链场景,如果用户在ETHW分叉链上进行操作的话,反而会在ETH主网上遭受消息重放攻击。

UniswapV2为例,目前在UnswapV2pool合约中,存在permit函数,该函数中存在变量PERMIT_TYPEHASH,其中包含变量DOMAIN_SEPARATOR

functionpermit(addressowner,addressspender,uintvalue,uintdeadline,uint8v,bytes32r,bytes32s)external{
require(deadline>=block.timestamp,'UniswapV2:EXPIRED');
bytes32digest=keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH,owner,spender,value,nonces[owner]++,deadline))
)
);
addressrecoveredAddress=ecrecover(digest,v,r,s);
require(recoveredAddress!=address(0)&&recoveredAddress==owner,'UniswapV2:INVALID_SIGNATURE');
_approve(owner,spender,value);
}

此变量最早在EIP712中定义,该变量中含有chainId,在设计之初就包含可能的多链场景的重放预防,但是根据uniswapV2 pool合约的逻辑,如下:

constructor()public{
uintchainId;
assembly{
chainId:=chainid
}
DOMAIN_SEPARATOR=keccak256(
abi.encode(
keccak256('EIP712Domain(stringname,stringversion,uint256chainId,addressverifyingContract)'),
keccak256(bytes(name)),
keccak256(bytes('1')),
chainId,
address(this)
)
);
}

DOMAIN_SEPARATOR在构造函数中已经定义好,也就是说在硬分叉后,就算链本身的chainId已经改变,pool合约也无法获取到新的chianId来更新DOMAIN_SEPARATOR,如果未来用户在ETHW上进行相关授权,那么ETHW上的permit签名授权可以被重放到ETH主网上。除了Uniswap外,类似的协议还有很多,比如特定版本下的yearn vault合约,同样也是采用了固定DOMAIN_SEPARATOR的情况。用户在ETHW上交互的时候也需要防范此类协议的重放风险。

协议设计之初的防范措施

对于开发者而言,在为协议本身定制消息签名机制的时候,应该考虑后续可能的多链场景,如果路线图中存在多链部署的可能,应该把chainId作为变量加入到签名消息中,同时,在验证签名的时候,由于硬分叉不会改变分叉前的任何状态,用于验证签名消息的chainId不应该设置为合约变量,而应该在每次验证前重新获取,然后进行验签,保证安全性。

影响

对用户的影响

普通在协议不支持分叉链的情况下,应尽量不在分叉链上进行任何操作,防止对应的签名消息重放到主网上,造成用户在主网上损失资产

对交易所和托管机构的影响

由于很多交易所本身都支持了ETHW代币,所以这些由于攻击而提取出来的代币都有可能充值到交易所中进行抛售,但需要注意的是,此类攻击并不是链共识本身的问题而导致的恶意增发,所以对交易所而言,此类攻击无需进行额外的防范

总结

随着多链场景的发展,重放攻击从理论层面逐步变成主流的攻击方式,开发者应当仔细考量协议设计,在进行消息签名机制的设计时,尽可能的加入chainId等因子作为签名内容,并遵循相关的最佳实践,防止用户资产的损失。

免责声明:本文章仅代表作者个人观点,不代表本平台的立场和观点。本文章仅供信息分享,不构成对任何人的任何投资建议。用户与作者之间的任何争议,与本平台无关。如网页中刊载的文章或图片涉及侵权,请提供相关的权利证明和身份证明发送邮件到support@aicoin.com,本平台相关工作人员将会进行核查。

欧易返20%,前100送AiCoin保温杯
链接:https://www.okx.com/zh-hans/join/aicoin20
Ad
Share To
APP

X

Telegram

Facebook

Reddit

CopyLink