此篇文章由 Cobo 区块链安全团队供稿,团队成员来自知名区块链安全厂商,具备丰富的智能合约审计经验,曾在多个 DeFi 项目中发现高危漏洞。团队目前重点关注智能合约安全、DeFi安全等方向,研究并分享前沿区块链安全技术。
我们也希望对加密数字货币领域有研究精神和科学方法论的终身迭代学习者可以加入我们的行列,向行业输出思考洞察与研究观点!
此篇是Cobo Global 的第 16 篇文章
前言
随着 ETH
升级 PoS
共识系统,原有的 PoW
机制的 ETH
链在部分社区的支持下成功硬分叉(下文简称 ETHW
)。但是,由于某些链上协议在设计之初没有对可能的硬分叉做好准备,导致对应的协议在 ETHW
分叉链存在一定的安全隐患,其中最为严重的安全隐患则是重放攻击。
在完成硬分叉后, ETHW
主网出现了至少2起利用重放机制进行的攻击,分别是 OmniBridge
的重放攻击和 Polygon Bridge
的重放攻击。本文将以这两个事件作为案例,分别分析重放攻击对分叉链的影响,以及协议应如何防范此类攻击。
重放的类型
首先,在开始分析之前,我们需要先对重放攻击的类型做一个初步的了解,一般而言,我们对重放攻击分成两类,分别是 交易重放
和 签名消息重放
。下面,我们来分别说下这两类重放机制的区别
交易重放
交易重放指的是将在原有链的交易原封不动的迁移到目标链的操作,属于是交易层面上的重放,重放过后交易也是可以正常执行并完成交易验证。最著名的案例莫过于 Wintermute
在 Optimism
上的攻击事件,直接导致了超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
的攻击原理
下面我们来分析 OmniBridge
和 Polygon Bridge
的攻击原理。首先抛出结论,这两起攻击事件本身都不是交易重放
攻击,原因在于 ETHW
使用了区别于 ETH
主网的 chainId
,所以直接重放交易无法被验证通过。那么剩下的选项就只有消息重放了,那下面我们就来逐个分析它们各自是如何在 ETHW
分叉链上被消息重放攻击的。
OmniBridge
OmniBridge
是用于在 xDAI
和 ETH
主网之间进行资产转移而使用的桥,主要依赖桥的指定的 validator
提交跨链消息完成跨链接资产的转移。在 OmniBridge
中,validator
提交的验证消息的逻辑是这样的
function executeSignatures(bytes _data, bytes _signatures) public {
_allowMessageExecution(_data, _signatures);
bytes32 msgId;
address sender;
address executor;
uint32 gasLimit;
uint8 dataType;
uint256[2] memory chainIds;
bytes memory data;
(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(
bytes32 msgId,
address sender,
address executor,
uint32 gasLimit,
uint8 dataType,
uint256[2] memory chainIds,
bytes memory data
) 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) internal returns (bool res) {
return _chainId == sourceChainId();
}
function sourceChainId() public view returns (uint256) {
return uintStorage[SOURCE_CHAIN_ID];
}
通过继续分析后续的函数逻辑,不难发现其实针对 chainId
的检查其实并没有使用 evm
原生的 chainId
操作码来获取链本身的 chainId
,而是直接使用存储在 uintStorage
变量中的值,那这个值很明显是管理员设置进去的,所以可以认为消息本身并不带有链标识,那么理论上就是可以进行签名消息重放的。
由于在硬分叉过程中,分叉前的所有状态在两条链上都会原封不动的保留,在后续 xDAI
团队没有额外操作的情况下。分叉后 ETHW
和 ETH
主网上 Omni Bridge
合约的状态是不会有变化的,也就是说合约的 validator
也是不会有变化的。根据这一个情况,我们就能推断出 validator
在主网上的签名也是可以在 ETHW
上完成验证的。那么,由于签名消息本身不包含 chainId
,攻击者就可以利用签名重放,在 ETHW
上提取同一个合约的资产。
Polygon Bridge
和 Omni Bridge
一样,Polygon Bridge
是用于在 Polygon
和 ETH
主网进行资产转移的桥。与 Omni Bridge
不同,Polygon Bridge
依赖区块证明进行提款,逻辑如下:
function exit(bytes calldata inputData) external override {
//...省略不重要逻辑
// verify receipt inclusion
require(
MerklePatriciaProof.verify(
receipt.toBytes(),
branchMaskBytes,
payload.getReceiptProof(),
payload.getReceiptRoot()
),
"RootChainManager: INVALID_PROOF"
);
// verify checkpoint inclusion
_checkBlockMembershipInCheckpoint(
payload.getBlockNumber(),
payload.getBlockTime(),
payload.getTxRoot(),
payload.getReceiptRoot(),
payload.getHeaderNumber(),
payload.getBlockProof()
);
ITokenPredicate(predicateAddress).exitTokens(
_msgSender(),
rootToken,
log.toRlpBytes()
);
}
通过函数逻辑,不难发现合约通过2个检查确定消息的合法性,分别是通过检查 transactionRoot
和 BlockNumber
来确保交易真实发生在子链 (Ploygon Chain
),第一个检查其实可以绕过,因为任何人都可以通过交易数据来构造属于自己的 transactionRoot
,但是第二个检查是无法绕过的,因为通过查看 _checkBlockMembershipInCheckpoint
逻辑可以发现:
function _checkBlockMembershipInCheckpoint(
uint256 blockNumber,
uint256 blockTime,
bytes32 txRoot,
bytes32 receiptRoot,
uint256 headerNumber,
bytes memory blockProof
) private view returns (uint256) {
(
bytes32 headerRoot,
uint256 startBlock,
,
uint256 createdAt,
) = _checkpointManager.headerBlocks(headerNumber);
require(
keccak256(
abi.encodePacked(blockNumber, blockTime, txRoot, receiptRoot)
)
.checkMembership(
blockNumber.sub(startBlock),
headerRoot,
blockProof
),
"RootChainManager: INVALID_HEADER"
);
return createdAt;
}
对应的 headerRoot
是从 _checkpointManager
合约中提取的,顺着这个逻辑我们查看 _checkpointManager
设置 headerRoot
的地方
function submitCheckpoint(bytes calldata data, uint[3][] calldata sigs) external {
(address proposer, uint256 start, uint256 end, bytes32 rootHash, bytes32 accountHash, uint256 _borChainID) = abi
.decode(data, (address, uint256, uint256, bytes32, bytes32, uint256));
require(CHAINID == _borChainID, "Invalid bor chain id");
require(_buildHeaderBlock(proposer, start, end, rootHash), "INCORRECT_HEADER_DATA");
// check if it is better to keep it in local storage instead
IStakeManager stakeManager = IStakeManager(registry.getStakeManagerAddress());
uint256 _reward = stakeManager.checkSignatures(
end.sub(start).add(1),
/**
prefix 01 to data
01 represents positive vote on data and 00 is negative vote
malicious validator can try to send 2/3 on negative vote so 01 is appended
*/
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 链的套利
-
首先依靠钞能力主网交易所提币。 -
在 Ploygon 链上通过 Polygon Bridge 的 depositFor 函数进行充币; -
ETH 主网调用 Polygon Bridge 的 exit 函数提币; -
复制提取 ETH 主网 proposer 提交的 headerRoot; -
在 ETHW 中重放上一步提取的 proposer 的签名消息; -
在 ETHW 中的 Polygon Bridge 上调用 exit 进行提币
为什么会发生这种情况?
从上面分析的两个例子中,不难发现这两个协议在 ETHW
上遭遇重放攻击是因为协议本身没有做好防重放的保护,导致协议对应的资产在分叉链上被掏空。但是由于这两个桥本身并不支持 ETHW
分叉链,所以用户并没有遭受任何损失。但我们要考虑的事情是为什么这两个桥在设计之初就没有加入重放保护的措施呢?其实原因很简单,因为无论是 OmniBridge
还是 Polygon Bridge
,他们设计的应用场景都非常单一,只是用于到自己指定的对应链上进行资产转移,并没有一个多链部署的计划,所以没有重放保护而言对协议本身并不造成安全影响。
反观 ETHW
上的用户,由于这些桥本身并不支持多链场景,如果用户在 ETHW
分叉链上进行操作的话,反而会在 ETH
主网上遭受消息重放攻击。
以 UniswapV2
为例,目前在 UnswapV2
的 pool
合约中,存在 permit
函数,该函数中存在变量 PERMIT_TYPEHASH
,其中包含变量 DOMAIN_SEPARATOR
。
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
bytes32 digest = keccak256(
abi.encodePacked(
'x19x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
)
);
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
_approve(owner, spender, value);
}
此变量最早在 EIP712
中定义,该变量中含有 chainId
,在设计之初就包含可能的多链场景的重放预防,但是根据 uniswapV2 pool
合约的逻辑,如下:
constructor() public {
uint chainId;
assembly {
chainId := chainid
}
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
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
等因子作为签名内容,并遵循相关的最佳实践,防止用户资产的损失。
Cobo是亚太地区最大的加密货币托管机构,自成立以来已为超过500家行业顶尖机构以及高净值人士提供卓越的服务,在保证加密资产安全存储的前提下,同时兑现了加密资产的稳健增益,深受全球用户信赖。Cobo专注于搭建可扩展的基础设施,为机构管理多类型的资产提供安全托管、资产增值、链上交互以及跨链跨层等多重解决方案,为机构迈向 Web 3.0 转型提供最强有力的技术底层支持和赋能。Cobo 旗下包含Cobo Custody、Cobo DaaS、Cobo MaaS、Cobo StaaS、Cobo Ventures、Cobo DeFi Yield Fund等业务板块,满足您的多种需求。
原文始发于微信公众号(Cobo Global):Cobo安全团队——ETH 硬分叉里那些隐藏的风险和套利机会